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

+
2倍速学习助手 · 1.2.0

+
+
错题根据平台反馈重试;合格后平台可能不允许重考。
运行记录
`; 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();},