import { describe, expect, it } from 'vitest'; import { buildFacets, applyBannerManifest, applyGeneratedFileLinks, normalizeCatalog, parseReleaseDate, slugifyStem, sortHacks, } from './catalog'; import type { RawCatalog } from './types'; const baseCatalog: RawCatalog = { generated: '2026-06-08', count: 3, image_base: 'https://romhacks-files.ginnoir.com', facets: { platform: { GBA: 2, Unknown: 1 }, base: { FireRed: 1, Unknown: 2 }, status: { Complete: 2, Unknown: 1 }, type: { Difficulty: 1, Expansion: 1 }, play_status: { Unplayed: 3 }, }, hacks: [ { stem: 'Radical Red', title: 'Pokémon Radical Red', platform: 'GBA', base: 'FireRed', status: 'Complete', version: '4.1', generations: ['Gen I', 'Gen II', 'Gen III', 'Gen IV', 'Gen V', 'Gen VI', 'Gen VII', 'Gen VIII', 'Gen IX'], fakemon: 'No', developer: 'Soupercell', release_date: '2024', banner: 'https://romhacks-files.ginnoir.com/_meta/radical/banner.jpg', source: 'https://example.com/radical', library_path: 'roms/gba/Hacks/Pokemon - Radical Red (Hack).gba', play_status: 'Unplayed', rating: 5, type: ['Difficulty', 'Expansion'], tags: ['hack'], summary: 'Fast, difficult FireRed expansion.', notability: 'Flagship difficulty hack.', story: '', features: ['Expanded Pokédex'], links: [{ label: 'Source / info', url: 'https://example.com/radical' }], }, { stem: 'A FarfetchΓÇÖD Story', title: 'Pokémon A Farfetch’D Story', platform: 'PC', base: null, status: 'Complete', version: 'completed', developer: 'KennyCatches', release_date: '2023-08-10', scrape_dir: 'Pokemon_A_Farfetch_D_Story', library_path: '/storage1/labdata/romhacks/library/Pokemon_A_Farfetch_D_Story/', play_status: 'Unplayed', type: ['New Experience'], tags: [], summary: 'A short RPGXP adventure.', notability: '', story: '', features: [], links: [], }, { stem: 'Unknown Base', title: 'Pokémon Unknown Base', platform: null, base: '—', status: null, version: '', library_path: '—', play_status: 'Unplayed', type: [], tags: [], summary: '', notability: '', story: '', features: [], links: [], }, ], }; describe('catalog normalization', () => { it('creates deterministic URL-safe slugs from stems', () => { expect(slugifyStem('Radical Red')).toBe('radical-red'); expect(slugifyStem('A FarfetchΓÇÖD Story')).toBe('a-farfetch-d-story'); expect(slugifyStem('Pokémon: Crystal Clear!')).toBe('pokemon-crystal-clear'); }); it('normalizes records without rendering null-like values', () => { const catalog = normalizeCatalog(baseCatalog); const unknown = catalog.hacks.find((hack) => hack.stem === 'Unknown Base'); expect(catalog.count).toBe(3); expect(catalog.facets.platform).toEqual(baseCatalog.facets.platform); expect(unknown?.platform).toBeUndefined(); expect(unknown?.base).toBeUndefined(); expect(unknown?.status).toBeUndefined(); expect(unknown?.chips.map((chip) => chip.label)).not.toContain('—'); expect(unknown?.searchText).not.toMatch(/\bnull\b|—/i); }); it('normalizes roster generations and Fakemon metadata into chips', () => { const catalog = normalizeCatalog(baseCatalog); const radical = catalog.hacks.find((hack) => hack.slug === 'radical-red'); expect(radical?.generations).toEqual([ 'Gen I', 'Gen II', 'Gen III', 'Gen IV', 'Gen V', 'Gen VI', 'Gen VII', 'Gen VIII', 'Gen IX', ]); expect(radical?.fakemon).toBe('No'); expect(radical?.chips).toEqual( expect.arrayContaining([ { label: 'Roster', value: 'Gen I, Gen II, Gen III, Gen IV, Gen V, Gen VI, Gen VII, Gen VIII, Gen IX', kind: 'generation' }, { label: 'Fakemon', value: 'No', kind: 'fakemon' }, ]), ); expect(radical?.searchText).toContain('gen ix'); }); it('maps ROM and library paths to internal file-server links', () => { const catalog = normalizeCatalog(baseCatalog); const radical = catalog.hacks.find((hack) => hack.slug === 'radical-red'); const farfetchd = catalog.hacks.find((hack) => hack.slug === 'a-farfetch-d-story'); expect(radical?.fileLinks).toContainEqual({ label: 'ROM file', url: 'https://romhacks-files.ginnoir.com/_roms/gba/Hacks/Pokemon%20-%20Radical%20Red%20%28Hack%29.gba', kind: 'rom', }); expect(farfetchd?.fileLinks).toContainEqual({ label: 'Browse related files', url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/', kind: 'library', }); }); it('builds facets from normalized records when counts are not supplied', () => { const facets = buildFacets(normalizeCatalog(baseCatalog).hacks); expect(facets.platform.GBA).toBe(1); expect(facets.platform.PC).toBe(1); expect(facets.platform.Unknown).toBe(1); expect(facets.type.Difficulty).toBe(1); expect(facets.type.Expansion).toBe(1); }); it('applies downloaded banner manifest paths and placeholder fallback', () => { const catalog = normalizeCatalog(baseCatalog); const withBanners = applyBannerManifest(catalog, { 'radical-red': '/assets/banners/radical-red.jpg', }); const radical = withBanners.hacks.find((hack) => hack.slug === 'radical-red'); const unknown = withBanners.hacks.find((hack) => hack.slug === 'unknown-base'); expect(radical?.bannerLocal).toBe('/assets/banners/radical-red.jpg'); expect(unknown?.bannerLocal).toBe('/assets/placeholders/hack.svg'); }); it('merges generated guide links without dropping ROM or browse links', () => { const catalog = normalizeCatalog(baseCatalog); const withFiles = applyGeneratedFileLinks(catalog, { 'a-farfetch-d-story': [ { label: 'guide.pdf', url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/guide.pdf', kind: 'guide', }, ], }); const farfetchd = withFiles.hacks.find((hack) => hack.slug === 'a-farfetch-d-story'); expect(farfetchd?.fileLinks).toEqual([ { label: 'Browse related files', url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/', kind: 'library', }, { label: 'guide.pdf', url: 'https://romhacks-files.ginnoir.com/Pokemon_A_Farfetch_D_Story/guide.pdf', kind: 'guide', }, ]); }); it('sorts by name, release date, and platform', () => { const hacks = normalizeCatalog(baseCatalog).hacks; expect(sortHacks(hacks, 'name').map((hack) => hack.slug)).toEqual([ 'a-farfetch-d-story', 'radical-red', 'unknown-base', ]); expect(sortHacks(hacks, 'release_date').map((hack) => hack.slug)).toEqual([ 'radical-red', 'a-farfetch-d-story', 'unknown-base', ]); expect(sortHacks(hacks, 'platform').map((hack) => hack.slug)).toEqual([ 'radical-red', 'a-farfetch-d-story', 'unknown-base', ]); }); it('parses bare years and ISO dates for descending date sort', () => { expect(parseReleaseDate('2024')).toBe(new Date('2024-01-01').getTime()); expect(parseReleaseDate('2023-08-10')).toBe(new Date('2023-08-10').getTime()); expect(parseReleaseDate(null)).toBe(0); }); });