Fix playback recovery after switching course videos

This commit is contained in:
2026-10-06 01:54:54 +08:00
commit b8d830d231
9 changed files with 1149 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const assert=require('node:assert/strict');
const {harness,ID,RID}=require('./study-helper.test.cjs');
let passed=0;
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
async function start(h){h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();}
function mountPanel(h){
const elements=new Map();
const panel={querySelector(s){if(!elements.has(s))elements.set(s,{textContent:'',value:'all',checked:true});return elements.get(s);}};
h.context.document.body={append(){}};
h.context.document.createElement=()=>({style:{},attachShadow:()=>panel});
h.api.mount();return elements;
}
(async()=>{
await test('new video gets its own load deadline even after a long course',async()=>{
const h=harness();h.advance(3600000);h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);
assert.equal(h.api.state.running,true);assert.equal(h.video.playCount,0);
});
await test('metadata alone does not start playback; loaded data must settle',async()=>{
const h=harness();h.video.readyState=1;h.api.begin();await h.api.tickCourse(ID);assert.equal(h.video.playCount,0);
h.video.readyState=2;await h.api.tickCourse(ID);assert.equal(h.video.playCount,0);
h.advance(1000);await h.api.tickCourse(ID);assert.equal(h.video.playCount,1);assert.equal(h.video.playbackRate,2);
});
await test('stuck loading tries the website play overlay once',async()=>{
const h=harness();let clicks=0;h.selectors.set('.player__play-overlay',{getClientRects:()=>[{}],click(){clicks++;}});
h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);h.advance(10000);await h.api.tickCourse(ID);
h.advance(10000);await h.api.tickCourse(ID);assert.equal(clicks,1);assert.equal(h.api.state.running,true);
});
await test('90-second stuck load saves a bounded recovery checkpoint and reloads',async()=>{
const h=harness();h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);h.advance(90000);await h.api.tickCourse(ID);
assert.equal(h.context.location.reloadCount,1);const saved=JSON.parse(h.session.get('icampus.study.v1.recovery'));
assert.deepEqual(saved.used,[`${ID}/${RID}`]);assert.equal(saved.hash,h.context.location.hash);assert.equal(h.api.state.running,false);
});
await test('a recovered video cannot enter an infinite reload loop',async()=>{
const h=harness();h.api.recovered(RID);h.video.readyState=0;h.api.begin();await h.api.tickCourse(ID);h.advance(90000);await h.api.tickCourse(ID);
assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/恢复一次/);
});
await test('source-switch AbortError retries then confirms advancing playback',async()=>{
const h=harness();let calls=0;h.video.paused=true;
h.video.play=function(){calls++;if(calls===1)return Promise.reject(Object.assign(new Error('loading'),{name:'AbortError'}));this.paused=false;return Promise.resolve();};
await start(h);assert.equal(h.api.state.running,true);h.advance(2000);h.api.monitor();await Promise.resolve();
h.video.currentTime+=2;h.api.monitor();assert.equal(calls,2);assert.equal(h.api.state.active.phase,'playing');
});
await test('real autoplay permission rejection asks for a click without reloading',async()=>{
const h=harness();h.video.play=()=>Promise.reject(Object.assign(new Error('blocked'),{name:'NotAllowedError'}));
await start(h);assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/浏览器阻止/);assert.equal(h.context.location.reloadCount,undefined);
});
await test('rebuilt player returns to readiness checking instead of stopping the helper',async()=>{
const h=harness();await start(h);const old=h.video;const replacement={...old,currentSrc:'replacement',readyState:0};
h.selectors.set('video.player__video',replacement);old.isConnected=false;h.api.monitor();
assert.equal(h.api.state.running,true);assert.equal(h.api.state.active,null);
await h.api.tickCourse(ID);assert.equal(h.api.state.active,null);
replacement.readyState=4;await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);
assert.equal(h.api.state.active.video,replacement);
});
await test('ordinary pause after confirmed playback remains respected',async()=>{
const h=harness();await start(h);h.video.currentTime+=2;h.api.monitor();h.video.paused=true;h.api.monitor();
assert.equal(h.api.state.running,false);assert.equal(h.video.playCount,1);
});
await test('switching resource does not play the previous video source',async()=>{
const h=harness();h.api.details.get(ID).resources[0].completed=true;
let secondActive=false;h.buttons[1].classList.contains=()=>secondActive;h.buttons[1].click=()=>{secondActive=true;};
h.api.begin();await h.api.tickCourse(ID);h.advance(5000);await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);
assert.equal(h.video.playCount,0);
h.video.currentSrc='second-video';await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);
assert.equal(h.video.playCount,1);assert.equal(h.api.state.active.id,'2093583972926885888');
});
await test('stop clears pending recovery checkpoint',()=>{
const h=harness();h.session.set('icampus.study.v1.recovery','{}');h.api.stop();assert.equal(h.session.has('icampus.study.v1.recovery'),false);
});
await test('recovery reload resumes the same course and consumes its checkpoint',()=>{
const h=harness();h.session.set('icampus.study.v1.recovery',JSON.stringify({hash:h.context.location.hash,expires:200000,mode:'current',autoQuiz:false,used:[`${ID}/${RID}`]}));
const ui=mountPanel(h);assert.equal(h.api.state.running,true);assert.equal(ui.get('select').value,'current');assert.equal(ui.get('[data-quiz]').checked,false);
assert.equal(h.session.has('icampus.study.v1.recovery'),false);
h.api.recoverMedia(ID,RID,'再次失败');assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.api.state.running,false);
});
await test('ordinary refresh and mismatched recovery routes remain paused',()=>{
const h=harness();mountPanel(h);assert.equal(h.api.state.running,false);
const other=harness();other.session.set('icampus.study.v1.recovery',JSON.stringify({hash:'#/other',expires:200000,used:[]}));
mountPanel(other);assert.equal(other.api.state.running,false);assert.equal(other.session.has('icampus.study.v1.recovery'),false);
});
console.log(`${passed} playback recovery tests passed.`);
})().catch(error=>{console.error(error);process.exitCode=1;});