Simpl-Resultat/src/pages/TransactionsPage.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

237 lines
9.9 KiB
TypeScript

// TransactionsPage and its getting-started card (#399). No jsdom:
// react-dom/server renders in vitest's node environment. The page runs the
// real `useTransactions` (its reducer and initial state; no effect runs, so
// nothing is read) with the rows a first read would have brought, and the path
// is a fake `useOnboarding()` built from the real checklist. What is pinned is
// the page's wiring: the guided entry's start filters, where the card sits,
// what it says, and which control carries the one highlight.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { TransactionFilters, TransactionRow } from "../shared/types";
// The page's services pull the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({
onboarding: null as unknown,
rows: [] as TransactionRow[],
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
filters: null as Partial<TransactionFilters> | null,
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
vi.mock("../hooks/useTransactions", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
return {
...actual,
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args);
const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters;
return { ...hook, state: { ...hook.state, rows: harness.rows, filters } };
},
};
});
import TransactionsPage from "./TransactionsPage";
import { TRANSACTION_CATEGORY_TARGET } from "../components/transactions/TransactionTable";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep, stepNavState } from "../utils/onboardingPath";
import type { OnboardingCounters } from "../shared/types";
/** Folder, source and import done: the next step is the manual category. */
const IMPORTED: OnboardingCounters = {
sources: 1,
imports: 1,
manualCategorized: 0,
budgetEntries: 0,
transactions: 41,
};
/** A transaction categorized by hand: the step is checked. */
const CATEGORIZED: OnboardingCounters = { ...IMPORTED, manualCategorized: 1 };
/** The path latched on /transactions (card mode), as the provider shares it. */
function path(
counters: OnboardingCounters,
overrides: Partial<OnboardingValue> = {},
budgetAllowed = true,
): OnboardingValue {
// Stopped after the import: the visit steps are acknowledged (rule of the templates, #425).
const checklist = computeChecklist(counters, {
importFolderSet: true,
budgetAllowed,
visitStepsInitialized: true,
acknowledged: ["categories", "reports"],
});
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
visitStepsInitialized: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/transactions",
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,
};
}
const row = (id: number, description: string): TransactionRow => ({
id,
date: "2025-12-18",
description,
amount: -42.5,
category_id: null,
category_name: null,
category_color: null,
source_name: "Exemple Desjardins",
notes: null,
is_manually_categorized: false,
is_split: false,
});
/** What the link of the "categorize by hand" step carries. */
const GUIDED = stepNavState("manualCategory");
const render = (state: unknown = null) =>
renderToStaticMarkup(
<MemoryRouter initialEntries={[{ pathname: "/transactions", state }]}>
<TransactionsPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`;
const count = (html: string, needle: string) => html.split(needle).length - 1;
/**
* The card's sentence: the paragraph that follows its title. The card always
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
*/
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
/** The filter bar's start date, "" without a bound (React writes `value` last). */
const dateFrom = (html: string) =>
html.match(/<input type="date" placeholder="t\(transactions\.filters\.dateFrom\)"[^>]* value="([^"]*)"/)?.[1];
/** The period button shown as active. */
const activePeriod = (html: string) =>
html.match(/<button class="[^"]*bg-\[var\(--primary\)\] text-white">t\(dashboard\.period\.(\w+)\)<\/button>/)?.[1];
beforeEach(() => {
harness.onboarding = path(IMPORTED);
harness.rows = [];
harness.filters = null;
});
describe("TransactionsPage — the guided entry (#399)", () => {
it("arriving by the step's link: the uncategorized transactions, without a date bound", () => {
const html = render(GUIDED);
expect(dateFrom(html)).toBe("");
expect(activePeriod(html)).toBe("all");
expect(html).toContain('value="t(transactions.filters.uncategorized)"');
});
it("an ordinary visit keeps the default filters: since January 1st, every category", () => {
const html = render();
expect(dateFrom(html)).toBe(JANUARY_FIRST);
expect(html).toContain('value="t(transactions.filters.allCategories)"');
expect(html).not.toContain('value="t(transactions.filters.uncategorized)"');
});
it("reads a malformed navigation state as an ordinary visit (CWE-476)", () => {
for (const state of ["guided", { guided: true }, { onboarding: null }, { onboarding: { guided: "yes" } }]) {
expect(dateFrom(render(state)), JSON.stringify(state)).toBe(JANUARY_FIRST);
}
});
});
describe("TransactionsPage — the step card (#399)", () => {
it("renders the card first, before the page's heading, at its place in the path", () => {
const html = render(GUIDED);
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=8])");
expect(html).toContain("t(onboarding.checklist.items.manualCategory)");
});
it("the guided entry: only the uncategorized transactions, the filter and 'Add as keyword' named by their own labels (#430)", () => {
expect(sentence(render(GUIDED))).toBe(
"t(onboarding.card.transactions.uncategorized[filter=t(transactions.filters.uncategorized),addKeyword=t(reports.keyword.addFromTransaction)])",
);
});
it("an ordinary visit: the sentence for every transaction, 'Add as keyword' named by its own label (#430)", () => {
expect(sentence(render())).toBe("t(onboarding.card.transactions.all[addKeyword=t(reports.keyword.addFromTransaction)])");
});
// The sentence follows the list's filter, not the navigation state.
it("the guided entry after its fallback: the sentence for every transaction", () => {
harness.filters = { uncategorizedOnly: false };
expect(sentence(render(GUIDED))).toBe("t(onboarding.card.transactions.all[addKeyword=t(reports.keyword.addFromTransaction)])");
});
it("an ordinary visit with 'Uncategorized' picked by hand: the uncategorized sentence", () => {
harness.filters = { uncategorizedOnly: true };
expect(sentence(render())).toBe(
"t(onboarding.card.transactions.uncategorized[filter=t(transactions.filters.uncategorized),addKeyword=t(reports.keyword.addFromTransaction)])",
);
});
it("the first row's category is the one target", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
const html = render(GUIDED);
expect(count(html, HIGHLIGHT)).toBe(1);
expect(html).toContain(`data-onboarding-target="${TRANSACTION_CATEGORY_TARGET}" ${HIGHLIGHT}`);
const ring = html.indexOf(HIGHLIGHT);
expect(ring).toBeGreaterThan(html.indexOf("IGA 123"));
expect(ring).toBeLessThan(html.indexOf("METRO 456"));
});
it("without the card on screen, no control is highlighted", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/import" },
]) {
harness.onboarding = path(IMPORTED, overrides);
const html = render(GUIDED);
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).toContain("IGA 123");
}
});
it("a transaction categorized by hand: the card stays, says 'Done.' and offers the Budget step", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
harness.onboarding = path(CATEGORIZED);
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(
/<a [^>]*href="\/budget"[^>]*>t\(onboarding\.card\.next\[step=t\(onboarding\.checklist\.items\.budget\)\]\)/,
);
expect(sentence(html)).toBeNull();
expect(html).not.toContain("t(onboarding.card.skip)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("in the Free edition, the step checked ends the path", () => {
harness.onboarding = path(CATEGORIZED, {}, false);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=6,total=7])");
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("t(onboarding.banner.complete)");
expect(html).not.toContain('href="/budget"');
});
});