Files
icampus-learning-assistant/overall-progress.test.cjs

92 lines
6.9 KiB
JavaScript

'use strict';
const assert=require('node:assert/strict');
const {overallProgress,learningPercent}=require('./icampus-study-helper.user.js');
const {harness,ID,RID}=require('./study-helper.test.cjs');
let passed=0;
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
const course=(id,progress=0,passed=false)=>({id,courseName:id,progressPercent:progress,examRequired:true,examPassed:passed});
const detail=(c,duration=1200,position=0)=>({course:c,resources:[{id:'r',type:'VIDEO',durationSeconds:duration,currentPositionSeconds:position,completed:false}]});
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();return nodes;}
async function start(h){h.api.begin();await h.api.tickCourse(ID);h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();await Promise.resolve();}
(async()=>{
await test('overall completion includes passing exam and differs from average learning',()=>{
const cs=[course('a',100,true),course('b',100,false),course('c',50,true)];
const s=overallProgress(cs,new Map());assert.equal(s.total,3);assert.equal(s.done,1);assert.ok(Math.abs(s.percent-100/3)<1e-9);assert.equal(s.learningPercent,250/3);assert.equal(s.quizzes,1);
});
await test('known remaining video durations use saved position and 2x rate',()=>{
const c=course('a',50,true),s=overallProgress([c],new Map([['a',detail(c,1200,600)]]));
assert.equal(s.seconds,315);assert.equal(s.estimated,0);
});
await test('unseen durations are labelled extrapolations and include exam allowances',()=>{
const a=course('a',50,true),b=course('b',0,false);
const s=overallProgress([a,b],new Map([['a',detail(a,1200,600)]]));
assert.equal(s.seconds,1050);assert.equal(s.estimated,1);assert.equal(s.samples,1);
});
await test('missing duration never becomes a zero-minute promise',()=>{
const s=overallProgress([course('a')],new Map());assert.equal(s.seconds,null);assert.equal(s.unknown,1);
});
await test('pending exam after learning completion still reserves time',()=>{
const s=overallProgress([course('a',100,false)],new Map());assert.equal(s.seconds,135);assert.equal(s.done,0);
});
await test('completed courses take no remaining time despite old resource positions',()=>{
const c=course('a',100,true),s=overallProgress([c],new Map([['a',detail(c)]]));
assert.equal(s.seconds,0);assert.equal(s.percent,100);
});
await test('duplicate course ids are counted once',()=>{
assert.equal(overallProgress([course('a'),course('a')],new Map()).total,1);
});
await test('invalid durations cannot produce NaN or Infinity',()=>{
for(const value of [undefined,null,'',0,-1,Infinity,'bad']){
const c=course('a'),d=detail(c);d.resources[0].durationSeconds=value;
const s=overallProgress([c],new Map([['a',d]]));assert.equal(s.seconds,null);
}
});
await test('progress is bounded and empty scope has no finish estimate',()=>{
assert.equal(learningPercent(course('a',-1)),0);assert.equal(learningPercent(course('a',200)),100);
assert.equal(overallProgress([],new Map()).seconds,null);
});
await test('unfinished non-video work suppresses the finish prediction',()=>{
const c=course('a'),d=detail(c);d.resources.push({id:'manual',type:'DOCUMENT',completed:false});
const s=overallProgress([c],new Map([['a',d]]));assert.equal(s.manual,1);assert.equal(s.seconds,null);
});
await test('panel labels a current-course-only fallback when list was never loaded',()=>{
const h=harness(),ui=mount(h);assert.match(ui.get('[data-overall]').textContent,/未读取列表/);
assert.match(ui.get('[data-estimate]').textContent,/等待视频时长/);
});
await test('fresh list updates summary without starting playback and overrides stale flags',()=>{
const h=harness(),ui=mount(h);h.api.details.get(ID).course.progressPercent=0;
h.api.receive({id:'packages'},{bizCode:0,data:[{courses:[{...course(ID,100,true),courseName:'课程一'},course('b')]}]},200);
assert.match(ui.get('[data-overall]').textContent,/50.0% · 1\/2门/);assert.equal(h.video.playCount,0);
assert.equal(h.api.details.get(ID).course.progressPercent,100);
});
await test('pause removes clock finish prediction but retains conditional remaining time',()=>{
const h=harness();const d=h.api.details.get(ID);d.resources.forEach(r=>{r.durationSeconds=600;r.currentPositionSeconds=0;});
const ui=mount(h);assert.match(ui.get('[data-estimate]').textContent,/等待开始或恢复/);
ui.get('[data-start]').onclick();assert.doesNotMatch(ui.get('[data-estimate]').textContent,/等待开始或恢复/);
h.api.park('暂停');assert.match(ui.get('[data-estimate]').textContent,/等待开始或恢复/);
});
await test('real heartbeat updates remaining time without extrapolating server completion',()=>{
const h=harness(),d=h.api.details.get(ID);d.resources[0].durationSeconds=1200;d.resources[1].completed=true;d.resources[1].durationSeconds=600;
const before=overallProgress([d.course],h.api.details).seconds;
h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId:RID}},{bizCode:0,data:{currentPositionSeconds:600,courseProgressPercent:50}},200);
assert.equal(overallProgress([d.course],h.api.details).seconds,before-300);assert.equal(d.resources[0].completed,false);
});
await test('autoplay rejection retries once muted and succeeds without reload',async()=>{
const h=harness();let calls=0;h.video.play=function(){calls++;return this.muted?Promise.resolve():Promise.reject({name:'NotAllowedError'});};
await start(h);assert.equal(calls,2);assert.equal(h.video.muted,true);assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
});
await test('blocked muted playback waits passively without timed permission retry loops',async()=>{
const h=harness();let calls=0;h.video.play=()=>{calls++;return Promise.reject({name:'NotAllowedError'});};
await start(h);assert.equal(calls,2);assert.equal(h.api.state.waiting.passive,true);assert.equal(h.video.muted,false);
h.advance(60000);await h.api.tick();assert.equal(calls,2);assert.equal(h.context.location.reloadCount,undefined);
h.video.paused=false;await h.api.tick();assert.equal(h.api.state.running,true);
});
await test('muted fallback can be disabled and preference is stored',async()=>{
const h=harness(),ui=mount(h);ui.get('[data-muted]').checked=false;ui.get('[data-muted]').onchange();
let calls=0;h.video.play=()=>{calls++;return Promise.reject({name:'NotAllowedError'});};
await start(h);assert.equal(calls,1);assert.equal(h.storage.get('icampus.study.v1.mutedFallback'),'off');
});
console.log(`${passed} overall progress and autoplay tests passed.`);
})().catch(e=>{console.error(e);process.exitCode=1;});