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>
198 lines
8.2 KiB
TypeScript
198 lines
8.2 KiB
TypeScript
// BudgetPage — what the page does with the grid's target (#400, #409).
|
|
//
|
|
// A static render runs no effect, so the grid never reports there. React's
|
|
// hooks come from the shared harness (src/test/hookHarness.ts): the page runs
|
|
// as a plain function, its state survives from one call to the next as
|
|
// between two renders of a mounted component, and its setters keep their
|
|
// identity as React's do. The test plays the grid by calling the callback the
|
|
// page hands it, then reads what the page hands the card and the grid.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
|
|
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
|
const ctx = vi.hoisted(() => ({
|
|
pathname: "/budget",
|
|
onboarding: null as unknown,
|
|
budget: null as unknown,
|
|
}));
|
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-router-dom")>()),
|
|
useLocation: () => ({ pathname: ctx.pathname }),
|
|
}));
|
|
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
|
// The page's modules pull the database: stub the connection.
|
|
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => ctx.onboarding }));
|
|
vi.mock("../hooks/useReportsPeriod", () => ({
|
|
useReportsPeriod: () => ({ accountIds: [], setAccountIds: () => undefined }),
|
|
}));
|
|
vi.mock("../hooks/useBudget", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../hooks/useBudget")>()),
|
|
useBudget: () => ctx.budget,
|
|
}));
|
|
|
|
import { hookHarness } from "../test/hookHarness";
|
|
import BudgetPage from "./BudgetPage";
|
|
import BudgetTable from "../components/budget/BudgetTable";
|
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
|
import { initialBudgetState, type BudgetState } from "../hooks/useBudget";
|
|
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
|
|
import { computeChecklist } from "../utils/onboardingChecklist";
|
|
import { budgetCardModel, nextStep, type BudgetCardModel } from "../utils/onboardingPath";
|
|
import type { BudgetOnboardingTargetKind, OnboardingCounters } from "../shared/types";
|
|
|
|
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)];
|
|
}
|
|
|
|
/** Steps 2 to 5 done: the next step is the budget. */
|
|
const BUDGET_LEFT: OnboardingCounters = {
|
|
sources: 1,
|
|
imports: 1,
|
|
manualCategorized: 1,
|
|
budgetEntries: 0,
|
|
transactions: 41,
|
|
};
|
|
|
|
/** The path latched on /budget (card mode), as the provider shares it. */
|
|
function path(counters: OnboardingCounters, overrides: Partial<OnboardingValue> = {}): OnboardingValue {
|
|
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
|
|
const checklist = computeChecklist(counters, {
|
|
importFolderSet: true,
|
|
budgetAllowed: true,
|
|
visitStepsInitialized: true,
|
|
acknowledged: ["categories", "reports"],
|
|
});
|
|
return {
|
|
status: "ready",
|
|
checklist,
|
|
counters,
|
|
hidden: false,
|
|
budgetReady: true,
|
|
visitStepsInitialized: true,
|
|
nextStep: nextStep(checklist),
|
|
mode: "card",
|
|
cardPage: "/budget",
|
|
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),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** One render of the mounted page: its hooks are read in the same order each time. */
|
|
function render() {
|
|
const page = harness.render(() => BudgetPage() as AnyElement);
|
|
const [card] = find(page, (el) => el.type === OnboardingStepCard);
|
|
const [grid] = find(page, (el) => el.type === BudgetTable);
|
|
return {
|
|
page,
|
|
model: card.props.model as BudgetCardModel,
|
|
highlight: grid.props.onboardingTarget as boolean,
|
|
report: grid.props.onOnboardingTargetChange as (kind: BudgetOnboardingTargetKind | null) => void,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
harness.clear();
|
|
ctx.pathname = "/budget";
|
|
ctx.onboarding = path(BUDGET_LEFT);
|
|
const state: BudgetState = { ...initialBudgetState(), totalInDb: 41 };
|
|
ctx.budget = new Proxy({ state } as Record<string, unknown>, {
|
|
get: (target, key) => (key in target ? target[key as string] : vi.fn()),
|
|
});
|
|
});
|
|
|
|
describe("BudgetPage — the grid's target and the card (#400)", () => {
|
|
it("renders the card as the page's first child, ahead of its heading", () => {
|
|
const { page } = render();
|
|
const children = page.props.children as ReactNode[];
|
|
expect((children[0] as AnyElement).type).toBe(OnboardingStepCard);
|
|
expect(find(children[0], (el) => el.type === "h1")).toHaveLength(0);
|
|
expect(find(children.slice(1), (el) => el.type === "h1")).toHaveLength(1);
|
|
});
|
|
|
|
it("follows what the grid reports: its sentence, and the ring once a target is known", () => {
|
|
const checklist = (ctx.onboarding as OnboardingValue).checklist;
|
|
let view = render();
|
|
expect(view.model).toEqual(budgetCardModel({ target: null }, checklist));
|
|
expect(view.highlight).toBe(false);
|
|
|
|
view.report("expandAll");
|
|
view = render();
|
|
expect(view.model.text?.key).toBe("onboarding.card.budget.collapsed");
|
|
expect(view.highlight).toBe(true);
|
|
|
|
view.report("cell");
|
|
view = render();
|
|
expect(view.model.text?.key).toBe("onboarding.card.budget.flat");
|
|
expect(view.highlight).toBe(true);
|
|
|
|
view.report(null);
|
|
view = render();
|
|
expect(view.model.text).toBeNull();
|
|
expect(view.highlight).toBe(false);
|
|
});
|
|
|
|
it("asks for no ring while the card is not on screen", () => {
|
|
for (const overrides of [
|
|
{ mode: "banner" as const, cardPage: null },
|
|
{ mode: "none" as const, cardPage: null },
|
|
{ mode: "complete" as const, cardPage: null },
|
|
{ mode: "card" as const, cardPage: "/transactions" },
|
|
]) {
|
|
harness.clear();
|
|
ctx.onboarding = path(BUDGET_LEFT, overrides);
|
|
render().report("cell");
|
|
expect(render().highlight, JSON.stringify(overrides)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("asks for no ring once the step is checked: the card says 'Done.'", () => {
|
|
ctx.onboarding = path({ ...BUDGET_LEFT, budgetEntries: 1 });
|
|
render().report("cell");
|
|
const view = render();
|
|
expect(view.model).toEqual({ text: null, done: true, target: null });
|
|
expect(view.highlight).toBe(false);
|
|
});
|
|
|
|
it("hands the grid the same callback at every render, so the grid does not report at every render", () => {
|
|
expect(render().report).toBe(render().report);
|
|
});
|
|
|
|
it("a profile without transactions: the grid's last report does not outlive the grid", () => {
|
|
// Steps 3 to 5 skipped: the budget step is next on a profile without any
|
|
// transaction. The rows can land before the count (both reads queue
|
|
// behind db.ts's lock): the grid mounts, reports "Expand all", then gives
|
|
// way to the empty state (review of #400).
|
|
const counters = { ...BUDGET_LEFT, imports: 0, manualCategorized: 0, transactions: 0 };
|
|
const checklist = computeChecklist(counters, {
|
|
importFolderSet: true,
|
|
budgetAllowed: true,
|
|
skipped: ["source", "import", "manualCategory"],
|
|
visitStepsInitialized: true,
|
|
acknowledged: ["categories", "reports"],
|
|
});
|
|
ctx.onboarding = path(counters, { checklist, nextStep: nextStep(checklist) });
|
|
const budget = ctx.budget as { state: BudgetState };
|
|
budget.state = { ...budget.state, totalInDb: null };
|
|
render().report("expandAll");
|
|
expect(render().model.text?.key).toBe("onboarding.card.budget.collapsed");
|
|
|
|
budget.state = { ...budget.state, totalInDb: 0 };
|
|
const page = harness.render(() => BudgetPage() as AnyElement);
|
|
expect(find(page, (el) => el.type === BudgetTable)).toHaveLength(0);
|
|
const [card] = find(page, (el) => el.type === OnboardingStepCard);
|
|
expect(card.props.model).toEqual({ text: null, done: false, target: null });
|
|
});
|
|
});
|