import test from 'node:test' import assert from 'node:assert/strict' import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js' const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` }) const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: media(code) }) const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' }) 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) } } /** Builds a session whose live start always fails, and records every attempt. */ function failingLive(clock, options = {}) { const attempts = [], updates = [] let visible = {} const live = createVideoLiveSession({ prepare: async (code) => prepared(code), start: async (session) => { attempts.push({ at: clock.now, code: session.deviceCode }) if (options.fail) throw options.fail(attempts.length) throw new Error('暂时无法连接视频') }, status: async () => ({ sessionId: 'session-A', status: 'FAILED' }), stop: async (session) => stopped(session), heartbeat: async () => {}, }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock) return { live, attempts, updates, visible: () => visible } } test('live reconnects at most five times and never for the first normal play', async () => { const clock = new Clock(), h = failingLive(clock) try { const opening = h.live.start('A') await new Promise(setImmediate) await clock.advance(200000) await opening assert.equal(h.attempts.length, 6, 'one initial attempt plus five bounded retries') assert.equal(h.visible().phase, 'failed') } finally { await h.live.dispose() } }) test('retries wait 2/4/8/16/30 seconds serially without stacking sessions', async () => { const clock = new Clock(), h = failingLive(clock) try { const opening = h.live.start('A') await new Promise(setImmediate) await clock.advance(200000) await opening const gaps = h.attempts.slice(1).map((attempt, index) => attempt.at - h.attempts[index].at) assert.deepEqual(gaps, [2000, 4000, 8000, 16000, 30000]) const retrying = h.updates.filter((patch) => patch.phase === 'retrying').map((patch) => patch.retryAttempt) assert.deepEqual(retrying, [1, 2, 3, 4, 5], 'each waiting attempt is observable') } finally { await h.live.dispose() } }) test('only thirty seconds of real playback restores the retry budget', async () => { // Control: playback shorter than the stable window keeps the already-spent budget, // so the following disconnect can only fan out from the remaining allowance. const short = await fixtureWithPlayback(29000) try { await short.drain() assert.equal(short.attempts.length, 6, 'a short window keeps the already-spent budget') } finally { await short.live.dispose() } // Full 30 seconds of playback replenishes the budget, so the next disconnect gets five fresh tries. const full = await fixtureWithPlayback(30000) try { await full.drain() assert.equal(full.attempts.length, 7, 'a full stable window restores the budget') } finally { await full.live.dispose() } }) /** Plays successfully, stays up for `stableMs`, then drops and reports how many starts happened. */ async function fixtureWithPlayback(stableMs) { const clock = new Clock(), attempts = [] let healthy = false const live = createVideoLiveSession({ prepare: async (code) => prepared(code), start: async () => { attempts.push(clock.now); if (healthy) return data('A'); throw new Error('暂时无法连接视频') }, stop: async (session) => stopped(session), heartbeat: async () => {}, }, () => {}, clock) const opening = live.start('A') await new Promise(setImmediate) healthy = true await clock.advance(2000) await opening live.ready() await clock.advance(stableMs) healthy = false live.fail('断流') await clock.advance(4000) return { live, attempts, drain: () => clock.advance(200000) } } test('unrecoverable outcomes never retry automatically', async () => { // VideoRequestError uses `category`; these are the real terminal categories from videoApi. for (const category of ['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE']) { const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(category), { category }) } }) try { const opening = h.live.start('A') await new Promise(setImmediate) await clock.advance(120000) await opening assert.equal(h.attempts.length, 1, `${category} must not be retried`) assert.equal(h.visible().phase, 'failed') } finally { await h.live.dispose() } } // A temporary 503-style failure is retried. const clock = new Clock(), retryable = failingLive(clock, { fail: () => { throw Object.assign(new Error('暂时不可用'), { category: 'VIDEO_START_FAILED' }) } }) try { const opening = retryable.live.start('A') await new Promise(setImmediate) await clock.advance(120000) await opening assert.equal(retryable.attempts.length, 6, 'a temporary failure spends the whole budget') } finally { await retryable.live.dispose() } }) test('closing cancels pending retries and releases the prepared identity', async () => { const clock = new Clock(), h = failingLive(clock) try { const opening = h.live.start('A') await new Promise(setImmediate) await h.live.stop() await clock.advance(200000) await opening assert.equal(h.attempts.length, 1, 'no retry may run after close') assert.equal(h.visible().media, null) } finally { await h.live.dispose() } })