From ba71a6421d662bd3f9bf283c89291e778cc37e58 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 21:05:30 -0400 Subject: [PATCH] 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 --- src/hooks/useTransactions.test.ts | 27 ++++++++++++++++++++++ src/pages/TransactionsPage.render.test.tsx | 26 ++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/hooks/useTransactions.test.ts b/src/hooks/useTransactions.test.ts index 312bad4..8f6df11 100644 --- a/src/hooks/useTransactions.test.ts +++ b/src/hooks/useTransactions.test.ts @@ -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); diff --git a/src/pages/TransactionsPage.render.test.tsx b/src/pages/TransactionsPage.render.test.tsx index 1ad3fde..04ba739 100644 --- a/src/pages/TransactionsPage.render.test.tsx +++ b/src/pages/TransactionsPage.render.test.tsx @@ -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 | 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) => { 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);