import test from 'node:test' import assert from 'node:assert/strict' import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js' const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: `https://media.invalid/${code}.mp4` } }) const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' }) const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' }) test('simultaneous open commands never create a video for a superseded device', async () => { const requests = [], updates = [] const live = createVideoLiveSession({ prepare: async (code) => prepared(code), start: async (session) => { const code = typeof session === 'string' ? session : session.deviceCode; requests.push(code); return data(code) }, heartbeat: async () => {}, stop: async (session) => stopped(session), }, (patch) => updates.push(patch)) try { await Promise.all([live.start('A'), live.start('B')]) assert.deepEqual(requests, ['B']) assert.equal(updates.some((patch) => patch.media?.url.endsWith('/A.mp4')), false) } finally { await live.dispose() } }) test('closing in the same turn cancels an open before it can start server media', async () => { const requests = [] const live = createVideoLiveSession({ prepare: async (code) => prepared(code), start: async (session) => { const code = typeof session === 'string' ? session : session.deviceCode; requests.push(code); return data(code) }, heartbeat: async () => {}, stop: async (session) => stopped(session), }, () => {}) try { const opening = live.start('A') await live.stop() await opening assert.deepEqual(requests, []) } finally { await live.dispose() } }) 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) } } test('STARTING is polled using the prepared identity and is never mistaken for active media', async () => { const clock = new Clock(), updates = [], releases = [], statuses = [] const live = createVideoLiveSession({ prepare: async () => prepared('A'), start: async () => ({ sessionId: 'session-A', status: 'STARTING' }), status: async (session) => { statuses.push(session.sessionId); return data('A') }, stop: async (session) => { releases.push(session.sessionId); return stopped(session) }, heartbeat: async () => {}, }, (patch) => updates.push(patch), clock) try { const opening = live.start('A'); await new Promise(setImmediate) assert.equal(updates.some((patch) => patch.media), false) await clock.advance(5000); await opening assert.deepEqual(statuses, ['session-A']) assert.equal(updates.some((patch) => patch.media?.url.endsWith('/A.mp4')), true) assert.deepEqual(releases, []) } finally { await live.dispose() } }) test('accepted cancellation stays unconfirmed until status attests terminal cleanup', async () => { const clock = new Clock(), visible = {}, statusCalls = [] const live = createVideoLiveSession({ prepare: async () => prepared('A'), start: async () => data('A'), heartbeat: async () => {}, stop: async () => ({ sessionId: 'session-A', status: 'CLEANUP_PENDING' }), status: async (session) => { statusCalls.push(session.sessionId); return { sessionId: 'session-A', status: 'STOPPED' } }, }, (patch) => Object.assign(visible, patch), clock) try { await live.start('A'); live.ready(); await live.stop() assert.equal(visible.cleanupError.includes('未确认'), true) await clock.advance(5000) assert.deepEqual(statusCalls, ['session-A']) assert.equal(visible.cleanupError, '') assert.equal(visible.media, null) } finally { await live.dispose() } }) test('a confirmed cancel permits the new device while the old live response is still in flight', async () => { const updates = [], starts = [] let arrived, finishOld const entered = new Promise((resolve) => { arrived = resolve }) const late = new Promise((resolve) => { finishOld = resolve }) const live = createVideoLiveSession({ prepare: async (code) => prepared(code), start: async (session) => { starts.push(session.deviceCode); if (session.deviceCode === 'A') { arrived(); return late }; return data('B') }, stop: async (session) => stopped(session), heartbeat: async () => {}, }, (patch) => updates.push(patch)) const first = live.start('A') try { await entered await live.start('B') assert.deepEqual(starts, ['A', 'B']) finishOld(data('A')); await first const displayed = updates.filter((patch) => patch.media).map((patch) => patch.media.url) assert.deepEqual(displayed, ['https://media.invalid/B.mp4']) } finally { finishOld(data('A')); await first; await live.dispose() } }) test('cancel releases the prepared identity while live is still pending and a late response cannot publish media', async () => { const releases = [], updates = [] let started, respond const entered = new Promise((resolve) => { started = resolve }) const delayed = new Promise((resolve) => { respond = resolve }) const live = createVideoLiveSession({ prepare: async (code) => prepared(code), start: async () => { started(); return delayed }, stop: async (session) => { releases.push({ sessionId: session.sessionId, sessionToken: session.sessionToken }); return stopped(session) }, heartbeat: async () => {}, }, (patch) => updates.push(patch)) const opening = live.start('A') try { await entered await live.stop() assert.deepEqual(releases, [{ sessionId: 'session-A', sessionToken: 'owner-A' }]) respond(data('A')); await opening assert.equal(updates.some((patch) => patch.media), false) } finally { respond(data('A')); await opening; await live.dispose() } })