Simpl-Resultat/src/hooks/useTransactions.test.ts
le king fu ba71a6421d 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>
2026-10-08 21:05:30 -04:00

241 lines
9.8 KiB
TypeScript

// 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);
});
});