diff --git a/src/hooks/useImportWizard.mount.test.ts b/src/hooks/useImportWizard.mount.test.ts new file mode 100644 index 0000000..8dc802e --- /dev/null +++ b/src/hooks/useImportWizard.mount.test.ts @@ -0,0 +1,128 @@ +// useImportWizard — the folder read at mount marks itself done (#384, #398). +// +// No renderer in this project, and the reducer alone cannot reach the mount +// read: it is an effect of the hook. React's hooks used by `useImportWizard` +// are stubbed (technique of useImportWizard.executeImport.test.ts): the hook +// runs as a plain function, its effect is captured and run by hand, and the +// services it calls are mocked. What is pinned here is the `finally`: the +// folder counts as read once the read and its scan are over, success or not. + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const harness = vi.hoisted(() => ({ + state: null as unknown, + dispatch: vi.fn(), + effects: [] as Array<() => void>, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useReducer: () => [harness.state, harness.dispatch], + useCallback: (fn: unknown) => fn, + useEffect: (fn: () => void) => { + harness.effects.push(fn); + }, + }; +}); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); +vi.mock("../services/userPreferenceService", () => ({ + getImportFolder: vi.fn(), + setImportFolder: vi.fn(), +})); +vi.mock("../services/importSourceService", () => ({ + getAllSources: vi.fn(async () => []), + getSourceByName: vi.fn(async () => null), + createSource: vi.fn(), + updateSource: vi.fn(), +})); +vi.mock("../services/importedFileService", () => ({ + existsByHash: vi.fn(), + createImportedFile: vi.fn(), + updateFileStatus: vi.fn(), + getFilesBySourceId: vi.fn(async () => []), +})); +vi.mock("../services/importConfigTemplateService", () => ({ + getAllTemplates: vi.fn(async () => []), + createTemplate: vi.fn(), + updateTemplate: vi.fn(), + deleteTemplate: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { useImportWizard, initialWizardState } from "./useImportWizard"; +import { getImportFolder } from "../services/userPreferenceService"; +import type { ScannedSource } from "../shared/types"; + +const SOURCES: ScannedSource[] = [ + { folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] }, +]; + +/** Lets the mount read's promises settle. */ +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; + +/** Mounts the hook and runs its mount effect, the first one it registers. */ +async function mount() { + useImportWizard(); + const [readFolder] = harness.effects; + readFolder(); + await flush(); +} + +const types = () => harness.dispatch.mock.calls.map((c) => (c[0] as { type: string }).type); + +beforeEach(() => { + harness.state = initialWizardState; + harness.effects = []; + harness.dispatch.mockReset(); + vi.mocked(getImportFolder).mockReset(); + vi.mocked(invoke).mockReset(); +}); + +describe("useImportWizard — the mount read marks the folder read (#384, #398)", () => { + it("with a folder: after the folder and its first scan, as the last dispatch", async () => { + vi.mocked(getImportFolder).mockResolvedValue("/releves"); + vi.mocked(invoke).mockResolvedValue(SOURCES); + await mount(); + + expect(invoke).toHaveBeenCalledWith("scan_import_folder", { folderPath: "/releves" }); + const order = types(); + expect(order[0]).toBe("SET_IMPORT_FOLDER"); + expect(order.indexOf("SET_SCANNED_SOURCES")).toBeGreaterThan(0); + expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED"); + expect(order.filter((t) => t === "SET_IMPORT_FOLDER_LOADED")).toHaveLength(1); + }); + + it("without a folder: marked read, nothing scanned", async () => { + vi.mocked(getImportFolder).mockResolvedValue(null); + await mount(); + + expect(invoke).not.toHaveBeenCalled(); + expect(harness.dispatch.mock.calls.map((c) => c[0])).toEqual([ + { type: "SET_IMPORT_FOLDER", payload: null }, + { type: "SET_IMPORT_FOLDER_LOADED" }, + ]); + }); + + it("when the preference cannot be read: still marked read (the empty state then offers a folder)", async () => { + vi.mocked(getImportFolder).mockRejectedValue(new Error("database is locked")); + await mount(); + + expect(types()).toEqual(["SET_IMPORT_FOLDER_LOADED"]); + }); + + it("when the scan fails: marked read after the error", async () => { + vi.mocked(getImportFolder).mockResolvedValue("/releves"); + vi.mocked(invoke).mockRejectedValue(new Error("folder not found")); + await mount(); + + const order = types(); + expect(order).toContain("SET_ERROR"); + expect(order[order.length - 1]).toBe("SET_IMPORT_FOLDER_LOADED"); + }); +}); diff --git a/src/hooks/useImportWizard.test.ts b/src/hooks/useImportWizard.test.ts index 0f29cf3..3081ec1 100644 --- a/src/hooks/useImportWizard.test.ts +++ b/src/hooks/useImportWizard.test.ts @@ -110,6 +110,46 @@ describe("wizardReducer — sampleCreated (#356)", () => { }); }); +describe("wizardReducer — importFolderLoaded (#384, #398)", () => { + it("starts unread: the page cannot tell 'no folder' from 'not read yet'", () => { + expect(initialWizardState.importFolderLoaded).toBe(false); + expect(initialWizardState.importFolder).toBeNull(); + }); + + it("SET_IMPORT_FOLDER_LOADED marks the folder read, and nothing else", () => { + const before = afterCreation(); + const after = wizardReducer(before, { type: "SET_IMPORT_FOLDER_LOADED" }); + expect(after.importFolderLoaded).toBe(true); + expect({ ...after, importFolderLoaded: false }).toEqual(before); + }); + + it("RESET ('Cancel', 'Done') keeps it: the mount read does not run again to restore it", () => { + const loaded = reduce([{ type: "SET_IMPORT_FOLDER_LOADED" }], afterCreation()); + const inWizard = reduce( + [ + { type: "SET_STEP", payload: "report" }, + { type: "SET_LOADING", payload: true }, + ], + loaded, + ); + const after = wizardReducer(inWizard, { type: "RESET" }); + expect(after.importFolderLoaded).toBe(true); + expect(after.step).toBe("source-list"); + expect(after.isLoading).toBe(false); + }); + + it("RESET before the read leaves it unread", () => { + expect(wizardReducer(initialWizardState, { type: "RESET" }).importFolderLoaded).toBe(false); + }); + + it("choosing another folder keeps it read", () => { + const loaded = reduce([{ type: "SET_IMPORT_FOLDER_LOADED" }]); + expect( + wizardReducer(loaded, { type: "SET_IMPORT_FOLDER", payload: "/autre" }).importFolderLoaded, + ).toBe(true); + }); +}); + describe("formatLocalDate (#356)", () => { // Pin a timezone west of Greenwich: under TZ=UTC (the CI runner) local and // UTC dates coincide, so a `toISOString()` regression would pass unnoticed. diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 65b8ace..1af0bbf 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -71,6 +71,14 @@ function errorMessage(e: unknown): string { export interface WizardState { step: ImportWizardStep; importFolder: string | null; + /** + * The stored import folder has been read at mount, and scanned when there is + * one (#384, #398). Until then `importFolder` is null for lack of reading, + * not for lack of a folder: the empty state and the step card wait for it, + * or the "no folder" variant would flash. Set once, success or failure, and + * kept by `RESET`, which the mount read never runs again to restore. + */ + importFolderLoaded: boolean; scannedSources: ScannedSource[]; selectedSource: ScannedSource | null; selectedFiles: ScannedFile[]; @@ -125,6 +133,7 @@ export type WizardAction = | { type: "SET_ERROR"; payload: string | null } | { type: "SET_STEP"; payload: ImportWizardStep } | { type: "SET_IMPORT_FOLDER"; payload: string | null } + | { type: "SET_IMPORT_FOLDER_LOADED" } | { type: "SET_SCANNED_SOURCES"; payload: ScannedSource[] } | { type: "SET_SELECTED_SOURCE"; payload: ScannedSource } | { type: "SET_SELECTED_FILES"; payload: ScannedFile[] } @@ -163,6 +172,7 @@ const defaultConfig: SourceConfig = { const initialState: WizardState = { step: "source-list", importFolder: null, + importFolderLoaded: false, scannedSources: [], selectedSource: null, selectedFiles: [], @@ -201,6 +211,8 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS case "SET_IMPORT_FOLDER": // The sample-folder banner describes the previous folder: drop it. return { ...state, importFolder: action.payload, sampleCreated: null }; + case "SET_IMPORT_FOLDER_LOADED": + return { ...state, importFolderLoaded: true }; case "SET_SCANNED_SOURCES": return { ...state, scannedSources: action.payload, isLoading: false }; case "SET_SELECTED_SOURCE": @@ -289,9 +301,15 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS case "SET_SAMPLE_CREATED": return { ...state, sampleCreated: action.payload }; case "RESET": + // Rebuilt from `initialState`, minus what the page read once and keeps: + // the folder, whether it was read (#384: the mount read does not run + // again, so "Cancel" or "Done" would otherwise hide the empty state and + // the step card for good), its scan, the configured sources and the + // templates. return { ...initialState, importFolder: state.importFolder, + importFolderLoaded: state.importFolderLoaded, scannedSources: state.scannedSources, configuredSourceNames: state.configuredSourceNames, importedFilesBySource: state.importedFilesBySource, @@ -328,6 +346,11 @@ export function useImportWizard() { } } catch { // No folder configured yet + } finally { + // Read, and scanned when there is a folder (#384): the empty state and + // the step card can tell "no folder" from "not read yet". After the + // scan, so the "no source" variant does not flash either. + dispatch({ type: "SET_IMPORT_FOLDER_LOADED" }); } })(); }, []); // eslint-disable-line react-hooks/exhaustive-deps