Simpl-Resultat/src/components/dashboard/OnboardingChecklist.test.tsx
le king fu 64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
Two visit steps join the getting-started path: "Browse categories and
keywords" (5th) and "Browse the reports" (8th). Nothing in the database
counts them: they are done by "Got it" on their card, stored as
onboarding_acknowledged_<key> in user_preferences (no migration).

- computeChecklist: eight rows; the visit rows are locked until the
  profile's visit steps are initialized, so a finished path does not
  flash a "Next step" before they are marked done.
- initializeVisitSteps: once per profile, after the first successful
  load with the Budget entitlement known; acknowledges both steps when
  every other step is done or skipped, then sets the flag. No
  transaction (D16): idempotent writes, the flag last.
- Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps
  the card latch, so the card says "Done." and offers the next step.
- StepCard: "Got it" before "Skip this step" when onAcknowledge is
  given; OnboardingStepCard passes it for a visit step.
- Banner counter, card rank and end of path wait for
  budgetReady && visitStepsInitialized; the list shows the visit rows
  "pending" until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:20 -04:00

318 lines
14 KiB
TypeScript

// OnboardingChecklist — unit tests (#352, #397).
//
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
// mocked, the component called as a function and its element tree walked.
// `t` returns the key, with the interpolation values appended, so the asserts
// read the i18n keys and never a hard-coded string.
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Link } from "react-router-dom";
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
vi.mock("lucide-react", () => ({
CheckCircle2: () => null,
Circle: () => null,
Lock: () => null,
SkipForward: () => null,
}));
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types";
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
if (!isValidElement(node)) return [];
const el = node as AnyElement;
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
}
function texts(node: ReactNode): string[] {
if (Array.isArray(node)) return node.flatMap(texts);
if (typeof node === "string") return [node];
if (!isValidElement(node)) return [];
return texts((node as AnyElement).props.children);
}
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock, SkipForward]);
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
function expand(node: ReactNode): ReactNode {
if (Array.isArray(node)) return node.map(expand);
if (!isValidElement(node)) return node;
const el = node as AnyElement;
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
const render = el.type as (props: Record<string, unknown>) => ReactNode;
return expand(render(el.props));
}
const children = el.props.children;
return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode;
}
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
function render(
overrides: Partial<OnboardingChecklistProps> = {},
counters = ZERO,
budgetAllowed = true,
skipped: ChecklistItemKey[] = [],
visitStepsInitialized = true,
) {
const checklist = computeChecklist(counters, {
importFolderSet: false,
budgetAllowed,
skipped,
visitStepsInitialized,
});
const onHide = vi.fn();
const onResume = vi.fn();
const props: OnboardingChecklistProps = {
items: checklist.items,
done: checklist.done,
total: checklist.total,
budgetReady: true,
visitStepsInitialized,
onHide,
onResume,
...overrides,
};
return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist };
}
function rowOf(element: ReactNode, key: string): AnyElement {
const row = find(element, (e) => e.type === "li" && e.key === key)[0];
expect(row, `row ${key}`).toBeDefined();
return row;
}
/** The visually hidden texts of a row: what assistive technologies read after the label. */
function stateTextsOf(row: AnyElement): string[] {
return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) =>
texts(e.props.children).map((s) => s.trim()).filter(Boolean),
);
}
describe("OnboardingChecklist", () => {
it("renders the title, the counter 1 / 8 and the hide button", () => {
const { element, onHide } = render();
const text = texts(element);
expect(text).toContain("t(onboarding.checklist.title)");
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])");
const [hide] = find(element, (e) => e.type === "button");
expect(hide.props.type).toBe("button");
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
(hide.props.onClick as () => void)();
expect(onHide).toHaveBeenCalledTimes(1);
});
it("renders eight rows, in order, with their i18n labels (#425)", () => {
const { element } = render();
const rows = find(element, (e) => e.type === "li");
const order = [
"profile",
"importFolder",
"source",
"import",
"categories",
"manualCategory",
"budget",
"reports",
];
expect(rows.map((r) => r.key)).toEqual(order);
const text = texts(element);
for (const key of order) {
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
}
});
it("links every undone row to its `to`, and never a done row", () => {
const { element, checklist } = render();
const links = find(element, (e) => e.type === Link);
const undone = checklist.items.filter((i) => !i.done);
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
const profile = rowOf(element, "profile");
expect(find(profile, (e) => e.type === Link)).toHaveLength(0);
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
});
it("gives the link of a row to do the step's navigation state, guided on the manual category", () => {
const { element } = render();
const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link);
expect(folder.props.state).toEqual({ onboarding: { focus: true } });
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
expect(manual.props.to).toBe("/transactions");
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("marks a row done when its counter is reached", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
});
it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => {
const { element } = render({}, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(texts(budget)).toContain("t(onboarding.checklist.lockedBudget)");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
// The license card carries no step card: nothing to focus there.
expect(link.props.state).toBeUndefined();
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])");
});
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(0);
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
expect(texts(budget)).not.toContain("t(onboarding.checklist.lockedBudget)");
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
});
});
describe("OnboardingChecklist — states read out (#384, #397)", () => {
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
const { element } = render({}, ZERO, false, ["source"]);
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["t(onboarding.state.done)"]);
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["t(onboarding.state.skipped)"]);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
});
it("reads the budget row as pending while the entitlement is unknown", () => {
const { element } = render({ budgetReady: false }, ZERO, false);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
});
it("puts the state text right after the label, inside the same element", () => {
const { element } = render({}, { ...ZERO, sources: 1 });
const [labelled] = find(
rowOf(element, "source"),
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.items.source)"),
);
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
"t(onboarding.checklist.items.source)",
"t(onboarding.state.done)",
]);
});
});
describe("OnboardingChecklist — skipped rows (#397)", () => {
it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => {
const { element } = render({}, ZERO, true, ["source"]);
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1);
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
const [badge] = find(
source,
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.skippedBadge)"),
);
// The hidden "(skipped)" text already says it: the badge is not read twice.
expect(badge.props["aria-hidden"]).toBe("true");
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
});
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]);
const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link);
// The label is no link: only "Resume" leads to the page, after taking the step back.
expect(sourceLinks).toHaveLength(1);
const [resume] = sourceLinks;
expect(texts(resume.props.children)).toEqual(["t(onboarding.checklist.resume)"]);
expect(resume.props.to).toBe("/import");
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
(resume.props.onClick as () => void)();
expect(onResume).toHaveBeenCalledTimes(1);
expect(onResume).toHaveBeenCalledWith("source");
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
(manual.props.onClick as () => void)();
expect(onResume).toHaveBeenLastCalledWith("manualCategory");
});
it("a row done and skipped shows as done, without 'Resume'", () => {
const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]);
const source = rowOf(element, "source");
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
expect(texts(source)).not.toContain("t(onboarding.checklist.resume)");
});
it("a locked row stays locked even when skipped", () => {
const { element } = render({}, ZERO, false, ["budget"]);
const budget = rowOf(element, "budget");
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
expect(texts(budget)).not.toContain("t(onboarding.checklist.resume)");
const [link] = find(budget, (e) => e.type === Link);
expect(link.props.to).toBe("/settings/users");
});
});
describe("OnboardingChecklist — visit steps (#425)", () => {
it("a visit row to do links to its page with the step's navigation state", () => {
const { element } = render();
const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link);
expect(categories.props.to).toBe("/categories");
expect(categories.props.state).toEqual({ onboarding: { focus: true } });
const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link);
expect(reports.props.to).toBe("/reports");
});
it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => {
const { element } = render({}, ZERO, false, [], false);
for (const key of ["categories", "reports"]) {
const row = rowOf(element, key);
expect(find(row, (e) => e.type === Lock), key).toHaveLength(0);
expect(find(row, (e) => e.type === Circle), key).toHaveLength(1);
expect(find(row, (e) => e.type === Link), key).toHaveLength(0);
expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)");
expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]);
}
// The Budget row keeps its own rule: entitlement known, Free → locked.
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
});
it("the Budget row's pending state follows budgetReady alone", () => {
const { element } = render({ budgetReady: false }, ZERO, true, [], true);
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]);
const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element;
expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]);
});
it("an acknowledged visit row shows as done", () => {
const checklist = computeChecklist(ZERO, {
importFolderSet: false,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: ["reports"],
});
const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total });
const reports = rowOf(element, "reports");
expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1);
expect(find(reports, (e) => e.type === Link)).toHaveLength(0);
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
});
});