Simpl-Resultat/src/utils/onboardingPath.test.ts
le king fu ed5eeb0265 feat(onboarding): step card with highlight, and the Import page's card
On the page of the step in progress, a card takes the place of the
banner. `StepCard` is driven by props: an h2 title that takes the focus
("Step n of N", then the step), the page's sentence, a status region
always rendered that receives "Done.", "Skip this step" until the step is
done, then "Next step" or the end of the path. `OnboardingStepCard` is
the only piece that reads the path: it renders on the latched card page
only, so the card survives its own success, focuses its title once per
arrival by "Next step" and consumes the navigation state.

The control to use carries `data-onboarding-highlight`, drawn in
styles.css as a 3px accent outline with a pulse bounded to three cycles
and none under prefers-reduced-motion; a test reads the stylesheet and
checks 3:1 against the background and the cards in both themes. The
target is passed by props to EmptyState's primary action, the first
SourceList card and WizardNavigation's next button, never queried.

`importCardModel` reads the wizard and the checklist in order: importing
before the counters, a report that added nothing as a failure, and the
"steps 3 and 4 checked" row at rest, where it can apply. CARD_PAGES gets
/import; the Import page builds the input, renders the card first and
highlights the model's target only while the card shows, and its empty
state waits for the folder to be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:34:45 -04:00

581 lines
22 KiB
TypeScript

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<string> = 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<BannerModeInput>) => 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<ImportCardInput>, 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({});
});
});