// @vitest-environment jsdom // // The card layout's pager clamps an out-of-range page DURING RENDER, which is the right // shape — React discards that pass, so nothing is committed on a page that no longer exists — but // it is also the shape that turns a non-convergent condition into "Too many re-renders" rather than // a visual glitch. Neither the hook nor either of its two callers (ChannelDiscovery, Channels) had // a test, so nothing held either half. This file holds both: the clamp happens, and it terminates. import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); import { useCardPager } from "./useCardPager"; import { claimLocalData } from "./storage"; const rows = (n: number) => Array.from({ length: n }, (_, i) => i); const KEY = "siftlode.test.cardSize"; beforeEach(() => { localStorage.clear(); claimLocalData(1); }); describe("useCardPager — an out-of-range page", () => { it("clamps to the last page that still exists when the list shrinks", () => { const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), { initialProps: { n: 30 }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); expect(result.current.pagerProps.page).toBe(2); expect(result.current.paged).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29]); rerender({ n: 5 }); // an unsubscribe, a refetch — page 2 no longer exists expect(result.current.pagerProps.page).toBe(0); expect(result.current.paged).toEqual([0, 1, 2, 3, 4]); }); it("does not spring back to the stale page when the list grows again", () => { const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), { initialProps: { n: 30 }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); rerender({ n: 5 }); rerender({ n: 30 }); // The clamp was COMMITTED, not merely displayed — `safePage` alone would have let 2 come back. expect(result.current.pagerProps.page).toBe(0); }); it("terminates on an empty list — the write falsifies its own condition", () => { const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), { initialProps: { n: 30 }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); // totalPages floors at 1, so the clamp target is 0 and the condition is false next pass. If it // were not, this line would throw "Too many re-renders" instead of returning. rerender({ n: 0 }); expect(result.current.pagerProps.page).toBe(0); expect(result.current.paged).toEqual([]); }); it("holds still in table mode, where DataTable does its own paging", () => { const { result, rerender } = renderHook(({ n, on }) => useCardPager(rows(n), KEY, on), { initialProps: { n: 30, on: false }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); rerender({ n: 5, on: false }); // the same shrink — but nothing is committed here rerender({ n: 30, on: false }); // `safePage` still clamps for DISPLAY while the list is short; what must not happen is the // clamp being written, which would lose the page for real. Growing back proves it was not. expect(result.current.pagerProps.page).toBe(2); expect(result.current.paged).toEqual(rows(30)); // and the rows are never sliced in table mode }); }); describe("useCardPager — following a moved row", () => { it("turns to the page that holds the row on each new seq, and not on mount", () => { const { result, rerender } = renderHook( ({ list, seq }) => useCardPager(list, KEY, true, seq ? { seq, match: (r: number) => r === 3 } : null), { initialProps: { list: rows(30), seq: 0 } }, ); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); // The row 3 sank to place 25: seq 1 follows it to the third page… const sunk = [...rows(30).filter((r) => r !== 3)]; sunk.splice(25, 0, 3); rerender({ list: sunk, seq: 1 }); expect(result.current.pagerProps.page).toBe(2); // …and bubbled up to place 4: seq 2 follows it back to the first. rerender({ list: rows(30), seq: 2 }); expect(result.current.pagerProps.page).toBe(0); // A re-render with the same seq leaves the reader's own paging alone. act(() => result.current.pagerProps.onPageChange(1)); rerender({ list: rows(30), seq: 2 }); expect(result.current.pagerProps.page).toBe(1); }); }); describe("useCardPager — another list", () => { it("starts on page one when the caller's scope changes", () => { const { result, rerender } = renderHook( ({ scope }) => useCardPager(rows(100), KEY, true, null, undefined, scope), { initialProps: { scope: "folder 1" } }, ); act(() => result.current.pagerProps.onPageChange(3)); expect(result.current.pagerProps.page).toBe(3); rerender({ scope: "folder 2" }); expect(result.current.pagerProps.page).toBe(0); }); });