docs(onboarding): user guide, architecture and QA checklist for the guided getting-started path #407
2 changed files with 50 additions and 3 deletions
|
|
@ -16,6 +16,7 @@ import {
|
||||||
initTransactionsState,
|
initTransactionsState,
|
||||||
showsEmptyState,
|
showsEmptyState,
|
||||||
GUIDED_ENTRY_FILTERS,
|
GUIDED_ENTRY_FILTERS,
|
||||||
|
type TransactionsAction,
|
||||||
type TransactionsState,
|
type TransactionsState,
|
||||||
} from "./useTransactions";
|
} from "./useTransactions";
|
||||||
import type { TransactionPageResult } from "../shared/types";
|
import type { TransactionPageResult } from "../shared/types";
|
||||||
|
|
@ -170,6 +171,32 @@ describe("transactionsReducer — the guided entry's fallback (#399)", () => {
|
||||||
expect(after.guidedFallback).toBe(false);
|
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", () => {
|
it("falls back once: an empty page after the fallback keeps the filters, so nothing reads again", () => {
|
||||||
const fellBack = result(guided(), 0);
|
const fellBack = result(guided(), 0);
|
||||||
const again = result(fellBack, 0);
|
const again = result(fellBack, 0);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
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.
|
// The page's services pull the database: stub the connection.
|
||||||
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
|
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 }));
|
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("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||||
|
|
@ -30,7 +35,8 @@ vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||||
...actual,
|
...actual,
|
||||||
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
|
useTransactions: (...args: Parameters<typeof actual.useTransactions>) => {
|
||||||
const hook = actual.useTransactions(...args);
|
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(() => {
|
beforeEach(() => {
|
||||||
harness.onboarding = path(IMPORTED);
|
harness.onboarding = path(IMPORTED);
|
||||||
harness.rows = [];
|
harness.rows = [];
|
||||||
|
harness.filters = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TransactionsPage — the guided entry (#399)", () => {
|
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[])");
|
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", () => {
|
it("the first row's category is the one target", () => {
|
||||||
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
harness.rows = [row(1, "IGA 123"), row(2, "METRO 456")];
|
||||||
const html = render(GUIDED);
|
const html = render(GUIDED);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue