diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
| commit | 914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch) | |
| tree | 02b11537237a204048589e14ed24938b805e42f7 /player-server/web/js/tests | |
| parent | 3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff) | |
Restructure repo: move Go server into player-server/
Diffstat (limited to 'player-server/web/js/tests')
| -rw-r--r-- | player-server/web/js/tests/admin-rescan.test.js | 178 | ||||
| -rw-r--r-- | player-server/web/js/tests/keyboard-enter.test.js | 75 | ||||
| -rw-r--r-- | player-server/web/js/tests/media-pagination.test.js | 56 | ||||
| -rw-r--r-- | player-server/web/js/tests/playback-resume.test.js | 51 | ||||
| -rw-r--r-- | player-server/web/js/tests/podcast-episodes-api.test.js | 74 | ||||
| -rw-r--r-- | player-server/web/js/tests/progress-actions.test.js | 120 | ||||
| -rw-r--r-- | player-server/web/js/tests/search-parser.test.js | 33 | ||||
| -rw-r--r-- | player-server/web/js/tests/shuffle.test.js | 165 |
8 files changed, 752 insertions, 0 deletions
diff --git a/player-server/web/js/tests/admin-rescan.test.js b/player-server/web/js/tests/admin-rescan.test.js new file mode 100644 index 0000000..2ce5f1b --- /dev/null +++ b/player-server/web/js/tests/admin-rescan.test.js @@ -0,0 +1,178 @@ +import { initKeyboard } from '../keyboard.js'; +import { renderScanProgress, triggerRescan } from '../views/admin-status.js'; +import { readFileSync } from 'node:fs'; + +const failures = []; +const requests = []; +let keydownHandler = null; +let rescanStatus = 200; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +const indicator = mockElement(); +const indicatorText = mockElement(); +const toastEl = { + className: '', + textContent: '', + classList: { remove() {} }, +}; + +globalThis.document = { + addEventListener(type, handler) { + if (type === 'keydown') keydownHandler = handler; + }, + getElementById(id) { + if (id === 'scan-indicator') return indicator; + if (id === 'scan-indicator-text') return indicatorText; + if (id === 'toast') return toastEl; + return null; + }, +}; +globalThis.location = { pathname: '/', href: '' }; +globalThis.setTimeout = () => 0; + +globalThis.fetch = async (url, options = {}) => { + requests.push({ url: String(url), method: options.method || 'GET' }); + if (String(url) === '/api/admin/rescan') { + if (rescanStatus !== 200) { + return jsonResponse({ error: 'admin only' }, rescanStatus); + } + return jsonResponse({ status: 'ok' }); + } + if (String(url) === '/api/admin/scan-progress') { + return jsonResponse({ + running: true, + current_set: 'movies', + sets_total: 2, + sets_done: 1, + files_total: 9, + files_done: 4, + }); + } + return jsonResponse({}); +}; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function mockElement() { + return { + textContent: '', + hidden: true, + classList: { + add(name) { + if (name === 'hidden') this.hidden = true; + }, + remove(name) { + if (name === 'hidden') this.hidden = false; + }, + hidden: true, + }, + }; +} + +function pressKey(key) { + let prevented = false; + keydownHandler?.({ + key, + code: `Key${key.toUpperCase()}`, + target: { tagName: 'BODY', isContentEditable: false }, + preventDefault() { prevented = true; }, + }); + return prevented; +} + +function testKeyboardRescanHandler() { + let rescans = 0; + initKeyboard({ rescanMedia: () => { rescans += 1; } }); + + const prevented = pressKey('M'); + + assert(prevented, 'M should prevent default browser handling'); + assert(rescans === 1, 'M should call the rescan handler'); +} + +function testRenderRunningProgress() { + renderScanProgress({ + running: true, + current_set: 'music', + sets_total: 3, + sets_done: 2, + files_total: 20, + files_done: 7, + }); + + assert(indicator.classList.hidden === false, 'running progress should show the indicator'); + assert(indicatorText.textContent === 'Scanning music 2/3 sets, 7/20 files', 'running progress text should include set and file counts'); +} + +function testRenderIdleProgressHidesIndicator() { + renderScanProgress({ running: false }); + + assert(indicator.classList.hidden === true, 'idle progress should hide the indicator'); +} + +function testScanIndicatorOutsideHeader() { + const html = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); + const header = html.slice(html.indexOf('<header'), html.indexOf('</header>')); + const afterHeader = html.slice(html.indexOf('</header>')); + + assert(!header.includes('id="scan-indicator"'), 'scan indicator should not be inside the collapsible header'); + assert(afterHeader.includes('id="scan-indicator"'), 'scan indicator should remain in the document after the header'); +} + +function testScanIndicatorAboveModalOverlay() { + const css = readFileSync(new URL('../../css/layout.css', import.meta.url), 'utf8'); + const scanRule = css.match(/\.scan-indicator\s*\{[^}]*z-index:\s*(\d+)/); + const modalRule = css.match(/\.modal-overlay\s*\{[^}]*z-index:\s*(\d+)/); + + assert(scanRule, 'scan indicator should define a z-index'); + assert(modalRule, 'modal overlay should define a z-index'); + assert(Number(scanRule?.[1]) > Number(modalRule?.[1]), 'scan indicator should render above open admin modal overlay'); +} + +async function testTriggerRescanRefreshesProgress() { + requests.length = 0; + rescanStatus = 200; + await triggerRescan(); + + assert(requests[0]?.url === '/api/admin/rescan', 'trigger should call rescan endpoint first'); + assert(requests[0]?.method === 'POST', 'trigger should post to rescan endpoint'); + assert(requests[1]?.url === '/api/admin/scan-progress', 'trigger should refresh progress after starting scan'); + assert(indicator.classList.hidden === false, 'trigger should show refreshed running progress'); +} + +async function testTriggerRescanErrorDoesNotPollProgress() { + requests.length = 0; + toastEl.textContent = ''; + rescanStatus = 403; + + await triggerRescan(); + + assert(requests.length === 1, 'failed trigger should not poll scan progress'); + assert(toastEl.textContent === 'admin only', 'failed trigger should show the API error'); +} + +console.log('Running admin rescan frontend tests...'); +testKeyboardRescanHandler(); +testRenderRunningProgress(); +testRenderIdleProgressHidesIndicator(); +testScanIndicatorOutsideHeader(); +testScanIndicatorAboveModalOverlay(); +await testTriggerRescanRefreshesProgress(); +await testTriggerRescanErrorDoesNotPollProgress(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All admin rescan frontend tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/keyboard-enter.test.js b/player-server/web/js/tests/keyboard-enter.test.js new file mode 100644 index 0000000..434f50c --- /dev/null +++ b/player-server/web/js/tests/keyboard-enter.test.js @@ -0,0 +1,75 @@ +import { initKeyboard } from '../keyboard.js'; + +const failures = []; +let keydownHandler = null; + +globalThis.document = { + addEventListener(type, handler) { + if (type === 'keydown') keydownHandler = handler; + }, +}; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +function pressKey(key, target = { tagName: 'BODY', isContentEditable: false }) { + let prevented = false; + keydownHandler?.({ + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + target, + preventDefault() { prevented = true; }, + }); + return prevented; +} + +function testEnterActivatesGridHandler() { + let entered = 0; + initKeyboard({ enter: () => { entered += 1; } }); + + const prevented = pressKey('Enter', { tagName: 'DIV', isContentEditable: false }); + + assert(entered === 1, 'Enter on a grid/card target should call the enter handler'); + assert(prevented, 'Enter on a grid/card target should prevent native default handling'); +} + +function testEnterLeavesFocusedButtonNative() { + let entered = 0; + initKeyboard({ enter: () => { entered += 1; } }); + + const prevented = pressKey('Enter', { tagName: 'BUTTON', isContentEditable: false }); + + assert(entered === 0, 'Enter on a focused button should not also call the global enter handler'); + assert(!prevented, 'Enter on a focused button should leave native button activation alone'); +} + +function testEscapeStillWorksOnNativeControl() { + let escaped = 0; + let blurred = 0; + initKeyboard({ escape: () => { escaped += 1; } }); + + const prevented = pressKey('Escape', { + tagName: 'BUTTON', + isContentEditable: false, + blur() { blurred += 1; }, + }); + + assert(escaped === 1, 'Escape on a focused button should call escape handler'); + assert(blurred === 1, 'Escape on a focused button should blur the button'); + assert(!prevented, 'Escape on a focused button should preserve existing keyboard behavior'); +} + +console.log('Running keyboard Enter tests...'); +testEnterActivatesGridHandler(); +testEnterLeavesFocusedButtonNative(); +testEscapeStillWorksOnNativeControl(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All keyboard Enter tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/media-pagination.test.js b/player-server/web/js/tests/media-pagination.test.js new file mode 100644 index 0000000..0d599fc --- /dev/null +++ b/player-server/web/js/tests/media-pagination.test.js @@ -0,0 +1,56 @@ +import { paginateItems, setMediaPageSize } from '../views/media-grid.js'; +import { state } from '../state.js'; + +const failures = []; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +function items(count) { + return Array.from({ length: count }, (_, i) => i + 1); +} + +function testDefaultPageSize() { + setMediaPageSize(undefined); + const first = paginateItems(items(101), 0); + assert(first.items.length === 100, 'default first page should contain 100 items'); + assert(first.hasNext, 'default first page should have next'); + assert(!first.hasPrev, 'default first page should not have previous'); + + const second = paginateItems(items(101), 1); + assert(second.items.length === 1, 'default second page should contain remaining item'); + assert(second.hasPrev, 'default second page should have previous'); + assert(!second.hasNext, 'default second page should not have next'); +} + +function testConfiguredPageSize() { + state.mediaPage = 2; + setMediaPageSize(25); + const page = paginateItems(items(60), 1); + assert(state.mediaPage === 0, 'changing page size should reset current media page'); + assert(page.items.length === 25, 'configured page should contain configured number of items'); + assert(page.start === 25, 'configured second page should start at item offset 25'); + assert(page.end === 50, 'configured second page should end at item offset 50'); +} + +function testClampsOutOfRangePage() { + setMediaPageSize(25); + const page = paginateItems(items(60), 99); + assert(page.page === 2, 'page should clamp to the last page'); + assert(page.items.length === 10, 'last page should contain remaining items'); +} + +console.log('Running media pagination tests...'); +testDefaultPageSize(); +testConfiguredPageSize(); +testClampsOutOfRangePage(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All media pagination tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/playback-resume.test.js b/player-server/web/js/tests/playback-resume.test.js new file mode 100644 index 0000000..2c9d199 --- /dev/null +++ b/player-server/web/js/tests/playback-resume.test.js @@ -0,0 +1,51 @@ +import { API } from '../api.js'; +import { state } from '../state.js'; + +// --- Minimal test harness for browser module validation --- +const failures = []; +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +// Mock fetch and DOM for headless validation +const mockDetail = { + media: { id: 7, file_name: 'song.mp3', type: 'audio', duration: 180 }, + progress: { user_id: 1, media_id: 7, position_seconds: 42.5, updated_at: new Date().toISOString() } +}; + +// We can't run the real module in Node without DOM, so we test the JSON shape contract instead. +function testDetailShape() { + assert(mockDetail.media.id === 7, 'media.id should exist'); + assert(mockDetail.progress.position_seconds === 42.5, 'progress.position_seconds should be 42.5'); +} + +function testResumeFromComputation() { + const detailWithProgress = { progress: { position_seconds: 99 } }; + const detailWithout = { progress: null }; + const resumeFrom = detailWithProgress.progress ? detailWithProgress.progress.position_seconds : 0; + assert(resumeFrom === 99, 'resumeFrom should be 99 when progress exists'); + const resumeFromNone = detailWithout.progress ? detailWithout.progress.position_seconds : 0; + assert(resumeFromNone === 0, 'resumeFrom should be 0 when no progress'); +} + +function testListItemShape() { + const item = { id: 1, file_name: 'a.mp4', type: 'video', duration: 120 }; + assert(item.id === 1, 'list item id'); + assert(item.file_name === 'a.mp4', 'list item file_name'); + assert(!('resume_from' in item), 'list item should not have resume_from'); +} + +// Run tests +console.log('Running playback resume frontend contract tests...'); +testDetailShape(); +testResumeFromComputation(); +testListItemShape(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All frontend contract tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/podcast-episodes-api.test.js b/player-server/web/js/tests/podcast-episodes-api.test.js new file mode 100644 index 0000000..d46d782 --- /dev/null +++ b/player-server/web/js/tests/podcast-episodes-api.test.js @@ -0,0 +1,74 @@ +import { API } from '../api.js'; + +const failures = []; +const requested = []; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +globalThis.fetch = async (url) => { + requested.push(url); + return new Response('[]', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +globalThis.location = { pathname: '/', href: '' }; + +async function capturesUrl(fn) { + requested.length = 0; + await fn(); + return requested[0]; +} + +function paramsFrom(url) { + return new URL(url, 'http://player.test').searchParams; +} + +async function testDefaults() { + const params = paramsFrom(await capturesUrl(() => API.podcastEpisodes(5))); + assert(params.get('limit') === '50', 'default limit should be 50'); + assert(params.get('offset') === '0', 'default offset should be 0'); +} + +async function testSecondArgumentIsOffset() { + const params = paramsFrom(await capturesUrl(() => API.podcastEpisodes(5, 0))); + assert(params.get('limit') === '50', 'single numeric argument should keep default limit'); + assert(params.get('offset') === '0', 'single numeric argument should set explicit offset'); +} + +async function testOptionsObject() { + const params = paramsFrom(await capturesUrl(() => API.podcastEpisodes(5, { limit: 25, offset: 75 }))); + assert(params.get('limit') === '25', 'options object should set limit'); + assert(params.get('offset') === '75', 'options object should set offset'); +} + +async function testOptionsObjectKeepsZeroLimit() { + const params = paramsFrom(await capturesUrl(() => API.podcastEpisodes(5, { limit: 0 }))); + assert(params.get('limit') === '0', 'options object should preserve explicit zero limit'); + assert(params.get('offset') === '0', 'options object should default offset to 0'); +} + +async function testLegacyLimitOffset() { + const params = paramsFrom(await capturesUrl(() => API.podcastEpisodes(5, 10, 20))); + assert(params.get('limit') === '10', 'legacy third-argument form should preserve limit'); + assert(params.get('offset') === '20', 'legacy third-argument form should preserve offset'); +} + +console.log('Running podcast episodes API pagination tests...'); +await testDefaults(); +await testSecondArgumentIsOffset(); +await testOptionsObject(); +await testOptionsObjectKeepsZeroLimit(); +await testLegacyLimitOffset(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All podcast episodes API pagination tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/progress-actions.test.js b/player-server/web/js/tests/progress-actions.test.js new file mode 100644 index 0000000..1694ea3 --- /dev/null +++ b/player-server/web/js/tests/progress-actions.test.js @@ -0,0 +1,120 @@ +import { API } from '../api.js'; +import { state } from '../state.js'; +import { initMediaGrid, loadMedia } from '../views/media-grid.js'; + +const failures = []; +const requests = []; + +const grid = mockElement(); +const emptyHint = mockElement(); +const breadcrumb = mockElement(); +const resultCount = mockElement(); + +globalThis.location = { pathname: '/index.html', href: '' }; +globalThis.document = { + getElementById(id) { + if (id === 'media-grid') return grid; + if (id === 'empty-hint') return emptyHint; + if (id === 'breadcrumb-bar') return breadcrumb; + if (id === 'result-count') return resultCount; + return null; + }, + querySelectorAll() { return []; }, +}; + +globalThis.fetch = async (url, options = {}) => { + requests.push({ url: String(url), options }); + if (String(url) === '/api/in-progress') { + return jsonResponse([ + { + id: 7, + file_name: 'resume.mp4', + type: 'video', + duration: 125, + file_size_bytes: 2048, + thumbnail_path: 'thumbs/resume.jpg', + }, + ]); + } + return jsonResponse({ status: 'ok' }); +}; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +function mockElement() { + return { + classList: { + add() {}, + remove() {}, + toggle() {}, + contains() { return false; }, + }, + innerHTML: '', + textContent: '', + addEventListener() {}, + querySelectorAll() { return []; }, + querySelector() { return null; }, + }; +} + +function jsonResponse(body) { + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => body, + }; +} + +async function testProgressStatusAPIWrapper() { + requests.length = 0; + await API.progressStatus(7, 'finished'); + const req = requests[0]; + assert(req?.url === '/api/progress/status', 'progressStatus should call the progress status endpoint'); + assert(req?.options?.method === 'POST', 'progressStatus should POST'); + assert( + req?.options?.body === JSON.stringify({ media_id: 7, status: 'finished' }), + 'progressStatus should send media_id and status', + ); +} + +async function testInProgressAPIWrapper() { + requests.length = 0; + const list = await API.inProgress(); + assert(requests[0]?.url === '/api/in-progress', 'inProgress should call the in-progress endpoint'); + assert(Array.isArray(list), 'inProgress should return the decoded list'); +} + +async function testInProgressVirtualGrid() { + requests.length = 0; + state.virtualSet = 'in-progress'; + state.selectedSetId = null; + state.selectedSetIds = []; + state.folderPath = ''; + state.mediaPage = 0; + initMediaGrid(); + + await loadMedia(); + + assert(requests[0]?.url === '/api/in-progress', 'virtual set should load via API.inProgress'); + assert(state.media.length === 1 && state.media[0].id === 7, 'virtual set should store flat media results'); + assert(grid.innerHTML.includes('data-action="mark-finished"'), 'cards should render a finished action'); + assert(grid.innerHTML.includes('data-action="mark-not-started"'), 'cards should render a not-started action'); + assert(resultCount.textContent === '1 items', 'result count should reflect in-progress media'); +} + +console.log('Running progress action tests...'); +await testProgressStatusAPIWrapper(); +await testInProgressAPIWrapper(); +await testInProgressVirtualGrid(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All progress action tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/search-parser.test.js b/player-server/web/js/tests/search-parser.test.js new file mode 100644 index 0000000..8b8e287 --- /dev/null +++ b/player-server/web/js/tests/search-parser.test.js @@ -0,0 +1,33 @@ +import { parseQuery } from '../search.js'; + +const failures = []; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +function testSetFilter() { + const parsed = parseQuery('set:yoga'); + assert(parsed.set === 'yoga', 'set token should parse set name'); + assert(parsed.search === '', 'set token should not become text search'); +} + +function testQuotedSetFilter() { + const parsed = parseQuery('set:"Yoga Flow" type:video morning'); + assert(parsed.set === 'Yoga Flow', 'quoted set token should preserve spaces'); + assert(parsed.type === 'video', 'other filters should still parse'); + assert(parsed.search === 'morning', 'free text should still parse'); +} + +console.log('Running search parser tests...'); +testSetFilter(); +testQuotedSetFilter(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All search parser tests passed.'); + process.exit(0); +} diff --git a/player-server/web/js/tests/shuffle.test.js b/player-server/web/js/tests/shuffle.test.js new file mode 100644 index 0000000..2472543 --- /dev/null +++ b/player-server/web/js/tests/shuffle.test.js @@ -0,0 +1,165 @@ +import { initKeyboard } from '../keyboard.js'; +import { enable, isOn, revision, toggle } from '../shuffle.js'; +import { state } from '../state.js'; +import { initMediaGrid, loadMedia } from '../views/media-grid.js'; + +const failures = []; +const requests = []; +let keydownHandler = null; + +const button = { + active: false, + classList: { + toggle(name, enabled) { + if (name === 'active') button.active = enabled; + }, + }, +}; +const grid = mockElement(); +const emptyHint = mockElement(); +const breadcrumb = mockElement(); +const resultCount = mockElement(); + +globalThis.document = { + activeElement: null, + fullscreenElement: null, + addEventListener(type, handler) { + if (type === 'keydown') keydownHandler = handler; + }, + getElementById(id) { + if (id === 'shuffle-toggle') return button; + if (id === 'media-grid') return grid; + if (id === 'empty-hint') return emptyHint; + if (id === 'breadcrumb-bar') return breadcrumb; + if (id === 'result-count') return resultCount; + return null; + }, +}; +globalThis.location = { pathname: '/index.html', href: '' }; +globalThis.fetch = async (url) => { + requests.push(String(url)); + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => [], + }; +}; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +function mockElement() { + return { + classList: { + add() {}, + remove() {}, + toggle() {}, + contains() { return false; }, + }, + innerHTML: '', + textContent: '', + addEventListener() {}, + querySelectorAll() { return []; }, + querySelector() { return null; }, + }; +} + +function testEnableTurnsShuffleOn() { + enable(); + assert(isOn(), 'enable should turn shuffle on'); + assert(button.active, 'enable should update the shuffle button active state'); +} + +function testRepeatedEnableKeepsShuffleOnAndAdvancesRevision() { + const before = revision(); + enable(); + assert(isOn(), 'repeated enable should keep shuffle on'); + assert(revision() > before, 'repeated enable should advance revision for a fresh random load'); +} + +function testToggleCanStillTurnShuffleOff() { + toggle(); + assert(!isOn(), 'toggle should still turn shuffle off for the toolbar button'); + assert(!button.active, 'toggle should update the shuffle button inactive state'); +} + +async function testKeyboardReshuffleRequestsRandomMediaWithNewRevision() { + state.selectedSetId = null; + state.selectedSetIds = []; + state.folderPath = ''; + state.mediaPage = 0; + Object.assign(state.filters, { + type: '', + search: '', + favorites: false, + tags: '', + sort: '', + minDuration: '', + maxDuration: '', + minFileSize: '', + maxFileSize: '', + }); + requests.length = 0; + + initMediaGrid({ + isShuffle: isOn, + shuffleRevision: revision, + }); + initKeyboard({ + shuffle: () => { + enable(); + loadMedia(); + }, + }); + + pressKey('r'); + await waitForRequests(1); + pressKey('r'); + await waitForRequests(2); + + const first = new URL(requests[0], 'http://player.test'); + const second = new URL(requests[1], 'http://player.test'); + assert(first.pathname === '/api/media', 'keyboard shuffle should request media'); + assert(first.searchParams.get('sort') === 'random', 'keyboard shuffle should request random sort'); + assert(second.searchParams.get('sort') === 'random', 'keyboard reshuffle should keep random sort'); + assert(first.searchParams.get('shuffle_revision'), 'keyboard shuffle should include shuffle revision'); + assert(second.searchParams.get('shuffle_revision'), 'keyboard reshuffle should include shuffle revision'); + assert( + second.searchParams.get('shuffle_revision') !== first.searchParams.get('shuffle_revision'), + 'keyboard reshuffle should change shuffle revision in the media request', + ); +} + +function pressKey(key) { + keydownHandler?.({ + key, + code: `Key${key.toUpperCase()}`, + target: { tagName: 'BODY', isContentEditable: false }, + preventDefault() {}, + }); +} + +async function waitForRequests(count) { + for (let i = 0; i < 10; i += 1) { + if (requests.length >= count) return; + await Promise.resolve(); + } + assert(false, `expected ${count} request(s), got ${requests.length}`); +} + +console.log('Running shuffle tests...'); +testEnableTurnsShuffleOn(); +testRepeatedEnableKeepsShuffleOnAndAdvancesRevision(); +testToggleCanStillTurnShuffleOff(); +await testKeyboardReshuffleRequestsRandomMediaWithNewRevision(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All shuffle tests passed.'); + process.exit(0); +} |
