Keep normal playback and progress saving active across tab switches

This commit is contained in:
2026-10-06 02:10:13 +08:00
parent b8d830d231
commit 08c9e0aa4d
6 changed files with 161 additions and 28 deletions
+6 -4
View File
@@ -1,4 +1,4 @@
# 学员中心 · 2倍速学习助手 v1.1.1
# 学员中心 · 2倍速学习助手 v1.2.0
适用网页:https://icampus.yunyinsh.com/course/
@@ -10,6 +10,7 @@
4. 左下角出现“2倍速学习助手”,点击“开始 / 继续”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
5. 可选择“仅当前课程”。按“暂停”会暂停助手当前控制的视频;刷新后不会自行启动。
6. 默认勾选“视频完成后自动做测验”。也可在学习页点击“只做本门测验”,按网页提供的入口直接进入作答流程。
7. 默认勾选“切标签页继续播放”。先启动助手,再切到其他标签页;无需一直把课程页放在前台。关闭开关会恢复网站原有的隐藏页面处理。
## 已实现
@@ -34,12 +35,13 @@
- 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。
- 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。
- 自动恢复刷新会接续当前课程;普通刷新仍默认暂停。临时恢复记录两分钟过期、读取后删除;若手动刷新恰好遇到尚未消费的恢复记录,也可能接续运行。登录失效、前置限制或浏览器拒绝自动播放时,需要手动处理。
- 保持电脑唤醒,建议让课程页在前台。后台计时尚未验证。
- 保持电脑唤醒。后台模式已实现并通过模拟测试,但尚未完成真实标签页的长时间实测。Chrome 降低后台定时器频率时切视频、答题可能变慢;后台超时窗口延长至至少180秒,仍无保存响应则暂停。
- 电脑休眠、浏览器关闭或标签页被冻结/丢弃时不能保证继续运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”。
- 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。
- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆及最近提交快照。标签页会话存储保存短期刷新恢复记录。不保存登录凭据或视频链接。
- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆、最近提交快照和后台模式偏好。标签页会话存储保存短期刷新恢复记录。不保存登录凭据或视频链接。
## 验证情况
本地语法检查及41项行为测试通过(16项播放/进度测试、12项测验测试、13项媒体恢复测试)。模拟页面完整跑通开始、选择、交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、无反馈时不重复提交、次数耗尽停止、低于100%合格时不误记整卷全对。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
本地语法检查及53项行为测试通过(16项播放/进度测试、12项测验测试、13项媒体恢复测试、12项后台播放测试)。模拟页面完整跑通开始、选择、交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、无反馈时不重复提交、次数耗尽停止、低于100%合格时不误记整卷全对。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
+15 -2
View File
@@ -1,6 +1,6 @@
# 学员中心 · 2倍速学习助手
适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.1.1**。
适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.2.0**。
## 安装
@@ -14,6 +14,17 @@
- 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。
- 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。
- 自动暂停、异常提示、单标签页运行锁,本机答案记忆。
- 默认开启“切标签页继续播放”,助手运行时保留网站原有的后台进度保存定时器。
## 1.2.0 后台播放
安装后刷新课程页,点击“开始 / 继续”,保持“切标签页继续播放”勾选,然后切换其他标签页。开关偏好会保存在本机。
已加载的课程组件在页面隐藏时主动停止心跳。后台模式在课程页的助手运行期间拦截这个隐藏事件,保留正常播放和网页原本的心跳;不会改写 `document.visibilityState`、播放位置或服务器请求。回到前台仍执行网站原来的恢复逻辑。暂停助手、关闭后台模式或离开页面时恢复正常处理。
考虑到 Chrome 可能降低后台定时器频率,后台等待窗口及标签页运行锁延长至至少180秒,返回前台后给保存请求20秒恢复时间。连续180秒没有保存确认仍会暂停,后台切换视频和答题可能比前台慢。
电脑休眠、浏览器关闭、标签页冻结/丢弃、断网或媒体自动播放被浏览器拒绝,无法靠网页脚本保证运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”;脚本不会自动修改浏览器设置。
## 1.1.1 修复
@@ -25,10 +36,12 @@
## 验证
运行 `npm test`,无需安装依赖。当前41项本地行为测试覆盖播放、进度保存、测验循环及媒体加载恢复。
运行 `npm test`,无需安装依赖。当前53项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复及后台模式。
现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。
后台模式测试模拟了网页真实的可见性处理和心跳响应,验证拦截、恢复、暂停、后台超时和延迟跳转等情况。尚未在用户浏览器中完成新版的长时间真实后台运行验证。
## 限制
计入进度、课程完成和重试次数均以平台返回结果为准。合格后平台若关闭重考入口,无法继续重考到100%。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
+70
View File
@@ -0,0 +1,70 @@
'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 visibility(h,state){h.context.document.visibilityState=state;const event=new h.context.Event('visibilitychange');h.context.document.dispatchEvent(event);return event;}
function site(h){
let timer=true,calls=0;
// Mirrors the observed course component: hidden stops, visible restarts.
h.context.document.addEventListener('visibilitychange',()=>{calls++;timer=h.context.document.visibilityState!=='hidden';});
return {get timer(){return timer;},get calls(){return calls;},heartbeat(){if(timer&&!h.video.paused){
h.api.receive({id:ID,tail:'sessions/session/heartbeat',body:{resourceId:RID}},
{bizCode:0,data:{resourceCompleted:false,currentPositionSeconds:h.video.currentTime,courseProgressPercent:10}},200);
}}};
}
(async()=>{
await test('hidden event keeps the website heartbeat alive while real visibility stays hidden',()=>{
const h=harness(),web=site(h);h.api.begin();h.api.track(h.video);
const event=visibility(h,'hidden');assert.equal(event.stopped,true);assert.equal(web.timer,true);assert.equal(web.calls,0);
assert.equal(h.context.document.visibilityState,'hidden');assert.equal(h.video.playbackRate,2);
h.advance(15000);h.video.currentTime+=30;web.heartbeat();assert.equal(h.api.state.active.saved,true);
assert.equal(h.api.state.active.serverPosition,150);
});
await test('an idle assistant does not intercept website visibility handling',()=>{
const h=harness(),web=site(h);visibility(h,'hidden');assert.equal(web.timer,false);assert.equal(web.calls,1);
});
await test('returning to foreground lets the normal website handler run',()=>{
const h=harness(),web=site(h);h.api.begin();visibility(h,'hidden');visibility(h,'visible');
assert.equal(web.calls,1);assert.equal(web.timer,true);
});
await test('turning off background mode restores hidden behavior and persists the preference',()=>{
const h=harness(),web=site(h);h.api.begin();visibility(h,'hidden');h.api.setBackgroundEnabled(false);
assert.equal(web.timer,false);assert.equal(h.storage.get('icampus.study.v1.background'),'off');
assert.equal(visibility(h,'hidden').stopped,undefined);
});
await test('stop while hidden pauses media and restores website cleanup',()=>{
const h=harness(),web=site(h);h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.api.stop();
assert.equal(web.timer,false);assert.equal(h.video.paused,true);assert.equal(h.api.state.running,false);
});
await test('pagehide is not suppressed when the tab is closed or reloaded',()=>{
const h=harness();let cleaned=false;h.window.addEventListener('pagehide',()=>{cleaned=true;});h.api.begin();visibility(h,'hidden');
h.window.dispatchEvent(new h.context.Event('pagehide'));assert.equal(cleaned,true);assert.equal(h.api.state.running,false);
});
await test('a 60-second background timer delay does not expire the active tab lease',()=>{
const h=harness();h.api.begin();visibility(h,'hidden');h.advance(61000);
const lease=JSON.parse(h.storage.get('icampus.study.v1.lease'));assert.ok(lease.until>h.context.Date.now());
});
await test('background delay allows a real heartbeat to arrive but missing saves still stop',()=>{
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');
h.advance(80000);h.video.currentTime+=160;h.api.monitor();assert.equal(h.api.state.running,true);
h.advance(101000);h.video.currentTime+=202;h.api.monitor();assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/180秒/);
});
await test('foreground return gives the restarted heartbeat 20 seconds to respond',()=>{
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.advance(80000);visibility(h,'visible');
h.video.currentTime+=160;h.api.monitor();assert.equal(h.api.state.running,true);
h.advance(21000);h.video.currentTime+=42;h.api.monitor();assert.equal(h.api.state.running,false);
});
await test('manual pause is respected in background mode',()=>{
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.video.paused=true;h.api.monitor();
assert.equal(h.api.state.running,false);assert.equal(h.video.playCount,0);
});
await test('successful course navigation survives a delayed background tick',async()=>{
const h=harness();h.api.begin();visibility(h,'hidden');h.api.pendingCourse('123');h.context.location.hash='#/my-course/learn?id=123';
h.advance(61000);await h.api.tick();assert.equal(h.api.state.running,true);
});
await test('non-course routes do not suppress hidden events',()=>{
const h=harness(),web=site(h);h.api.begin();h.context.location.hash='#/user';visibility(h,'hidden');assert.equal(web.timer,false);
});
console.log(`${passed} background playback tests passed.`);
})().catch(error=>{console.error(error);process.exitCode=1;});
+57 -16
View File
@@ -1,8 +1,8 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
// @version 1.1.1
// @description 2倍速顺序播放、测验自动作答、按逐题反馈记忆答案并重试。
// @version 1.2.0
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
// @grant none
@@ -100,6 +100,10 @@
const BANK_KEY = 'icampus.study.v1.answers';
const SNAPSHOT_KEY = 'icampus.study.v1.lastExam';
const RESUME_KEY = 'icampus.study.v1.recovery';
const BACKGROUND_KEY = 'icampus.study.v1.background';
let backgroundEnabled = true;
try { backgroundEnabled = localStorage.getItem(BACKGROUND_KEY) !== 'off'; } catch {}
let foregroundGraceUntil = 0;
let mediaLoad = null;
let recoveryUsed = new Set();
let bank;
@@ -143,13 +147,39 @@
}
function show(message) { if (status !== message) { status = message; log(message); } render(); }
function readLease() { try { return JSON.parse(localStorage.getItem(LEASE_KEY) || 'null'); } catch { return null; } }
const isHidden = () => document.visibilityState === 'hidden';
const backgroundActive = () => backgroundEnabled && isHidden();
const waitLimit = normal => backgroundActive() ? Math.max(normal,180000) : normal;
function holdLease() {
if (!leaseAvailable(readLease(), owner, Date.now())) return false;
try { localStorage.setItem(LEASE_KEY, JSON.stringify({owner, until: Date.now() + 12000})); }
try { localStorage.setItem(LEASE_KEY, JSON.stringify({owner, until: Date.now() + waitLimit(12000)})); }
catch { return false; }
return readLease()?.owner === owner;
}
function releaseLease() { try { if (readLease()?.owner === owner) localStorage.removeItem(LEASE_KEY); } catch {} }
function restoreVisibilityHandling() {
if (isHidden()) document.dispatchEvent(new Event('visibilitychange'));
}
function setBackgroundEnabled(enabled) {
backgroundEnabled = !!enabled;
try { localStorage.setItem(BACKGROUND_KEY,backgroundEnabled?'on':'off'); } catch {}
if(!backgroundEnabled) restoreVisibilityHandling();
log(backgroundEnabled?'已开启切标签页继续播放。':'已关闭后台模式,恢复网页的标签页切换行为。');
render();
}
function onVisibilityChange(event) {
if(!isHidden()) {
foregroundGraceUntil=Date.now()+20000;
render(); return; // Let the website restart its normal timer on return.
}
if(!running || !backgroundEnabled || !route(location.hash).id) return;
if(!holdLease()) { stop('其他学习页接管了助手,本页已暂停。'); return; }
// The course component's hidden handler stops its heartbeat timer.
// Keep the existing timer alive; leave true visibility and all requests unchanged.
event.stopImmediatePropagation();
log('已切到后台,继续正常播放并等待平台保存。'); render();
}
document.addEventListener('visibilitychange',onVisibilityChange,true);
function stop(message = '已暂停;点击开始可继续。') {
running = false; ++runNumber;
const video = active?.video;
@@ -157,6 +187,7 @@
mediaLoad = null;
try { sessionStorage.removeItem(RESUME_KEY); } catch {}
if (video && !video.paused) video.pause();
restoreVisibilityHandling();
releaseLease(); show(message);
}
function safeCourse(c) {
@@ -334,7 +365,7 @@
}
function quizWait(message, timeout = 25000) {
show(message);
if (Date.now() - quiz.since > timeout) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
if (Date.now() - quiz.since > waitLimit(timeout)) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
}
function tickQuiz(id) {
if (autoQuiz && !autoQuiz.checked && !quizOnly) return stop('自动测验已关闭,请开启后继续。');
@@ -387,7 +418,7 @@
const option=questions.find(q=>q.id===pending.id)?.options.find(o=>o.key===pending.key);
if (option?.selected===pending.want) quiz.lastClick=null;
else {
if(Date.now()-pending.at>8000) stop('选项点击后没有生效,已暂停,请检查页面。');
if(Date.now()-pending.at>waitLimit(8000)) stop('选项点击后没有生效,已暂停,请检查页面。');
return;
}
}
@@ -444,7 +475,7 @@
}
function tickList() {
if (!packagesAt || packagesAt < mustObservePackagesAfter) {
if (Date.now() - routeSince > 25000) stop('未收到新的课程列表数据,请刷新页面后开始。');
if (Date.now() - routeSince > waitLimit(25000)) stop('未收到新的课程列表数据,请刷新页面后开始。');
return;
}
if (verifyAfterBack) {
@@ -455,7 +486,7 @@
const cards = [...document.querySelectorAll('article.course-row')].filter(visible)
.map(el => ({el, title: el.querySelector('.course-row__title')?.textContent}));
if (!cards.length) {
if (Date.now() - routeSince > 25000) stop('没有找到课程列表,请检查是否已登录。');
if (Date.now() - routeSince > waitLimit(25000)) stop('没有找到课程列表,请检查是否已登录。');
return;
}
const mapped = resolveCards(cards, courses);
@@ -499,7 +530,7 @@
load.overlayTried=true; overlay.click();
}
show(`正在加载:${resource.name}(${Math.floor(elapsed/1000)}秒)`);
if(elapsed>=90000) recoverMedia(id,resource.id,'视频加载超过90秒');
if(elapsed>=waitLimit(90000)) recoverMedia(id,resource.id,`视频加载超过${waitLimit(90000)/1000}秒`);
return false;
}
if(load.video!==video || load.src!==video.currentSrc || !load.settledAt) {
@@ -513,7 +544,7 @@
expectedCourse = null;
const detail = details.get(id);
if (!detail) {
if (Date.now() - routeSince > 20000) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
if (Date.now() - routeSince > waitLimit(20000)) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。');
return;
}
const plan = videoPlan(detail);
@@ -524,7 +555,7 @@
active.endedAt ||= Date.now();
if (resource?.completed !== true) {
show('视频已结束,等待平台确认保存…');
if (Date.now() - active.endedAt > 25000) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
if (Date.now() - active.endedAt > waitLimit(25000)) stop('平台尚未确认视频完成,请返回列表核对后再继续。');
return;
}
log('平台已确认本段视频完成。'); active = null;
@@ -544,7 +575,7 @@
if (!button.classList.contains('is-active')) {
if (button.disabled || resource.playable === false) return stop('下一段视频尚未解锁,请按平台提示完成前置学习。');
if (resourceSwitch?.id === resource.id) {
if (Date.now() - resourceSwitch.at > 20000) stop('切换视频没有成功,请手动选择该视频后继续。');
if (Date.now() - resourceSwitch.at > waitLimit(20000)) stop('切换视频没有成功,请手动选择该视频后继续。');
return;
}
resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc};
@@ -594,7 +625,7 @@
if(a.phase==='starting') {
if(!v.paused && v.currentTime>a.lastPosition+0.1) { a.phase='playing'; show('2倍速播放中,正在核对平台保存进度。'); }
else {
if(Date.now()-a.started>20000) return recoverMedia(a.courseId,a.id,'视频未能开始播放');
if(Date.now()-a.started>waitLimit(20000)) return recoverMedia(a.courseId,a.id,'视频未能开始播放');
if(v.paused && v.readyState>=2) attemptPlay(a);
return;
}
@@ -602,8 +633,10 @@
if (Math.abs(v.playbackRate - 2) > 0.01) return stop('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
if (v.paused && !v.ended) return stop('视频已暂停;点击助手开始可继续。');
if (v.currentTime > a.lastPosition + 0.1) { a.progressedAt = Date.now(); a.lastPosition = v.currentTime; }
if (Date.now() - a.progressedAt > 60000) return recoverMedia(a.courseId,a.id,'视频超过60秒没有推进');
if (Date.now() - lastNetworkAt > 75000) return stop('超过75秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。');
if (Date.now() - a.progressedAt > waitLimit(60000)) return recoverMedia(a.courseId,a.id,'视频长时间没有推进');
const saveTimeout=waitLimit(75000);
if (Date.now()>foregroundGraceUntil && Date.now()-lastNetworkAt>saveTimeout)
return stop(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
}
async function tick() {
if (!running || busy) return;
@@ -612,7 +645,8 @@
if (!holdLease()) return stop('另一个学习页正在使用助手,或浏览器禁止本地存储。');
if (Date.now() - startAt < 600) return;
if (location.hash !== lastHash) {
const expected = navigatingUntil > Date.now();
const destination=route(location.hash);
const expected=courseNavigationPending && (expectedCourse ? destination.id===expectedCourse : destination.list);
lastHash = location.hash; routeSince = Date.now(); active = null; navigatingUntil = 0;
courseNavigationPending = false; resourceSwitch = null;
if (!expected) return stop('页面已被手动切换;点击开始可继续。');
@@ -637,6 +671,8 @@
: '以平台保存结果为准;电脑需保持唤醒。';
panel.querySelector('[data-start]').disabled = running;
panel.querySelector('[data-quiz-only]').disabled = running;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
mode.disabled = running;
}
function mount() {
@@ -645,15 +681,20 @@
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
panel = host.attachShadow({mode: 'open'});
panel.innerHTML = `<style>:host{font:14px/1.6 system-ui;color:#eaf0fa}section{width:290px;background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:16px}p{margin:9px 0;white-space:pre-line}small{color:#b7c7df}button,select{font:inherit;border-radius:6px;border:1px solid #7186a5;padding:5px 9px;margin:3px 5px 3px 0}button{cursor:pointer;background:#dbeaff;color:#123}button:disabled{opacity:.5;cursor:default}select{width:100%;background:#fff;color:#123}[data-status]{min-height:45px}details{margin-top:8px}pre{white-space:pre-wrap;max-height:160px;overflow:auto;font-size:12px}</style>
<section><strong>2倍速学习助手 · 1.1.1</strong><p data-status></p><small data-progress></small>
<section><strong>2倍速学习助手 · 1.2.0</strong><p data-status></p><small data-progress></small>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
<div><button data-start>开始 / 继续</button><button data-stop>暂停</button></div>
<label><input type="checkbox" data-quiz checked> 视频完成后自动做测验</label>
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
<small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div>
<small>错题根据平台反馈重试;合格后平台可能不允许重考。</small>
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
mode = panel.querySelector('select');
autoQuiz = panel.querySelector('[data-quiz]');
const backgroundToggle=panel.querySelector('[data-background]');
backgroundToggle.checked=backgroundEnabled;
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
function beginRun(only, resume) {
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
if (!holdLease()) return show('另一个学习页正在运行助手,或本地存储不可用。请先暂停另一个页面。');
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "e-learning-assistant",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"description": "学员中心2倍速播放和测验辅助脚本",
"scripts": {
"test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs"
"test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs && node background-playback.test.cjs"
},
"engines": { "node": ">=18" }
}
+11 -4
View File
@@ -17,7 +17,13 @@ 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() {},
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; }
@@ -25,17 +31,18 @@ function harness() {
addEventListener(name, fn) { this[name] = fn; }
}
const pending = new Promise(() => {});
const window = {fetch() { return pending; }, addEventListener() {}};
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,
URL, URLSearchParams, Date: TestDate, crypto: {randomUUID: () => 'test-owner'}, TextDecoder,Event:TestEvent,
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,
`window.testing = {receive, stop, 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(){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();},