|
|
@@ -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() }
|
|
|
+})
|