Simpl-Resultat/src/pages/DashboardPage.render.test.tsx
le king fu 64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
Two visit steps join the getting-started path: "Browse categories and
keywords" (5th) and "Browse the reports" (8th). Nothing in the database
counts them: they are done by "Got it" on their card, stored as
onboarding_acknowledged_<key> in user_preferences (no migration).

- computeChecklist: eight rows; the visit rows are locked until the
  profile's visit steps are initialized, so a finished path does not
  flash a "Next step" before they are marked done.
- initializeVisitSteps: once per profile, after the first successful
  load with the Budget entitlement known; acknowledges both steps when
  every other step is done or skipped, then sets the flag. No
  transaction (D16): idempotent writes, the flag last.
- Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps
  the card latch, so the card says "Done." and offers the next step.
- StepCard: "Got it" before "Skip this step" when onAcknowledge is
  given; OnboardingStepCard passes it for a visit step.
- Banner counter, card rank and end of path wait for
  budgetReady && visitStepsInitialized; the list shows the visit rows
  "pending" until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:20 -04:00

165 lines
6.3 KiB
TypeScript

// DashboardPage reads the shared getting-started path (#396). No jsdom:
// react-dom/server renders in vitest's node environment. The path comes from a
// fake `useOnboarding()`, and the dashboard's data hook and widgets are stubbed
// to markers: what is pinned is the page's branching on the path. Since #397
// the page has no list of its own once transactions exist: the banner of
// AppShell replaced the compact bar (D7).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
let onboarding: OnboardingValue;
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => onboarding }));
vi.mock("../hooks/useDashboard", () => ({
useDashboard: () => ({
state: {
categoryBreakdown: [],
categoryOverTime: { categories: [], data: [] },
cartesSnapshot: null,
period: "year",
customDateFrom: "2026-01-01",
customDateTo: "2026-10-08",
referenceYear: 2026,
referenceMonth: 9,
accountIds: [],
accounts: [],
netWorth: null,
isLoading: false,
error: null,
},
setPeriod: vi.fn(),
setCustomDates: vi.fn(),
setReferencePeriod: vi.fn(),
setAccountIds: vi.fn(),
}),
}));
// Hoisted with the mocks below, which run before the imports.
const { marker, emptyStateProps } = vi.hoisted(() => ({
marker: (name: string) => ({ default: () => <i data-marker={name} /> }),
emptyStateProps: { current: null as Record<string, unknown> | null },
}));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
vi.mock("../components/dashboard/CategoriesV1DiscoveryBanner", () => marker("discovery"));
vi.mock("../components/dashboard/DashboardEmptyState", () => ({
default: (props: Record<string, unknown>) => {
emptyStateProps.current = props;
return <i data-marker="empty-state" />;
},
}));
// The page no longer imports the list; the marker would show a compact bar
// that came back.
vi.mock("../components/dashboard/OnboardingChecklist", () => marker("checklist"));
vi.mock("../components/dashboard/PeriodSelector", () => marker("period"));
vi.mock("../components/dashboard/NetWorthTile", () => marker("net-worth"));
vi.mock("../components/reports/CategoryBarChart", () => marker("bar-chart"));
vi.mock("../components/reports/CategoryOverTimeChart", () => marker("over-time"));
vi.mock("../components/reports/FilterPanel", () => marker("filters"));
vi.mock("../components/reports/cards/KpiCard", () => marker("kpi"));
vi.mock("../components/reports/cards/IncomeExpenseOverlayChart", () => marker("overlay"));
vi.mock("../components/reports/cards/TopMoversList", () => marker("movers"));
vi.mock("../components/reports/cards/BudgetAdherenceCard", () => marker("adherence"));
vi.mock("../components/shared/TransactionDetailModal", () => marker("detail"));
import DashboardPage from "./DashboardPage";
import { computeChecklist } from "../utils/onboardingChecklist";
import type { OnboardingCounters } from "../shared/types";
const counters = (transactions: number): OnboardingCounters => ({
sources: transactions > 0 ? 1 : 0,
imports: transactions > 0 ? 1 : 0,
manualCategorized: 0,
budgetEntries: 0,
transactions,
});
function path(overrides: Partial<OnboardingValue> & { transactions?: number } = {}): OnboardingValue {
const { transactions = 25, ...rest } = overrides;
const c = counters(transactions);
return {
status: "ready",
checklist: computeChecklist(c, {
importFolderSet: true,
budgetAllowed: true,
visitStepsInitialized: true,
acknowledged: c.imports >= 1 ? ["categories", "reports"] : [],
}),
counters: c,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: null,
mode: "banner",
cardPage: null,
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
acknowledge: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...rest,
};
}
const render = () =>
renderToStaticMarkup(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>,
);
beforeEach(() => {
onboarding = path();
emptyStateProps.current = null;
});
describe("DashboardPage on the shared path (#352, #396, #397)", () => {
it("renders only its header while the path first loads", () => {
onboarding = path({ status: "loading", checklist: null, counters: null });
const html = render();
expect(html).toContain("t(dashboard.title)");
expect(html).not.toContain('data-marker="empty-state"');
expect(html).not.toContain('data-marker="filters"');
});
it("shows the welcome card on a profile without any transaction", () => {
onboarding = path({ transactions: 0 });
const html = render();
expect(html).toContain('data-marker="empty-state"');
expect(html).not.toContain('data-marker="filters"');
});
it("wires the welcome card's list to the path: hidden state, 'Hide' and 'Resume'", () => {
onboarding = path({ transactions: 0, hidden: true });
render();
const props = emptyStateProps.current!;
expect(props.checklist).toBe(onboarding.checklist);
expect(props.hidden).toBe(true);
expect(props.budgetReady).toBe(true);
expect(props.onHide).toBe(onboarding.hide);
expect(props.onResume).toBe(onboarding.resume);
});
it.each([
["shown", false],
["hidden", true],
])("has no list of its own on a profile with transactions, path %s: the banner replaced the compact bar (D7)", (_, hidden) => {
onboarding = path({ hidden, mode: hidden ? "none" : "banner" });
const html = render();
expect(html).not.toContain('data-marker="checklist"');
expect(html).not.toContain('data-marker="empty-state"');
expect(html).toContain('data-marker="filters"');
});
it("renders the page without the list when the first load failed", () => {
onboarding = path({ status: "error", checklist: null, counters: null, mode: "none" });
const html = render();
expect(html).not.toContain('data-marker="checklist"');
expect(html).not.toContain('data-marker="empty-state"');
expect(html).toContain('data-marker="filters"');
});
});