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>
237 lines
9.9 KiB
TypeScript
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"');
|
|
});
|
|
});
|