// ImportPage and its getting-started card (#398). No jsdom: react-dom/server // renders in vitest's node environment. The wizard is a fake // `useImportWizard()` over its real initial state, the path a fake // `useOnboarding()` built from the real checklist, and the wizard's heavy // panels are stubbed. What is pinned is the page's wiring: where the card // sits, what it says at each step, which control carries the one highlight, // and what waits for the folder to be read (#384). import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; // Importing the real wizard module (for its initial state) pulls the import // services, then the database: stub the connection. vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string, o?: Record) => o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`, i18n: { language: "fr" }, }), })); const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown })); vi.mock("../hooks/useImportWizard", async (importOriginal) => ({ ...(await importOriginal()), useImportWizard: () => harness.wizard, })); vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding })); const { stub } = vi.hoisted(() => ({ stub: (name: string) => ({ default: () => }), })); vi.mock("../components/import/ImportHistoryPanel", () => stub("history")); vi.mock("../components/import/SourceConfigPanel", () => stub("source-config")); vi.mock("../components/import/FilePreviewTable", () => stub("file-preview")); vi.mock("../components/import/DuplicateCheckPanel", () => stub("duplicate-check")); vi.mock("../components/import/ImportConfirmation", () => stub("confirmation")); vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null })); import ImportPage from "./ImportPage"; import { initialWizardState, type WizardState } from "../hooks/useImportWizard"; import type { OnboardingValue } from "../hooks/useOnboardingChecklist"; import { computeChecklist } from "../utils/onboardingChecklist"; import { nextStep } from "../utils/onboardingPath"; import type { ImportReport, OnboardingCounters, ScannedSource } from "../shared/types"; const ZERO: OnboardingCounters = { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0, }; /** Source and import checked (the re-read after a successful import). */ const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 }; const SOURCES: ScannedSource[] = ["Desjardins", "Tangerine"].map((name) => ({ folder_name: name, folder_path: `/home/max/Relevés/${name}`, files: [], })); const report = (importedCount: number): ImportReport => ({ totalRows: 41, importedCount, skippedDuplicates: 41 - importedCount, errorCount: 0, categorizedCount: importedCount, uncategorizedCount: 0, errors: [], }); const DUPLICATES = { fileAlreadyImported: false, duplicateRows: [], newRows: [] }; /** The wizard after its mount read, with `overrides`. */ function wizard(overrides: Partial = {}) { const state: WizardState = { ...initialWizardState, importFolderLoaded: true, ...overrides }; const noop = vi.fn(); return new Proxy({ state } as Record, { get: (target, key) => (key in target ? target[key as string] : noop), }); } /** The path latched on /import (card mode), as the provider shares it. */ function path( counters: OnboardingCounters, importFolderSet: boolean, overrides: Partial = {}, ): OnboardingValue { const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true }); return { status: "ready", checklist, counters, hidden: false, budgetReady: true, nextStep: nextStep(checklist), mode: "card", cardPage: "/import", hide: vi.fn(async () => undefined), show: vi.fn(async () => undefined), skip: vi.fn(async () => undefined), resume: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined), ...overrides, }; } const render = () => renderToStaticMarkup( , ); const CARD = 'aria-label="t(onboarding.banner.label)"'; const HIGHLIGHT = 'data-onboarding-highlight="true"'; const count = (html: string, needle: string) => html.split(needle).length - 1; /** The one highlighted control: its tag and its inner markup. */ function highlighted(html: string): { tag: string; inner: string } { expect(count(html, HIGHLIGHT)).toBe(1); const match = html.match(/<(button|a) [^>]*data-onboarding-highlight="true"[^>]*>(.*?)<\/\1>/); expect(match).not.toBeNull(); return { tag: match![1], inner: match![2] }; } /** * The card's sentence: the paragraph that follows its title. The card always * hands `t` its interpolations, so a sentence without any reads `t(key[])`. */ const sentence = (html: string) => html.match(/<\/h2>

]*>(.*?)<\/p>/)?.[1] ?? null; beforeEach(() => { harness.wizard = wizard(); harness.onboarding = path(ZERO, false); }); describe("ImportPage — the step card (#398)", () => { it("renders the card first, before the page's heading", () => { const html = render(); expect(count(html, CARD)).toBe(1); expect(html.indexOf(CARD)).toBeLessThan(html.indexOf(" { const html = render(); expect(sentence(html)).toBe( "t(onboarding.card.import.noFolder[button=t(import.empty.noFolder.cta)])", ); const target = highlighted(html); expect(target.tag).toBe("button"); expect(target.inner).toBe("t(import.empty.noFolder.cta)"); }); it("a folder without bank sub-folder: 'Create a sample folder', the folder named by its last segment", () => { harness.wizard = wizard({ importFolder: "/home/max/Relevés" }); harness.onboarding = path(ZERO, true); const html = render(); expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])"); expect(sentence(html)).toBe( "t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])", ); expect(highlighted(html).inner).toBe("t(import.empty.createSample)"); }); it("sources found: the first source card", () => { harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); harness.onboarding = path(ZERO, true); const html = render(); expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])"); const target = highlighted(html); expect(target.inner).toContain("Desjardins"); expect(target.inner).not.toContain("Tangerine"); }); it.each([ ["source-config", "sourceConfig", "import.wizard.preview"], ["file-preview", "filePreview", "import.wizard.checkDuplicates"], ["duplicate-check", "duplicateCheck", "import.wizard.confirm"], ["confirm", "confirm", "import.wizard.import"], ] as const)("%s: the next button, the one the sentence names", (step, text, label) => { harness.wizard = wizard({ step, importFolder: "/home/max/Relevés", scannedSources: SOURCES, selectedSource: SOURCES[0], duplicateResult: DUPLICATES, }); harness.onboarding = path({ ...ZERO, sources: 0 }, true); const html = render(); expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`); expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})])`); const target = highlighted(html); expect(target.tag).toBe("button"); expect(target.inner).toContain(`t(${label})`); }); it("importing: the card says so and points at nothing, though the counters already tick", () => { harness.wizard = wizard({ step: "importing", importFolder: "/home/max/Relevés", scannedSources: SOURCES }); harness.onboarding = path(IMPORTED, true); const html = render(); expect(html).toContain("t(import.progress.title)"); expect(sentence(html)).toBe("t(onboarding.card.import.importing[])"); expect(html).not.toContain("t(onboarding.card.done)"); expect(count(html, HIGHLIGHT)).toBe(0); }); it("the report of an import that added transactions: 'Done.' and the next step", () => { harness.wizard = wizard({ step: "report", importFolder: "/home/max/Relevés", scannedSources: SOURCES, importReport: report(41), }); harness.onboarding = path(IMPORTED, true); const html = render(); expect(html).toMatch(/

]*>.*t\(onboarding\.card\.done\)/); expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/); expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])"); expect(count(html, HIGHLIGHT)).toBe(0); }); it("the report of an import that added nothing: the failure sentence, never 'Done.'", () => { harness.wizard = wizard({ step: "report", importFolder: "/home/max/Relevés", scannedSources: SOURCES, importReport: report(0), }); // Duplicates only: this file brought no transaction, and the step stays // checked by the earlier import that brought the ones it duplicates. harness.onboarding = path(IMPORTED, true); const html = render(); expect(sentence(html)).toBe("t(onboarding.card.import.failed[])"); expect(html).not.toContain("t(onboarding.card.done)"); expect(html).not.toContain("onboarding.card.next"); }); it("after 'Done' on an import that added nothing, the wizard at rest: no 'Done.', step 4 again on the first source card", () => { // The file row is written, but the import step only counts a file that // brought a transaction: the re-read keeps the step to do (review of #398). harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); harness.onboarding = path({ ...ZERO, sources: 1 }, true); const html = render(); expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])"); expect(html).not.toContain("t(onboarding.card.done)"); expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])"); const target = highlighted(html); expect(target.inner).toContain("Desjardins"); expect(target.inner).not.toContain("Tangerine"); }); it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => { harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); harness.onboarding = path(IMPORTED, true); const html = render(); expect(html).toContain("t(onboarding.card.done)"); expect(html).toContain("Desjardins"); expect(count(html, HIGHLIGHT)).toBe(0); }); it("without the card on screen, no control is highlighted", () => { harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES }); for (const overrides of [ { mode: "banner" as const, cardPage: null }, { mode: "none" as const, cardPage: null }, { mode: "card" as const, cardPage: "/transactions" }, ]) { harness.onboarding = path(ZERO, true, overrides); const html = render(); expect(html).not.toContain(CARD); expect(count(html, HIGHLIGHT)).toBe(0); expect(html).toContain("Desjardins"); } }); }); describe("ImportPage — before the folder is read (#384)", () => { it("shows neither variant of the empty state, nor the list's empty box, nor the card", () => { harness.wizard = wizard({ importFolderLoaded: false }); const html = render(); expect(html).not.toContain("import.empty.noFolder.title"); expect(html).not.toContain("import.empty.noSources.title"); expect(html).not.toContain("import.sources.empty"); expect(html).not.toContain(CARD); expect(count(html, HIGHLIGHT)).toBe(0); // The page itself is there: its heading, the folder card and the history. expect(html).toContain("t(import.title)"); expect(html).toContain('data-stub="history"'); }); it("shows the empty state once read", () => { expect(render()).toContain("t(import.empty.noFolder.title)"); }); });