diff --git a/frontend/src/utils/filters.js b/frontend/src/utils/filters.js index 9716df2..48b7f11 100644 --- a/frontend/src/utils/filters.js +++ b/frontend/src/utils/filters.js @@ -120,6 +120,24 @@ export const POPULAR_AUTHORS = [ 'Chimamanda Ngozi Adichie', 'Kazuo Ishiguro', 'Salman Rushdie', 'Milan Kundera', ]; +/** + * @typedef {Object} FilterState + * @property {''|'new'|'old'|'rating desc'|'readinglog desc'|'title'} sort + * Sort value from SORT_OPTIONS. + * @property {''|'readable'|'borrowable'|'open'} availability + * Availability filter from AVAILABILITY_OPTIONS. + * @property {''|'fiction'|'nonfiction'} fictionFilter + * Fiction/nonfiction filter. + * @property {string[]} [languages] + * ISO 639-2/B codes e.g. ['eng', 'spa']. + * @property {string[]} [genres] + * Values from GENRE_OPTIONS e.g. ['mystery']. + * @property {string[]} [authors] + * Free-text author names. + * @property {string[]} [subjects] + * Free-text subject names. + */ + // DEFAULT_FILTERS is the canonical name — "EMPTY" was a misnomer because // availability defaults to 'readable', not to a truly empty state. export const DEFAULT_FILTERS = { @@ -173,8 +191,11 @@ export function shufflePick(arr, n) { /** * Derive the chip array from a filter state object. - * Each chip label includes a human-readable "type: value" prefix. + * Chip labels are human-readable; language/genre/author/subject labels include a + * "type: value" prefix, while availability and fiction use plain descriptive labels. * Ordering: availability → fictionFilter → language → genre → author → subject + * @param {FilterState} filters + * @returns {{ type: string, label: string, value: string|null }[]} */ export function buildChips(filters) { const chips = []; @@ -223,6 +244,11 @@ export function buildChips(filters) { * Build URLSearchParams for a search request. * Multi-value arrays (languages, genres, authors, subjects) are each appended * as repeated params; the backend ORs them together within each facet. + * @param {string} q + * @param {FilterState} filters + * @param {number} page + * @param {number} limit + * @returns {URLSearchParams} */ export function buildSearchParams(q, filters, page, limit) { const p = new URLSearchParams(); diff --git a/frontend/src/utils/filters.test.js b/frontend/src/utils/filters.test.js index 1f9bd9b..e353cb9 100644 --- a/frontend/src/utils/filters.test.js +++ b/frontend/src/utils/filters.test.js @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; import { toggleArrayValue, shufflePick, @@ -447,3 +448,34 @@ describe('DEFAULT_FILTERS', () => { expect(EMPTY_FILTERS).toBeDefined(); }); }); + +// ── FilterState typedef static-analysis ────────────────────────────────────── + +const filtersSrc = readFileSync(new URL('./filters.js', import.meta.url), 'utf8'); + +describe('FilterState typedef', () => { + it('@typedef {Object} FilterState is declared in filters.js', () => { + expect(filtersSrc).toMatch(/@typedef\s*\{Object\}\s*FilterState/); + }); + + it('all seven filter fields are documented with @property', () => { + for (const field of ['sort', 'availability', 'fictionFilter', 'languages', 'genres', 'authors', 'subjects']) { + expect(filtersSrc).toMatch(new RegExp(`@property.*${field}`)); + } + }); + + it('buildChips and buildSearchParams each have a @param {FilterState} annotation (2 total)', () => { + const matches = [...filtersSrc.matchAll(/@param\s*\{FilterState\}/g)]; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it('FilterState uses literal-union types for sort, availability, and fictionFilter', () => { + const typedef = filtersSrc.slice( + filtersSrc.indexOf('@typedef {Object} FilterState'), + filtersSrc.indexOf('@typedef {Object} FilterState') + 600, + ); + expect(typedef).toMatch(/'new'.*'old'|'old'.*'new'/); // sort has at least two literals + expect(typedef).toMatch(/'readable'/); // availability literal + expect(typedef).toMatch(/'fiction'.*'nonfiction'|'nonfiction'.*'fiction'/); // fictionFilter literals + }); +});