// useTransactions — reducer unit tests (issues #353, #399). // // The project has no jsdom / renderHook harness, so the EXPORTED reducer and // its initial state are exercised directly (same approach as // useSnapshotEditor.test.ts and useBudget.test.ts). What is pinned here: the // profile-wide transaction count (`totalInDb`) is orthogonal to the paging // state — setting it never touches `filters`, `sort` or `page` — and the // 0 → N transition takes the page from the empty state back to the table. // Then the start filters (#399): the guided entry, its single fallback when // no transaction is left uncategorized, and the ordinary visit. import { describe, it, expect } from "vitest"; import { transactionsReducer, initialTransactionsState, initTransactionsState, showsEmptyState, GUIDED_ENTRY_FILTERS, type TransactionsAction, type TransactionsState, } from "./useTransactions"; import type { TransactionPageResult } from "../shared/types"; const populated = (): TransactionsState => transactionsReducer( transactionsReducer( transactionsReducer(initialTransactionsState, { type: "SET_FILTER", payload: { key: "search", value: "epicerie" }, }), { type: "SET_SORT", payload: { column: "amount", direction: "asc" } }, ), { type: "SET_PAGE", payload: 3 }, ); describe("transactionsReducer — SET_TOTAL_IN_DB (issue #353)", () => { it("starts unknown: totalInDb is null, which keeps the regular layout", () => { expect(initialTransactionsState.totalInDb).toBeNull(); expect(showsEmptyState(initialTransactionsState)).toBe(false); }); it("stores the count without touching filters, sort or page", () => { const before = populated(); const after = transactionsReducer(before, { type: "SET_TOTAL_IN_DB", payload: 0 }); expect(after.totalInDb).toBe(0); // Same references, not merely deep-equal: no hidden reset of the paging state. expect(after.filters).toBe(before.filters); expect(after.sort).toBe(before.sort); expect(after.page).toBe(before.page); expect(after.filters.search).toBe("epicerie"); expect(after.sort).toEqual({ column: "amount", direction: "asc" }); expect(after.page).toBe(3); // And nothing else moved either. expect({ ...after, totalInDb: before.totalInDb }).toEqual(before); }); it("0 shows the empty state, then N > 0 renders the table again", () => { const empty = transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0, }); expect(showsEmptyState(empty)).toBe(true); const filled = transactionsReducer(empty, { type: "SET_TOTAL_IN_DB", payload: 42 }); expect(filled.totalInDb).toBe(42); expect(showsEmptyState(filled)).toBe(false); }); it("keeps the regular layout while a page is loading, even at 0", () => { // The loading placeholder stays in place of the table so the card does not // flash during the first fetch of a profile whose count is already known. const loadingEmpty = transactionsReducer( transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 0 }), { type: "SET_LOADING", payload: true }, ); expect(showsEmptyState(loadingEmpty)).toBe(false); }); it("an empty page on a populated profile is NOT the empty state (default filter is 'since Jan 1st')", () => { const state = transactionsReducer( transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 7 }), { type: "SET_PAGE_RESULT", payload: { rows: [], totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, }, ); expect(state.rows).toHaveLength(0); expect(showsEmptyState(state)).toBe(false); }); it("the other actions preserve totalInDb", () => { const known = transactionsReducer(initialTransactionsState, { type: "SET_TOTAL_IN_DB", payload: 5 }); const after = transactionsReducer( transactionsReducer(known, { type: "SET_FILTER", payload: { key: "uncategorizedOnly", value: true } }), { type: "SET_ERROR", payload: "boom" }, ); expect(after.totalInDb).toBe(5); }); }); /** A page of `totalCount` transactions, as `getTransactionPage` returns it. */ const page = (totalCount: number): TransactionPageResult => ({ rows: [], totalCount, totalAmount: 0, incomeTotal: 0, expenseTotal: 0, }); const result = (state: TransactionsState, totalCount: number) => transactionsReducer(state, { type: "SET_PAGE_RESULT", payload: page(totalCount) }); describe("initTransactionsState — the start filters (#399)", () => { const JANUARY_FIRST = `${new Date().getFullYear()}-01-01`; it("an ordinary visit keeps the default filters: since January 1st, every category", () => { for (const state of [initTransactionsState(), initTransactionsState(undefined)]) { expect(state).toEqual(initialTransactionsState); expect(state.filters).toEqual({ search: "", categoryId: null, sourceId: null, dateFrom: JANUARY_FIRST, dateTo: null, uncategorizedOnly: false, }); expect(state.guidedFallback).toBe(false); } }); it("the guided entry starts on the uncategorized transactions, without a date bound", () => { expect(GUIDED_ENTRY_FILTERS).toEqual({ dateFrom: null, uncategorizedOnly: true }); const guided = initTransactionsState(GUIDED_ENTRY_FILTERS); expect(guided.filters).toEqual({ search: "", categoryId: null, sourceId: null, dateFrom: null, dateTo: null, uncategorizedOnly: true, }); expect(guided.guidedFallback).toBe(true); // Nothing else moves: same sort, first page, nothing loaded yet. expect({ ...guided, filters: initialTransactionsState.filters, guidedFallback: false }).toEqual( initialTransactionsState, ); }); it("applies any start filter over the defaults; only 'uncategorized only' arms the fallback", () => { const searched = initTransactionsState({ search: "epicerie" }); expect(searched.filters).toEqual({ ...initialTransactionsState.filters, search: "epicerie" }); expect(searched.guidedFallback).toBe(false); expect(initTransactionsState({ uncategorizedOnly: false }).guidedFallback).toBe(false); }); }); describe("transactionsReducer — the guided entry's fallback (#399)", () => { const guided = () => initTransactionsState(GUIDED_ENTRY_FILTERS); it("an empty first page drops 'uncategorized only', keeps the other start filters, and reads again", () => { const before = guided(); const after = result(before, 0); expect(after.filters).toEqual({ ...before.filters, uncategorizedOnly: false }); expect(after.filters.dateFrom).toBeNull(); // A new filters object is what makes the hook read again. expect(after.filters).not.toBe(before.filters); expect(after.page).toBe(1); // The loading placeholder, not an empty table, until that read. expect(after.isLoading).toBe(true); 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); expect(again.filters).toBe(fellBack.filters); expect(again.isLoading).toBe(false); expect(again.guidedFallback).toBe(false); expect(result(again, 0).filters).toBe(fellBack.filters); }); it("a first page with uncategorized transactions keeps the filter, and a later empty page too", () => { const before = guided(); const listed = result(before, 3); expect(listed.filters).toBe(before.filters); expect(listed.isLoading).toBe(false); expect(listed.guidedFallback).toBe(false); // Every one categorized, then a read comes back empty: the filter stays. const emptied = result(listed, 0); expect(emptied.filters).toBe(before.filters); expect(emptied.filters.uncategorizedOnly).toBe(true); }); it("a filter changed by the user before the first page is never dropped", () => { const touched = transactionsReducer(guided(), { type: "SET_FILTER", payload: { key: "search", value: "epicerie" }, }); expect(touched.guidedFallback).toBe(false); const after = result(touched, 0); expect(after.filters).toBe(touched.filters); expect(after.filters.uncategorizedOnly).toBe(true); }); it("an ordinary visit never drops 'uncategorized only', even picked by hand and empty", () => { const picked = transactionsReducer(initTransactionsState(), { type: "SET_FILTER", payload: { key: "uncategorizedOnly", value: true }, }); const after = result(picked, 0); expect(after.filters).toBe(picked.filters); expect(after.filters.uncategorizedOnly).toBe(true); }); });