// useImportWizard — reducer unit tests (#356). // // No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts): // the EXPORTED reducer and initial state are driven directly. What is pinned // here is the lifecycle of `sampleCreated`, the names behind the "sample // folders created" banner: set by its own action, dropped by `RESET` (the // wizard's `reset`) and by `SET_IMPORT_FOLDER` (what `browseFolder` // dispatches), and never touched by an error — which also leaves the scanned // sources exactly as they were. Plus the local-date helper handed to Rust. import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; // The hook module imports the import services (→ db → tauri plugins). Stub the // connection so importing the module never touches a real database. vi.mock("../services/db", () => { const getDb = vi.fn(); return { getDb, connectToProfile: vi.fn(), initializeNewProfileDb: vi.fn(), withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), }; }); import { readFileSync } from "fs"; import { resolve } from "path"; import { wizardReducer, initialWizardState, formatLocalDate, sampleFolderError, type WizardAction, type WizardState, } from "./useImportWizard"; import type { ImportFolderScan, ScannedSource } from "../shared/types"; const NAMES = ["Exemple Desjardins", "Exemple Tangerine"]; const sources: ScannedSource[] = [ { folder_name: "Exemple Desjardins", folder_path: "/releves/Exemple Desjardins", files: [] }, { folder_name: "Exemple Tangerine", folder_path: "/releves/Exemple Tangerine", files: [] }, ]; const reduce = (actions: WizardAction[], from: WizardState = initialWizardState): WizardState => actions.reduce(wizardReducer, from); /** The state right after a successful creation: folder set, sources rescanned, banner up. */ const afterCreation = (): WizardState => reduce([ { type: "SET_IMPORT_FOLDER", payload: "/releves" }, { type: "SET_SCANNED_SOURCES", payload: { sources, root_files: [] } }, { type: "SET_SAMPLE_CREATED", payload: NAMES }, ]); describe("wizardReducer — sampleCreated (#356)", () => { it("starts with no sample folders to report", () => { expect(initialWizardState.sampleCreated).toBeNull(); }); it("SET_SAMPLE_CREATED stores the folder names and nothing else", () => { const before = reduce([ { type: "SET_IMPORT_FOLDER", payload: "/releves" }, { type: "SET_SCANNED_SOURCES", payload: { sources, root_files: [] } }, ]); const after = wizardReducer(before, { type: "SET_SAMPLE_CREATED", payload: NAMES }); expect(after.sampleCreated).toEqual(NAMES); expect(after.scannedSources).toBe(before.scannedSources); expect(after.importFolder).toBe("/releves"); expect(after.step).toBe("source-list"); }); it("SET_SAMPLE_CREATED null dismisses the banner", () => { const after = wizardReducer(afterCreation(), { type: "SET_SAMPLE_CREATED", payload: null }); expect(after.sampleCreated).toBeNull(); }); it("RESET clears it while keeping the folder and the scanned sources", () => { const before = afterCreation(); const after = wizardReducer(before, { type: "RESET" }); expect(after.sampleCreated).toBeNull(); expect(after.importFolder).toBe("/releves"); expect(after.scannedSources).toBe(before.scannedSources); }); it("SET_IMPORT_FOLDER (what browseFolder dispatches) clears it", () => { const after = wizardReducer(afterCreation(), { type: "SET_IMPORT_FOLDER", payload: "/autre-dossier", }); expect(after.sampleCreated).toBeNull(); expect(after.importFolder).toBe("/autre-dossier"); }); it("a creation error leaves the scanned sources untouched and raises no banner", () => { const before = reduce([ { type: "SET_IMPORT_FOLDER", payload: "/releves" }, { type: "SET_SCANNED_SOURCES", payload: { sources: [], root_files: [] } }, { type: "SET_LOADING", payload: true }, ]); const after = wizardReducer(before, { type: "SET_ERROR", payload: "Folder already exists: Exemple Desjardins", }); expect(after.error).toBe("Folder already exists: Exemple Desjardins"); expect(after.scannedSources).toBe(before.scannedSources); expect(after.scannedSources).toEqual([]); expect(after.sampleCreated).toBeNull(); expect(after.isLoading).toBe(false); }); }); 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. const originalTz = process.env.TZ; beforeAll(() => { process.env.TZ = "America/Toronto"; }); afterAll(() => { if (originalTz === undefined) delete process.env.TZ; else process.env.TZ = originalTz; }); it("formats the LOCAL calendar date, zero-padded", () => { // Built from local components, so the expectation holds in every timezone — // unlike `toISOString()`, which would shift this evening date to the 6th // in Toronto (04:30Z the next day). expect(formatLocalDate(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05"); expect(new Date(2026, 0, 5, 23, 30).toISOString().slice(0, 10)).not.toBe("2026-01-05"); expect(formatLocalDate(new Date(2026, 11, 31, 0, 0))).toBe("2026-12-31"); }); }); describe("wizardReducer — rootFiles (#427)", () => { const scan = (root_files: string[], from: ScannedSource[] = sources): ImportFolderScan => ({ sources: from, root_files, }); it("starts with no root file", () => { expect(initialWizardState.rootFiles).toEqual([]); }); it("SET_SCANNED_SOURCES sets the sources and the root files together", () => { const after = reduce([ { type: "SET_IMPORT_FOLDER", payload: "/releves" }, { type: "SET_LOADING", payload: true }, { type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) }, ]); expect(after.scannedSources).toBe(sources); expect(after.rootFiles).toEqual(["a.csv"]); expect(after.isLoading).toBe(false); }); it("a new scan ('Refresh', another folder) replaces both", () => { const after = reduce([ { type: "SET_SCANNED_SOURCES", payload: scan(["a.csv", "b.csv"]) }, { type: "SET_SCANNED_SOURCES", payload: scan([], []) }, ]); expect(after.scannedSources).toEqual([]); expect(after.rootFiles).toEqual([]); }); it("RESET ('Cancel', 'Done') keeps both: the warning stays with the source list", () => { const inWizard = reduce([ { type: "SET_IMPORT_FOLDER", payload: "/releves" }, { type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) }, { type: "SET_STEP", payload: "report" }, ]); const after = wizardReducer(inWizard, { type: "RESET" }); expect(after.step).toBe("source-list"); expect(after.scannedSources).toBe(sources); expect(after.rootFiles).toEqual(["a.csv"]); }); it("a scan error leaves the root files of the last scan in place", () => { const before = reduce([{ type: "SET_SCANNED_SOURCES", payload: scan(["a.csv"]) }]); const after = wizardReducer(before, { type: "SET_ERROR", payload: "folder not found" }); expect(after.rootFiles).toBe(before.rootFiles); }); }); describe("sampleFolderError — the refusals of create_sample_import_folders (#384, D2)", () => { it("a sample folder already there: its key, with the folder's name", () => { expect(sampleFolderError("sample_folder_exists:Exemple Desjardins")).toEqual({ key: "import.empty.sampleErrors.folderExists", values: { name: "Exemple Desjardins" }, }); expect(sampleFolderError("sample_folder_exists:Exemple Tangerine")).toEqual({ key: "import.empty.sampleErrors.folderExists", values: { name: "Exemple Tangerine" }, }); }); it("the import folder missing, the date unreadable: their keys, without values", () => { expect(sampleFolderError("import_folder_missing")).toEqual({ key: "import.empty.sampleErrors.importFolderMissing", }); expect(sampleFolderError("invalid_date")).toEqual({ key: "import.empty.sampleErrors.invalidDate" }); }); it("any other message is unknown, so the banner shows it raw", () => { for (const raw of [ "Cannot write Exemple Desjardins/releve-2026-09.csv: Permission denied (os error 13)", "Cannot create folder Exemple Tangerine: File exists (os error 17)", "Cannot compute the previous month", // The messages of the versions before #384, and codes bent out of shape. "Folder already exists: Exemple Desjardins", "Folder does not exist: /home/max/Relevés", "sample_folder_exists:", "sample_folder_exists", "import_folder_missing: /home/max/Relevés", "Invalid date 'x': input contains invalid characters", "", ]) { expect(sampleFolderError(raw), raw).toBeNull(); } }); // The codes are Rust constants (`SAMPLE_ERROR_*` of fs_commands.rs): a code // renamed or added there, with no mapping here, would reach the user raw. // Read from the source like onboarding-samples.test.ts reads the seed. A PR // that touches only `src-tauri/**` skips `check-frontend`: the drift then // shows at the next frontend run. it("maps every refusal code the Rust command declares", () => { const rust = readFileSync( resolve(import.meta.dirname, "../../src-tauri/src/commands/fs_commands.rs"), "utf-8", ); const codes = [...rust.matchAll(/^const SAMPLE_ERROR_[A-Z_]+: &str = "([a-z_]+)";$/gm)].map((m) => m[1]); expect(codes.sort()).toEqual(["import_folder_missing", "invalid_date", "sample_folder_exists"]); for (const code of codes) { const raw = code === "sample_folder_exists" ? `${code}:Exemple Desjardins` : code; expect(sampleFolderError(raw), code).not.toBeNull(); } }); }); describe("wizardReducer — errorValues (#384)", () => { it("starts with none", () => { expect(initialWizardState.errorValues).toBeNull(); }); it("SET_ERROR sets the values with the key, and any later SET_ERROR drops them", () => { const refused = wizardReducer(initialWizardState, { type: "SET_ERROR", payload: "import.empty.sampleErrors.folderExists", values: { name: "Exemple Desjardins" }, }); expect(refused.error).toBe("import.empty.sampleErrors.folderExists"); expect(refused.errorValues).toEqual({ name: "Exemple Desjardins" }); const raw = wizardReducer(refused, { type: "SET_ERROR", payload: "folder not found" }); expect(raw.error).toBe("folder not found"); expect(raw.errorValues).toBeNull(); const cleared = wizardReducer(refused, { type: "SET_ERROR", payload: null }); expect(cleared.error).toBeNull(); expect(cleared.errorValues).toBeNull(); }); it("RESET drops them with the error", () => { const refused = wizardReducer(initialWizardState, { type: "SET_ERROR", payload: "import.empty.sampleErrors.folderExists", values: { name: "Exemple Desjardins" }, }); const after = wizardReducer(refused, { type: "RESET" }); expect(after.error).toBeNull(); expect(after.errorValues).toBeNull(); }); });