Simpl-Resultat/src/components/layout/AppShell.slot.test.tsx
le king fu 1ba3b8bae9 test: move the React stubs of six test files to the shared hook harness
useOnboardingChecklist.wiring gains three pins the old stubs could not hold:
one loader for the provider's life (refresh and the shared value keep their
identity, no re-read), 'Skip' moving the next step on the next render, and a
change of mode alone reaching the consumers. UsersSettingsPage now commits
its effects, so an effect that read the preference goes red on the
assertion rather than on 'Invalid hook call'. BudgetTable.wiring,
BudgetPage.wiring, AppShell.slot and OnboardingStepCard drop their local
slot copies; OnboardingStepCard plays StrictMode through replayEffects and
runs its effect only on a changed dependency, which now pins location.key.

Refs #409

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

129 lines
5.2 KiB
TypeScript

// OnboardingBannerSlot — the list's fold state and the path's wiring (#397, #409).
//
// renderToStaticMarkup runs no click, so React's hooks come from the shared
// harness (src/test/hookHarness.ts): the slot runs as a plain function, and
// its state survives from one call to the next as it would between two
// renders of a mounted component.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { Outlet } from "react-router-dom";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
const ctx = vi.hoisted(() => ({
pathname: "/reports",
onboarding: null as unknown,
}));
vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useLocation: () => ({ pathname: ctx.pathname }),
}));
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => children,
useOnboarding: () => ctx.onboarding,
}));
vi.mock("./Sidebar", () => ({ default: () => null }));
import { hookHarness } from "../../test/hookHarness";
import AppShell, { OnboardingBannerSlot } from "./AppShell";
import OnboardingBanner, { type OnboardingBannerProps } from "../onboarding/OnboardingBanner";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { nextStep } from "../../utils/onboardingPath";
import type { OnboardingValue } from "../../hooks/useOnboardingChecklist";
const harness = hookHarness();
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 onboardingValue(): OnboardingValue {
const counters = { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 };
const checklist = computeChecklist(counters, { importFolderSet: true, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "banner",
cardPage: null,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
};
}
/** One render of the mounted slot: its hooks are read in the same order each time. */
function renderSlot(): OnboardingBannerProps {
const element = harness.render(() => OnboardingBannerSlot() as ReactElement<OnboardingBannerProps>);
expect(element.type).toBe(OnboardingBanner);
return element.props;
}
beforeEach(() => {
harness.clear();
ctx.pathname = "/reports";
ctx.onboarding = onboardingValue();
});
describe("OnboardingBannerSlot (#397)", () => {
it("hands the shared path to the banner, the list folded", () => {
const value = ctx.onboarding as OnboardingValue;
const props = renderSlot();
expect(props.mode).toBe("banner");
expect(props.checklist).toBe(value.checklist);
expect(props.nextStep).toBe(value.nextStep);
expect(props.budgetReady).toBe(true);
expect(props.listOpen).toBe(false);
expect(props.onHide).toBe(value.hide);
expect(props.onResume).toBe(value.resume);
});
it("passes the mode and the entitlement as they come: the banner decides what to show", () => {
ctx.onboarding = { ...onboardingValue(), mode: "none", budgetReady: false };
const props = renderSlot();
expect(props.mode).toBe("none");
expect(props.budgetReady).toBe(false);
});
it("'Show the list' unfolds it, 'Close the list' folds it back", () => {
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(true);
// Another render without a click keeps it unfolded.
expect(renderSlot().listOpen).toBe(true);
renderSlot().onToggleList();
expect(renderSlot().listOpen).toBe(false);
});
it("AppShell mounts one slot per route, so each page starts with the list folded", () => {
ctx.pathname = "/import";
const shell = harness.render(() => AppShell() as ReactNode);
const [slot] = find(shell, (e) => e.type === OnboardingBannerSlot);
expect(slot).toBeDefined();
expect(slot.key).toBe("/import");
ctx.pathname = "/transactions";
const [next] = find(harness.render(() => AppShell() as ReactNode), (e) => e.type === OnboardingBannerSlot);
expect(next.key).toBe("/transactions");
});
it("AppShell puts the slot before the routed page, inside the provider, inside main", () => {
const shell = harness.render(() => AppShell() as ReactNode);
const [main] = find(shell, (e) => e.type === "main");
expect(main).toBeDefined();
const [provider] = find(main.props.children, (e) => typeof e.type === "function");
const children = provider.props.children as ReactNode[];
expect(Array.isArray(children)).toBe(true);
const [first, second] = children as AnyElement[];
expect(first.type).toBe(OnboardingBannerSlot);
expect(second.type).toBe(Outlet);
});
});