Every decision paging needs, as pure functions: which page a URL is asking for, which page is actually showable given how much there is, which slice of the items that is, and what page size to use. Pure because that is the only thing this project can unit-test — vitest runs in a node environment with no jsdom and no testing-library, so a hook or a component is only reachable through Playwright. Keeping the rules here means the rules have tests and the React wrapper stays thin enough not to need any, which is the same split filters.ts already uses for the URL. An unrecognised page size is refused rather than clamped. A stored or hand-edited 5000 would render the entire catalogue in one page, which is the exact failure this issue exists to prevent, and clamping would quietly honour a value nobody offered. Storage access is guarded on both sides because localStorage is absent when there is no window and throws outright in some privacy modes, and neither is a reason for a customer to lose the catalogue — the worst acceptable outcome of a broken preference is the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.0 KiB
TypeScript
157 lines
5.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
DEFAULT_PAGE_SIZE,
|
|
PAGE_SIZE_OPTIONS,
|
|
PAGE_SIZE_STORAGE_KEY,
|
|
clampPage,
|
|
pageFromSearchParams,
|
|
pageSlice,
|
|
readPageSize,
|
|
readStoredPageSize,
|
|
writeStoredPageSize
|
|
} from '../../src/pagination';
|
|
|
|
describe('the page size a customer may choose', () => {
|
|
it('defaults to 10, the conservative choice', () => {
|
|
expect(DEFAULT_PAGE_SIZE).toBe(10);
|
|
});
|
|
|
|
it('offers exactly the four sizes the issue settled on', () => {
|
|
expect([...PAGE_SIZE_OPTIONS]).toEqual([10, 20, 50, 100]);
|
|
});
|
|
|
|
it('accepts a size that is on the list', () => {
|
|
expect(readPageSize('50')).toBe(50);
|
|
});
|
|
|
|
// Anything else falls back rather than being honoured. A hand-edited value
|
|
// of 5000 would render the whole catalogue, which is the thing this issue
|
|
// exists to stop.
|
|
it('falls back to the default for a size that is not on the list', () => {
|
|
expect(readPageSize('5000')).toBe(DEFAULT_PAGE_SIZE);
|
|
expect(readPageSize('7')).toBe(DEFAULT_PAGE_SIZE);
|
|
});
|
|
|
|
it('falls back to the default for nonsense and for nothing at all', () => {
|
|
expect(readPageSize('abc')).toBe(DEFAULT_PAGE_SIZE);
|
|
expect(readPageSize('')).toBe(DEFAULT_PAGE_SIZE);
|
|
expect(readPageSize(null)).toBe(DEFAULT_PAGE_SIZE);
|
|
});
|
|
});
|
|
|
|
describe('the page number in the URL', () => {
|
|
it('is 1 when the parameter is absent', () => {
|
|
expect(pageFromSearchParams(new URLSearchParams())).toBe(1);
|
|
});
|
|
|
|
it('reads a sensible page', () => {
|
|
expect(pageFromSearchParams(new URLSearchParams('page=3'))).toBe(3);
|
|
});
|
|
|
|
// A mangled link falls back to the first page rather than to an empty view,
|
|
// the same rule filtersFromSearchParams follows for a mangled filter.
|
|
it('falls back to 1 for zero, negatives and nonsense', () => {
|
|
expect(pageFromSearchParams(new URLSearchParams('page=0'))).toBe(1);
|
|
expect(pageFromSearchParams(new URLSearchParams('page=-4'))).toBe(1);
|
|
expect(pageFromSearchParams(new URLSearchParams('page=abc'))).toBe(1);
|
|
expect(pageFromSearchParams(new URLSearchParams('page=2.5'))).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('keeping a page number inside the result set', () => {
|
|
// The case that matters: someone shares a link to page 7, the catalogue
|
|
// shrinks, and the link must show the last page rather than nothing at all.
|
|
it('clamps a page past the end back to the last page', () => {
|
|
expect(clampPage(7, 25, 10)).toBe(3);
|
|
});
|
|
|
|
it('leaves a page inside the range alone', () => {
|
|
expect(clampPage(2, 25, 10)).toBe(2);
|
|
});
|
|
|
|
// An empty result set still has a first page to show the empty state on.
|
|
it('is 1 when there is nothing to show', () => {
|
|
expect(clampPage(3, 0, 10)).toBe(1);
|
|
});
|
|
|
|
it('is 1 when everything fits on one page', () => {
|
|
expect(clampPage(4, 6, 10)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('taking one page out of the items', () => {
|
|
const items = Array.from({ length: 25 }, (_, i) => i + 1);
|
|
|
|
it('takes the first page', () => {
|
|
expect(pageSlice(items, 1, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
|
});
|
|
|
|
it('takes a middle page', () => {
|
|
expect(pageSlice(items, 2, 10)).toEqual([11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
|
|
});
|
|
|
|
it('takes a short last page', () => {
|
|
expect(pageSlice(items, 3, 10)).toEqual([21, 22, 23, 24, 25]);
|
|
});
|
|
|
|
it('is empty past the end rather than throwing', () => {
|
|
expect(pageSlice(items, 9, 10)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/** A Storage that is present and works. */
|
|
function fakeStorage(initial: Record<string, string> = {}) {
|
|
const data = { ...initial };
|
|
return {
|
|
getItem: (key: string) => data[key] ?? null,
|
|
setItem: (key: string, value: string) => {
|
|
data[key] = value;
|
|
},
|
|
read: () => data
|
|
};
|
|
}
|
|
|
|
describe('remembering the choice', () => {
|
|
it('reads a stored size', () => {
|
|
expect(readStoredPageSize(fakeStorage({ [PAGE_SIZE_STORAGE_KEY]: '20' }))).toBe(20);
|
|
});
|
|
|
|
it('defaults when nothing was stored', () => {
|
|
expect(readStoredPageSize(fakeStorage())).toBe(DEFAULT_PAGE_SIZE);
|
|
});
|
|
|
|
it('writes the choice under the agreed key', () => {
|
|
const storage = fakeStorage();
|
|
writeStoredPageSize(storage, 50);
|
|
expect(storage.read()[PAGE_SIZE_STORAGE_KEY]).toBe('50');
|
|
});
|
|
|
|
// Storage is absent during server rendering and throws outright in some
|
|
// privacy modes. Neither may take the catalogue down over a preference.
|
|
it('defaults rather than throwing when storage is missing', () => {
|
|
expect(readStoredPageSize(null)).toBe(DEFAULT_PAGE_SIZE);
|
|
});
|
|
|
|
it('defaults rather than throwing when storage refuses to be read', () => {
|
|
const hostile = {
|
|
getItem: () => {
|
|
throw new Error('access denied');
|
|
}
|
|
};
|
|
expect(readStoredPageSize(hostile)).toBe(DEFAULT_PAGE_SIZE);
|
|
});
|
|
|
|
it('gives up quietly when storage refuses to be written', () => {
|
|
const hostile = {
|
|
setItem: () => {
|
|
throw new Error('quota exceeded');
|
|
}
|
|
};
|
|
expect(() => writeStoredPageSize(hostile, 20)).not.toThrow();
|
|
});
|
|
|
|
it('does nothing when there is no storage to write to', () => {
|
|
expect(() => writeStoredPageSize(null, 20)).not.toThrow();
|
|
});
|
|
});
|