144 lines
9.1 KiB
JavaScript
144 lines
9.1 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}, 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() {
|
|
let now = 100000;
|
|
class TestDate extends Date { static now() { return now; } }
|
|
const storage = new Map(), session = new Map(), selectors = new Map();
|
|
const document = {body: null, getElementById: () => null, addEventListener() {},
|
|
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 = {fetch() { return pending; }, addEventListener() {}};
|
|
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,
|
|
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, tick, tickCourse, tickQuiz, monitor, details, holdLease, waitMedia, recoverMedia, mount,
|
|
recovered(id){recoveryUsed.add('${ID}/'+id);},
|
|
begin(){running=true;runNumber++;mode={value:'all'};startAt=Date.now()-1000;},
|
|
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 {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, 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; });
|