import { describe, it, expect } from "vitest"; import { computeChecklist, type Checklist } from "./onboardingChecklist"; import { CARD_PAGES, bannerMode, cardNext, cardStep, importCardModel, latchCardPage, nextStep, onboardingTargetAttributes, readOnboardingNavState, showsStepCard, stepNavState, stepsOfPage, type BannerModeInput, type ImportCardModel, } from "./onboardingPath"; import { CHECKLIST_ITEM_KEYS, type ChecklistItem, type ChecklistItemKey, type ImportCardInput, type OnboardingCounters, } from "../shared/types"; const zero: OnboardingCounters = { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0, }; /** Steps 2 to 5 done (folder, source, import, manual category). */ const upToManual: OnboardingCounters = { ...zero, sources: 1, imports: 1, manualCategorized: 1, transactions: 20, }; const checklistOf = ( counters: OnboardingCounters, options: { importFolderSet?: boolean; budgetAllowed?: boolean; skipped?: ChecklistItemKey[] } = {}, ): Checklist => computeChecklist(counters, { importFolderSet: options.importFolderSet ?? false, budgetAllowed: options.budgetAllowed ?? true, skipped: options.skipped, }); /** A row of the given step, as `computeChecklist` builds it. */ const step = (key: ChecklistItemKey): ChecklistItem => checklistOf(zero).items.find((item) => item.key === key)!; const ALL_CARD_PAGES: ReadonlySet = new Set(["/import", "/transactions", "/budget"]); describe("nextStep (#396)", () => { it("is the folder step on a new profile", () => { expect(nextStep(checklistOf(zero))?.key).toBe("importFolder"); }); it("skips the done rows", () => { expect(nextStep(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))?.key).toBe( "import", ); }); it("skips the skipped rows", () => { expect(nextStep(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import"); }); it("skips a locked row: Free edition with every other step done → null", () => { expect( nextStep(checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false })), ).toBeNull(); }); it("returns null at the end", () => { expect( nextStep(checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true })), ).toBeNull(); expect( nextStep(checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] })), ).toBeNull(); }); it("links through ChecklistItem.to", () => { expect(nextStep(checklistOf(upToManual, { importFolderSet: true }))).toMatchObject({ key: "budget", to: "/budget", }); expect(nextStep(checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true }))) .toMatchObject({ key: "manualCategory", to: "/transactions" }); }); }); describe("stepsOfPage (#396)", () => { it("maps each guided page to its steps, in checklist order", () => { expect(stepsOfPage("/import")).toEqual(["importFolder", "source", "import"]); expect(stepsOfPage("/transactions")).toEqual(["manualCategory"]); expect(stepsOfPage("/budget")).toEqual(["budget"]); }); it("gives no step to any other page, inherited object keys included", () => { for (const page of ["/", "/settings/users", "/reports", "constructor", "__proto__", ""]) { expect(stepsOfPage(page)).toEqual([]); } }); it("every card page guides at least one step", () => { for (const page of CARD_PAGES) expect(stepsOfPage(page).length).toBeGreaterThan(0); }); }); describe("latchCardPage (#396)", () => { const latch = ( previous: string | null, pathname: string, next: ChecklistItem | null, hidden = false, ) => latchCardPage(previous, { pathname, nextStep: next, hidden, cardPages: ALL_CARD_PAGES }); it("is set on the next step's page when that page carries a card", () => { expect(latch(null, "/import", step("importFolder"))).toBe("/import"); expect(latch(null, "/transactions", step("manualCategory"))).toBe("/transactions"); }); it("is not set on the next step's page when that page carries no card", () => { expect( latchCardPage(null, { pathname: "/import", nextStep: step("importFolder"), hidden: false, cardPages: new Set(["/budget"]), }), ).toBeNull(); }); it("follows CARD_PAGES by default", () => { for (const [page, key] of [ ["/import", "importFolder"], ["/transactions", "manualCategory"], ["/budget", "budget"], ] as const) { const latched = latchCardPage(null, { pathname: page, nextStep: step(key), hidden: false }); expect(latched).toBe(CARD_PAGES.has(page) ? page : null); } }); it("is not set on a page that is not the next step's", () => { expect(latch(null, "/budget", step("importFolder"))).toBeNull(); expect(latch(null, "/", step("importFolder"))).toBeNull(); }); it("is not set at the end of the path", () => { expect(latch(null, "/import", null)).toBeNull(); }); it("is released when the path is hidden, even on its page", () => { expect(latch("/import", "/import", step("importFolder"), true)).toBeNull(); expect(latch(null, "/import", step("importFolder"), true)).toBeNull(); }); it("holds when the step gets done with the page open, the next step being elsewhere", () => { expect(latch("/transactions", "/transactions", step("budget"))).toBe("/transactions"); }); it("holds when the last step gets done with the page open", () => { expect(latch("/budget", "/budget", null)).toBe("/budget"); }); it("is released by navigation, then set again only on the next step's card page", () => { expect(latch("/import", "/reports", step("manualCategory"))).toBeNull(); expect(latch("/import", "/budget", step("manualCategory"))).toBeNull(); expect(latch("/import", "/transactions", step("manualCategory"))).toBe("/transactions"); }); it('after "Skip" (latch cleared), it is set again when the new next step is on the same page', () => { // Skipping the folder step on /import: the source step is on /import too. expect(latch(null, "/import", step("source"))).toBe("/import"); }); it('after "Skip" (latch cleared), it stays released when the new next step is on another page', () => { // Skipping the import step on /import: the manual category is on /transactions. expect(latch(null, "/import", step("manualCategory"))).toBeNull(); }); it("is idempotent", () => { const cases: Array<[string | null, string, ChecklistItem | null, boolean]> = [ [null, "/import", step("importFolder"), false], ["/import", "/import", step("budget"), false], ["/import", "/transactions", step("manualCategory"), false], ["/import", "/reports", step("manualCategory"), false], ["/import", "/import", step("importFolder"), true], [null, "/budget", null, false], ]; for (const [previous, pathname, next, hidden] of cases) { const once = latch(previous, pathname, next, hidden); expect(latch(once, pathname, next, hidden)).toBe(once); } }); }); describe("bannerMode (#396)", () => { const midway = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }); const complete = checklistOf({ ...upToManual, budgetEntries: 1 }, { importFolderSet: true }); const base: BannerModeInput = { status: "ready", checklist: midway, hidden: false, pathname: "/reports", isEmptyDashboard: false, budgetReady: true, cardPage: null, }; const mode = (overrides: Partial) => bannerMode({ ...base, ...overrides }); it("shows the banner on an ordinary page", () => { expect(mode({})).toBe("banner"); }); it("shows nothing during the first load, or after a first load that failed", () => { expect(mode({ status: "loading", checklist: null })).toBe("none"); expect(mode({ status: "error", checklist: null })).toBe("none"); expect(mode({ status: "loading" })).toBe("none"); }); it("keeps the banner after a failed re-read (the reducer keeps status ready and the last state)", () => { expect(mode({ status: "ready", checklist: midway })).toBe("banner"); }); it("shows nothing when the path is hidden, card page included", () => { expect(mode({ hidden: true })).toBe("none"); expect(mode({ hidden: true, pathname: "/import", cardPage: "/import" })).toBe("none"); expect(mode({ hidden: true, checklist: complete })).toBe("none"); }); it("shows nothing on the empty dashboard, whose welcome card holds the list (D11)", () => { expect(mode({ pathname: "/", isEmptyDashboard: true })).toBe("none"); }); it("shows the banner on a dashboard with transactions", () => { expect(mode({ pathname: "/" })).toBe("banner"); }); it("shows the card on the latched page", () => { expect(mode({ pathname: "/import", cardPage: "/import" })).toBe("card"); }); it("shows the banner on a page that carries no card", () => { expect(mode({ pathname: "/import", cardPage: null })).toBe("banner"); }); it("shows the banner while a latch from another page has not been released yet", () => { expect(mode({ pathname: "/transactions", cardPage: "/import" })).toBe("banner"); }); it("keeps the card once the step is done with the page open, end of the path included", () => { expect(mode({ pathname: "/budget", cardPage: "/budget", checklist: complete })).toBe("card"); }); it("shows the end message when every step is done or skipped (D4)", () => { expect(mode({ checklist: complete })).toBe("complete"); const skippedEnd = checklistOf(upToManual, { importFolderSet: true, skipped: ["budget"] }); expect(mode({ checklist: skippedEnd })).toBe("complete"); }); it("waits for the Budget entitlement before announcing the end", () => { // Until the license is known, Budget counts as locked: 5/5 could be 5/6. const failClosed = checklistOf(upToManual, { importFolderSet: true, budgetAllowed: false }); expect(failClosed.complete).toBe(true); expect(mode({ checklist: failClosed, budgetReady: false })).toBe("none"); expect(mode({ checklist: failClosed, budgetReady: true })).toBe("complete"); }); it("shows the banner before the Budget entitlement is known when steps remain", () => { expect(mode({ budgetReady: false })).toBe("banner"); }); }); describe("readOnboardingNavState (#396)", () => { const none = { focus: false, guided: false }; it("reads anything that is not an object as no request", () => { expect(readOnboardingNavState(null)).toEqual(none); expect(readOnboardingNavState(undefined)).toEqual(none); expect(readOnboardingNavState("onboarding")).toEqual(none); expect(readOnboardingNavState(42)).toEqual(none); expect(readOnboardingNavState(true)).toEqual(none); }); it("reads a partial or malformed object as no request", () => { expect(readOnboardingNavState({})).toEqual(none); expect(readOnboardingNavState([])).toEqual(none); expect(readOnboardingNavState({ onboarding: null })).toEqual(none); expect(readOnboardingNavState({ onboarding: "focus" })).toEqual(none); expect(readOnboardingNavState({ onboarding: {} })).toEqual(none); expect(readOnboardingNavState({ onboarding: { focus: "yes" } })).toEqual(none); expect(readOnboardingNavState({ onboarding: { focus: 1, guided: "true" } })).toEqual(none); expect(readOnboardingNavState({ focus: true, guided: true })).toEqual(none); }); it("reads the valid requests", () => { expect(readOnboardingNavState({ onboarding: { focus: true } })).toEqual({ focus: true, guided: false, }); expect(readOnboardingNavState({ onboarding: { guided: true } })).toEqual({ focus: false, guided: true, }); expect(readOnboardingNavState({ onboarding: { focus: true, guided: true } })).toEqual({ focus: true, guided: true, }); expect( readOnboardingNavState({ from: "/", onboarding: { focus: true, guided: false, extra: 1 } }), ).toEqual({ focus: true, guided: false }); }); }); describe("stepNavState (#397)", () => { it("asks for the focus on every step, and for the guided entry on the manual category only", () => { for (const key of CHECKLIST_ITEM_KEYS) { expect(stepNavState(key), key).toEqual( key === "manualCategory" ? { onboarding: { focus: true, guided: true } } : { onboarding: { focus: true } }, ); } }); it("reads back through readOnboardingNavState", () => { expect(readOnboardingNavState(stepNavState("manualCategory"))).toEqual({ focus: true, guided: true, }); expect(readOnboardingNavState(stepNavState("import"))).toEqual({ focus: true, guided: false }); }); }); describe("CARD_PAGES (#398)", () => { it("carries the Import page", () => { expect(CARD_PAGES.has("/import")).toBe(true); }); }); describe("showsStepCard (#398)", () => { it("is true in card mode on the latched route only", () => { expect(showsStepCard("card", "/import", "/import")).toBe(true); expect(showsStepCard("card", "/import", "/transactions")).toBe(false); expect(showsStepCard("card", null, "/import")).toBe(false); for (const mode of ["none", "banner", "complete"] as const) { expect(showsStepCard(mode, "/import", "/import"), mode).toBe(false); } }); }); describe("cardStep (#398)", () => { const at = (checklist: Checklist, pathname = "/import") => { const found = cardStep(checklist, pathname); return found && { key: found.step.key, n: found.n, total: found.total }; }; it("on /import, shows the first of the page's steps not done yet, ranked among the unlocked rows", () => { expect(at(checklistOf(zero))).toEqual({ key: "importFolder", n: 2, total: 6 }); expect(at(checklistOf(zero, { importFolderSet: true }))).toEqual({ key: "source", n: 3, total: 6 }); expect(at(checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }))).toEqual({ key: "import", n: 4, total: 6, }); }); it("shows the import step once the three are done", () => { expect(at(checklistOf(upToManual, { importFolderSet: true }))).toEqual({ key: "import", n: 4, total: 6, }); }); it("passes over a skipped step, as the next step does", () => { // After "Skip" on the folder step, the card latches again on /import for // the source step: it must show that step, not the skipped one. expect(at(checklistOf(zero, { skipped: ["importFolder"] }))?.key).toBe("source"); expect(at(checklistOf(zero, { skipped: ["importFolder", "source"] }))?.key).toBe("import"); expect(at(checklistOf(zero, { skipped: ["importFolder", "source", "import"] }))?.key).toBe( "import", ); }); it("counts the unlocked rows only: five in the Free edition", () => { expect(at(checklistOf(zero, { budgetAllowed: false }))).toEqual({ key: "importFolder", n: 2, total: 5, }); expect(at(checklistOf(upToManual, { importFolderSet: true }), "/transactions")).toEqual({ key: "manualCategory", n: 5, total: 6, }); }); it("is null on a page that guides no unlocked step", () => { expect(cardStep(checklistOf(zero), "/reports")).toBeNull(); expect(cardStep(checklistOf(zero, { budgetAllowed: false }), "/budget")).toBeNull(); expect(cardStep(checklistOf(zero), "/budget")?.step.key).toBe("budget"); }); }); describe("cardNext (#398)", () => { it("links the next step when it lives on another page", () => { expect(cardNext(step("manualCategory"), "/import", true)).toEqual({ kind: "step", step: step("manualCategory"), }); // The link does not wait for the entitlement: only the end does. expect(cardNext(step("manualCategory"), "/import", false).kind).toBe("step"); }); it("is the end without a next step, once the Budget entitlement is known", () => { expect(cardNext(null, "/import", true)).toEqual({ kind: "end" }); expect(cardNext(null, "/import", false)).toEqual({ kind: "wait" }); }); it("waits while the next step is still one of this page's own (re-read not landed)", () => { for (const key of ["importFolder", "source", "import"] as const) { expect(cardNext(step(key), "/import", true), key).toEqual({ kind: "wait" }); } expect(cardNext(step("budget"), "/budget", true)).toEqual({ kind: "wait" }); expect(cardNext(step("budget"), "/import", true).kind).toBe("step"); }); }); describe("importCardModel (#398)", () => { /** A new profile: folder read, none chosen, nothing scanned. */ const NEW: ImportCardInput = { wizardStep: "source-list", folderLoaded: true, hasFolder: false, folderName: null, sourceCount: 0, importing: false, importedCount: null, }; /** A folder with two bank sub-folders, the wizard at rest. */ const AT_REST: ImportCardInput = { ...NEW, hasFolder: true, folderName: "Relevés", sourceCount: 2 }; const fresh = checklistOf(zero, { importFolderSet: true }); /** Steps 3 and 4 checked: a source and a completed `imported_files` row. */ const imported = checklistOf({ ...zero, sources: 1, imports: 1 }, { importFolderSet: true }); const model = (input: Partial, checklist: Checklist | null = fresh) => importCardModel({ ...AT_REST, ...input }, checklist); const wizardNext = (text: string, button: string): ImportCardModel => ({ text: { key: `onboarding.card.import.${text}`, labels: { button } }, done: false, target: "wizardNext", }); const DONE: ImportCardModel = { text: null, done: true, target: null }; const IMPORTING: ImportCardModel = { text: { key: "onboarding.card.import.importing" }, done: false, target: null, }; const FAILED: ImportCardModel = { text: { key: "onboarding.card.import.failed" }, done: false, target: null, }; it("1. no card before the folder is read, whatever else is true", () => { expect(importCardModel({ ...NEW, folderLoaded: false }, fresh)).toBeNull(); expect(model({ folderLoaded: false, importing: true, wizardStep: "importing" })).toBeNull(); }); it("2. an import running says so, before the counters that already tick", () => { expect(model({ wizardStep: "importing", importing: true })).toEqual(IMPORTING); // The `imported_files` row is written `completed` before the inserts: // steps 3 and 4 read as checked while the import runs. expect(model({ wizardStep: "importing", importing: true }, imported)).toEqual(IMPORTING); // The wizard's own step says it too. expect(model({ wizardStep: "importing", importing: false }, imported)).toEqual(IMPORTING); }); it("3. a report of an import that added nothing is a failure, never 'Done.'", () => { expect(model({ wizardStep: "report", importedCount: 0 })).toEqual(FAILED); // Duplicates only: the file row is `completed`, so the checklist counts // the import step as done; the card still does not say "Done.". expect(model({ wizardStep: "report", importedCount: 0 }, imported)).toEqual(FAILED); // No count at all is no success either. expect(model({ wizardStep: "report", importedCount: null }, imported)).toEqual(FAILED); }); it("4. a report of an import that added transactions is 'Done.', even before the re-read", () => { expect(model({ wizardStep: "report", importedCount: 41 }, imported)).toEqual(DONE); expect(model({ wizardStep: "report", importedCount: 1 }, fresh)).toEqual(DONE); }); it("5. without a folder: choose one, with the empty state's button", () => { const noFolder: ImportCardModel = { text: { key: "onboarding.card.import.noFolder", labels: { button: "import.empty.noFolder.cta" }, }, done: false, target: "emptyStateAction", }; expect(importCardModel(NEW, checklistOf(zero))).toEqual(noFolder); // Before "steps checked": the folder step is the one left. expect(importCardModel(NEW, imported)).toEqual(noFolder); }); it("6. a folder without bank sub-folder: create them, or the sample folder", () => { expect(model({ sourceCount: 0 })).toEqual({ text: { key: "onboarding.card.import.noSources", labels: { refresh: "import.empty.refresh", sample: "import.empty.createSample" }, values: { folder: "Relevés" }, }, done: false, target: "emptyStateAction", }); }); it("7. the wizard: the first source card, then its next button with the label it shows", () => { expect(model({ wizardStep: "source-list" })).toEqual({ text: { key: "onboarding.card.import.sourceList" }, done: false, target: "firstSource", }); expect(model({ wizardStep: "source-config" })).toEqual( wizardNext("sourceConfig", "import.wizard.preview"), ); expect(model({ wizardStep: "file-preview" })).toEqual( wizardNext("filePreview", "import.wizard.checkDuplicates"), ); expect(model({ wizardStep: "duplicate-check" })).toEqual( wizardNext("duplicateCheck", "import.wizard.confirm"), ); expect(model({ wizardStep: "confirm" })).toEqual(wizardNext("confirm", "import.wizard.import")); }); it("8. steps 3 and 4 checked, the wizard at rest: 'Done.' (after 'Done' or 'Cancel')", () => { expect(model({ wizardStep: "source-list" }, imported)).toEqual(DONE); // Ahead of the rows of the wizard at rest: an emptied folder too. expect(model({ wizardStep: "source-list", sourceCount: 0 }, imported)).toEqual(DONE); }); it("8. ...but only at rest: a new pass through the wizard is guided again", () => { expect(model({ wizardStep: "source-config" }, imported)).toEqual( wizardNext("sourceConfig", "import.wizard.preview"), ); expect(model({ wizardStep: "confirm" }, imported)).toEqual( wizardNext("confirm", "import.wizard.import"), ); }); it("8. ...and only when both are checked, by the data: skipped is not checked", () => { const sourceOnly = checklistOf({ ...zero, sources: 1 }, { importFolderSet: true }); expect(model({}, sourceOnly)?.target).toBe("firstSource"); const skipped = checklistOf(zero, { importFolderSet: true, skipped: ["source", "import"] }); expect(model({}, skipped)?.target).toBe("firstSource"); expect(model({}, null)?.target).toBe("firstSource"); }); }); describe("onboardingTargetAttributes (#398)", () => { it("marks the target and its highlight when on, nothing otherwise", () => { expect(onboardingTargetAttributes("wizard-next", true)).toEqual({ "data-onboarding-target": "wizard-next", "data-onboarding-highlight": "true", }); expect(onboardingTargetAttributes("wizard-next", false)).toEqual({}); expect(onboardingTargetAttributes("wizard-next", undefined)).toEqual({}); }); });