Simpl-Resultat/src/components/onboarding/OnboardingBanner.render.test.tsx
le king fu 063f6c3d7d test(i18n): replace the local marked fake t copies with i18nMock
The 12 test files that already marked their output each carried a copy
of the same fake t. They now use the shared i18nMock; no assertion
changes. LicenseCard keeps its per-test language through a getter.

Refs #389

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:56:51 -04:00

208 lines
8.8 KiB
TypeScript

// OnboardingBanner — render tests (#397). No jsdom: react-dom/server renders
// in vitest's node environment for the markup (region, aria-expanded, hrefs),
// and the component is called as a function for what the markup does not
// carry (a link's navigation state, the handlers).
import { describe, it, expect, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Link, MemoryRouter } from "react-router-dom";
// The shared fake `t` (src/test/i18nMock.ts) marks what it translates, `t(key[options])`: a string that
// skipped `t` would show unmarked.
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
import OnboardingBanner, {
GETTING_STARTED_GUIDE,
ONBOARDING_BANNER_LIST_ID,
type OnboardingBannerProps,
} from "./OnboardingBanner";
import OnboardingChecklist from "../dashboard/OnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { 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);
}
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** Profile, folder and source done: 3/6, the next step is "import" on /import. */
const THREE_DONE: OnboardingCounters = { ...ZERO, sources: 1 };
/** Up to the import: the next step is "manualCategory" on /transactions. */
const FOUR_DONE: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 40 };
const ALL_DONE: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 1,
budgetEntries: 1,
transactions: 40,
};
function bannerProps(
overrides: Partial<OnboardingBannerProps> = {},
counters: OnboardingCounters = THREE_DONE,
): OnboardingBannerProps {
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
mode: checklist.complete ? "complete" : "banner",
checklist,
nextStep: nextStep(checklist),
budgetReady: true,
listOpen: false,
onToggleList: vi.fn(),
onHide: vi.fn(),
onResume: vi.fn(),
...overrides,
};
}
const markup = (props: OnboardingBannerProps) =>
renderToStaticMarkup(
<MemoryRouter>
<OnboardingBanner {...props} />
</MemoryRouter>,
);
const tree = (props: OnboardingBannerProps) => OnboardingBanner(props) as ReactNode;
const buttonNamed = (node: ReactNode, key: string) => {
const [button] = find(node, (e) => e.type === "button" && texts(e.props.children).includes(`t(${key})`));
expect(button, `button ${key}`).toBeDefined();
return button;
};
describe("OnboardingBanner — banner mode (#397)", () => {
it("is a region named for the path, with the counter, the next step and its link", () => {
const html = markup(bannerProps());
expect(html).toContain('role="region"');
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.progress[done=3,total=6])");
expect(html).toContain("t(onboarding.banner.next)");
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(onboarding\.checklist\.items\.import\)/);
expect(html).toContain("t(onboarding.checklist.hide)");
});
it("links the next step to its page with the step's navigation state", () => {
const [toImport] = find(tree(bannerProps()), (e) => e.type === Link);
expect(toImport.props.to).toBe("/import");
expect(toImport.props.state).toEqual({ onboarding: { focus: true } });
// The manual category opens Transactions in its guided entry too.
const [toTransactions] = find(tree(bannerProps({}, FOUR_DONE)), (e) => e.type === Link);
expect(toTransactions.props.to).toBe("/transactions");
expect(toTransactions.props.state).toEqual({ onboarding: { focus: true, guided: true } });
});
it("the counter waits for the Budget entitlement, the next step does not", () => {
const html = markup(bannerProps({ budgetReady: false }));
expect(html).not.toContain("onboarding.banner.progress");
expect(html).toMatch(/href="\/import"/);
});
it("folded: 'Show the list' with aria-expanded false, and no list", () => {
const html = markup(bannerProps());
expect(html).toMatch(
new RegExp(`<button [^>]*aria-expanded="false"[^>]*aria-controls="${ONBOARDING_BANNER_LIST_ID}"[^>]*>t\\(onboarding\\.banner\\.showList\\)`),
);
expect(html).toContain(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(html).not.toContain("onboarding.checklist.title");
expect(html).not.toContain("onboarding.banner.hideList");
});
it("unfolded: 'Close the list' with aria-expanded true, and the detailed list in place", () => {
const html = markup(bannerProps({ listOpen: true }));
expect(html).toMatch(/<button [^>]*aria-expanded="true"[^>]*>t\(onboarding\.banner\.hideList\)/);
expect(html).not.toContain("onboarding.banner.showList");
const container = html.indexOf(`id="${ONBOARDING_BANNER_LIST_ID}"`);
expect(container).toBeGreaterThan(-1);
expect(html.indexOf("t(onboarding.checklist.title)")).toBeGreaterThan(container);
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
expect(html).toContain(`t(onboarding.checklist.items.${key})`);
}
});
it("the unfolded list is the detailed list, wired to the path", () => {
const props = bannerProps({ listOpen: true });
const [list] = find(tree(props), (e) => e.type === OnboardingChecklist);
expect(list.props.items).toBe(props.checklist!.items);
expect(list.props.done).toBe(3);
expect(list.props.total).toBe(6);
expect(list.props.budgetReady).toBe(true);
expect(list.props.onHide).toBe(props.onHide);
expect(list.props.onResume).toBe(props.onResume);
expect(find(tree(bannerProps()), (e) => e.type === OnboardingChecklist)).toHaveLength(0);
});
it("'Show the list' toggles the list and 'Hide' hides the path", () => {
const props = bannerProps();
const node = tree(props);
(buttonNamed(node, "onboarding.banner.showList").props.onClick as () => void)();
expect(props.onToggleList).toHaveBeenCalledTimes(1);
expect(props.onHide).not.toHaveBeenCalled();
(buttonNamed(node, "onboarding.checklist.hide").props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("sits in the page flow, never fixed over the page (D1)", () => {
const root = tree(bannerProps()) as AnyElement;
expect(root.props.role).toBe("region");
expect(String(root.props.className)).not.toMatch(/\b(fixed|sticky|absolute)\b/);
});
});
describe("OnboardingBanner — end of the path (D4)", () => {
it("says the path is complete, opens the guide's getting-started section and offers 'Close'", () => {
const props = bannerProps({}, ALL_DONE);
expect(props.mode).toBe("complete");
const html = markup(props);
expect(html).toContain('aria-label="t(onboarding.banner.label)"');
expect(html).toContain("t(onboarding.banner.complete)");
expect(GETTING_STARTED_GUIDE).toBe("/settings/users#gettingStarted");
expect(html).toMatch(/<a [^>]*href="\/settings\/users#gettingStarted"[^>]*>t\(onboarding\.banner\.openGuide\)/);
expect(html).toContain("t(common.close)");
expect(html).not.toContain("onboarding.banner.progress");
expect(html).not.toContain("onboarding.banner.next");
expect(html).not.toContain("aria-expanded");
expect(html).not.toContain("onboarding.checklist.hide");
});
it("'Close' hides the path", () => {
const props = bannerProps({}, ALL_DONE);
(buttonNamed(tree(props), "common.close").props.onClick as () => void)();
expect(props.onHide).toHaveBeenCalledTimes(1);
});
it("shows no list at the end, even if it was unfolded", () => {
const html = markup(bannerProps({ listOpen: true }, ALL_DONE));
expect(html).not.toContain("onboarding.checklist.title");
});
});
describe("OnboardingBanner — nothing to show", () => {
it("renders nothing in mode none, nor on a page that carries a step card", () => {
expect(markup(bannerProps({ mode: "none" }))).toBe("");
expect(markup(bannerProps({ mode: "card" }))).toBe("");
});
it("renders nothing without a checklist", () => {
expect(markup(bannerProps({ checklist: null, nextStep: null }))).toBe("");
});
});