import test from 'node:test' import assert from 'node:assert/strict' import { createVideoPlaybackSession, defaultHistoryWindow } from '../../src/modules/cockpit/services/videoPlaybackSession.js' const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` }) const started = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'PLAYBACK', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', media: media(code) }) const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' }) class Clock { now = 0; next = 1; jobs = new Map() setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id } clearTimeout(id) { this.jobs.delete(id) } setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id } clearInterval(id) { this.clearTimeout(id) } async advance(duration) { const end = this.now + duration while (true) { const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0] if (!due) break const [id, job] = due; this.now = job.at if (job.interval) job.at += job.interval; else this.jobs.delete(id) job.fn(); await new Promise(setImmediate) } this.now = end; await new Promise(setImmediate) } } /** UTC+8 wall clock, independent of the machine zone. */ test('the default history window is today 00:00 to now in UTC+8', async () => { const at = Date.parse('2026-11-10T03:30:00Z') // 11:30 in UTC+8 const window = defaultHistoryWindow(() => at) assert.deepEqual(window, { startTime: '2026-11-10 00:00:00', endTime: '2026-11-10 11:30:00' }) }) /** The 7-day ceiling is a query bound, not a retention promise. */ test('a window longer than seven days is refused before any request', async () => { const asked = [] let visible = {} const playback = createVideoPlaybackSession({ records: async (...args) => { asked.push(args); return { records: [] } }, playback: async () => started('A'), stop: async (session) => stopped(session), }, (patch) => { visible = { ...visible, ...patch } }) try { // Refused locally and surfaced as an error state, never sent to the server. assert.deepEqual(await playback.query('A', '2026-11-01 00:00:00', '2026-11-10 00:00:00'), []) assert.deepEqual(asked, [], 'an illegal window must never reach the server') assert.equal(visible.phase, 'failed') assert.ok(visible.error.includes('7 天'), visible.error) } finally { await playback.dispose() } }) test('an inverted or malformed window is refused locally', async () => { const asked = [] let visible = {} const playback = createVideoPlaybackSession({ records: async (...args) => { asked.push(args); return { records: [] } }, playback: async () => started('A'), stop: async (session) => stopped(session), }, (patch) => { visible = { ...visible, ...patch } }) try { for (const [from, to] of [['2026-11-10 02:00:00', '2026-11-10 01:00:00'], ['2026-13-10 00:00:00', '2026-11-10 01:00:00']]) { assert.deepEqual(await playback.query('A', from, to), []) assert.equal(visible.phase, 'failed') } assert.deepEqual(asked, [], 'an illegal window must never reach the server') } finally { await playback.dispose() } }) test('querying publishes the returned list and distinguishes empty from failure', async () => { const updates = [] let visible = {} let reply = { records: [{ name: 'CAM/01', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00' }] } const playback = createVideoPlaybackSession({ records: async () => reply, playback: async () => started('A'), stop: async (session) => stopped(session), }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }) try { await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00') assert.equal(visible.phase, 'listed') assert.equal(visible.records.length, 1) assert.equal(visible.empty, false) reply = { records: [] } await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00') assert.equal(visible.empty, true, 'an empty list is a result, not an error') assert.equal(visible.phase, 'listed') // A genuine failure is shown as such and never retried automatically. const failing = createVideoPlaybackSession({ records: async () => { throw Object.assign(new Error('查询失败'), { category: 'VIDEO_READ_FAILED' }) }, playback: async () => started('A'), stop: async (session) => stopped(session), }, (patch) => { visible = { ...visible, ...patch } }) try { await failing.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00') assert.equal(visible.phase, 'failed') assert.equal(visible.empty, false, 'a failure is not an empty result') } finally { await failing.dispose() } } finally { await playback.dispose() } }) test('playback starts only for a chosen recording and never stops another viewer', async () => { const calls = [] const updates = [] let visible = {} const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), playback: async (code, from, to) => { calls.push([code, from, to]); return started(code) }, stop: async (session) => stopped(session), }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }) try { // Choosing a recording is what starts playback; listing alone never does. await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00') assert.deepEqual(calls, [], 'a query must not start playback') await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') assert.deepEqual(calls, [['A', '2026-11-10 01:00:00', '2026-11-10 02:00:00']]) assert.equal(visible.phase, 'playing') assert.equal(visible.media.url.includes('A.mp4'), true) } finally { await playback.dispose() } }) test('an occupied channel is reported without stopping the existing playback', async () => { const stops = [], updates = [] let visible = {} const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), playback: async () => { throw Object.assign(new Error('此通道已有录像回放'), { category: 'PLAYBACK_OCCUPIED' }) }, stop: async (session) => { stops.push(session); return stopped(session) }, }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }) try { await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') assert.equal(visible.phase, 'failed') assert.ok(visible.error.includes('已有录像回放'), visible.error) assert.deepEqual(stops, [], 'a losing request must never release somebody else playback') } finally { await playback.dispose() } }) test('an unconfirmed playback control is refused locally without a request', async () => { const sent = [] const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), playback: async () => started('A'), control: async (session, action, value) => { sent.push([action, value]); return {} }, stop: async (session) => stopped(session), }, () => {}) try { await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') // The default capability set confirms nothing, so every control must be refused locally. for (const key of ['pause', 'resume', 'seek', 'speed-1', 'speed-4']) { assert.equal(await playback.control(key), false, `${key} 未确认时不得发送`) } assert.deepEqual(sent, [], 'no unconfirmed control may reach the server') // An unrecognised action is rejected too, never forwarded blindly. assert.equal(await playback.control('reboot'), false) assert.deepEqual(sent, []) } finally { await playback.dispose() } }) test('a confirmed control is forwarded with its action and rate, and a speed rate maps to one capability', async () => { const sent = [] let visible = {} const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), // A device that proved pause and speed support, but not seek. playback: async () => ({ ...started('A'), controls: { pause: true, resume: true, speed: true, seek: false } }), control: async (session, action, value) => { sent.push([action, value]); return { sessionId: session.sessionId, status: 'ACTIVE' } }, stop: async (session) => stopped(session), }, (patch) => { visible = { ...visible, ...patch } }) try { await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') assert.equal(visible.controls.pause, true, 'a confirmed capability must reach the panel') assert.equal(await playback.control('pause'), true) // Each rate key is one capability on the server but carries its own rate value. assert.equal(await playback.control('speed-0.25'), true) assert.equal(await playback.control('speed-4'), true) assert.equal(await playback.control('seek'), false, 'an unconfirmed seek must still be refused') assert.deepEqual(sent, [['pause', undefined], ['speed', 0.25], ['speed', 4]]) } finally { await playback.dispose() } }) test('stopping resets the playback capability set so it cannot leak into the next session', async () => { let visible = {} const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), playback: async () => ({ ...started('A'), controls: { pause: true, resume: true, speed: true, seek: true } }), stop: async (session) => stopped(session), }, (patch) => { visible = { ...visible, ...patch } }) try { await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') assert.equal(visible.controls.pause, true) await playback.stop() assert.deepEqual(visible.controls, {}, 'a released session must not keep its capabilities') } finally { await playback.dispose() } }) test('stopping releases the owned playback session and reports unconfirmed cleanup truthfully', async () => { const clock = new Clock(), updates = [] let visible = {} let stopReply = stopped({ sessionId: 'session-A' }) const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), playback: async () => started('A'), stop: async () => stopReply, status: async () => ({ sessionId: 'session-A', status: 'STOPPED' }), }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock) try { await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') assert.equal(await playback.stop(), true) assert.equal(visible.media, null) assert.equal(visible.cleanupError, '') // An unconfirmed release must not be reported as success. stopReply = { sessionId: 'session-A', status: 'CLEANUP_PENDING' } await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') assert.equal(await playback.stop(), false, 'an unconfirmed cleanup is not a clean stop') assert.notEqual(visible.cleanupError, '') } finally { await playback.dispose() } }) test('switching back to live ends the old playback before live starts', async () => { const events = [] const playback = createVideoPlaybackSession({ records: async () => ({ records: [] }), playback: async () => { events.push('playback-start'); return started('A') }, stop: async (session) => { events.push('playback-stop'); return stopped(session) }, }, () => {}) try { await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00') await playback.stop() assert.deepEqual(events, ['playback-start', 'playback-stop'], 'the old stream ends before anything replaces it') } finally { await playback.dispose() } })