// 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) => o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`, i18n: { language: "fr" }, }), })); vi.mock("./Sidebar", () => ({ default: () => })); 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( }> page

} />
, ); } 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(" { 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"); }); });