create_sample_import_folders refuses with stable codes instead of English
sentences: sample_folder_exists:<name>, import_folder_missing (no longer
carrying the folder's path) and invalid_date. The wizard maps them to
import.empty.sampleErrors.* through the pure sampleFolderError, the folder
name interpolated from the new errorValues of the state; any other message
stays raw. The raw refusal also goes to the Journal as a warning: a code, or
a write error naming a sample folder and the OS error, never a path.
scanFolderInternal now tells whether the scan went through, and the success
banner only follows a rescan that did: a failed rescan leaves its error
alone on screen.
ImportEmptyState keeps folderName alone (null without a folder); the page
passes "" for a folder stored as "", as hasFolder did.
Rust tests: the three codes asserted exactly, the case of the second sample
folder already there (nothing written), and {{M1}} pinned as the most
recent month line by line against the templates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
// 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();
|
|
});
|
|
});
|