Files
icampus-learning-assistant/study-helper.test.cjs
T

153 lines
10 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const core = require('./icampus-study-helper.user.js');
const ID = '2107129233351512064';
const RID = '2093583772296548352';
let passed = 0;
async function test(name, fn) { await fn(); ++passed; console.log(`PASS ${name}`); }
function detail(overrides = {}) {
return {course: {id: ID, courseName: '课程一', status: 'LEARNING', completedAt: null,
examRequired: true, examPassed: true,examScorePercent:100}, resources: [
{id: RID, name: '视频一', type: 'VIDEO', sortNo: 1, completed: false, playable: true},
{id: '2093583972926885888', name: '视频二', type: 'VIDEO', sortNo: 2, completed: false, playable: true}], ...overrides};
}
function harness({sessionEntries=[],storageEntries=[]}={}) {
let now = 100000;
class TestDate extends Date { static now() { return now; } }
const storage = new Map(storageEntries), session = new Map(sessionEntries), selectors = new Map();
const mutations=[];
class TestMutationObserver {constructor(callback){mutations.push(callback);}observe(){}}
function eventSurface(){
const listeners=new Map();
return {addEventListener(type,fn,options){const list=listeners.get(type)||[];list.push({fn,capture:options===true||!!options?.capture});listeners.set(type,list);},
dispatchEvent(event){for(const item of [...(listeners.get(event.type)||[])].sort((a,b)=>Number(b.capture)-Number(a.capture))){item.fn(event);if(event.stopped)break;}return true;}};
}
class TestEvent {constructor(type){this.type=type;}stopImmediatePropagation(){this.stopped=true;}}
const document = {...eventSurface(),body: null, visibilityState:'visible',getElementById: () => null,
querySelector: s => selectors.get(s) || null, querySelectorAll: s => selectors.get(s) || []};
class XHR {
open(...args) { this.openArgs = args; }
send(...args) { this.sendArgs = args; }
addEventListener(name, fn) { this[name] = fn; }
}
const pending = new Promise(() => {});
const window = {...eventSurface(),fetch() { return pending; }};
window.self = window.top = window;
const context = {window, document, XMLHttpRequest: XHR, location: {
href: `https://icampus.yunyinsh.com/course/#/my-course/learn?id=${ID}`, hash: `#/my-course/learn?id=${ID}`, reload(){this.reloadCount=(this.reloadCount||0)+1;}},
URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder,Event:TestEvent,MutationObserver:TestMutationObserver,
localStorage: {getItem: k => storage.get(k) ?? null, setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)},
sessionStorage: {getItem:k=>session.get(k)??null,setItem:(k,v)=>session.set(k,v),removeItem:k=>session.delete(k)},
setInterval() {}};
const source = fs.readFileSync('./icampus-study-helper.user.js', 'utf8').replace(
'setInterval(() => { void tick(); }, 1000);',
`window.testing = {receive, stop, park, tick, tickCourse, tickQuiz, monitor, details, holdLease, waitMedia, recoverMedia, mount, setBackgroundEnabled,
pendingCourse(id){expectedCourse=id;courseNavigationPending=true;navigatingUntil=Date.now()+20000;},
recovered(id){recoveryUsed.add('${ID}/'+id);},
begin(){armed=true;running=true;runNumber++;mode={value:'all'};startAt=Date.now()-1000;persistIntent();},
track(v){active={video:v,id:'${RID}',courseId:'${ID}',src:v.currentSrc,lastPosition:v.currentTime,progressedAt:Date.now(),started:Date.now()};lastNetworkAt=Date.now();},
pending(){courseNavigationPending=true;navigatingUntil=Date.now()-1;},
get state(){return {armed,waiting,running,status,active,courses,packagesAt,busy,bank,lastExam,quiz};}};`);
vm.runInNewContext(source, context);
const api = window.testing;
const video = {currentTime: 120, currentSrc: 'media', readyState: 4, isConnected: true, playbackRate: 2,
paused: false, ended: false, error: null, pauseCount: 0, playCount: 0,
pause() { this.paused = true; ++this.pauseCount; },
play() { this.paused = false; ++this.playCount; return Promise.resolve(); }};
selectors.set('video.player__video', video);
const buttons = ['视频一','视频二'].map((name,i) => ({disabled: false, clicks: 0,
classList: {contains: () => i === 0}, querySelector: () => ({textContent: name}), click() { ++this.clicks; }}));
selectors.set('button.resource-item', buttons);
api.receive({id: ID, tail: ''}, {bizCode: 0, data: detail()}, 200);
return {api, video, buttons, context, window, pending, selectors, storage, session, mutate(){for(const callback of mutations)callback();}, advance(ms) { now += ms; }};
}
module.exports = {harness, detail, ID, RID};
if (require.main === module) (async () => {
await test('route preserves identifiers beyond Number.MAX_SAFE_INTEGER', () => {
assert.equal(core.route(`#/my-course/learn?id=${ID}`).id, ID);
});
await test('observer excludes foreign origins and signed media URLs', () => {
const base = 'https://icampus.yunyinsh.com/course/';
assert.equal(core.endpoint('https://foreign.test/smtp/api/my-courses/packages',base), null);
assert.equal(core.endpoint('/video.mp4?signature=secret',base), null);
assert.equal(core.endpoint(`/smtp/api/my-courses/${ID}/sessions/s/heartbeat`,base).id, ID);
});
await test('exam passed alone never marks unfinished videos complete', () => {
assert.equal(core.videoPlan(detail()).kind, 'video');
assert.equal(core.courseDone(detail().course), false);
});
await test('resource order, completion and quiz decisions', () => {
const d = detail(); d.resources.reverse();
assert.equal(core.videoPlan(d).resource.id, RID);
d.resources.forEach(r => r.completed = true); d.course.examPassed = false;
assert.equal(core.videoPlan(d).kind, 'quiz');
d.course.examPassed = true; assert.equal(core.videoPlan(d).kind, 'finish');
d.resources[0].completed = false; d.resources[0].type = 'DOCUMENT';
assert.equal(core.videoPlan(d).kind, 'manual');
});
await test('queue follows visible cards, ambiguous titles are rejected', () => {
const courses = [{id:'2',courseName:'B'}, {id:'1',courseName:'A'}];
assert.deepEqual(core.resolveCards([{title:'A'},{title:'B'}],courses).map(x=>x.course.id), ['1','2']);
assert.equal(core.resolveCards([{title:'A'}],[...courses,{id:'3',courseName:'A'}])[0].course,null);
});
await test('startup is paused, wrappers preserve original fetch promise and XHR arguments', () => {
const h = harness(); assert.equal(h.api.state.running,false);
assert.equal(h.window.fetch('/smtp/api/my-courses/packages'),h.pending);
const x = new h.context.XMLHttpRequest(); x.open('POST',`/smtp/api/my-courses/${ID}/sessions`,true); x.send(null);
assert.deepEqual(x.openArgs,['POST',`/smtp/api/my-courses/${ID}/sessions`,true]);
assert.deepEqual(x.sendArgs,[null]);
});
await test('2x applied once and ordinary play called', async () => {
const h=harness(); h.video.paused=true; h.video.playbackRate=1; h.api.begin();
await h.api.tickCourse(ID); h.advance(1000); await h.api.tickCourse(ID);
assert.equal(h.video.playbackRate,2); assert.equal(h.video.playCount,1);
});
await test('manual pause stops instead of restarting playback', () => {
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.paused=true; h.api.monitor();
assert.equal(h.api.state.running,false); assert.equal(h.video.playCount,0);
});
await test('platform rate reset stops without fighting the setter', () => {
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.playbackRate=1; h.api.monitor();
assert.equal(h.api.state.running,false); assert.equal(h.video.playbackRate,1); assert.equal(h.video.paused,true);
});
await test('ended without server completion never clicks next, then stops on timeout', async () => {
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.ended=true;
await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,0);
h.advance(26000); await h.api.tickCourse(ID);
assert.equal(h.api.state.running,false); assert.equal(h.buttons[1].clicks,0);
});
await test('matching genuine heartbeat plus natural end allows next video', async () => {
const h=harness(); h.api.begin(); h.api.track(h.video);
h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId:RID}},
{bizCode:0,data:{resourceCompleted:true,currentPositionSeconds:1161,courseProgressPercent:50,courseStatus:'LEARNING'}},200);
await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,0);
h.video.ended=true; await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,1);
});
await test('heartbeat for another resource cannot confirm this video', () => {
const h=harness(); h.api.begin(); h.api.track(h.video);
h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId:'other'}},{bizCode:0,data:{resourceCompleted:true}},200);
assert.equal(h.api.details.get(ID).resources[0].completed,false);
});
await test('prerequisite failure stops without retrying', () => {
const h=harness(); h.api.begin(); h.api.track(h.video);
h.api.receive({id:ID,tail:'sessions'},{bizCode:47235,message:'上一个视频尚未完成'},200);
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/47235/);
});
await test('missing heartbeat stops even if media continues advancing', () => {
const h=harness(); h.api.begin(); h.api.track(h.video); h.advance(76000); h.video.currentTime+=100; h.api.monitor();
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/75秒/);
});
await test('failed course navigation is bounded', async () => {
const h=harness(); h.api.begin(); h.api.pending(); await h.api.tick();
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/跳转/);
});
await test('another tab lease prevents concurrent controller', () => {
const h=harness(); h.storage.set('icampus.study.v1.lease',JSON.stringify({owner:'other',until:200000}));
assert.equal(h.api.holdLease(),false);
h.advance(110000); assert.equal(h.api.holdLease(),true);
});
console.log(`${passed} tests passed.`);
})().catch(error => { console.error(error); process.exitCode=1; });