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>
208 lines
8.8 KiB
TypeScript
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("");
|
|
});
|
|
});
|