From 8aa495df3b3da9213bdf29d7a15c1473fefcc63b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 9 May 2026 20:16:41 +0300 Subject: s1 add media rescan hotkey and progress indicator --- web/js/tests/admin-rescan.test.js | 156 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 web/js/tests/admin-rescan.test.js (limited to 'web/js/tests') diff --git a/web/js/tests/admin-rescan.test.js b/web/js/tests/admin-rescan.test.js new file mode 100644 index 0000000..f1661db --- /dev/null +++ b/web/js/tests/admin-rescan.test.js @@ -0,0 +1,156 @@ +import { initKeyboard } from '../keyboard.js'; +import { renderScanProgress, triggerRescan } from '../views/admin-status.js'; + +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'); +} + +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(); +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); +} -- cgit v1.2.3