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