Recover stalled platform progress and missing saves with bounded reloads

This commit is contained in:
2026-10-12 18:08:54 +08:00
parent 1f6706efbb
commit 27293ad195
8 changed files with 173 additions and 25 deletions
+98
View File
@@ -0,0 +1,98 @@
'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}`);}
function heartbeat(h,data,resourceId=RID){h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId}},{bizCode:0,data},200);}
async function start(h){
h.api.details.get(ID).course.progressPercent=0;
h.api.details.get(ID).resources.forEach(r=>{r.currentPositionSeconds=0;r.durationSeconds=1200;});
h.video.currentTime=0;h.video.paused=true;h.api.begin();await h.api.tickCourse(ID);
h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();
h.advance(1000);h.video.currentTime=2;h.api.monitor();
}
function cycles(h,count,data){for(let i=0;i<count;i++){
h.advance(15000);h.video.currentTime+=30;heartbeat(h,typeof data==='function'?data(i):data);h.api.monitor();
if(!h.api.state.running)break;
}}
function mount(h){const nodes=new Map();const panel={querySelector(s){if(!nodes.has(s))nodes.set(s,{textContent:'',value:'all',checked:true});return nodes.get(s);}};
h.context.document.body={append(){}};h.context.document.createElement=()=>({style:{},attachShadow:()=>panel});h.api.mount();}
(async()=>{
await test('no save confirmation refreshes once and stores enabled intent and quota',async()=>{
const h=harness();await start(h);h.advance(76000);h.video.currentTime+=152;h.api.monitor();
assert.equal(h.context.location.reloadCount,1);assert.equal(h.api.state.armed,true);assert.equal(h.video.paused,true);
const checkpoint=JSON.parse(h.session.get('icampus.study.v1.recovery'));assert.deepEqual(checkpoint.used,[`${ID}/${RID}`]);
assert.equal(JSON.parse(h.session.get('icampus.study.v1.enabled')).enabled,true);
});
await test('repeated successful heartbeats with frozen saved counters trigger recovery',async()=>{
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});
cycles(h,9,{currentPositionSeconds:0,courseProgressPercent:0});
assert.equal(h.context.location.reloadCount,1);assert.match(h.api.state.status,/120秒未推进/);
});
await test('rounded course percentage can stay fixed while saved video position advances',async()=>{
const h=harness();await start(h);cycles(h,20,i=>({currentPositionSeconds:(i+1)*30,courseProgressPercent:0}));
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
await test('advancing platform learning progress prevents a false position stall',async()=>{
const h=harness();await start(h);cycles(h,15,i=>({currentPositionSeconds:0,courseProgressPercent:i+1}));
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
await test('missing or invalid counter fields are not interpreted as zero progress',async()=>{
const h=harness();await start(h);cycles(h,15,i=>i%2?{}:{currentPositionSeconds:null,courseProgressPercent:'invalid'});
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
await test('background allows 180 seconds before saved counter stall recovery',async()=>{
const h=harness();await start(h);h.context.document.visibilityState='hidden';h.context.document.dispatchEvent(new h.context.Event('visibilitychange'));
heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});cycles(h,12,{currentPositionSeconds:0,courseProgressPercent:0});
assert.equal(h.api.state.running,true);cycles(h,1,{currentPositionSeconds:0,courseProgressPercent:0});
assert.equal(h.context.location.reloadCount,1);assert.match(h.api.state.status,/180秒未推进/);
});
await test('returning to foreground grants time for server counters to resume',async()=>{
const h=harness();await start(h);h.context.document.visibilityState='hidden';h.context.document.dispatchEvent(new h.context.Event('visibilitychange'));
heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});cycles(h,9,{currentPositionSeconds:0,courseProgressPercent:0});
h.context.document.visibilityState='visible';h.context.document.dispatchEvent(new h.context.Event('visibilitychange'));
h.api.monitor();assert.equal(h.context.location.reloadCount,undefined);
h.advance(21000);h.video.currentTime+=42;heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});h.api.monitor();
assert.equal(h.context.location.reloadCount,1);
});
await test('natural end without server completion refreshes without skipping the resource',async()=>{
const h=harness();await start(h);h.video.ended=true;await h.api.tickCourse(ID);h.advance(26000);await h.api.tickCourse(ID);
assert.equal(h.context.location.reloadCount,1);assert.equal(h.buttons[1].clicks,0);
});
await test('server-confirmed completion avoids stalled-counter refresh and permits next video',async()=>{
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:1200,courseProgressPercent:50,resourceCompleted:true});
h.video.ended=true;await h.api.tickCourse(ID);assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.buttons[1].clicks,1);
});
await test('manual pause and manual cancellation never activate the progress refresh',async()=>{
const h=harness();await start(h);h.video.paused=true;h.advance(200000);h.api.monitor();
assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.api.state.waiting.passive,true);
h.api.stop();h.advance(200000);await h.api.tick();assert.equal(h.context.location.reloadCount,undefined);
});
await test('ordinary refresh resumes but missing-save and stall recovery share one resource quota',async()=>{
const h=harness();await start(h);h.advance(76000);h.video.currentTime+=152;h.api.monitor();
const next=harness({sessionEntries:[...h.session]});mount(next);assert.equal(next.api.state.armed,true);
await start(next);heartbeat(next,{currentPositionSeconds:0,courseProgressPercent:0});cycles(next,9,{currentPositionSeconds:0,courseProgressPercent:0});
assert.equal(next.context.location.reloadCount,undefined);assert.match(next.api.state.status,/恢复一次后仍失败/);
assert.equal(next.api.state.waiting.passive,true);assert.equal(next.api.state.armed,true);
});
await test('heartbeats for another resource cannot conceal missing saves on the active resource',async()=>{
const h=harness();await start(h);h.advance(76000);h.video.currentTime+=152;
heartbeat(h,{currentPositionSeconds:150,courseProgressPercent:5},'2093583972926885888');h.api.monitor();
assert.equal(h.context.location.reloadCount,1);
});
await test('an already confirmed resource can finish playing with static server counters',async()=>{
const h=harness();await start(h);cycles(h,10,{currentPositionSeconds:1200,courseProgressPercent:50,resourceCompleted:true});
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
await test('oscillating stale counters do not restart the stall timer indefinitely',async()=>{
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});
cycles(h,10,i=>({currentPositionSeconds:i%2?0:2,courseProgressPercent:0}));assert.equal(h.context.location.reloadCount,1);
});
await test('a temporary counter plateau below the timeout keeps playing',async()=>{
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});
cycles(h,7,{currentPositionSeconds:0,courseProgressPercent:0});assert.equal(h.api.state.running,true);
heartbeat(h,{currentPositionSeconds:220,courseProgressPercent:10});cycles(h,7,{currentPositionSeconds:220,courseProgressPercent:10});
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
console.log(`${passed} progress recovery tests passed.`);
})().catch(e=>{console.error(e);process.exitCode=1;});