'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 refreshes 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);assert.equal(h.context.location.reloadCount,1); }); 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 refreshes 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秒/);assert.equal(h.context.location.reloadCount,1); }); 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; });