Przeglądaj źródła

test(cockpit): 补齐人工重播复位迟到接收与逐次释放断言

ChenJZ 5 godzin temu
rodzic
commit
6b285bc88c

+ 3 - 2
src/modules/cockpit/services/videoLiveSession.js

@@ -27,8 +27,8 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
     const retryDelays = [2000, 4000, 8000, 16000, 30000]
     const stablePlaybackMs = 30000
     // VideoRequestError carries `category`; the player and accepted errors use `code`.
-    const unrecoverable = new Set(['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE',
-        'NOT_CONFIGURED', 'INVALID_REQUEST', 'UNSUPPORTED_MEDIA', 'VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED'])
+    const unrecoverable = new Set(['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'SESSION_EXPIRED',
+        'INVALID_RESPONSE', 'NOT_CONFIGURED', 'INVALID_REQUEST', 'UNSUPPORTED_MEDIA', 'VIDEO_URL_INVALID', 'VIDEO_UNSUPPORTED'])
     let retryBudget = retryDelays.length, retryTimer, retryAttempt = 0, stableTimer
     const publish = (patch) => { if (!disposed) update(patch) }
     const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
@@ -204,6 +204,7 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
         if (disposed || version !== generation) return
         // A manual replay starts a fresh budget; automatic retries never call this.
         retryBudget = retryDelays.length; retryAttempt = 0
+        publish({ retryAttempt: 0, retryDelayMs: 0, retriesLeft: retryDelays.length })
         await begin(deviceCode)
     }
     async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }

+ 62 - 2
tests/cockpit-video/reconnect.mjs

@@ -34,11 +34,12 @@ function failingLive(clock, options = {}) {
         prepare: async (code) => prepared(code),
         start: async (session) => {
             attempts.push({ at: clock.now, code: session.deviceCode })
+            options.onStart?.()
             if (options.fail) throw options.fail(attempts.length)
             throw new Error('暂时无法连接视频')
         },
         status: async () => ({ sessionId: 'session-A', status: 'FAILED' }),
-        stop: async (session) => stopped(session),
+        stop: async (session) => { options.onStop?.(); return stopped(session) },
         heartbeat: async () => {},
     }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
     return { live, attempts, updates, visible: () => visible }
@@ -110,7 +111,7 @@ async function fixtureWithPlayback(stableMs) {
 
 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']) {
+    for (const category of ['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'SESSION_EXPIRED', 'INVALID_RESPONSE']) {
         const clock = new Clock(), h = failingLive(clock, { fail: () => { throw Object.assign(new Error(category), { category }) } })
         try {
             const opening = h.live.start('A')
@@ -180,3 +181,62 @@ test('closing during a retry wait both cancels and releases cleanly', async () =
         assert.equal(h.attempts.length, 2, 'reopening must actually attempt a new live start')
     } finally { await h.live.dispose() }
 })
+
+test('a manual replay after an exhausted budget starts a fresh budget', 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.visible().phase, 'failed')
+        assert.ok(h.visible().error.includes('已自动重试 5 次仍失败'), h.visible().error)
+        assert.equal(h.visible().retriesLeft, 0, 'an exhausted budget reports zero left')
+        // A human clicking replay must get all five automatic attempts back.
+        const replay = h.live.start('A')
+        await new Promise(setImmediate)
+        await clock.advance(200000)
+        await replay
+        assert.equal(h.attempts.length, 12, 'the manual replay spends a full second budget')
+    } finally { await h.live.dispose() }
+})
+
+test('a late live response during the retry wait cannot present media for a stopped attempt', async () => {
+    const clock = new Clock(), updates = []
+    let visible = {}, respond
+    const delayed = new Promise((resolve) => { respond = resolve })
+    const live = createVideoLiveSession({
+        prepare: async (code) => prepared(code),
+        start: async () => delayed,
+        stop: async (session) => stopped(session), heartbeat: async () => {},
+    }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
+    try {
+        const opening = live.start('A')
+        await new Promise(setImmediate)
+        // The start deadline fires, so the attempt is cancelled and a retry is scheduled.
+        await clock.advance(35000)
+        assert.equal(visible.phase, 'retrying')
+        // The cancelled attempt's ACTIVE response arrives late; it must never reach the panel.
+        respond(data('A'))
+        await new Promise(setImmediate); await new Promise(setImmediate)
+        assert.equal(visible.media, null, 'a late response must not publish media')
+        assert.equal(visible.phase, 'retrying')
+        assert.equal(updates.some((patch) => patch.phase === 'connecting'), false)
+        await clock.advance(200000)
+        await opening
+    } finally { await live.dispose() }
+})
+
+test('every attempt releases its own identity exactly once before the next begins', async () => {
+    const clock = new Clock(), events = []
+    const h = failingLive(clock, { onStop: () => events.push(['stop', clock.now]), onStart: () => events.push(['start', clock.now]) })
+    try {
+        const opening = h.live.start('A')
+        await new Promise(setImmediate)
+        await clock.advance(200000)
+        await opening
+        // Six attempts, six releases, alternating strictly so no two overlap.
+        assert.deepEqual(events.map(([kind]) => kind), ['start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop', 'start', 'stop'],
+            'every attempt releases exactly once before the next begins')
+    } finally { await h.live.dispose() }
+})