Simpl-Resultat/src/components/layout/AppShell.render.test.tsx
le king fu 2386486762 feat(onboarding): the Budget page's step card and its highlight
The budget grid opens collapsed and only its leaf rows take an amount,
so the card first points at "Expand all", then at the current month's
cell of the first editable row shown (D12). `budgetOnboardingTarget`
(pure, beside budgetTableResults.ts) decides it from the rows each
section shows, in the order it shows them; the collapse state stays
BudgetTable's own and the card never unfolds the grid.

BudgetTable rings that one control when the page asks
(`onboardingTarget`) and reports its kind through
`onOnboardingTargetChange`, in an effect, at mount and on change only:
the page needs it for the card's sentence before it asks for the ring.

`budgetCardModel(input, checklist)` gives the "collapsed" sentence for
"Expand all", the "flat" one for a cell, no sentence and no ring before
the grid says, and "Done." once the budget step is checked, which the
card needs to end the path. CARD_PAGES gets /budget. BudgetPage renders
the card first, outside the loading dim the grid gets after each save,
and asks for the ring only while the card shows. The Free edition never
mounts the page and never latches it: its path ends at 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:29 -04:00

227 lines
9.2 KiB
TypeScript

// AppShell and the getting-started banner (#397). No jsdom: react-dom/server
// renders in vitest's node environment. The path comes from a fake
// `useOnboarding()` built like the provider builds it: a loaded state, the
// real `computeChecklist`, and the provider's own pure derivation
// (`deriveOnboardingView`, with the real `CARD_PAGES`) at the rendered route.
// What is pinned is where the banner shows, and where it sits.
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter, Route, Routes } from "react-router-dom";
// Importing the hook module pulls the onboarding service (→ db → tauri
// plugins) and useEntitlement (→ LicenseContext): stub both.
vi.mock("../../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
vi.mock("./Sidebar", () => ({ default: () => <nav>sidebar</nav> }));
let onboarding: OnboardingValue;
vi.mock("../../contexts/OnboardingContext", () => ({
OnboardingProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useOnboarding: () => onboarding,
}));
import AppShell from "./AppShell";
import {
deriveOnboardingView,
initialOnboardingChecklistState,
onboardingChecklistReducer,
type OnboardingChecklistState,
type OnboardingValue,
} from "../../hooks/useOnboardingChecklist";
import { computeChecklist } from "../../utils/onboardingChecklist";
import { CARD_PAGES } from "../../utils/onboardingPath";
import type { OnboardingSnapshot } from "../../services/onboardingService";
const NEW_PROFILE: OnboardingSnapshot = {
counters: { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
importFolderSet: false,
hidden: false,
skipped: [],
};
/** Folder, source and import done: the next step is the manual category, on /transactions. */
const IMPORTED: OnboardingSnapshot = {
...NEW_PROFILE,
counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 40 },
importFolderSet: true,
};
/** Everything but the budget: the next step is on /budget. */
const BUDGET_LEFT: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1 },
};
const ALL_DONE: OnboardingSnapshot = {
...IMPORTED,
counters: { ...IMPORTED.counters, manualCategorized: 1, budgetEntries: 1 },
};
/** The provider's state after a first successful load of `snapshot`. */
const loaded = (snapshot: OnboardingSnapshot): OnboardingChecklistState =>
[
{ type: "LOAD_START", seq: 1 } as const,
{ type: "LOAD_DONE", seq: 1, snapshot } as const,
].reduce(onboardingChecklistReducer, initialOnboardingChecklistState);
/** What `useOnboarding()` gives at `pathname` for `state`, derived as the provider does. */
function pathAt(
pathname: string,
state: OnboardingChecklistState,
budgetReady = true,
budgetAllowed = true,
): OnboardingValue {
const checklist = state.counters
? computeChecklist(state.counters, {
importFolderSet: state.importFolderSet,
budgetAllowed,
skipped: state.skipped,
})
: null;
const { nextStep, cardPage, mode } = deriveOnboardingView({ state, checklist, pathname, budgetReady });
return {
status: state.status,
checklist,
counters: state.counters,
hidden: state.hidden,
budgetReady,
nextStep,
mode,
cardPage,
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),
};
}
function renderWith(pathname: string, value: OnboardingValue): string {
onboarding = value;
return renderToStaticMarkup(
<MemoryRouter initialEntries={[pathname]}>
<Routes>
<Route element={<AppShell />}>
<Route path="*" element={<p data-page="">page</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
const renderAt = (pathname: string, state: OnboardingChecklistState, budgetReady = true) =>
renderWith(pathname, pathAt(pathname, state, budgetReady));
const BANNER = 'aria-label="t(onboarding.banner.label)"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
describe("AppShell — the getting-started banner (#397)", () => {
it("puts the banner on top of the routed page, inside main", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(count(html, BANNER)).toBe(1);
const main = html.indexOf("<main");
const banner = html.indexOf(BANNER);
expect(main).toBeGreaterThan(-1);
expect(banner).toBeGreaterThan(main);
expect(html.indexOf("data-page")).toBeGreaterThan(banner);
});
it("shows nothing before the first load", () => {
const html = renderAt("/import", initialOnboardingChecklistState);
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing when the path is hidden", () => {
const html = renderAt("/import", loaded({ ...IMPORTED, hidden: true }));
expect(html).not.toContain(BANNER);
expect(html).toContain("data-page");
});
it("shows nothing on the dashboard of a profile without transactions (D11), but on its other pages", () => {
expect(renderAt("/", loaded(NEW_PROFILE))).not.toContain(BANNER);
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain(BANNER);
expect(renderAt("/settings/users", loaded(NEW_PROFILE))).toContain(BANNER);
});
it("shows the banner on a dashboard with transactions, and on any other page", () => {
for (const pathname of ["/", "/reports", "/categories", "/settings/users"]) {
expect(renderAt(pathname, loaded(IMPORTED)), pathname).toContain(BANNER);
}
});
it("gives /import, the page of the next step, its step card instead of the banner (#398)", () => {
expect(CARD_PAGES.has("/import")).toBe(true);
const value = pathAt("/import", loaded(NEW_PROFILE));
expect(value.nextStep?.to).toBe("/import");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/import");
expect(renderWith("/import", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /import.
expect(renderAt("/reports", loaded(NEW_PROFILE))).toContain('href="/import"');
});
it("gives /transactions, the page of the next step, its step card instead of the banner (#399)", () => {
expect(CARD_PAGES.has("/transactions")).toBe(true);
const value = pathAt("/transactions", loaded(IMPORTED));
expect(value.nextStep?.to).toBe("/transactions");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/transactions");
expect(renderWith("/transactions", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /transactions.
expect(renderAt("/reports", loaded(IMPORTED))).toContain('href="/transactions"');
});
it("gives /budget, the page of the next step, its step card instead of the banner (#400)", () => {
expect(CARD_PAGES.has("/budget")).toBe(true);
const value = pathAt("/budget", loaded(BUDGET_LEFT));
expect(value.nextStep?.to).toBe("/budget");
expect(value.mode).toBe("card");
expect(value.cardPage).toBe("/budget");
expect(renderWith("/budget", value)).not.toContain(BANNER);
// Anywhere else, the same profile keeps the banner and its link to /budget.
expect(renderAt("/reports", loaded(BUDGET_LEFT))).toContain('href="/budget"');
});
it("in the Free edition, /budget never gets a card and the path ends at 5/5 (#400)", () => {
const value = pathAt("/budget", loaded(BUDGET_LEFT), true, false);
expect(value.checklist?.total).toBe(5);
expect(value.nextStep).toBeNull();
expect(value.cardPage).toBeNull();
expect(value.mode).toBe("complete");
const html = renderWith("/budget", value);
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
it("shows nothing on a page that carries a step card", () => {
const value = { ...pathAt("/import", loaded(NEW_PROFILE)), mode: "card" as const, cardPage: "/import" };
expect(renderWith("/import", value)).not.toContain(BANNER);
});
it("shows the end message once every step is done (D4)", () => {
const html = renderAt("/reports", loaded(ALL_DONE));
expect(html).toContain(BANNER);
expect(html).toContain("t(onboarding.banner.complete)");
});
it("waits for the Budget entitlement before showing the counter", () => {
const waiting = renderAt("/reports", loaded(IMPORTED), false);
expect(waiting).toContain(BANNER);
expect(waiting).not.toContain("onboarding.banner.progress");
expect(renderAt("/reports", loaded(IMPORTED))).toContain(
"t(onboarding.banner.progress[done=4,total=6])",
);
});
it("starts with the list folded", () => {
const html = renderAt("/reports", loaded(IMPORTED));
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain("onboarding.checklist.title");
});
});