From 08c9e0aa4d1ecf3f6de6a7f8a1f6358cb140d639 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=96=87=E6=98=9F=E7=8E=AF=E6=B5=B7?= <3032732426@qq.com>
Date: Tue, 6 Oct 2026 02:10:13 +0800
Subject: [PATCH] Keep normal playback and progress saving active across tab
switches
---
2倍速脚本使用说明.md | 10 +++--
README.md | 17 ++++++++-
background-playback.test.cjs | 70 ++++++++++++++++++++++++++++++++++
icampus-study-helper.user.js | 73 ++++++++++++++++++++++++++++--------
package.json | 4 +-
study-helper.test.cjs | 15 ++++++--
6 files changed, 161 insertions(+), 28 deletions(-)
create mode 100644 background-playback.test.cjs
diff --git a/2倍速脚本使用说明.md b/2倍速脚本使用说明.md
index 244216d..a982f9b 100644
--- a/2倍速脚本使用说明.md
+++ b/2倍速脚本使用说明.md
@@ -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门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
diff --git a/README.md b/README.md
index 4ed478a..aa44a0d 100644
--- a/README.md
+++ b/README.md
@@ -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%。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
diff --git a/background-playback.test.cjs b/background-playback.test.cjs
new file mode 100644
index 0000000..c6e5ecc
--- /dev/null
+++ b/background-playback.test.cjs
@@ -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;});
diff --git a/icampus-study-helper.user.js b/icampus-study-helper.user.js
index da2165a..231724f 100644
--- a/icampus-study-helper.user.js
+++ b/icampus-study-helper.user.js
@@ -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 = `
- 2倍速学习助手 · 1.1.1
+ `;
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('另一个学习页正在运行助手,或本地存储不可用。请先暂停另一个页面。');
diff --git a/package.json b/package.json
index 8126874..a50f2c7 100644
--- a/package.json
+++ b/package.json
@@ -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" }
}
diff --git a/study-helper.test.cjs b/study-helper.test.cjs
index f2ebe7e..23d7633 100644
--- a/study-helper.test.cjs
+++ b/study-helper.test.cjs
@@ -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();},