fix(import): mark the import folder read once its mount read is over
The wizard starts with `importFolder` null for lack of reading, which the Import page took for "no folder": the "No import folder" empty state flashed before the stored folder came back (#384). `importFolderLoaded` is set in the `finally` of the mount read, after the first scan, so neither variant of the empty state flashes, and it is kept by RESET, which rebuilds from the initial state while the mount read never runs again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
46e714234e
commit
d024f5aeb9
3 changed files with 191 additions and 0 deletions
128
src/hooks/useImportWizard.mount.test.ts
Normal file
128
src/hooks/useImportWizard.mount.test.ts
Normal file
|
|
@ -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<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 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue