|
|
@@ -0,0 +1,162 @@
|
|
|
+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 = []
|
|
|
+ const playback = createVideoPlaybackSession({
|
|
|
+ records: async (...args) => { asked.push(args); return { records: [] } },
|
|
|
+ playback: async () => started('A'), stop: async (session) => stopped(session),
|
|
|
+ }, () => {})
|
|
|
+ try {
|
|
|
+ await assert.rejects(
|
|
|
+ () => playback.query('A', '2026-11-01 00:00:00', '2026-11-10 00:00:00'),
|
|
|
+ (error) => error.category === 'INVALID_REQUEST')
|
|
|
+ assert.deepEqual(asked, [], 'an illegal window must never reach the server')
|
|
|
+ } finally { await playback.dispose() }
|
|
|
+})
|
|
|
+
|
|
|
+test('an inverted or malformed window is refused locally', async () => {
|
|
|
+ const playback = createVideoPlaybackSession({
|
|
|
+ records: async () => ({ records: [] }), playback: async () => started('A'),
|
|
|
+ stop: async (session) => stopped(session),
|
|
|
+ }, () => {})
|
|
|
+ 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']]) {
|
|
|
+ await assert.rejects(() => playback.query('A', from, to), (error) => error.category === 'INVALID_REQUEST')
|
|
|
+ }
|
|
|
+ } 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('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() }
|
|
|
+})
|