Fix desktop quiz submission and keep runs enabled until cancelled

This commit is contained in:
2026-10-06 02:23:01 +08:00
parent 08c9e0aa4d
commit 606d264917
7 changed files with 218 additions and 96 deletions
+12 -12
View File
@@ -1,4 +1,4 @@
# 学员中心 · 2倍速学习助手 v1.2.0 # 学员中心 · 2倍速学习助手 v1.3.0
适用网页:https://icampus.yunyinsh.com/course/ 适用网页:https://icampus.yunyinsh.com/course/
@@ -7,9 +7,9 @@
1. 在篡改猴中停用旧的“学员中心 · 本地网络诊断”脚本,避免误触倍速测试。 1. 在篡改猴中停用旧的“学员中心 · 本地网络诊断”脚本,避免误触倍速测试。
2. 打开篡改猴管理面板 → 实用工具 → 从文件导入,选择 `icampus-study-helper.user.js`,按提示安装/更新。同名、同 namespace 的1.0版本应被更新,不要同时启用两份。也可以在原脚本编辑器中用该文件的全部内容替换并保存。 2. 打开篡改猴管理面板 → 实用工具 → 从文件导入,选择 `icampus-study-helper.user.js`,按提示安装/更新。同名、同 namespace 的1.0版本应被更新,不要同时启用两份。也可以在原脚本编辑器中用该文件的全部内容替换并保存。
3. 刷新课程网页,让助手能从页面加载时读取平台返回的课程信息。 3. 刷新课程网页,让助手能从页面加载时读取平台返回的课程信息。
4. 左下角出现“2倍速学习助手”,点击“开始 / 继续”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。 4. 左下角出现“2倍速学习助手 · 1.3.0”,点击“开始”。默认从列表首个未完成课程开始;已经进入学习页时,先完成当前课程。
5. 可选择“仅当前课程”。按“暂停”会暂停助手当前控制的视频;刷新后不会自行启动。 5. 可选择“仅当前课程”。开始后始终保持启用,等待评分或处理异常不要求再次点开始;刷新同一标签页也会接续。只有按“人工取消”才关闭助手并暂停当前控制的视频。
6. 默认勾选“视频完成后自动做测验”。也可在学习页点击“只做本门测验”,按网页提供的入口直接进入作答流程。 6. 默认勾选“自动测验,达到100%才继续”。先自动作答、交卷并重考,测验达到100%后再继续本门视频或下一课程。也可点击“只做本门测验”。
7. 默认勾选“切标签页继续播放”。先启动助手,再切到其他标签页;无需一直把课程页放在前台。关闭开关会恢复网站原有的隐藏页面处理。 7. 默认勾选“切标签页继续播放”。先启动助手,再切到其他标签页;无需一直把课程页放在前台。关闭开关会恢复网站原有的隐藏页面处理。
## 已实现 ## 已实现
@@ -18,7 +18,7 @@
- 自然播完且平台确认该视频完成后,切换下一段。 - 自然播完且平台确认该视频完成后,切换下一段。
- 本门视频和测验条件满足后,返回列表核对课程完成状态,然后按列表显示顺序继续。 - 本门视频和测验条件满足后,返回列表核对课程完成状态,然后按列表显示顺序继续。
- 显示实际倍速、播放位置、平台课程进度和是否收到保存确认。 - 显示实际倍速、播放位置、平台课程进度和是否收到保存确认。
- 用户暂停、倍速被改变、视频加载失败、前置课程未完成、平台保存超时等情况自动暂停并提示。 - 视频暂停、倍速变化、加载失败、前置未完成或保存超时等情况转入“已启动 · 等待处理”,条件恢复后继续;人工取消才清除启用状态。
- 同一网站只允许一个标签页的助手运行。 - 同一网站只允许一个标签页的助手运行。
- 单选、判断和多选题自动选择选项、交卷、确认交卷,等待平台评分。 - 单选、判断和多选题自动选择选项、交卷、确认交卷,等待平台评分。
- 未合格时,通过页面的“重新测验”流程观察答题卡反馈;对已确认正确的答案保持不变,答错的题尝试其他选项或组合。 - 未合格时,通过页面的“重新测验”流程观察答题卡反馈;对已确认正确的答案保持不变,答错的题尝试其他选项或组合。
@@ -27,21 +27,21 @@
## 当前边界 ## 当前边界
- 平台未合格且允许重考时会继续尝试,目标是全部答对。但该网页在达到合格线后隐藏重考按钮;合格线低于100%的课程可能提前合格,无法保证继续重考到100%。 - 当前现场试卷显示不限次数,新版不设置脚本的重考次数上限。严格达到100%才放行;即使平台已显示合格,不满100%也保持等待重考,不切换视频或课程。
- 有限次数以平台为准;次数耗尽会暂停。无逐题反馈时不会猜测哪些题正确,也不会反复提交完全相同的答案。 - 如果平台返回次数耗尽、入口不可用或没有逐题反馈,保持启用并等待处理,不反复提交完全相同的答案。
- 图片题、材料题、填空、问答,以及无法唯一识别选项的题需要手动作答。单选/判断/多选支持每题最多8个不同文字选项;一轮最多自动重试256次。 - 图片题、材料题、填空、问答,以及无法唯一识别选项的题需要手动作答。单选/判断/多选支持每题最多8个不同文字选项。
- 更新版未在真实的未通过试卷上完成端到端验证。当前第一门已通过且没有重考入口,因此验证使用已加载网页源码与模拟页面/响应,不能把模拟结果当成真实平台成绩。 - 现场已确认电脑端交卷在侧栏、手机端底部按钮被隐藏;新版本已经修正选择器,但尚未安装新版完成真实自动重考到100%的端到端验证。
- 平台自己发送学习记录;助手只观察必要的课程详情和保存结果,不发送或修改进度请求。 - 平台自己发送学习记录;助手只观察必要的课程详情和保存结果,不发送或修改进度请求。
- 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。 - 如果视频结束后25秒内平台仍未确认完成,会停下提示核对,不强行切换。
- 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。 - 切换视频后等待新媒体数据就绪;短暂播放中断最多尝试三次。视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频共享一次恢复额度;仍失败则暂停。
- 自动恢复刷新会接续当前课程;普通刷新仍默认暂停。临时恢复记录两分钟过期、读取后删除;若手动刷新恰好遇到尚未消费的恢复记录,也可能接续运行。登录失效、前置限制或浏览器拒绝自动播放时,需要手动处理。 - 自动恢复刷新及普通刷新都会接续已启用的任务,人工取消后刷新不会启动。登录失效、前置限制或浏览器拒绝自动播放时保持启用、等待人工处理。
- 保持电脑唤醒。后台模式已实现并通过模拟测试,但尚未完成真实标签页的长时间实测。Chrome 降低后台定时器频率时切视频、答题可能变慢;后台超时窗口延长至至少180秒,仍无保存响应则暂停。 - 保持电脑唤醒。后台模式已实现并通过模拟测试,但尚未完成真实标签页的长时间实测。Chrome 降低后台定时器频率时切视频、答题可能变慢;后台超时窗口延长至至少180秒,仍无保存响应则暂停。
- 电脑休眠、浏览器关闭或标签页被冻结/丢弃时不能保证继续运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”。 - 电脑休眠、浏览器关闭或标签页被冻结/丢弃时不能保证继续运行。若 Chrome 经常回收该页,可在性能设置中将该站点加入“始终保持活跃的网站”。
- 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。 - 列表同名课程无法唯一匹配时会停止。可以进入具体课程并选择“仅当前课程”。
- 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆、最近提交快照和后台模式偏好。标签页会话存储保存短期刷新恢复记录。不保存登录凭据或视频链接。 - 无外部依赖,不上传日志或账户信息。日志仅保存在本页内存;本地存储写入标签页运行锁、题目答案记忆、最近提交快照和后台模式偏好。标签页会话存储保存运行开关与刷新恢复记录。不保存登录凭据或视频链接。
## 验证情况 ## 验证情况
本地语法检查及53项行为测试通过(16项播放/进度测试、12项测验测试、13项媒体恢复测试、12项后台播放测试)。模拟页面完整跑通开始、选择、交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、无反馈时不重复提交、次数耗尽停止、低于100%合格时不误记整卷全对。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。 本地语法检查及61项行为测试通过(16项播放/进度测试、13项测验测试、13项媒体恢复测试、12项后台播放测试、7项持续运行测试)。模拟页面完整跑通开始、选择、电脑端交卷、确认、逐题反馈、重试,并在第三次达到100%;验证了正确题不重复改选、低于100%不放行、超过旧重试上限仍可继续、刷新接续及人工取消。此前已实测网页原生2倍速可以持续播放,并收到平台保存响应。
新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。 新助手还需要在安装后验证实际页面运行;尚未完成整门课程或全部34门课程的自动播放实测,不能据此保证最终学分或所有课程结构兼容。
+16 -8
View File
@@ -1,24 +1,32 @@
# 学员中心 · 2倍速学习助手 # 学员中心 · 2倍速学习助手
适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.2.0**。 适用于 `https://icampus.yunyinsh.com/course/` 的篡改猴脚本。当前版本 **1.3.0**。
## 安装 ## 安装
下载 `icampus-study-helper.user.js`,在篡改猴管理面板的“实用工具 → 从文件导入”中安装或更新原脚本。刷新课程页,点击左下角“开始 / 继续”。不要同时启用旧诊断脚本。 下载 `icampus-study-helper.user.js`,在篡改猴管理面板的“实用工具 → 从文件导入”中安装或更新原脚本。刷新课程页,确认面板版本为1.3.0,点击左下角“开始”。不要同时启用旧诊断脚本。
默认按课程列表顺序播放,支持仅当前课程,以及只做本门测验。安装后不会自行开始播放。 默认按课程列表顺序处理,支持仅当前课程,以及只做本门测验。首次安装等待点击开始;开始后保持启用,刷新同一标签页也会接续,只有“人工取消”才清除运行状态。
## 1.3.0 自动交卷与持续运行
电脑端的“交卷”在侧栏,手机端的底部交卷按钮在电脑上隐藏。旧版只查找底部按钮,导致已答完却无法提交。新版选择当前实际可见的交卷按钮,并自动确认交卷。
测验必须达到 **100%** 才允许继续本门视频或下一课程。低于100%时,根据逐题反馈保存正确答案、排除错误答案并重新测验;不设脚本自己的重考次数上限。当前现场试卷显示“答题次数:不限”。若入口暂不可用,保留已启动状态等待,不跳过该测验。
“运行中”和“等待恢复”都属于已启动。网络异常、等待评分、当前范围完成或需要人工处理时保持启用;自动定期检查或在页面变化后接续。需要停止时点“人工取消”。手动暂停视频后会等待用户恢复视频,不主动抢占暂停操作。
## 功能 ## 功能
- 2倍速正常播放,平台确认完成后切换视频。 - 2倍速正常播放,平台确认完成后切换视频。
- 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。 - 单选、判断、多选自动作答、交卷,根据平台逐题反馈保存答案并重试。
- 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。 - 保留正确答案,排除错误选项或组合;按题干和选项文字匹配,避免选项字母换序导致误答。
- 自动暂停、异常提示、单标签页运行锁,本机答案记忆。 - 持续启用、等待恢复、异常提示、单标签页运行锁和本机答案记忆。
- 默认开启“切标签页继续播放”,助手运行时保留网站原有的后台进度保存定时器。 - 默认开启“切标签页继续播放”,助手运行时保留网站原有的后台进度保存定时器。
## 1.2.0 后台播放 ## 1.2.0 后台播放
安装后刷新课程页,点击“开始 / 继续”,保持“切标签页继续播放”勾选,然后切换其他标签页。开关偏好会保存在本机。 安装后刷新课程页,点击“开始”,保持“切标签页继续播放”勾选,然后切换其他标签页或把浏览器放到后台。开关偏好会保存在本机。
已加载的课程组件在页面隐藏时主动停止心跳。后台模式在课程页的助手运行期间拦截这个隐藏事件,保留正常播放和网页原本的心跳;不会改写 `document.visibilityState`、播放位置或服务器请求。回到前台仍执行网站原来的恢复逻辑。暂停助手、关闭后台模式或离开页面时恢复正常处理。 已加载的课程组件在页面隐藏时主动停止心跳。后台模式在课程页的助手运行期间拦截这个隐藏事件,保留正常播放和网页原本的心跳;不会改写 `document.visibilityState`、播放位置或服务器请求。回到前台仍执行网站原来的恢复逻辑。暂停助手、关闭后台模式或离开页面时恢复正常处理。
@@ -30,13 +38,13 @@
修复视频切换后不继续播放的问题:为每段视频单独计时,等待新媒体数据就绪和网站恢复播放位置;播放器重建时重新绑定;临时 `AbortError` 最多重试三次。加载卡住时尝试网页播放按钮,90秒仍未就绪则最多刷新恢复一次。播放停滞或启动超时也共享这一次恢复额度。 修复视频切换后不继续播放的问题:为每段视频单独计时,等待新媒体数据就绪和网站恢复播放位置;播放器重建时重新绑定;临时 `AbortError` 最多重试三次。加载卡住时尝试网页播放按钮,90秒仍未就绪则最多刷新恢复一次。播放停滞或启动超时也共享这一次恢复额度。
自动恢复会临时保存当前课程与恢复额度,刷新后接续。普通手动刷新仍默认暂停;若恰好发生在待执行的恢复刷新期间,则短期恢复记录可能被该刷新使用。记录两分钟过期,读取后即删除。 自动恢复会临时保存当前课程与恢复额度,刷新后接续。1.3.0起,运行开关另外保存在标签页会话中,因此普通刷新也接续;人工取消后刷新不会启动。
浏览器明确拒绝自动播放时提示手动点击播放,不会无限刷新。正常播放后的手动暂停仍会停止助手。 浏览器明确拒绝自动播放时提示手动点击播放,不会无限刷新。正常播放后的手动暂停仍会停止助手。
## 验证 ## 验证
运行 `npm test`,无需安装依赖。当前53项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复及后台模式。 运行 `npm test`,无需安装依赖。当前61项本地行为测试覆盖播放、进度保存、测验循环、媒体加载恢复、后台模式、严格100%条件和刷新接续。测验模拟包含隐藏的手机端按钮与可见电脑端按钮,验证自动选择、交卷、确认、重试直至100%。
现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。 现场已确认原问题出现时第二段视频处于 `readyState=0` 且旧版已超时停止;修复通过模拟播放器与页面回归。尚未安装修复版验证真实课程完整连播。
@@ -44,6 +52,6 @@
## 限制 ## 限制
计入进度、课程完成和重试次数均以平台返回结果为准。合格后平台若关闭重考入口,无法继续重考到100%。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。 计入进度、课程完成和重试能力均以平台返回结果为准。重考入口暂不可用时会等待;即使平台显示“合格”,不足100%仍不会放行。图片、材料、填空和问答题需要人工处理。请保持电脑唤醒。
更多说明见 [使用说明](2倍速脚本使用说明.md)。 更多说明见 [使用说明](2倍速脚本使用说明.md)。
+120 -63
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name 学员中心 · 2倍速学习助手 // @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study // @namespace local.icampus.study
// @version 1.2.0 // @version 1.3.0
// @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。 // @description 支持切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/* // @match https://icampus.yunyinsh.com/course/*
// @run-at document-start // @run-at document-start
@@ -28,13 +28,14 @@
function bodyJSON(body) { function bodyJSON(body) {
try { return typeof body === 'string' && body.length < 65536 ? JSON.parse(body) : null; } catch { return null; } try { return typeof body === 'string' && body.length < 65536 ? JSON.parse(body) : null; } catch { return null; }
} }
function courseDone(c) { return !!c && (c.status === 'COMPLETED' || !!c.completedAt); } function examPerfect(c) { return !!c && Number(c.examScorePercent)===100 && (c.examPassed===true || c.examPassed==='true'); }
function courseDone(c) { return !!c && (c.status === 'COMPLETED' || !!c.completedAt) && (!c.examRequired || examPerfect(c)); }
function videoPlan(detail) { function videoPlan(detail) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'}; if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo); const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
const next = resources.find(r => r.completed !== true); const next = resources.find(r => r.completed !== true);
if (next) return {kind: next.type === 'VIDEO' ? 'video' : 'manual', resource: next}; if (next) return {kind: next.type === 'VIDEO' ? 'video' : 'manual', resource: next};
if (detail.course.examRequired && detail.course.examPassed !== true) return {kind: 'quiz'}; if (detail.course.examRequired && !examPerfect(detail.course)) return {kind: 'quiz'};
return {kind: 'finish'}; return {kind: 'finish'};
} }
function resolveCards(cards, courses) { function resolveCards(cards, courses) {
@@ -91,7 +92,7 @@
return m ? {kind:'card', paperId:m[1]} : null; return m ? {kind:'card', paperId:m[1]} : null;
} catch { return null; } } catch { return null; }
} }
const core = {normalize, route, endpoint, bodyJSON, courseDone, videoPlan, resolveCards, leaseAvailable, const core = {normalize, route, endpoint, bodyJSON, courseDone, examPerfect, videoPlan, resolveCards, leaseAvailable,
questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint}; questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint};
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; } if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return; if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
@@ -101,6 +102,9 @@
const SNAPSHOT_KEY = 'icampus.study.v1.lastExam'; const SNAPSHOT_KEY = 'icampus.study.v1.lastExam';
const RESUME_KEY = 'icampus.study.v1.recovery'; const RESUME_KEY = 'icampus.study.v1.recovery';
const BACKGROUND_KEY = 'icampus.study.v1.background'; const BACKGROUND_KEY = 'icampus.study.v1.background';
const INTENT_KEY = 'icampus.study.v1.enabled';
let armed = false;
let waiting = null;
let backgroundEnabled = true; let backgroundEnabled = true;
try { backgroundEnabled = localStorage.getItem(BACKGROUND_KEY) !== 'off'; } catch {} try { backgroundEnabled = localStorage.getItem(BACKGROUND_KEY) !== 'off'; } catch {}
let foregroundGraceUntil = 0; let foregroundGraceUntil = 0;
@@ -173,14 +177,30 @@
render(); return; // Let the website restart its normal timer on return. render(); return; // Let the website restart its normal timer on return.
} }
if(!running || !backgroundEnabled || !route(location.hash).id) return; if(!running || !backgroundEnabled || !route(location.hash).id) return;
if(!holdLease()) { stop('其他学习页接管了助手,本页已暂停。'); return; } if(!holdLease()) { park('其他学习页接管了助手,本页已暂停。'); return; }
// The course component's hidden handler stops its heartbeat timer. // The course component's hidden handler stops its heartbeat timer.
// Keep the existing timer alive; leave true visibility and all requests unchanged. // Keep the existing timer alive; leave true visibility and all requests unchanged.
event.stopImmediatePropagation(); event.stopImmediatePropagation();
log('已切到后台,继续正常播放并等待平台保存。'); render(); log('已切到后台,继续正常播放并等待平台保存。'); render();
} }
document.addEventListener('visibilitychange',onVisibilityChange,true); document.addEventListener('visibilitychange',onVisibilityChange,true);
function stop(message = '已暂停;点击开始可继续。') { function workSignature() {
const root=document.querySelector('.course-quiz');
return JSON.stringify([location.hash,root?.textContent,
[...(root?.querySelectorAll('.quiz-option.is-selected') || [])].map(b=>b.textContent),
[...(root?.querySelectorAll('input, textarea') || [])].map(b=>b.value),
document.querySelector('.course-head__top')?.textContent,
currentVideo()?.paused,currentVideo()?.readyState,
[...document.querySelectorAll('article.course-row')].map(b=>b.textContent)]);
}
function persistIntent() {
try {
if(armed) sessionStorage.setItem(INTENT_KEY,JSON.stringify({enabled:true,mode:mode?.value || 'all',
quizOnly,autoQuiz:autoQuiz?.checked!==false,used:[...recoveryUsed]}));
else sessionStorage.removeItem(INTENT_KEY);
} catch { /* Run remains active in this page if session storage is unavailable. */ }
}
function park(message = '等待页面恢复。', {passive=false}={}) {
running = false; ++runNumber; running = false; ++runNumber;
const video = active?.video; const video = active?.video;
active = null; active = null;
@@ -188,7 +208,12 @@
try { sessionStorage.removeItem(RESUME_KEY); } catch {} try { sessionStorage.removeItem(RESUME_KEY); } catch {}
if (video && !video.paused) video.pause(); if (video && !video.paused) video.pause();
restoreVisibilityHandling(); restoreVisibilityHandling();
releaseLease(); show(message); waiting=armed?{message,passive,signature:workSignature(),nextAt:Date.now()+15000}:null;
releaseLease(); show(armed?`已启动 · 等待处理:${message}`:message);
}
function stop() {
armed=false; waiting=null; persistIntent();
park('已人工取消自动运行。');
} }
function safeCourse(c) { function safeCourse(c) {
if (!c || typeof c.id !== 'string') return null; if (!c || typeof c.id !== 'string') return null;
@@ -198,10 +223,10 @@
if (!meta) return; if (!meta) return;
if (meta.kind) return receiveQuiz(meta, response, httpStatus); if (meta.kind) return receiveQuiz(meta, response, httpStatus);
const relevant = meta.id === route(location.hash).id || meta.id === 'packages'; const relevant = meta.id === route(location.hash).id || meta.id === 'packages';
if (relevant && running && (httpStatus === 401 || httpStatus === 403)) return stop('登录状态失效或无权限,请重新登录后刷新。'); if (relevant && running && (httpStatus === 401 || httpStatus === 403)) return park('登录状态失效或无权限,请重新登录后刷新。');
if (!response || typeof response !== 'object' || response.bizCode === undefined) return; if (!response || typeof response !== 'object' || response.bizCode === undefined) return;
if (response.bizCode !== 0) { if (response.bizCode !== 0) {
if (running && relevant) stop(`平台未接受操作(${response.bizCode}):${response.message || '请查看页面提示'}`); if (running && relevant) park(`平台未接受操作(${response.bizCode}):${response.message || '请查看页面提示'}`);
return; return;
} }
const data = response.data; const data = response.data;
@@ -289,7 +314,7 @@
localStorage.setItem(BANK_KEY, JSON.stringify(bank)); localStorage.setItem(BANK_KEY, JSON.stringify(bank));
if (lastExam) localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(lastExam)); if (lastExam) localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(lastExam));
return true; return true;
} catch { if (running) stop('答案记忆保存失败,请检查浏览器本地存储空间。'); return false; } } catch { if (running) park('答案记忆保存失败,请检查浏览器本地存储空间。'); return false; }
} }
function savedAnswersMatch(snapshot, body) { function savedAnswersMatch(snapshot, body) {
if (!Array.isArray(body?.answers) || body.answers.length !== snapshot.questions.length) return false; if (!Array.isArray(body?.answers) || body.answers.length !== snapshot.questions.length) return false;
@@ -309,7 +334,7 @@
exams.get(id)?.examId === meta.examId; exams.get(id)?.examId === meta.examId;
if (!relevant) return; if (!relevant) return;
if (httpStatus === 401 || httpStatus === 403 || (response?.bizCode !== undefined && response.bizCode !== 0)) { if (httpStatus === 401 || httpStatus === 403 || (response?.bizCode !== undefined && response.bizCode !== 0)) {
if (running) stop(`测验操作失败:${response?.message || '登录状态或权限异常'}`); return; if (running) park(`测验操作失败:${response?.message || '登录状态或权限异常'}`); return;
} }
if (response?.bizCode !== 0) return; if (response?.bizCode !== 0) return;
const data = response.data; const data = response.data;
@@ -365,10 +390,10 @@
} }
function quizWait(message, timeout = 25000) { function quizWait(message, timeout = 25000) {
show(message); show(message);
if (Date.now() - quiz.since > waitLimit(timeout)) stop('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。'); if (Date.now() - quiz.since > waitLimit(timeout)) park('测验页面未在预期时间内响应。已保留答案记忆,请检查网页后继续。');
} }
function tickQuiz(id) { function tickQuiz(id) {
if (autoQuiz && !autoQuiz.checked && !quizOnly) return stop('自动测验已关闭,请开启后继续。'); if (autoQuiz && !autoQuiz.checked && !quizOnly) return park('自动测验已关闭,请开启后继续。');
if (!quiz || quiz.courseId !== id) quiz = {courseId:id,phase:'idle',since:Date.now(),round:0}; if (!quiz || quiz.courseId !== id) quiz = {courseId:id,phase:'idle',since:Date.now(),round:0};
const root = document.querySelector('.course-quiz'); const root = document.querySelector('.course-quiz');
if (!root) return quizWait('等待课程测验区域加载…'); if (!root) return quizWait('等待课程测验区域加载…');
@@ -379,17 +404,23 @@
const course = details.get(id)?.course; const course = details.get(id)?.course;
if (course) course.examPassed = true; if (course) course.examPassed = true;
const percent = result.querySelector('.quiz-result__score strong')?.textContent.trim(); const percent = result.querySelector('.quiz-result__score strong')?.textContent.trim();
log(`测验已合格(${percent || '平台确认'})。`); const score=percent && /^\d+(\.\d+)?%$/.test(percent) ? Number(percent.replace('%','')) : course?.examScorePercent;
if (quizOnly) return stop(`测验已合格(${percent || '平台确认'}),答案已按可用反馈保存。`); if(course && score!==undefined) course.examScorePercent=score;
if(Number(score)===100) {
log('测验已达到100%,允许继续。');
if(quizOnly) return park('本门测验已达到100%,保持已启动,等待后续操作。',{passive:true});
quiz=null; return; quiz=null; return;
} }
// A lower platform pass mark does not satisfy the user's 100% gate.
if(!quizButton(root,'重新测验')) return park(`当前正确率${score ?? '未知'}%,等待重考入口;达到100%前不切换视频或课程。`);
}
if (quiz.phase === 'opening' || quiz.phase === 'retrying') return quizWait('正在打开测验…'); if (quiz.phase === 'opening' || quiz.phase === 'retrying') return quizWait('正在打开测验…');
const exam = exams.get(id); const exam = exams.get(id);
if (exam?.canRetry === false || exam?.attemptsExhausted || root.querySelector('.quiz-result__exhausted')) if (exam?.canRetry === false || exam?.attemptsExhausted || root.querySelector('.quiz-result__exhausted'))
return stop('平台答题次数已用完,已保留答案记忆。'); return park('平台答题次数已用完,已保留答案记忆。');
const retry = quizButton(root, '重新测验'); const retry = quizButton(root, '重新测验');
if (!retry) return stop('本次未合格,但页面没有可用的重新测验按钮。'); if (!retry) return park('等待重新测验入口出现后继续重考。');
if (++quiz.round > 256) return stop('本轮重试达到256次,已暂停,请检查是否存在无法判断的题目。'); ++quiz.round;
quiz.phase='retrying'; quiz.since=Date.now(); quiz.plan=null; quiz.phase='retrying'; quiz.since=Date.now(); quiz.plan=null;
retry.click(); show('未合格,读取上次逐题反馈后重新作答…'); return; retry.click(); show('未合格,读取上次逐题反馈后重新作答…'); return;
} }
@@ -418,7 +449,7 @@
const option=questions.find(q=>q.id===pending.id)?.options.find(o=>o.key===pending.key); const option=questions.find(q=>q.id===pending.id)?.options.find(o=>o.key===pending.key);
if (option?.selected===pending.want) quiz.lastClick=null; if (option?.selected===pending.want) quiz.lastClick=null;
else { else {
if(Date.now()-pending.at>waitLimit(8000)) stop('选项点击后没有生效,已暂停,请检查页面。'); if(Date.now()-pending.at>waitLimit(8000)) park('选项点击后没有生效,已暂停,请检查页面。');
return; return;
} }
} }
@@ -426,24 +457,24 @@
const planned = []; const planned = [];
for (const q of questions) { for (const q of questions) {
if (q.unsupported || !q.title || !q.id || q.options.some(o => !o.key)) if (q.unsupported || !q.title || !q.id || q.options.some(o => !o.key))
return stop('本卷包含图片、材料题、填空或问答题,需要手动作答;已保留其他答案记忆。'); return park('本卷包含图片、材料题、填空或问答题,需要手动作答;已保留其他答案记忆。');
const key = questionKey(q); const key = questionKey(q);
const seed = VERIFIED_ANSWERS.find(a => normalize(a.question) === normalize(q.title)); const seed = VERIFIED_ANSWERS.find(a => normalize(a.question) === normalize(q.title));
if (!bank[key] && seed && q.type === 'single' && q.options.some(o => normalize(o.text) === normalize(seed.answerText))) if (!bank[key] && seed && q.type === 'single' && q.options.some(o => normalize(o.text) === normalize(seed.answerText)))
bank[key] = {correct:[normalize(seed.answerText)],wrong:[]}; bank[key] = {correct:[normalize(seed.answerText)],wrong:[]};
const choice = chooseAnswer(q, bank[key]); const choice = chooseAnswer(q, bank[key]);
if (!choice) return stop('某题的可用选项已全部尝试,或选项无法唯一识别,请人工核对。'); if (!choice) return park('某题的可用选项已全部尝试,或选项无法唯一识别,请人工核对。');
planned.push(pureQuestion(q, choice)); planned.push(pureQuestion(q, choice));
} }
const signature = qs => JSON.stringify(qs.map(q => [questionKey(q),selectionKey(q.chosen)]).sort()); const signature = qs => JSON.stringify(qs.map(q => [questionKey(q),selectionKey(q.chosen)]).sort());
if (lastExam?.courseId === id && lastExam.submitted && signature(lastExam.questions) === signature(planned)) if (lastExam?.courseId === id && lastExam.submitted && signature(lastExam.questions) === signature(planned))
return stop('未取得能改变答案的逐题反馈,已暂停,避免反复提交同一份答案。'); return park('未取得能改变答案的逐题反馈,已暂停,避免反复提交同一份答案。');
quiz.plan = planned; quiz.phase='answering'; quiz.since=Date.now(); quiz.plan = planned; quiz.phase='answering'; quiz.since=Date.now();
show(`自动作答:共 ${questions.length} 题,优先使用已确认答案。`); show(`自动作答:共 ${questions.length} 题,优先使用已确认答案。`);
} }
for (const planned of quiz.plan) { for (const planned of quiz.plan) {
const q = questions.find(q => q.id === planned.id); const q = questions.find(q => q.id === planned.id);
if (!q || questionKey(q) !== questionKey(planned)) return stop('作答过程中题目发生变化,已暂停。'); if (!q || questionKey(q) !== questionKey(planned)) return park('作答过程中题目发生变化,已暂停。');
for (const option of q.options) { for (const option of q.options) {
const want = planned.chosen.includes(normalize(option.text)); const want = planned.chosen.includes(normalize(option.text));
if (q.type === 'single' ? want && !option.selected : want !== option.selected) { if (q.type === 'single' ? want && !option.selected : want !== option.selected) {
@@ -452,9 +483,10 @@
} }
} }
if (selectionKey(q.options.filter(o => o.selected).map(o => o.text)) !== selectionKey(planned.chosen)) if (selectionKey(q.options.filter(o => o.selected).map(o => o.text)) !== selectionKey(planned.chosen))
return stop('页面选项状态与计划不一致,已暂停。'); return park('页面选项状态与计划不一致,已暂停。');
} }
const submit = root.querySelector('button.quiz-footer__submit'); // Desktop uses the side answer-card button; the mobile footer is hidden there.
const submit = quizButton(root,'交卷');
if (!visible(submit) || submit.disabled) return quizWait('等待交卷按钮可用…'); if (!visible(submit) || submit.disabled) return quizWait('等待交卷按钮可用…');
lastExam = {courseId:id,examId:exam.examId,paperId:exam.paperId,questions:quiz.plan, lastExam = {courseId:id,examId:exam.examId,paperId:exam.paperId,questions:quiz.plan,
answersSaved:false,submitted:false}; answersSaved:false,submitted:false};
@@ -464,7 +496,7 @@
function goBack(forVerification) { function goBack(forVerification) {
const back = document.querySelector('.van-nav-bar__left'); const back = document.querySelector('.van-nav-bar__left');
if (!back) return stop('找不到返回列表按钮,请手动返回课程列表后继续。'); if (!back) return park('找不到返回列表按钮,请手动返回课程列表后继续。');
if (forVerification) verifyAfterBack = route(location.hash).id; if (forVerification) verifyAfterBack = route(location.hash).id;
mustObservePackagesAfter = Date.now(); mustObservePackagesAfter = Date.now();
navigatingUntil = Date.now() + 20000; navigatingUntil = Date.now() + 20000;
@@ -475,24 +507,24 @@
} }
function tickList() { function tickList() {
if (!packagesAt || packagesAt < mustObservePackagesAfter) { if (!packagesAt || packagesAt < mustObservePackagesAfter) {
if (Date.now() - routeSince > waitLimit(25000)) stop('未收到新的课程列表数据,请刷新页面后开始。'); if (Date.now() - routeSince > waitLimit(25000)) park('未收到新的课程列表数据,请刷新页面,助手会自动接续。');
return; return;
} }
if (verifyAfterBack) { if (verifyAfterBack) {
const previous = courses.find(c => c.id === verifyAfterBack); const previous = courses.find(c => c.id === verifyAfterBack);
if (!courseDone(previous)) return stop('视频已播完,但平台尚未确认整门课程完成。请查看课程状态后继续。'); if (!courseDone(previous)) return park('视频已播完,但平台尚未确认整门课程完成。请查看课程状态后继续。');
log('平台已确认上一门课程完成。'); verifyAfterBack = null; log('平台已确认上一门课程完成。'); verifyAfterBack = null;
} }
const cards = [...document.querySelectorAll('article.course-row')].filter(visible) const cards = [...document.querySelectorAll('article.course-row')].filter(visible)
.map(el => ({el, title: el.querySelector('.course-row__title')?.textContent})); .map(el => ({el, title: el.querySelector('.course-row__title')?.textContent}));
if (!cards.length) { if (!cards.length) {
if (Date.now() - routeSince > waitLimit(25000)) stop('没有找到课程列表,请检查是否已登录。'); if (Date.now() - routeSince > waitLimit(25000)) park('没有找到课程列表,请检查是否已登录。');
return; return;
} }
const mapped = resolveCards(cards, courses); const mapped = resolveCards(cards, courses);
if (mapped.some(x => !x.course)) return stop('课程名称重复或列表结构发生变化,请进入具体课程后选择“仅当前课程”。'); if (mapped.some(x => !x.course)) return park('课程名称重复或列表结构发生变化,请进入具体课程后选择“仅当前课程”。');
const next = mapped.find(x => !courseDone(x.course)); const next = mapped.find(x => !courseDone(x.course));
if (!next) return stop('当前显示的课程均已由平台确认完成。'); if (!next) return park('当前显示的课程均已由平台确认完成。');
expectedCourse = next.course.id; expectedCourse = next.course.id;
details.delete(expectedCourse); details.delete(expectedCourse);
navigatingUntil = Date.now() + 20000; navigatingUntil = Date.now() + 20000;
@@ -502,12 +534,13 @@
} }
function recoverMedia(id, resourceId, reason) { function recoverMedia(id, resourceId, reason) {
const key = `${id}/${resourceId}`; const key = `${id}/${resourceId}`;
if (recoveryUsed.has(key)) return stop(`${reason},自动恢复一次后仍失败,请检查网络并手动刷新。`); if (recoveryUsed.has(key)) return park(`${reason},自动恢复一次后仍失败,请检查网络并手动刷新。`);
recoveryUsed.add(key); recoveryUsed.add(key);
persistIntent();
try { try {
sessionStorage.setItem(RESUME_KEY, JSON.stringify({hash:location.hash, expires:Date.now()+120000, sessionStorage.setItem(RESUME_KEY, JSON.stringify({hash:location.hash, expires:Date.now()+120000,
mode:mode.value, autoQuiz:autoQuiz?.checked !== false, used:[...recoveryUsed]})); mode:mode.value, autoQuiz:autoQuiz?.checked !== false, used:[...recoveryUsed]}));
} catch { return stop(`${reason},无法保存恢复状态,请手动刷新后继续。`); } } catch { return park(`${reason},无法保存恢复状态,请手动刷新后继续。`); }
// The website tears down its old session and fetches fresh media on reload. // The website tears down its old session and fetches fresh media on reload.
// Only this explicit recovery reload resumes; ordinary refreshes stay paused. // Only this explicit recovery reload resumes; ordinary refreshes stay paused.
running=false; ++runNumber; running=false; ++runNumber;
@@ -540,14 +573,20 @@
return Date.now()-load.settledAt>=1000; return Date.now()-load.settledAt>=1000;
} }
async function tickCourse(id) { async function tickCourse(id) {
if (expectedCourse && expectedCourse !== id) return stop('打开的课程与选定课程不一致,已暂停。'); if (expectedCourse && expectedCourse !== id) return park('打开的课程与选定课程不一致,已暂停。');
expectedCourse = null; expectedCourse = null;
const detail = details.get(id); const detail = details.get(id);
if (!detail) { if (!detail) {
if (Date.now() - routeSince > waitLimit(20000)) stop('未捕获课程详情。安装后请刷新学习页,再点击开始。'); if (Date.now() - routeSince > waitLimit(20000)) park('未捕获课程详情,请刷新学习页,助手会自动接续。');
return; return;
} }
const plan = videoPlan(detail); const plan = videoPlan(detail);
if(detail.course.examRequired && !examPerfect(detail.course)) {
const gatedVideo=active?.video || currentVideo();
if(gatedVideo && !gatedVideo.paused) gatedVideo.pause();
active=null;
return tickQuiz(id);
}
// 正在播放的资源自然结束后再切换,即便平台提前返回 completed。 // 正在播放的资源自然结束后再切换,即便平台提前返回 completed。
if (active && active.courseId === id && !active.video.ended) return monitor(); if (active && active.courseId === id && !active.video.ended) return monitor();
if (active?.video.ended) { if (active?.video.ended) {
@@ -555,27 +594,27 @@
active.endedAt ||= Date.now(); active.endedAt ||= Date.now();
if (resource?.completed !== true) { if (resource?.completed !== true) {
show('视频已结束,等待平台确认保存…'); show('视频已结束,等待平台确认保存…');
if (Date.now() - active.endedAt > waitLimit(25000)) stop('平台尚未确认视频完成,请返回列表核对后再继续。'); if (Date.now() - active.endedAt > waitLimit(25000)) park('平台尚未确认视频完成,请返回列表核对后再继续。');
return; return;
} }
log('平台已确认本段视频完成。'); active = null; log('平台已确认本段视频完成。'); active = null;
} }
if (plan.kind === 'manual') return stop('下一项不是视频,请在页面中完成该学习资源后继续。'); if (plan.kind === 'manual') return park('下一项不是视频,请在页面中完成该学习资源后继续。');
if (plan.kind === 'quiz' || quiz?.courseId === id) return tickQuiz(id); if (plan.kind === 'quiz' || quiz?.courseId === id) return tickQuiz(id);
if (plan.kind === 'finish') { if (plan.kind === 'finish') {
if (mode.value === 'current') return stop('本门视频及测验条件已满足;请返回列表核对最终完成状态。'); if (mode.value === 'current') return park('本门视频及测验条件已满足;请返回列表核对最终完成状态。');
return goBack(true); return goBack(true);
} }
if (plan.kind !== 'video') return; if (plan.kind !== 'video') return;
const resource = plan.resource; const resource = plan.resource;
const buttons = [...document.querySelectorAll('button.resource-item')]; const buttons = [...document.querySelectorAll('button.resource-item')];
const matches = buttons.filter(b => normalize(b.querySelector('.resource-item__title')?.textContent) === normalize(resource.name)); const matches = buttons.filter(b => normalize(b.querySelector('.resource-item__title')?.textContent) === normalize(resource.name));
if (matches.length !== 1) return stop('未能唯一识别下一段视频,请手动选择后检查页面。'); if (matches.length !== 1) return park('未能唯一识别下一段视频,请手动选择后检查页面。');
const button = matches[0]; const button = matches[0];
if (!button.classList.contains('is-active')) { if (!button.classList.contains('is-active')) {
if (button.disabled || resource.playable === false) return stop('下一段视频尚未解锁,请按平台提示完成前置学习。'); if (button.disabled || resource.playable === false) return park('下一段视频尚未解锁,请按平台提示完成前置学习。');
if (resourceSwitch?.id === resource.id) { if (resourceSwitch?.id === resource.id) {
if (Date.now() - resourceSwitch.at > waitLimit(20000)) stop('切换视频没有成功,请手动选择该视频后继续。'); if (Date.now() - resourceSwitch.at > waitLimit(20000)) park('切换视频没有成功,请手动选择该视频后继续。');
return; return;
} }
resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc}; resourceSwitch = {id: resource.id, at: Date.now(),previousVideo:currentVideo(),previousSrc:currentVideo()?.currentSrc};
@@ -594,7 +633,7 @@
mediaLoad=null; mediaLoad=null;
lastNetworkAt = Date.now(); lastNetworkAt = Date.now();
video.playbackRate = 2; video.playbackRate = 2;
if (Math.abs(video.playbackRate - 2) > 0.01) return stop('播放器未接受2倍速,请检查页面倍速设置。'); if (Math.abs(video.playbackRate - 2) > 0.01) return park('播放器未接受2倍速,请检查页面倍速设置。');
attemptPlay(active,run); attemptPlay(active,run);
if(running) show(`正在启动2倍速播放:${resource.name}`); if(running) show(`正在启动2倍速播放:${resource.name}`);
} }
@@ -604,7 +643,7 @@
const failed=error => { const failed=error => {
if(!running || run!==runNumber || active!==a) return; if(!running || run!==runNumber || active!==a) return;
a.playPending=false; a.playPending=false;
if(error?.name==='NotAllowedError') return stop('浏览器阻止自动播放,请手动点击网页“点击播放”,然后点助手继续。'); if(error?.name==='NotAllowedError') return park('浏览器阻止自动播放,请手动点击网页“点击播放”,助手会自动接续。');
if(error?.name!=='AbortError') recoverMedia(a.courseId,a.id,'播放启动失败'); if(error?.name!=='AbortError') recoverMedia(a.courseId,a.id,'播放启动失败');
// AbortError commonly means the website is replacing the media source. // AbortError commonly means the website is replacing the media source.
}; };
@@ -617,7 +656,7 @@
const a = active, v = a.video; const a = active, v = a.video;
const resource=details.get(a.courseId)?.resources.find(r=>r.id===a.id); const resource=details.get(a.courseId)?.resources.find(r=>r.id===a.id);
const selected=document.querySelector('button.resource-item.is-active .resource-item__title'); const selected=document.querySelector('button.resource-item.is-active .resource-item__title');
if(selected && normalize(selected.textContent)!==normalize(resource?.name)) return stop('视频已被手动切换,点击开始可继续。'); if(selected && normalize(selected.textContent)!==normalize(resource?.name)) return park('视频已被手动切换,等待页面稳定后继续。');
if (!v.isConnected || currentVideo() !== v || v.currentSrc !== a.src) { if (!v.isConnected || currentVideo() !== v || v.currentSrc !== a.src) {
active=null; mediaLoad=null; show('播放器正在重建,等待新视频就绪…'); return; active=null; mediaLoad=null; show('播放器正在重建,等待新视频就绪…'); return;
} }
@@ -630,35 +669,44 @@
return; return;
} }
} }
if (Math.abs(v.playbackRate - 2) > 0.01) return stop('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。'); if (Math.abs(v.playbackRate - 2) > 0.01) return park('播放器改变了倍速,已暂停;请检查是否仍有诊断脚本在运行。');
if (v.paused && !v.ended) return stop('视频已暂停;点击助手开始可继续。'); if (v.paused && !v.ended) return park('视频已手动暂停;点击视频播放可继续,或点人工取消关闭助手。',{passive:true});
if (v.currentTime > a.lastPosition + 0.1) { a.progressedAt = Date.now(); a.lastPosition = v.currentTime; } if (v.currentTime > a.lastPosition + 0.1) { a.progressedAt = Date.now(); a.lastPosition = v.currentTime; }
if (Date.now() - a.progressedAt > waitLimit(60000)) return recoverMedia(a.courseId,a.id,'视频长时间没有推进'); if (Date.now() - a.progressedAt > waitLimit(60000)) return recoverMedia(a.courseId,a.id,'视频长时间没有推进');
const saveTimeout=waitLimit(75000); const saveTimeout=waitLimit(75000);
if (Date.now()>foregroundGraceUntil && Date.now()-lastNetworkAt>saveTimeout) if (Date.now()>foregroundGraceUntil && Date.now()-lastNetworkAt>saveTimeout)
return stop(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`); return park(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
} }
async function tick() { async function tick() {
if (!running || busy) return; if(busy || !armed) return;
if(!running) {
if(!waiting) return;
const changed=waiting.signature!==workSignature();
if(!changed && (waiting.passive || Date.now()<waiting.nextAt)) return;
if(!holdLease()) { waiting.nextAt=Date.now()+15000; return; }
waiting=null;running=true;routeSince=Date.now();mediaLoad=null;
if(quiz) quiz.since=Date.now();
show('已启动 · 自动检查并继续…');
}
busy = true; busy = true;
try { try {
if (!holdLease()) return stop('另一个学习页正在使用助手,或浏览器禁止本地存储。'); if (!holdLease()) return park('另一个学习页正在使用助手,或浏览器禁止本地存储。');
if (Date.now() - startAt < 600) return; if (Date.now() - startAt < 600) return;
if (location.hash !== lastHash) { if (location.hash !== lastHash) {
const destination=route(location.hash); const destination=route(location.hash);
const expected=courseNavigationPending && (expectedCourse ? destination.id===expectedCourse : destination.list); const expected=courseNavigationPending && (expectedCourse ? destination.id===expectedCourse : destination.list);
lastHash = location.hash; routeSince = Date.now(); active = null; navigatingUntil = 0; lastHash = location.hash; routeSince = Date.now(); active = null; navigatingUntil = 0;
courseNavigationPending = false; resourceSwitch = null; courseNavigationPending = false; resourceSwitch = null;
if (!expected) return stop('页面已被手动切换;点击开始可继续。'); if (!expected) { quiz=null; expectedCourse=null; show('已启动 · 页面已切换,继续检查当前课程。'); }
} }
if (navigatingUntil > Date.now()) return; if (navigatingUntil > Date.now()) return;
if (courseNavigationPending) return stop('课程页面跳转没有成功,请手动打开课程后继续。'); if (courseNavigationPending) return park('课程页面跳转没有成功,请手动打开课程后继续。');
const here = route(location.hash); const here = route(location.hash);
if (here.list) tickList(); if (here.list) tickList();
else if (here.id) { if (quizOnly) tickQuiz(here.id); else await tickCourse(here.id); } else if (here.id) { if (quizOnly) tickQuiz(here.id); else await tickCourse(here.id); }
else stop('请打开“我的课程”列表或课程学习页。'); else park('请打开“我的课程”列表或课程学习页。');
render(); render();
} catch { stop('页面结构或播放操作出现异常,请刷新页面后重试。'); } } catch { park('页面结构或播放操作出现异常,请刷新页面后重试。'); }
finally { busy = false; } finally { busy = false; }
} }
function render() { function render() {
@@ -669,11 +717,12 @@
panel.querySelector('[data-progress]').textContent = v panel.querySelector('[data-progress]').textContent = v
? `实际 ${v.playbackRate.toFixed(1)}倍 · ${Math.floor(v.currentTime / 60)}:${String(Math.floor(v.currentTime % 60)).padStart(2, '0')} / ${Math.floor(v.duration / 60)}:${String(Math.floor(v.duration % 60)).padStart(2, '0')}\n平台进度 ${course?.progressPercent ?? '待确认'}% · ${active.saved ? '已收到保存确认' : '等待保存确认'}` ? `实际 ${v.playbackRate.toFixed(1)}倍 · ${Math.floor(v.currentTime / 60)}:${String(Math.floor(v.currentTime % 60)).padStart(2, '0')} / ${Math.floor(v.duration / 60)}:${String(Math.floor(v.duration % 60)).padStart(2, '0')}\n平台进度 ${course?.progressPercent ?? '待确认'}% · ${active.saved ? '已收到保存确认' : '等待保存确认'}`
: '以平台保存结果为准;电脑需保持唤醒。'; : '以平台保存结果为准;电脑需保持唤醒。';
panel.querySelector('[data-start]').disabled = running; panel.querySelector('[data-start]').disabled = armed;
panel.querySelector('[data-quiz-only]').disabled = running; panel.querySelector('[data-start]').textContent = armed ? (running?'已启动 · 运行中':'已启动 · 等待恢复') : '开始';
panel.querySelector('[data-quiz-only]').disabled = armed;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭'; ? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
mode.disabled = running; mode.disabled = armed;
} }
function mount() { function mount() {
if (!document.body) return; if (!document.body) return;
@@ -681,14 +730,14 @@
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646'; host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
panel = host.attachShadow({mode: 'open'}); 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> 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.2.0</strong><p data-status></p><small data-progress></small> <section><strong>2倍速学习助手 · 1.3.0</strong><p data-status></p><small data-progress></small>
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select> <select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
<div><button data-start>开始 / 继续</button><button data-stop>暂停</button></div> <div><button data-start>开始</button><button data-stop>人工取消</button></div>
<label><input type="checkbox" data-quiz checked> 视频完成后自动做测验</label> <label><input type="checkbox" data-quiz checked> 自动测验,达到100%才继续</label>
<div><label><input type="checkbox" data-background> 切标签页继续播放</label></div> <div><label><input type="checkbox" data-background> 切标签页继续播放</label></div>
<small data-background-state></small> <small data-background-state></small>
<div><button data-quiz-only>只做本门测验</button></div> <div><button data-quiz-only>只做本门测验</button></div>
<small>错题根据平台反馈重试;合格后平台可能不允许重考。</small> <small>启动后持续运行与等待;只有人工取消才关闭。测验100%后继续视频。</small>
<details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`; <details><summary>运行记录</summary><pre></pre><button data-copy>更新记录</button></details></section>`;
mode = panel.querySelector('select'); mode = panel.querySelector('select');
autoQuiz = panel.querySelector('[data-quiz]'); autoQuiz = panel.querySelector('[data-quiz]');
@@ -697,11 +746,12 @@
backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked); backgroundToggle.onchange=()=>setBackgroundEnabled(backgroundToggle.checked);
function beginRun(only, resume) { function beginRun(only, resume) {
if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。'); if (only && !route(location.hash).id) return show('请先进入需要作答的课程学习页。');
if (!holdLease()) return show('另一个学习页正在运行助手,或本地存储不可用。请先暂停另一个页面。');
if (only) currentVideo()?.pause(); if (only) currentVideo()?.pause();
quizOnly=only; quiz=null; quizOnly=only; quiz=null;
mediaLoad=null; recoveryUsed=new Set(resume?.used || []); mediaLoad=null; recoveryUsed=new Set(resume?.used || []);
running = true; ++runNumber; active = null; expectedCourse = null; verifyAfterBack = null; running = true; ++runNumber; active = null; expectedCourse = null; verifyAfterBack = null;
armed=true; waiting=null; persistIntent();
if(!holdLease()) return park('另一个学习页正在运行,等待其释放控制权。');
startAt = routeSince = Date.now(); lastHash = location.hash; navigatingUntil = 0; startAt = routeSince = Date.now(); lastHash = location.hash; navigatingUntil = 0;
courseNavigationPending = false; resourceSwitch = null; courseNavigationPending = false; resourceSwitch = null;
show('已开始,正在核对课程与平台数据…'); void tick(); show('已开始,正在核对课程与平台数据…'); void tick();
@@ -709,6 +759,7 @@
panel.querySelector('[data-start]').onclick = () => beginRun(false); panel.querySelector('[data-start]').onclick = () => beginRun(false);
panel.querySelector('[data-quiz-only]').onclick = () => beginRun(true); panel.querySelector('[data-quiz-only]').onclick = () => beginRun(true);
panel.querySelector('[data-stop]').onclick = () => stop(); panel.querySelector('[data-stop]').onclick = () => stop();
autoQuiz.onchange=()=>{persistIntent();if(waiting)waiting.nextAt=0;};
panel.querySelector('[data-copy]').onclick = () => { panel.querySelector('pre').textContent = logs.map(x => `${x.time} ${x.message}`).join('\n'); }; panel.querySelector('[data-copy]').onclick = () => { panel.querySelector('pre').textContent = logs.map(x => `${x.time} ${x.message}`).join('\n'); };
document.body.append(host); render(); document.body.append(host); render();
try { try {
@@ -717,10 +768,16 @@
if(resume?.hash===location.hash && resume.expires>Date.now() && Array.isArray(resume.used) && resume.used.length<=100) { if(resume?.hash===location.hash && resume.expires>Date.now() && Array.isArray(resume.used) && resume.used.length<=100) {
mode.value=resume.mode==='current'?'current':'all'; autoQuiz.checked=resume.autoQuiz!==false; mode.value=resume.mode==='current'?'current':'all'; autoQuiz.checked=resume.autoQuiz!==false;
beginRun(false,resume); beginRun(false,resume);
} else {
const intent=JSON.parse(sessionStorage.getItem(INTENT_KEY) || 'null');
if(intent?.enabled && Array.isArray(intent.used)) {
mode.value=intent.mode==='current'?'current':'all';autoQuiz.checked=intent.autoQuiz!==false;
beginRun(!!intent.quizOnly,intent);
} }
} catch { /* Ordinary reload or invalid checkpoint: wait for the user. */ }
} }
window.addEventListener('storage', e => { if (e.key === LEASE_KEY && running && readLease()?.owner !== owner) stop('其他学习页接管了助手,本页已暂停。'); }); } catch { /* Invalid stored settings: wait for an explicit start. */ }
}
window.addEventListener('storage', e => { if (e.key === LEASE_KEY && running && readLease()?.owner !== owner) park('其他学习页接管了助手,本页已暂停。'); });
window.addEventListener('pagehide', () => { running = false; releaseLease(); }); window.addEventListener('pagehide', () => { running = false; releaseLease(); });
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, {once: true}); if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, {once: true});
setInterval(() => { void tick(); }, 1000); setInterval(() => { void tick(); }, 1000);
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "e-learning-assistant", "name": "e-learning-assistant",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"description": "学员中心2倍速播放和测验辅助脚本", "description": "学员中心2倍速播放和测验辅助脚本",
"scripts": { "scripts": {
"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" "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 && node persistent-run.test.cjs"
}, },
"engines": { "node": ">=18" } "engines": { "node": ">=18" }
} }
+50
View File
@@ -0,0 +1,50 @@
'use strict';
const assert=require('node:assert/strict');
const core=require('./icampus-study-helper.user.js');
const {harness,ID,detail}=require('./study-helper.test.cjs');
let passed=0;
async function test(name,fn){await fn();passed++;console.log(`PASS ${name}`);}
function mount(h){const nodes=new Map();const panel={querySelector(s){if(!nodes.has(s))nodes.set(s,{textContent:'',value:'all',checked:true});return nodes.get(s);}};
h.context.document.body={append(){}};h.context.document.createElement=()=>({style:{},attachShadow:()=>panel});h.api.mount();return nodes;}
(async()=>{
await test('a platform pass below 100 never counts as a completed course',()=>{
const d=detail();d.course.status='COMPLETED';d.course.examScorePercent=60;d.resources.forEach(r=>r.completed=true);
assert.equal(core.courseDone(d.course),false);assert.equal(core.videoPlan(d).kind,'quiz');
d.course.examScorePercent=100;assert.equal(core.courseDone(d.course),true);
});
await test('a course below 100 starts its quiz before playing or switching video',async()=>{
const h=harness();let starts=0;const c=h.api.details.get(ID).course;c.examPassed=false;c.examScorePercent=0;
const button={textContent:'开始测验',disabled:false,getClientRects:()=>[{}],click(){starts++;}};
h.selectors.set('.course-quiz',{querySelector:()=>null,querySelectorAll:s=>s==='button'?[button]:[]});
h.api.begin();await h.api.tickCourse(ID);assert.equal(starts,1);assert.equal(h.video.playCount,0);assert.equal(h.buttons[1].clicks,0);assert.equal(h.video.paused,true);
});
await test('a low passing score with a missing retake entry stays armed and does not advance',async()=>{
const h=harness();const c=h.api.details.get(ID).course;c.examScorePercent=80;
const result={getClientRects:()=>[{}],querySelector:s=>s==='.quiz-result__status.is-pass'?{}:s==='.quiz-result__score strong'?{textContent:'80%'}:null};
h.selectors.set('.course-quiz',{querySelector:s=>s==='.quiz-result'?result:null,querySelectorAll:()=>[]});
h.api.begin();await h.api.tickCourse(ID);assert.equal(h.api.state.armed,true);assert.equal(h.api.state.running,false);
assert.match(h.api.state.status,/100%/);assert.equal(h.video.playCount,0);assert.equal(h.buttons[1].clicks,0);
});
await test('temporary waits retain the started button state and retry automatically',async()=>{
const h=harness();const ui=mount(h);ui.get('[data-start]').onclick();h.api.park('网络等待');
assert.equal(h.api.state.armed,true);assert.equal(ui.get('[data-start]').disabled,true);assert.match(ui.get('[data-start]').textContent,/已启动/);
h.advance(15001);await h.api.tick();h.advance(1000);await h.api.tick();assert.equal(h.video.playCount,1);
});
await test('ordinary refresh restores the enabled intent without another start click',()=>{
const h=harness();const ui=mount(h);ui.get('[data-start]').onclick();h.api.park('等待页面响应');
const restarted=harness({sessionEntries:[...h.session]});mount(restarted);
assert.equal(restarted.api.state.armed,true);assert.equal(restarted.api.state.running,true);
});
await test('manual cancellation is the only action that clears the stored enabled intent',async()=>{
const h=harness();const ui=mount(h);ui.get('[data-start]').onclick();h.api.park('暂时等待');
ui.get('[data-stop]').onclick();assert.equal(h.api.state.armed,false);assert.equal(h.session.has('icampus.study.v1.enabled'),false);
h.advance(60000);await h.api.tick();assert.equal(h.video.playCount,0);
const restarted=harness({sessionEntries:[...h.session]});mount(restarted);assert.equal(restarted.api.state.armed,false);
});
await test('manual video pause waits for a real resume without cancelling the intent',async()=>{
const h=harness();h.api.begin();h.api.track(h.video);h.video.paused=true;h.api.monitor();
assert.equal(h.api.state.armed,true);h.advance(30000);await h.api.tick();assert.equal(h.video.playCount,0);
h.video.paused=false;await h.api.tick();assert.equal(h.api.state.running,true);
});
console.log(`${passed} persistent-run tests passed.`);
})().catch(e=>{console.error(e);process.exitCode=1;});
+11 -4
View File
@@ -48,7 +48,7 @@ test('quiz observer scopes exact endpoints and origins', () => {
// The userscript must click choices, submit, confirm, read the answer card and retry. // The userscript must click choices, submit, confirm, read the answer card and retry.
function simulator({feedback=true,exhaust=false,threshold=100}={}) { function simulator({feedback=true,exhaust=false,threshold=100}={}) {
const h=harness(); let view='idle', dialog=false, attempt=0, submissions=0; const h=harness(); let view='idle', dialog=false, attempt=0, submissions=0;
let answers={}, selected={}, lastCard=[]; let answers={}, selected={}, lastCard=[],scorePercent=0;
const definitions=[q('q1'),q('q2'),q('q3','multi')]; const definitions=[q('q1'),q('q2'),q('q3','multi')];
const expected={q1:['A'],q2:['B'],q3:['A','B']}; const expected={q1:['A'],q2:['B'],q3:['A','B']};
const clicks=[]; const clicks=[];
@@ -66,11 +66,14 @@ function simulator({feedback=true,exhaust=false,threshold=100}={}) {
}); });
const start=button('开始测验',begin); const start=button('开始测验',begin);
const submit=button('交卷',()=>{dialog=true;}); const submit=button('交卷',()=>{dialog=true;});
const hiddenFooter=button('交卷',()=>{throw new Error('Must not click hidden mobile footer');});
hiddenFooter.getClientRects=()=>[];
const confirm=button('确定交卷',()=>{ const confirm=button('确定交卷',()=>{
dialog=false; ++submissions; answers=structuredClone(selected); dialog=false; ++submissions; answers=structuredClone(selected);
const body={answers:definitions.map(d=>d.type==='multi'?{questionId:d.id,selectedOptionKeys:answers[d.id]}:{questionId:d.id,selectedOptionKey:answers[d.id][0]})}; const body={answers:definitions.map(d=>d.type==='multi'?{questionId:d.id,selectedOptionKeys:answers[d.id]}:{questionId:d.id,selectedOptionKey:answers[d.id][0]})};
lastCard=body.answers.map(a=>({...a,correct:JSON.stringify([...(answers[a.questionId]||[])].sort())===JSON.stringify(expected[a.questionId])})); lastCard=body.answers.map(a=>({...a,correct:JSON.stringify([...(answers[a.questionId]||[])].sort())===JSON.stringify(expected[a.questionId])}));
const score=lastCard.filter(a=>a.correct).length/definitions.length*100; const score=lastCard.filter(a=>a.correct).length/definitions.length*100;
scorePercent=score;
const examId=String(900+attempt); const examId=String(900+attempt);
h.api.receive({kind:'answers',examId,body},{bizCode:0,data:{}},200); h.api.receive({kind:'answers',examId,body},{bizCode:0,data:{}},200);
h.api.receive({kind:'submit',examId},{bizCode:0,data:{scorePercent:score,passed:score>=threshold,canRetry:!exhaust,attemptsExhausted:exhaust}},200); h.api.receive({kind:'submit',examId},{bizCode:0,data:{scorePercent:score,passed:score>=threshold,canRetry:!exhaust,attemptsExhausted:exhaust}},200);
@@ -86,12 +89,12 @@ function simulator({feedback=true,exhaust=false,threshold=100}={}) {
})):[]})); })):[]}));
const root=element({ const root=element({
querySelector(s){ querySelector(s){
if(s==='.quiz-result'&&['pass','fail'].includes(view)) return element({querySelector:s=>s==='.quiz-result__status.is-pass'&&view==='pass'?{}:s==='.quiz-result__score strong'?{textContent:view==='pass'?'100%':'33%'}:null}); if(s==='.quiz-result'&&['pass','fail'].includes(view)) return element({querySelector:s=>s==='.quiz-result__status.is-pass'&&view==='pass'?{}:s==='.quiz-result__score strong'?{textContent:scorePercent+'%'}:null});
if(s==='.quiz-result__exhausted'&&view==='fail'&&exhaust)return {}; if(s==='.quiz-result__exhausted'&&view==='fail'&&exhaust)return {};
if(s==='button.quiz-footer__submit'&&view==='form') return submit; if(s==='button.quiz-footer__submit'&&view==='form') return hiddenFooter;
return null; return null;
}, },
querySelectorAll(s){if(s==='article.quiz-question')return view==='form'?nodes:[];if(s==='button')return view==='idle'?[start]:view==='fail'?[retry]:[];return [];} querySelectorAll(s){if(s==='article.quiz-question')return view==='form'?nodes:[];if(s==='button')return view==='idle'?[start]:view==='fail'?[retry]:view==='form'?[hiddenFooter,submit]:[];return [];}
}); });
h.selectors.set('.course-quiz',root); h.selectors.set('.course-quiz',root);
const original=h.context.document.querySelectorAll; const original=h.context.document.querySelectorAll;
@@ -124,4 +127,8 @@ test('foreign exam submission cannot teach this attempt', () => {
s.h.api.receive({kind:'submit',examId:'123456'},{bizCode:0,data:{scorePercent:100,passed:true}},200); s.h.api.receive({kind:'submit',examId:'123456'},{bizCode:0,data:{scorePercent:100,passed:true}},200);
assert.equal(Object.keys(s.h.api.state.bank).length,0); assert.equal(Object.keys(s.h.api.state.bank).length,0);
}); });
test('unlimited retakes are not stopped by the old 256-round cap',()=>{
const s=simulator();s.h.api.tickQuiz(ID);s.h.api.state.quiz.round=300;s.run();
assert.equal(s.view,'pass');assert.equal(s.submissions,3);assert.ok(s.h.api.state.armed);
});
console.log(`${passed} quiz tests passed.`); console.log(`${passed} quiz tests passed.`);
+6 -6
View File
@@ -9,14 +9,14 @@ let passed = 0;
async function test(name, fn) { await fn(); ++passed; console.log(`PASS ${name}`); } async function test(name, fn) { await fn(); ++passed; console.log(`PASS ${name}`); }
function detail(overrides = {}) { function detail(overrides = {}) {
return {course: {id: ID, courseName: '课程一', status: 'LEARNING', completedAt: null, return {course: {id: ID, courseName: '课程一', status: 'LEARNING', completedAt: null,
examRequired: true, examPassed: true}, resources: [ examRequired: true, examPassed: true,examScorePercent:100}, resources: [
{id: RID, name: '视频一', type: 'VIDEO', sortNo: 1, completed: false, playable: true}, {id: RID, name: '视频一', type: 'VIDEO', sortNo: 1, completed: false, playable: true},
{id: '2093583972926885888', name: '视频二', type: 'VIDEO', sortNo: 2, completed: false, playable: true}], ...overrides}; {id: '2093583972926885888', name: '视频二', type: 'VIDEO', sortNo: 2, completed: false, playable: true}], ...overrides};
} }
function harness() { function harness({sessionEntries=[]}={}) {
let now = 100000; let now = 100000;
class TestDate extends Date { static now() { return now; } } class TestDate extends Date { static now() { return now; } }
const storage = new Map(), session = new Map(), selectors = new Map(); const storage = new Map(), session = new Map(sessionEntries), selectors = new Map();
function eventSurface(){ function eventSurface(){
const listeners=new Map(); 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);}, return {addEventListener(type,fn,options){const list=listeners.get(type)||[];list.push({fn,capture:options===true||!!options?.capture});listeners.set(type,list);},
@@ -41,13 +41,13 @@ function harness() {
setInterval() {}}; setInterval() {}};
const source = fs.readFileSync('./icampus-study-helper.user.js', 'utf8').replace( const source = fs.readFileSync('./icampus-study-helper.user.js', 'utf8').replace(
'setInterval(() => { void tick(); }, 1000);', 'setInterval(() => { void tick(); }, 1000);',
`window.testing = {receive, stop, tick, tickCourse, tickQuiz, monitor, details, holdLease, waitMedia, recoverMedia, mount, setBackgroundEnabled, `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;}, pendingCourse(id){expectedCourse=id;courseNavigationPending=true;navigatingUntil=Date.now()+20000;},
recovered(id){recoveryUsed.add('${ID}/'+id);}, recovered(id){recoveryUsed.add('${ID}/'+id);},
begin(){running=true;runNumber++;mode={value:'all'};startAt=Date.now()-1000;}, 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();}, 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;}, pending(){courseNavigationPending=true;navigatingUntil=Date.now()-1;},
get state(){return {running,status,active,courses,packagesAt,busy,bank,lastExam,quiz};}};`); get state(){return {armed,waiting,running,status,active,courses,packagesAt,busy,bank,lastExam,quiz};}};`);
vm.runInNewContext(source, context); vm.runInNewContext(source, context);
const api = window.testing; const api = window.testing;
const video = {currentTime: 120, currentSrc: 'media', readyState: 4, isConnected: true, playbackRate: 2, const video = {currentTime: 120, currentSrc: 'media', readyState: 4, isConnected: true, playbackRate: 2,