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>
183 lines
7.7 KiB
TypeScript
183 lines
7.7 KiB
TypeScript
// ImportEmptyState — unit tests (#356).
|
|
//
|
|
// 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. The inner `EmptyState` is
|
|
// expanded (called as a function too) so its buttons and links are visible.
|
|
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", () => ({
|
|
FolderOpen: () => null,
|
|
Inbox: () => null,
|
|
}));
|
|
|
|
import { FolderOpen, Inbox } from "lucide-react";
|
|
import ImportEmptyState, {
|
|
CSV_EXPORT_GUIDE_PATH,
|
|
folderNameFromPath,
|
|
type ImportEmptyStateProps,
|
|
} from "./ImportEmptyState";
|
|
|
|
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, FolderOpen, Inbox]);
|
|
|
|
/** Resolves the inner `EmptyState` component so its buttons and links 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));
|
|
}
|
|
return { ...el, props: { ...el.props, children: expand(el.props.children) } } as ReactNode;
|
|
}
|
|
|
|
type ButtonEl = ReactElement<{ type: string; disabled: boolean; onClick: () => void; children?: ReactNode }>;
|
|
type LinkEl = ReactElement<{ to: string; children?: ReactNode }>;
|
|
|
|
function render(overrides: Partial<ImportEmptyStateProps> = {}) {
|
|
const props: ImportEmptyStateProps = {
|
|
hasFolder: false,
|
|
folderName: null,
|
|
onBrowse: vi.fn(),
|
|
onCreateSample: vi.fn(),
|
|
onRefresh: vi.fn(),
|
|
isCreatingSample: false,
|
|
...overrides,
|
|
};
|
|
const tree = expand(ImportEmptyState(props));
|
|
const buttons = find(tree, (e) => e.type === "button") as ButtonEl[];
|
|
const links = find(tree, (e) => e.type === Link) as LinkEl[];
|
|
return { props, tree, buttons, links };
|
|
}
|
|
|
|
describe("ImportEmptyState — no folder (#356)", () => {
|
|
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
|
|
const { props, tree, buttons, links } = render({ hasFolder: false });
|
|
|
|
expect(texts(tree)).toContain("t(import.empty.noFolder.title)");
|
|
expect(texts(tree)).toContain("t(import.empty.noFolder.description)");
|
|
|
|
expect(buttons).toHaveLength(1);
|
|
expect(texts(buttons[0].props.children)).toContain("t(import.empty.noFolder.cta)");
|
|
expect(buttons[0].props.disabled).toBe(false);
|
|
buttons[0].props.onClick();
|
|
expect(props.onBrowse).toHaveBeenCalledTimes(1);
|
|
expect(props.onCreateSample).not.toHaveBeenCalled();
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
|
|
expect(links[0].props.to).toBe("/settings/users#import-export");
|
|
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
|
|
});
|
|
|
|
it("never shows the sample or refresh actions without a folder", () => {
|
|
const { tree } = render({ hasFolder: false });
|
|
expect(texts(tree)).not.toContain("t(import.empty.createSample)");
|
|
expect(texts(tree)).not.toContain("t(import.empty.refresh)");
|
|
});
|
|
});
|
|
|
|
describe("ImportEmptyState — folder without sources (#356)", () => {
|
|
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
|
|
const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
|
|
|
|
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
|
|
|
|
expect(buttons).toHaveLength(2);
|
|
expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)");
|
|
expect(buttons[0].props.disabled).toBe(false);
|
|
buttons[0].props.onClick();
|
|
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
|
|
|
|
expect(texts(buttons[1].props.children)).toContain("t(import.empty.refresh)");
|
|
buttons[1].props.onClick();
|
|
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
|
expect(props.onBrowse).not.toHaveBeenCalled();
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].props.to).toBe("/settings/users#import-export");
|
|
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
|
|
});
|
|
|
|
it("disables the create button while the sample folders are being written", () => {
|
|
const { buttons } = render({ hasFolder: true, folderName: "Relevés", isCreatingSample: true });
|
|
expect(buttons[0].props.disabled).toBe(true);
|
|
// Refresh stays available: it is not the action in flight.
|
|
expect(buttons[1].props.disabled).toBe(false);
|
|
});
|
|
|
|
it("names the folder in the description, never the full path", () => {
|
|
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
|
|
const { tree } = render({ hasFolder: true, folderName });
|
|
const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description"));
|
|
|
|
expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])");
|
|
expect(description).not.toContain("Users");
|
|
});
|
|
});
|
|
|
|
describe("ImportEmptyState — getting-started target (#398)", () => {
|
|
const highlighted = (tree: ReactNode) =>
|
|
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
|
|
|
|
it("highlights 'Choose a folder' without a folder, and only it", () => {
|
|
const { tree } = render({ hasFolder: false, onboardingTarget: true });
|
|
const targets = highlighted(tree);
|
|
expect(targets).toHaveLength(1);
|
|
expect(targets[0].type).toBe("button");
|
|
expect(texts(targets[0].props.children)).toContain("t(import.empty.noFolder.cta)");
|
|
});
|
|
|
|
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
|
|
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
|
|
const targets = highlighted(tree);
|
|
expect(targets).toHaveLength(1);
|
|
expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)");
|
|
});
|
|
|
|
it("highlights nothing without the property", () => {
|
|
expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0);
|
|
expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
|
|
expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("folderNameFromPath (#356)", () => {
|
|
it("returns the last segment for POSIX and Windows paths", () => {
|
|
expect(folderNameFromPath("/home/max/releves")).toBe("releves");
|
|
expect(folderNameFromPath("C:\\Users\\max\\Relevés")).toBe("Relevés");
|
|
});
|
|
|
|
it("ignores trailing separators", () => {
|
|
expect(folderNameFromPath("/home/max/releves/")).toBe("releves");
|
|
expect(folderNameFromPath("C:\\Users\\max\\Relevés\\")).toBe("Relevés");
|
|
});
|
|
|
|
it("falls back to the path itself when it has no segment", () => {
|
|
expect(folderNameFromPath("/")).toBe("/");
|
|
expect(folderNameFromPath("")).toBe("");
|
|
});
|
|
});
|