/** * @jest-environment jsdom */ import { describe, test, expect, jest, beforeEach, afterEach, } from '@jest/globals'; globalThis.GAMEDB_CONFIG = { base_path: '/GameDB' }; globalThis.rankingSorter = () => () => 0; // item_loader.js reads platforms_container at module load time via getElementById // so we need the element in the DOM before require() document.body.innerHTML = `
`; const { splitString, fetchGameData, createGameCard, renderSearchResults, addMoreResultsNote, createPlatformBanner, createPlatformCardBody, getPlatformVersion, addVersionMetadataToFooter, addReleaseDatesToFooter, addMetadataItemToFooter, processPlatformsData, createPlatformCardElement, fetchJson, initializePlatformCards, run_search, } = require('../gh-pages-template/assets/js/item_loader.js'); describe('item_loader.js', () => { beforeEach(() => { // Reset just the dynamic containers, not the full body (platforms-container needed at load time) const sc = document.getElementById('search-container'); if (sc) sc.innerHTML = ''; const st = document.getElementById('search_term'); if (st) st.value = ''; const pc = document.getElementById('platforms-container'); if (pc) pc.innerHTML = ''; }); afterEach(() => { jest.restoreAllMocks(); }); describe('splitString', () => { test('returns string as-is when short', () => { expect(splitString('hello')).toEqual(['hello']); }); test('returns [undefined] for undefined', () => { expect(splitString(undefined)).toEqual([undefined]); }); test('splits at word boundary when longer than 200 chars', () => { const long = 'word '.repeat(50); // 250 chars const result = splitString(long); expect(result).toHaveLength(2); expect(result[0].length).toBeLessThanOrEqual(200); expect(result[1]).toBe(long); }); }); describe('fetchGameData', () => { test('fetches and returns game data on success', async () => { globalThis.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ name: 'Halo' }), }); const result = await fetchGameData(1, 'Halo'); expect(result).toEqual({ id: 1, game: { name: 'Halo' } }); }); test('returns fallback with gameName when response is not ok', async () => { globalThis.fetch = jest.fn().mockResolvedValue({ ok: false }); const result = await fetchGameData(2, 'Fallback Game'); expect(result).toEqual({ id: 2, game: { name: 'Fallback Game' } }); }); test('returns fallback with gameName when fetch rejects', async () => { globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network error')); const result = await fetchGameData(3, 'Error Game'); expect(result).toEqual({ id: 3, game: { name: 'Error Game' } }); }); }); describe('fetchJson', () => { test('fetches JSON with no-store cache behavior', async () => { globalThis.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }), }); await expect(fetchJson('/data.json')).resolves.toEqual({ ok: true }); expect(globalThis.fetch).toHaveBeenCalledWith('/data.json', { cache: 'no-store' }); }); }); describe('createGameCard', () => { test('renders card with cover image', () => { const col = createGameCard(1, { name: 'Test Game', cover: { url: '//images.igdb.com/igdb/image/upload/t_thumb/cover.jpg' }, }); expect(col.querySelector('img')).not.toBeNull(); expect(col.querySelector('img').src).toContain('t_cover_big_2x'); }); test('renders placeholder when no cover', () => { const col = createGameCard(2, { name: 'No Cover' }); expect(col.querySelector('img')).toBeNull(); expect(col.querySelector('.material-symbols-outlined')).not.toBeNull(); }); test('renders platforms with release years when allPlatforms provided', () => { const allPlatforms = { '1': { name: 'PC' }, '2': { name: 'PS5' }, '3': { name: 'Xbox' }, '4': { name: 'Switch' } }; const col = createGameCard(3, { name: 'Multi Platform', platforms: [1, 2, 3, 4], release_dates: [ { platform: 1, y: 2020 }, { platform: 2, y: 2021 }, { platform: 1, y: 2019 }, // earlier year replaces { platform: 2, y: 2022 }, // later year does NOT replace (covers false branch of rd.y < platformYears) ], }, allPlatforms); // Should show first 3 platforms + "+1 more" const badges = col.querySelectorAll('.badge'); expect(badges).toHaveLength(4); // 3 platforms + "+1 more" expect(col.textContent).toContain('+1 more'); }); test('renders platforms without release year when no release dates', () => { const allPlatforms = { '1': { name: 'PC' } }; const col = createGameCard(4, { name: 'PC Game', platforms: [1], }, allPlatforms); expect(col.textContent).toContain('PC'); }); test('uses platform ID as name when platform not in allPlatforms', () => { const col = createGameCard(5, { name: 'Unknown Platform Game', platforms: [999], }, {}); expect(col.textContent).toContain('999'); }); }); describe('renderSearchResults', () => { test('appends game cards to row', () => { const row = document.createElement('div'); renderSearchResults( [{ id: 1, game: { name: 'Game A' } }, { id: 2, game: { name: 'Game B' } }], row, null, ); expect(row.children).toHaveLength(2); }); }); describe('addMoreResultsNote', () => { test('appends note when total exceeds shown', () => { const container = document.createElement('div'); addMoreResultsNote(container, 100, 60); expect(container.children).toHaveLength(1); expect(container.textContent).toContain('60'); }); test('does nothing when total equals shown', () => { const container = document.createElement('div'); addMoreResultsNote(container, 60, 60); expect(container.children).toHaveLength(0); }); }); describe('createPlatformBanner', () => { test('creates banner with screenscraper image', () => { const banner = createPlatformBanner({ screenscraper_id: 123, screenscraper_region: 'us', id: 1 }, '/test'); expect(banner.src).toContain('screenscraper.fr'); expect(banner.src).toContain('123'); }); test('creates banner with IGDB logo (t_thumb replaced)', () => { const banner = createPlatformBanner({ screenscraper_id: null, screenscraper_region: null, platform_logo: { url: '//images.igdb.com/igdb/image/upload/t_thumb/logo.jpg' }, id: 1, }, '/test'); expect(banner.src).toContain('t_720p'); }); test('creates placeholder when no images', () => { const banner = createPlatformBanner({ screenscraper_id: null, screenscraper_region: null, id: 1 }, '/test'); expect(banner.src).toContain('no-logo.png'); }); }); describe('createPlatformCardBody', () => { test('creates card body with title and link', () => { const body = createPlatformCardBody({ id: 1, name: 'PS5', url: 'https://igdb.com' }, '/test'); expect(body.textContent).toContain('PS5'); expect(body.textContent).toContain('View on IGDB'); expect(body.querySelector('a').href).toContain('id=1'); }); test('includes game count (plural)', () => { const body = createPlatformCardBody({ id: 1, name: 'PS5', url: '', game_count: 42 }, '/test'); expect(body.textContent).toContain('42 games'); }); test('includes game count (singular)', () => { const body = createPlatformCardBody({ id: 1, name: 'PS5', url: '', game_count: 1 }, '/test'); expect(body.textContent).toContain('1 game'); expect(body.textContent).not.toContain('1 games'); }); }); describe('getPlatformVersion', () => { test('returns null when no versions', () => { expect(getPlatformVersion({ versions: [] })).toBeNull(); expect(getPlatformVersion({ category: 1 })).toBeNull(); }); test('returns last version for OS (category 4)', () => { const p = { category: 4, versions: [{ name: 'v1' }, { name: 'v3' }] }; expect(getPlatformVersion(p).name).toBe('v3'); }); test('returns first version for Console (category 1)', () => { const p = { category: 1, versions: [{ name: 'Original' }, { name: 'Slim' }] }; expect(getPlatformVersion(p).name).toBe('Original'); }); }); describe('addVersionMetadataToFooter', () => { const regionMap = { north_america: { code: '🇺🇸', size: 'fs-2' } }; const iconMap = { cpu: 'memory', platform_version_release_dates: null, summary: null, }; test('adds cpu metadata item', () => { const para = document.createElement('p'); const footer = document.createElement('div'); addVersionMetadataToFooter({ cpu: 'Intel 486' }, para, footer, {}, regionMap, iconMap); expect(footer.textContent).toContain('Intel 486'); }); test('sets summary on card_paragraph when platform has no summary', () => { const para = document.createElement('p'); const footer = document.createElement('div'); addVersionMetadataToFooter({ summary: 'Version summary' }, para, footer, {}, regionMap, iconMap); expect(para.textContent).toContain('Version summary'); }); test('skips summary when platform already has summary', () => { const para = document.createElement('p'); const footer = document.createElement('div'); addVersionMetadataToFooter( { summary: 'Version summary' }, para, footer, { summary: 'Platform summary' }, // platform has its own summary regionMap, iconMap, ); expect(para.textContent).toBe(''); }); test('adds release dates to footer', () => { const para = document.createElement('p'); const footer = document.createElement('div'); addVersionMetadataToFooter( { platform_version_release_dates: [{ release_region: { region: 'north_america' }, human: 'Nov 2001' }] }, para, footer, {}, regionMap, iconMap, ); expect(footer.textContent).toContain('Nov 2001'); }); test('skips keys not present in version', () => { const para = document.createElement('p'); const footer = document.createElement('div'); addVersionMetadataToFooter({}, para, footer, {}, regionMap, iconMap); expect(footer.children).toHaveLength(0); }); test('skips metadata key when it has null icon and is not a special key', () => { const para = document.createElement('p'); const footer = document.createElement('div'); // 'media' key has a value but null icon in the map passed const customIconMap = { media: null, platform_version_release_dates: null, summary: null }; addVersionMetadataToFooter({ media: 'Blu-ray' }, para, footer, {}, regionMap, customIconMap); // Should not add to footer since icon is null and not a special key expect(footer.children).toHaveLength(0); }); }); describe('addReleaseDatesToFooter', () => { test('adds release dates with regions', () => { const footer = document.createElement('div'); addReleaseDatesToFooter( [ { release_region: { region: 'north_america' }, human: 'Nov 15, 2013' }, { release_region: { region: 'unknown_region' }, y: 2014 }, { release_region: null, human: null, y: 2015 }, ], footer, { north_america: { code: '🇺🇸', size: 'fs-2' } }, ); expect(footer.textContent).toContain('Nov 15, 2013'); expect(footer.textContent).toContain('2015'); }); test('renders empty string when release date has no human or y', () => { const footer = document.createElement('div'); addReleaseDatesToFooter( [{ release_region: null, human: null, y: null }], footer, {}, ); // Should render the div without crashing expect(footer.querySelector('div')).not.toBeNull(); }); }); describe('addMetadataItemToFooter', () => { test('adds metadata item with icon', () => { const footer = document.createElement('div'); addMetadataItemToFooter('cpu', 'Intel Core i7', footer, 'memory'); expect(footer.textContent).toContain('Intel Core i7'); expect(footer.querySelector('.material-symbols-outlined').textContent).toBe('memory'); }); }); describe('processPlatformsData', () => { test('maps screenscraper IDs from xref', () => { const result = processPlatformsData( { ps5: { id: 167, name: 'PS5' }, xbox: { id: 169, name: 'Xbox' } }, { ps5: { ids: { igdb: 167, screenscraper: 1000 }, variables: { screenscraper: { region: 'us' } } } }, ); expect(result[0].screenscraper_id).toBe(1000); expect(result[0].screenscraper_region).toBe('us'); expect(result[1].screenscraper_id).toBeNull(); }); }); describe('createPlatformCardElement', () => { const regionMap = { north_america: { code: '🇺🇸', size: 'fs-2' } }; const iconMap = { cpu: 'memory', platform_version_release_dates: null, summary: null }; test('creates full card with version metadata', () => { const platform = { id: 1, name: 'PS5', url: 'https://igdb.com', summary: 'A great console.', category: 1, screenscraper_id: null, screenscraper_region: null, versions: [{ name: 'Launch', cpu: 'AMD Zen 2' }], }; const col = createPlatformCardElement(platform, regionMap, iconMap, '/test'); expect(col.textContent).toContain('PS5'); expect(col.textContent).toContain('AMD Zen 2'); }); test('creates card without version when no versions', () => { const platform = { id: 2, name: 'Switch', url: '', summary: 'Nintendo Switch.', screenscraper_id: 555, screenscraper_region: 'us', platform_logo: { url: '//images.igdb.com/t_thumb/sw.jpg' }, }; const col = createPlatformCardElement(platform, regionMap, iconMap, '/test'); expect(col.textContent).toContain('Switch'); }); }); describe('initializePlatformCards', () => { test('loads cross-reference before rendering platform cards', async () => { globalThis.fetch = jest.fn(url => Promise.resolve({ json: () => Promise.resolve( url.includes('cross-reference') ? { pc: { ids: { igdb: 1, screenscraper: 123 }, variables: { screenscraper: { region: 'us' } } } } : { '1': { id: 1, name: 'PC', url: 'https://igdb.com', summary: 'Personal computer.' } }, ), })); await initializePlatformCards(); expect(globalThis.fetch).toHaveBeenNthCalledWith( 1, 'http://localhost/GameDB/platforms/cross-reference.json', { cache: 'no-store' }, ); expect(globalThis.fetch).toHaveBeenNthCalledWith( 2, 'http://localhost/GameDB/platforms/all.json', { cache: 'no-store' }, ); expect(document.getElementById('platforms-container').textContent).toContain('PC'); }); }); describe('run_search', () => { /** * Wait for queued promise callbacks in fetch-driven rendering tests. * * @returns {Promise} Resolves after the current macrotask. */ const flushPromises = () => new Promise(r => setTimeout(r, 0)); test('does nothing when search term is empty', () => { document.getElementById('search_term').value = ' '; run_search(); expect(document.getElementById('search-container').innerHTML).toBe(''); }); test('shows loading, then renders results on success', async () => { document.getElementById('search_term').value = 'halo'; globalThis.fetch = jest.fn() .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ '1': { name: 'Halo: CE' }, '2': { name: 'Halo 2' }, }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}), // platforms }) .mockResolvedValue({ ok: true, json: () => Promise.resolve({ name: 'Halo: CE', cover: null }), }); run_search(); await flushPromises(); await flushPromises(); await flushPromises(); const container = document.getElementById('search-container'); expect(container.textContent).toContain('Halo'); }); test('shows no results message when no matches', async () => { document.getElementById('search_term').value = 'zzznomatch'; globalThis.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ '1': { name: 'Unrelated' } }), }); run_search(); await flushPromises(); await flushPromises(); expect(document.getElementById('search-container').textContent).toContain('No games found'); }); test('shows error when bucket fetch fails', async () => { document.getElementById('search_term').value = 'fail'; globalThis.fetch = jest.fn().mockResolvedValue({ ok: false }); run_search(); await flushPromises(); await flushPromises(); expect(document.getElementById('search-container').textContent).toContain('Search failed'); }); test('shows error when fetch rejects', async () => { document.getElementById('search_term').value = 'crash'; globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network down')); run_search(); await flushPromises(); await flushPromises(); expect(document.getElementById('search-container').textContent).toContain('Search failed'); }); test('uses @ bucket when search term has no alphanumeric characters', async () => { document.getElementById('search_term').value = '!!!'; globalThis.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}), }); run_search(); await flushPromises(); await flushPromises(); const fetchUrl = globalThis.fetch.mock.calls[0][0]; expect(fetchUrl).toContain('%40'); // @ URL-encoded }); test('renders results even when platform fetch fails', async () => { document.getElementById('search_term').value = 'mario'; globalThis.fetch = jest.fn() .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ '10': { name: 'Mario' } }), }) .mockRejectedValueOnce(new Error('Platform fetch failed')) .mockResolvedValue({ ok: true, json: () => Promise.resolve({ name: 'Mario' }), }); run_search(); await flushPromises(); await flushPromises(); await flushPromises(); const container = document.getElementById('search-container'); expect(container.textContent).toContain('Mario'); }); test('renders results even when platform JSON parsing fails', async () => { document.getElementById('search_term').value = 'mario'; globalThis.fetch = jest.fn() .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ '10': { name: 'Mario' } }), }) .mockResolvedValueOnce({ json: () => Promise.reject(new Error('Invalid platform JSON')), }) .mockResolvedValue({ ok: true, json: () => Promise.resolve({ name: 'Mario', platforms: [6] }), }); run_search(); await flushPromises(); const container = document.getElementById('search-container'); expect(container.querySelector('a').textContent).toContain('Mario'); expect(container.textContent).not.toContain('Search failed'); expect(globalThis.fetch).toHaveBeenCalledTimes(3); }); test.each([true, false])('shows rendering errors with platform fetch success=%s', async platformsAvailable => { document.getElementById('search_term').value = 'halo'; globalThis.fetch = jest.fn() .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ '1': { name: 'Halo' } }), }); if (platformsAvailable) { globalThis.fetch.mockResolvedValueOnce({ json: () => Promise.resolve({}) }); } else { globalThis.fetch.mockRejectedValueOnce(new Error('Platform fetch failed')); } globalThis.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ name: 'Halo' }), }); const createElement = document.createElement.bind(document); jest.spyOn(document, 'createElement').mockImplementation(tagName => { if (tagName === 'a') throw new Error('Rendering failed'); return createElement(tagName); }); run_search(); await flushPromises(); const container = document.getElementById('search-container'); expect(container.querySelector('.text-danger').textContent).toBe('Search failed: Rendering failed'); expect(globalThis.fetch).toHaveBeenCalledTimes(3); }); }); });