Simpl-Resultat/src/hooks/useImportWizard.mount.test.ts
le king fu 7d3a616312 feat(import): warn about the CSV files placed directly in the import folder
scan_import_folder now returns ImportFolderScan { sources, root_files }:
the CSV/TXT files at the root, which no source reads, are named in a
warning on the Import page (above the source list, or under the "no
source" empty state). One action sets sources and root files, RESET keeps
both. Names stay on screen, never in the Journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:59:20 -04:00

157 lines
5.7 KiB
TypeScript

// 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<typeof import("react")>();
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 { clearLogs, getLogs, initLogCapture } from "../services/logService";
import type { ImportFolderScan, ScannedSource } from "../shared/types";
const SOURCES: ScannedSource[] = [
{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] },
];
/** What `scan_import_folder` returns since #427: the sources and the root files. */
const SCAN: ImportFolderScan = { sources: SOURCES, root_files: ["releve-4510.csv"] };
/** 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(SCAN);
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");
});
});
describe("useImportWizard — the scan hands over its root files (#427)", () => {
it("dispatches the whole scan, so the state exposes `rootFiles` next to the sources", async () => {
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockResolvedValue(SCAN);
await mount();
const scanned = harness.dispatch.mock.calls
.map((c) => c[0] as { type: string; payload?: unknown })
.filter((a) => a.type === "SET_SCANNED_SOURCES");
expect(scanned).toEqual([{ type: "SET_SCANNED_SOURCES", payload: SCAN }]);
});
it("logs neither a root file name nor the folder path (the Journal travels with a feedback report)", async () => {
initLogCapture();
clearLogs();
vi.mocked(getImportFolder).mockResolvedValue("/releves");
vi.mocked(invoke).mockResolvedValue(SCAN);
await mount();
const journal = getLogs().map((l) => l.message).join("\n");
expect(journal).not.toContain("releve-4510");
expect(journal).not.toContain("/releves");
});
});