test(transactions): replay the hook's real sequence and pin the card's sentence
The reducer tests sent SET_PAGE_RESULT right after the initial state, while the hook sends SET_LOADING then SET_ERROR(null) before each read, and the mount effect's categories, sources and count before or after the first page. Had any of those three actions disarmed the guided fallback, the fallback would never run in the app, and the tests stayed green: a test now reduces both real orders. The page tests now also let the list's filters move, as a later dispatch would: after the fallback the card names every transaction, and an ordinary visit with "Uncategorized" picked by hand names the uncategorized ones. The sentence follows the list, not the navigation state. Review of #399 (PR #405). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
74a6572475
commit
ba71a6421d
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