| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- 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) => { sent.push(action); 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('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() }
- })
|