summaryrefslogtreecommitdiff
path: root/player-server/web/js/tests
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
commit914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch)
tree02b11537237a204048589e14ed24938b805e42f7 /player-server/web/js/tests
parent3b24f0e1be832584d6550e8cc3e5d24329f66f90 (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.js178
-rw-r--r--player-server/web/js/tests/keyboard-enter.test.js75
-rw-r--r--player-server/web/js/tests/media-pagination.test.js56
-rw-r--r--player-server/web/js/tests/playback-resume.test.js51
-rw-r--r--player-server/web/js/tests/podcast-episodes-api.test.js74
-rw-r--r--player-server/web/js/tests/progress-actions.test.js120
-rw-r--r--player-server/web/js/tests/search-parser.test.js33
-rw-r--r--player-server/web/js/tests/shuffle.test.js165
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);
+}