'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({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;});