The 15 test files whose fake t returned the bare key (the 12 of the issue, plus the three category render tests added by #423) now mock react-i18next with i18nMock. Assertions naming a displayed text are rewritten in the marked form, t(key[options]), so a text that skipped t fails them. Components without such an assertion get one: PriceFetchControl, PriceFetchConsentToggle (rendered through the hook harness, its loading slot preset), CategoryDetailPanel, SourceList and SourceCard, and the region label of OnboardingChecklist. Each component was proved by a mutation removing one t call: every one turns at least one test red (exit 1). Refs #389 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
// DocsSubsections — unit tests (Issue #355).
|
|
//
|
|
// Same approach as FilterPanel.test.tsx: no jsdom in this project, and the
|
|
// component's only hook is `useTranslation` (mocked by `i18nMock` over a
|
|
// fixture), so it is invoked directly as a function and its React-element
|
|
// tree is walked without a renderer.
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
// Hoisted: the factory of `vi.mock` below reads it when the module loads.
|
|
const FIXTURE = vi.hoisted((): Record<string, unknown> => ({
|
|
"docs.import.subsections": [
|
|
{
|
|
id: "import-export",
|
|
title: "Export a CSV from your bank",
|
|
steps: ["One subfolder per account", "Pick the widest range"],
|
|
note: "Trailing note",
|
|
},
|
|
{
|
|
id: "import-bnc",
|
|
title: "National Bank",
|
|
status: "No public procedure found",
|
|
steps: ["Expected header"],
|
|
},
|
|
],
|
|
}));
|
|
|
|
// The fixture stands for what the locale declares; every other key goes
|
|
// through the marked `t` (#389), as i18next returns a string for a missing key.
|
|
vi.mock("react-i18next", async () =>
|
|
(await import("../../test/i18nMock")).i18nMock({ fixture: FIXTURE }),
|
|
);
|
|
|
|
import DocsSubsections, { readSubsections } from "./DocsSubsections";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal React-element tree walker (no renderer/DOM involved)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ElementNode {
|
|
type: unknown;
|
|
props: Record<string, unknown> & { children?: unknown };
|
|
}
|
|
|
|
type FlatNode = ElementNode | string | number;
|
|
|
|
function isElementNode(x: unknown): x is ElementNode {
|
|
return typeof x === "object" && x !== null && "props" in x;
|
|
}
|
|
|
|
function flatten(node: unknown): FlatNode[] {
|
|
if (Array.isArray(node)) return node.flatMap(flatten);
|
|
if (node === null || node === undefined || typeof node === "boolean") return [];
|
|
if (typeof node === "string" || typeof node === "number") return [node];
|
|
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
|
|
return [];
|
|
}
|
|
|
|
function elementsOfType(root: unknown, type: string): ElementNode[] {
|
|
return flatten(root).filter(
|
|
(n): n is ElementNode => isElementNode(n) && n.type === type,
|
|
);
|
|
}
|
|
|
|
function textOf(node: ElementNode): string {
|
|
return flatten(node.props.children)
|
|
.filter((n): n is string => typeof n === "string")
|
|
.join("");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("DocsSubsections", () => {
|
|
it("renders nothing for a section without subsections", () => {
|
|
const tree = DocsSubsections({
|
|
sectionKey: "budget",
|
|
registerRef: () => {},
|
|
});
|
|
expect(tree).toBeNull();
|
|
});
|
|
|
|
it("renders one block per entry with its id and registers each element", () => {
|
|
const registered: string[] = [];
|
|
const tree = DocsSubsections({
|
|
sectionKey: "import",
|
|
registerRef: (id) => registered.push(id),
|
|
});
|
|
|
|
const blocks = flatten(tree).filter(
|
|
(n): n is ElementNode =>
|
|
isElementNode(n) && typeof n.props.id === "string",
|
|
);
|
|
expect(blocks.map((b) => b.props.id)).toEqual([
|
|
"import-export",
|
|
"import-bnc",
|
|
]);
|
|
|
|
// The ref callbacks are what DocsContent uses for hash navigation.
|
|
for (const block of blocks) {
|
|
(block.props.ref as (el: HTMLElement | null) => void)(null);
|
|
}
|
|
expect(registered).toEqual(["import-export", "import-bnc"]);
|
|
|
|
const titles = elementsOfType(tree, "h4").map(textOf);
|
|
expect(titles).toEqual(["Export a CSV from your bank", "National Bank"]);
|
|
|
|
const steps = elementsOfType(tree, "li").map(textOf);
|
|
expect(steps).toEqual([
|
|
"One subfolder per account",
|
|
"Pick the widest range",
|
|
"Expected header",
|
|
]);
|
|
});
|
|
|
|
it("renders the status line in italic only when present, and the note only when present", () => {
|
|
const tree = DocsSubsections({
|
|
sectionKey: "import",
|
|
registerRef: () => {},
|
|
});
|
|
const paragraphs = elementsOfType(tree, "p");
|
|
const italics = paragraphs.filter((p) =>
|
|
String(p.props.className).includes("italic"),
|
|
);
|
|
expect(italics.map(textOf)).toEqual(["No public procedure found"]);
|
|
|
|
const notes = paragraphs.filter(
|
|
(p) => !String(p.props.className).includes("italic"),
|
|
);
|
|
expect(notes.map(textOf)).toEqual(["Trailing note"]);
|
|
});
|
|
});
|
|
|
|
describe("readSubsections", () => {
|
|
it("returns an empty array for a missing key (i18next echoes the key)", () => {
|
|
expect(readSubsections("docs.budget.subsections")).toEqual([]);
|
|
});
|
|
|
|
it("drops malformed entries", () => {
|
|
expect(
|
|
readSubsections([
|
|
{ id: "ok", title: "Ok", steps: [] },
|
|
{ id: "no-steps", title: "Missing steps" },
|
|
"garbage",
|
|
null,
|
|
]),
|
|
).toEqual([{ id: "ok", title: "Ok", steps: [] }]);
|
|
});
|
|
});
|