docs(onboarding): user guide, architecture and QA checklist for the guided getting-started path #407

Merged
maximus merged 11 commits from issue-401-onboarding-guide-qa into main 2026-10-09 01:11:03 +00:00
2 changed files with 50 additions and 3 deletions
Showing only changes of commit ba71a6421d - Show all commits

View file

@ -16,6 +16,7 @@ import {
initTransactionsState,
showsEmptyState,
GUIDED_ENTRY_FILTERS,
type TransactionsAction,
type TransactionsState,
} from "./useTransactions";
import type { TransactionPageResult } from "../shared/types";
@ -170,6 +171,32 @@ describe("transactionsReducer — the guided entry's fallback (#399)", () => {
expect(after.guidedFallback).toBe(false);
});
// The actions the hook really sends around the first page: `fetchData` sends
// SET_LOADING then SET_ERROR(null) before each read, and the mount effect's
// categories, sources and count land before or after that page. None of
// them may disarm the fallback (review of #399).
it.each([
["the mount reads land first", ["mount", "fetch", "page"]],
["the first page lands first", ["fetch", "page", "mount"]],
] as const)("the hook's real sequence still falls back (%s)", (_name, order) => {
const sent: Record<(typeof order)[number], TransactionsAction[]> = {
fetch: [
{ type: "SET_LOADING", payload: true },
{ type: "SET_ERROR", payload: null },
],
mount: [
{ type: "SET_CATEGORIES", payload: [] },
{ type: "SET_SOURCES", payload: [] },
{ type: "SET_TOTAL_IN_DB", payload: 12 },
],
page: [{ type: "SET_PAGE_RESULT", payload: page(0) }],
};
const after = order.flatMap((group) => sent[group]).reduce(transactionsReducer, guided());
expect(after.filters.uncategorizedOnly).toBe(false);
expect(after.filters.dateFrom).toBeNull();
expect(after.isLoading).toBe(true);
});
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
const fellBack = result(guided(), 0);
const again = result(fellBack, 0);

View file

@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { TransactionRow } from "../shared/types";
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() }));
@ -21,7 +21,12 @@ vi.mock("react-i18next", () => ({
}));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
const harness = vi.hoisted(() => ({ onboarding: null as unknown, rows: [] as TransactionRow[] }));
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) => {
@ -30,7 +35,8 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
...actual,
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
const hook = actual.useTransactions(...args);
return { ...hook, state: { ...hook.state, rows: harness.rows } };
const filters = harness.filters ? { ...hook.state.filters, ...harness.filters } : hook.state.filters;
return { ...hook, state: { ...hook.state, rows: harness.rows, filters } };
},
};
});
@ -122,6 +128,7 @@ const activePeriod = (html: string) =>
beforeEach(() => {
harness.onboarding = path(IMPORTED);
harness.rows = [];
harness.filters = null;
});
describe("TransactionsPage — the guided entry (#399)", () => {
@ -165,6 +172,19 @@ describe("TransactionsPage — the step card (#399)", () => {
expect(sentence(render())).toBe("t(onboarding.card.transactions.all[])");
});
// 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[])");
});
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)])",
);
});
it("the first row's category is the one target", () => {
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
const html = render(GUIDED);