Recover stalled platform progress and missing saves with bounded reloads
This commit is contained in:
+28
-6
@@ -1,6 +1,6 @@
|
|||||||
# 学员中心 · 2倍速学习助手使用说明
|
# 学员中心 · 2倍速学习助手使用说明
|
||||||
|
|
||||||
适用版本:**1.4.2**。浏览器设置核对日期:**2026-10-06**。
|
适用版本:**1.4.3**。浏览器设置核对日期:**2026-10-06**。
|
||||||
|
|
||||||
[仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md)
|
[仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md)
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
|||||||
2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。
|
2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。
|
||||||
3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。
|
3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。
|
||||||
4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。
|
4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。
|
||||||
5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.2**,按提示确认安装或更新。仅选择文件不等于安装完成。
|
5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.3**,按提示确认安装或更新。仅选择文件不等于安装完成。
|
||||||
6. 回到“已安装脚本”,确认开关开启。
|
6. 回到“已安装脚本”,确认开关开启。
|
||||||
7. 刷新课程页,确认左下角版本为 **1.4.2**。
|
7. 刷新课程页,确认左下角版本为 **1.4.3**。
|
||||||
|
|
||||||
篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。
|
篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。
|
||||||
|
|
||||||
@@ -149,6 +149,27 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
|||||||
|
|
||||||
不要在正在手动填写尚未保存答案时直接刷新。普通刷新不会清除本地答案记忆和启动意图,但页面尚未提交的输入可能丢失。
|
不要在正在手动填写尚未保存答案时直接刷新。普通刷新不会清除本地答案记忆和启动意图,但页面尚未提交的输入可能丢失。
|
||||||
|
|
||||||
|
### 进度停滞自动刷新
|
||||||
|
|
||||||
|
1.4.3在已启动的自动视频流程中检测异常,超时后保存恢复状态、暂停旧播放器、刷新当前网页并接续,不需要再次点开始。
|
||||||
|
|
||||||
|
| 异常 | 前台等待时间 | 行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新视频加载未就绪 | 90秒 | 尝试刷新恢复 |
|
||||||
|
| 视频已请求播放但未开始推进 | 20秒 | 尝试刷新恢复 |
|
||||||
|
| 已在播放的视频位置不再推进 | 超过60秒 | 尝试刷新恢复 |
|
||||||
|
| 未收到当前视频的保存确认 | 超过75秒 | 尝试刷新恢复 |
|
||||||
|
| 保存请求成功,但平台返回的位置与学习进度均无增长 | 超过120秒 | 尝试刷新恢复 |
|
||||||
|
| 视频自然结束,但平台未确认该资源完成 | 超过25秒 | 刷新重新核对,不直接跳过 |
|
||||||
|
|
||||||
|
后台相关窗口延长至至少180秒;返回前台后的20秒内允许网站恢复保存。实际检查时刻还受后台定时器限制。
|
||||||
|
|
||||||
|
检测会区分视频实际位置、平台保存位置和课程百分比。百分比取整暂时不变、但保存位置在前进时,不会误判;任一平台计数真实增长也会延后判定。没有返回可用的计数字段时,不把缺失字段当成零;仍可通过“没有保存确认”或“视频不动”检测恢复。平台已确认资源完成时,不再要求其进度继续增长。
|
||||||
|
|
||||||
|
**所有恢复原因共享每段视频每次运行一次的自动刷新额度,普通和自动刷新后仍保留已用额度。** 恢复后同一视频仍异常时保持启用但等待人工处理,避免刷新循环;处理问题后可手动恢复,人工取消再开始才属于新一轮运行。
|
||||||
|
|
||||||
|
这些检查只在自动视频流程中触发。手动暂停、正在等待测验、需要手动答题、浏览器明确拒绝自动播放、登录失效或前置未解锁等情况仍按提示处理。刷新无法保证解除平台限制或断网问题。
|
||||||
|
|
||||||
## 8. 自动测验与答案记忆
|
## 8. 自动测验与答案记忆
|
||||||
|
|
||||||
当前流程可能先处理本门未合格测验,再继续视频。支持每题最多8个不同文字选项的单选、判断、多选。
|
当前流程可能先处理本门未合格测验,再继续视频。支持每题最多8个不同文字选项的单选、判断、多选。
|
||||||
@@ -165,7 +186,7 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
|
|||||||
|
|
||||||
Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活跃的网站”添加 `icampus.yunyinsh.com`。笔记本节能也可能减少后台活动,可根据电源情况调整。参考 [Chrome性能帮助](https://support.google.com/chrome/answer/12929150?hl=en)。
|
Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活跃的网站”添加 `icampus.yunyinsh.com`。笔记本节能也可能减少后台活动,可根据电源情况调整。参考 [Chrome性能帮助](https://support.google.com/chrome/answer/12929150?hl=en)。
|
||||||
|
|
||||||
后台模式保留网站原有的正常播放和进度保存定时器,不改写真实页面可见状态、视频位置,不伪造或重放进度请求。关闭后台模式、停止助手或离开页面后恢复正常处理。后台等待和运行锁窗口至少180秒,回前台后给保存20秒恢复时间;仍无保存确认会等待处理。休眠、关闭浏览器、冻结/丢弃标签页、断网仍无法保证继续。
|
后台模式保留网站原有的正常播放和进度保存定时器,不改写真实页面可见状态、视频位置,不伪造或重放进度请求。关闭后台模式、停止助手或离开页面后恢复正常处理。后台等待和运行锁窗口至少180秒,回前台后给保存20秒恢复时间;仍无保存确认则按一次恢复额度刷新,额度已用则等待处理。休眠、关闭浏览器、冻结/丢弃标签页、断网仍无法保证继续。
|
||||||
|
|
||||||
无痕使用需允许扩展无痕运行并刷新实际课程页。普通和无痕站点数据隔离,答案记忆、开关及状态不自动共用;所有无痕窗口关闭后站点数据通常清除。运行锁仅在同一站点存储环境内生效,不跨普通/无痕或不同浏览器,同账号请只运行一个页面。
|
无痕使用需允许扩展无痕运行并刷新实际课程页。普通和无痕站点数据隔离,答案记忆、开关及状态不自动共用;所有无痕窗口关闭后站点数据通常清除。运行锁仅在同一站点存储环境内生效,不跨普通/无痕或不同浏览器,同账号请只运行一个页面。
|
||||||
|
|
||||||
@@ -203,6 +224,7 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
|
|||||||
| 课程重名 | 手动进入目标课程,选仅当前课程 |
|
| 课程重名 | 手动进入目标课程,选仅当前课程 |
|
||||||
| 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 |
|
| 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 |
|
||||||
| 视频没有声音 | 如勾选了全过程静音,先关闭该开关;再检查播放器原有静音状态或受限播放的静音回退 |
|
| 视频没有声音 | 如勾选了全过程静音,先关闭该开关;再检查播放器原有静音状态或受限播放的静音回退 |
|
||||||
|
| 进度长期不动或没有保存确认 | 1.4.3会在超时后自动刷新一次并接续;仍失败时检查网络、登录和平台状态,查看运行记录 |
|
||||||
| 只看到小型助手状态条 | 面板已收纳,点击状态条展开;收纳期间任务仍继续 |
|
| 只看到小型助手状态条 | 面板已收纳,点击状态条展开;收纳期间任务仍继续 |
|
||||||
| 想移动面板 | 拖动标题或收纳条;不要拖动表单或操作按钮 |
|
| 想移动面板 | 拖动标题或收纳条;不要拖动表单或操作按钮 |
|
||||||
|
|
||||||
@@ -210,10 +232,10 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
|
|||||||
|
|
||||||
## 12. 验证与恢复边界
|
## 12. 验证与恢复边界
|
||||||
|
|
||||||
101项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音、10面板操作。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音回退、面板位置保存、状态恢复、拖动误点击抑制和存储失败等情况。
|
116项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音、10面板操作、15进度停滞恢复。覆盖平台保存不增长、有响应但进度不变、取整误判、后台宽限、自然结束未确认、刷新后继续和共享额度,及原有播放、测验与面板功能。
|
||||||
|
|
||||||
面板另在本地模拟课程页面使用Playwright与预装Chrome验证:1280×900桌面及360×640窗口下的收起、展开、标题/状态条拖动、刷新保留、运行意图保留、屏幕边界和键盘展开均通过,无相关控制台错误。该验证只覆盖本地面板交互,没有更新用户浏览器中的已安装脚本;真实课程页和实际触屏设备尚未验证。
|
面板另在本地模拟课程页面使用Playwright与预装Chrome验证:1280×900桌面及360×640窗口下的收起、展开、标题/状态条拖动、刷新保留、运行意图保留、屏幕边界和键盘展开均通过,无相关控制台错误。该验证只覆盖本地面板交互,没有更新用户浏览器中的已安装脚本;真实课程页和实际触屏设备尚未验证。
|
||||||
|
|
||||||
现场此前已观察到网页原生2倍速、平台保存响应及点击播放后恢复。全过程静音经过本地模拟测试,尚未在真实课程上验证。整体统计、估算和静音回退也尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
|
现场此前已观察到网页原生2倍速、平台保存响应及点击播放后恢复。全过程静音经过本地模拟测试,尚未在真实课程上验证。整体统计、估算和静音回退也尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
|
||||||
|
|
||||||
前台视频加载90秒未就绪、启动20秒未推进或播放停滞60秒时,会尝试一次刷新恢复,每段视频在一次运行内共享该额度。临时播放中断有界重试;播放权限拒绝不进入刷新循环。前台视频结束25秒仍无平台完成确认会等待,后台窗口更长。这些恢复不会伪造完成状态。
|
进度停滞检测与刷新恢复阈值见第7节,新增逻辑经过本地模拟回归,尚未在真实课程页验证。临时播放中断有界重试;播放权限拒绝不进入刷新循环。所有自动恢复均保留真实平台完成判断,不伪造状态。
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
[仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md)
|
[仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md)
|
||||||
|
|
||||||
|
## 1.4.3(2026-10-12)
|
||||||
|
|
||||||
|
- 当前视频长期没有保存确认时,改为尝试一次刷新恢复并接续。
|
||||||
|
- 检测“有保存响应但平台已保存位置与课程进度均不增长”,前台超过120秒、后台超过180秒时恢复。
|
||||||
|
- 视频结束后未得到资源完成确认时刷新重新核对,不直接跳过资源。
|
||||||
|
- 百分比取整但保存位置前进、计数字段缺失及已完成资源不会误按进度停滞处理。
|
||||||
|
- 与加载、启动和播放停滞共享每段一次的刷新额度,额度用完后等待人工处理,避免反复恢复。
|
||||||
|
- 新增15项回归测试,总计116项,更新使用说明。
|
||||||
|
|
||||||
## 1.4.2(2026-10-12)
|
## 1.4.2(2026-10-12)
|
||||||
|
|
||||||
- 新增面板“收起/展开”,收起后显示运行状态条,自动任务继续。
|
- 新增面板“收起/展开”,收起后显示运行状态条,自动任务继续。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、全过程静音、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
|
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、全过程静音、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
|
||||||
|
|
||||||
当前版本:**1.4.2**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
|
当前版本:**1.4.3**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
|
||||||
|
|
||||||
**[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)**
|
**[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)**
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。
|
2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。
|
||||||
3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。
|
3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。
|
||||||
4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。
|
4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。
|
||||||
5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.2”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
|
5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.3”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
|
||||||
|
|
||||||
扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
|
扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
|
||||||
|
|
||||||
@@ -47,7 +47,8 @@
|
|||||||
| 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 |
|
| 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 |
|
||||||
| 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 |
|
| 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 |
|
||||||
| 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 |
|
| 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 |
|
||||||
| 面板收纳与拖拽(新增) | 点击“收起”变为状态条,点击“展开”恢复;拖动标题或收纳条移动,刷新记住位置和状态 |
|
| 停滞自动刷新(新增) | 检测视频停滞、缺少保存确认、平台保存位置与进度不动及结束后未确认;超时刷新并接续,同一视频每次运行最多一次 |
|
||||||
|
| 面板收纳与拖拽 | 点击“收起”变为状态条,点击“展开”恢复;拖动标题或收纳条移动,刷新记住位置和状态 |
|
||||||
| 全过程静音 | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 |
|
| 全过程静音 | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 |
|
||||||
| 自动播放恢复 | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
|
| 自动播放恢复 | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
|
||||||
| 整体进度 | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
|
| 整体进度 | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
|
||||||
@@ -77,6 +78,8 @@
|
|||||||
|
|
||||||
“已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。
|
“已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。
|
||||||
|
|
||||||
|
自动运行时会检查卡住情况:前台视频60秒不推进、75秒无保存确认、平台保存位置与学习进度120秒均不推进,或视频结束25秒仍未确认完成,会触发刷新恢复;加载90秒或启动20秒卡住也会恢复。后台相关等待窗口延长至至少180秒,回前台后给保存20秒恢复时间。平台百分比取整不变但保存位置前进时,不按停滞处理。所有原因共享每段视频每次运行一次的刷新额度,刷新后保留额度和启用状态;仍失败则等待人工处理。手动暂停、答题等待、权限和登录问题不会被这些进度检测强行刷新。
|
||||||
|
|
||||||
**仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。
|
**仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。
|
||||||
|
|
||||||
## 后台运行与边界
|
## 后台运行与边界
|
||||||
@@ -89,7 +92,7 @@
|
|||||||
|
|
||||||
## 验证与开发
|
## 验证与开发
|
||||||
|
|
||||||
目前 **101项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复、全过程静音及面板操作。面板收纳、拖动、刷新恢复和窗口边界另通过本地模拟页面的Chrome浏览器验证(1280×900、360×640),无相关控制台错误。尚未在真实课程页验证新版面板;全过程静音、统计、预测和静音回退也尚未完成所有课程及长期后台的真实端到端验证。
|
目前 **116项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复、全过程静音、面板操作及进度停滞恢复。新增15项回归覆盖保存确认丢失、平台计数不推进、正常取整、后台宽限、刷新接续和共享刷新额度。面板在1.4.2时另通过本地Chrome模拟页面验证(1280×900、360×640)。新停滞检测尚未在真实课程页验证;也尚未完成全部课程及长期后台的真实端到端验证。
|
||||||
|
|
||||||
开发验证需要 Node.js 18+,无需安装第三方依赖:
|
开发验证需要 Node.js 18+,无需安装第三方依赖:
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ function site(h){
|
|||||||
const h=harness();h.api.begin();visibility(h,'hidden');h.advance(61000);
|
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());
|
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',()=>{
|
await test('background delay allows a real heartbeat to arrive before missing saves refresh',()=>{
|
||||||
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');
|
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(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秒/);
|
h.advance(101000);h.video.currentTime+=202;h.api.monitor();assert.equal(h.api.state.running,false);assert.match(h.api.state.status,/180秒/);assert.equal(h.context.location.reloadCount,1);
|
||||||
});
|
});
|
||||||
await test('foreground return gives the restarted heartbeat 20 seconds to respond',()=>{
|
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');
|
const h=harness();h.api.begin();h.api.track(h.video);visibility(h,'hidden');h.advance(80000);visibility(h,'visible');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 学员中心 · 2倍速学习助手
|
// @name 学员中心 · 2倍速学习助手
|
||||||
// @namespace local.icampus.study
|
// @namespace local.icampus.study
|
||||||
// @version 1.4.2
|
// @version 1.4.3
|
||||||
// @description 支持面板收纳与拖拽、全过程静音、后台2倍速播放、自动测验及错题重试。
|
// @description 支持面板收纳与拖拽、全过程静音、后台2倍速播放、自动测验及错题重试。
|
||||||
// @match https://icampus.yunyinsh.com/course/*
|
// @match https://icampus.yunyinsh.com/course/*
|
||||||
// @run-at document-start
|
// @run-at document-start
|
||||||
@@ -346,6 +346,16 @@
|
|||||||
if (active && active.courseId === meta.id && active.id === resourceId) {
|
if (active && active.courseId === meta.id && active.id === resourceId) {
|
||||||
lastNetworkAt = Date.now(); active.saved = true;
|
lastNetworkAt = Date.now(); active.saved = true;
|
||||||
active.serverPosition = data.currentPositionSeconds;
|
active.serverPosition = data.currentPositionSeconds;
|
||||||
|
const position=Number.isFinite(data.currentPositionSeconds) && data.currentPositionSeconds>=0 ? data.currentPositionSeconds : null;
|
||||||
|
const progress=Number.isFinite(data.courseProgressPercent) && data.courseProgressPercent>=0 && data.courseProgressPercent<=100 ? data.courseProgressPercent : null;
|
||||||
|
if(position!==null || progress!==null || data.resourceCompleted===true) {
|
||||||
|
if(!active.saveObserved || data.resourceCompleted===true ||
|
||||||
|
(position!==null && position>(active.savePosition ?? -1)+0.1) ||
|
||||||
|
(progress!==null && progress>(active.saveCoursePercent ?? -1)+0.001)) active.saveAdvancedAt=Date.now();
|
||||||
|
active.saveObserved=true;
|
||||||
|
if(position!==null) active.savePosition=Math.max(active.savePosition ?? position,position);
|
||||||
|
if(progress!==null) active.saveCoursePercent=Math.max(active.saveCoursePercent ?? progress,progress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
@@ -624,7 +634,7 @@
|
|||||||
}
|
}
|
||||||
function recoverMedia(id, resourceId, reason) {
|
function recoverMedia(id, resourceId, reason) {
|
||||||
const key = `${id}/${resourceId}`;
|
const key = `${id}/${resourceId}`;
|
||||||
if (recoveryUsed.has(key)) return park(`${reason},自动恢复一次后仍失败,请检查网络并手动刷新。`);
|
if (recoveryUsed.has(key)) return park(`${reason},自动恢复一次后仍失败,请检查网络并手动刷新。`,{passive:true});
|
||||||
recoveryUsed.add(key);
|
recoveryUsed.add(key);
|
||||||
persistIntent();
|
persistIntent();
|
||||||
try {
|
try {
|
||||||
@@ -632,7 +642,7 @@
|
|||||||
mode:mode.value, autoQuiz:autoQuiz?.checked !== false, used:[...recoveryUsed]}));
|
mode:mode.value, autoQuiz:autoQuiz?.checked !== false, used:[...recoveryUsed]}));
|
||||||
} catch { return park(`${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.
|
// Persisted intent resumes after reload while keeping this resource's recovery quota.
|
||||||
running=false; ++runNumber;
|
running=false; ++runNumber;
|
||||||
const v=active?.video; active=null; mediaLoad=null;
|
const v=active?.video; active=null; mediaLoad=null;
|
||||||
if(v && !v.paused) v.pause();
|
if(v && !v.paused) v.pause();
|
||||||
@@ -684,7 +694,7 @@
|
|||||||
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)) park('平台尚未确认视频完成,请返回列表核对后再继续。');
|
if (Date.now() - active.endedAt > waitLimit(25000)) recoverMedia(id,active.id,'视频已结束,但平台迟迟未确认完成');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log('平台已确认本段视频完成。'); active = null;
|
log('平台已确认本段视频完成。'); active = null;
|
||||||
@@ -719,7 +729,10 @@
|
|||||||
const run = runNumber;
|
const run = runNumber;
|
||||||
active = {video, id: resource.id, courseId: id, src: video.currentSrc,
|
active = {video, id: resource.id, courseId: id, src: video.currentSrc,
|
||||||
lastPosition: video.currentTime, progressedAt: Date.now(), started: Date.now(), saved: false, serverPosition: null,
|
lastPosition: video.currentTime, progressedAt: Date.now(), started: Date.now(), saved: false, serverPosition: null,
|
||||||
phase:'starting', attempts:0, playPending:false, nextPlayAt:0};
|
phase:'starting', attempts:0, playPending:false, nextPlayAt:0,
|
||||||
|
saveObserved:false, saveAdvancedAt:Date.now(),
|
||||||
|
savePosition:Number.isFinite(resource.currentPositionSeconds)?resource.currentPositionSeconds:null,
|
||||||
|
saveCoursePercent:Number.isFinite(detail.course.progressPercent)?detail.course.progressPercent:null};
|
||||||
mediaLoad=null;
|
mediaLoad=null;
|
||||||
lastNetworkAt = Date.now();
|
lastNetworkAt = Date.now();
|
||||||
video.playbackRate = 2;
|
video.playbackRate = 2;
|
||||||
@@ -774,7 +787,10 @@
|
|||||||
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 park(`超过${saveTimeout/1000}秒未收到平台保存确认,已暂停,避免继续播放却未计入进度。`);
|
return recoverMedia(a.courseId,a.id,`超过${saveTimeout/1000}秒未收到平台保存确认`);
|
||||||
|
const progressTimeout=waitLimit(120000);
|
||||||
|
if (Date.now()>foregroundGraceUntil && a.saveObserved && resource?.completed!==true && Date.now()-a.saveAdvancedAt>progressTimeout)
|
||||||
|
return recoverMedia(a.courseId,a.id,`平台已保存的位置和学习进度超过${progressTimeout/1000}秒未推进`);
|
||||||
}
|
}
|
||||||
async function tick() {
|
async function tick() {
|
||||||
applyFullMute();
|
applyFullMute();
|
||||||
@@ -918,7 +934,7 @@
|
|||||||
panel = host.attachShadow({mode: 'open'});
|
panel = host.attachShadow({mode: 'open'});
|
||||||
panel.innerHTML = `<style>:host{display:block;font:14px/1.6 system-ui;color:#eaf0fa}[hidden]{display:none!important}section{box-sizing:border-box;width:322px;max-width:calc(100vw - 16px);background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:14px}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}[data-drag-handle]{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:grab;touch-action:none;user-select:none}[data-collapse]{flex-shrink:0;margin:0}.compact{display:flex;align-items:center;gap:10px;max-width:calc(100vw - 16px);box-sizing:border-box;margin:0;padding:10px 13px;border-radius:999px;background:#17243a;color:#eaf0fa;cursor:grab;touch-action:none;user-select:none}.compact [data-mini-state]{color:#b7c7df;font-size:12px}.compact:focus-visible,button:focus-visible{outline:2px solid #a3ccff;outline-offset:-3px}</style>
|
panel.innerHTML = `<style>:host{display:block;font:14px/1.6 system-ui;color:#eaf0fa}[hidden]{display:none!important}section{box-sizing:border-box;width:322px;max-width:calc(100vw - 16px);background:#17243a;border:1px solid #6883a6;border-radius:12px;padding:15px;box-shadow:0 5px 24px #0005}strong{font-size:14px}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}[data-drag-handle]{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:grab;touch-action:none;user-select:none}[data-collapse]{flex-shrink:0;margin:0}.compact{display:flex;align-items:center;gap:10px;max-width:calc(100vw - 16px);box-sizing:border-box;margin:0;padding:10px 13px;border-radius:999px;background:#17243a;color:#eaf0fa;cursor:grab;touch-action:none;user-select:none}.compact [data-mini-state]{color:#b7c7df;font-size:12px}.compact:focus-visible,button:focus-visible{outline:2px solid #a3ccff;outline-offset:-3px}</style>
|
||||||
<button class="compact" data-expand hidden aria-expanded="false" aria-label="展开学习助手面板"><span>2×学习助手</span><span data-mini-state></span><span>展开</span></button>
|
<button class="compact" data-expand hidden aria-expanded="false" aria-label="展开学习助手面板"><span>2×学习助手</span><span data-mini-state></span><span>展开</span></button>
|
||||||
<section data-main-panel aria-label="学习助手面板"><header data-drag-handle title="按住标题拖动面板"><strong>2倍速学习助手 · 1.4.2</strong><button data-collapse aria-label="收起学习助手面板">收起</button></header><p data-status></p><small data-progress></small>
|
<section data-main-panel aria-label="学习助手面板"><header data-drag-handle title="按住标题拖动面板"><strong>2倍速学习助手 · 1.4.3</strong><button data-collapse aria-label="收起学习助手面板">收起</button></header><p data-status></p><small data-progress></small>
|
||||||
<p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress>
|
<p data-overall></p><progress data-overall-bar aria-label="整体课程完成百分比" max="100" value="0" style="width:100%"></progress>
|
||||||
<p data-estimate style="font-size:12px;color:#b7c7df"></p>
|
<p data-estimate style="font-size:12px;color:#b7c7df"></p>
|
||||||
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
|
<select aria-label="播放范围"><option value="all">按列表顺序继续课程</option><option value="current">仅当前课程</option></select>
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "e-learning-assistant",
|
"name": "e-learning-assistant",
|
||||||
"version": "1.4.2",
|
"version": "1.4.3",
|
||||||
"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 && node persistent-run.test.cjs && node overall-progress.test.cjs && node full-mute.test.cjs && node panel-controls.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 && node overall-progress.test.cjs && node full-mute.test.cjs && node panel-controls.test.cjs && node progress-recovery.test.cjs"
|
||||||
},
|
},
|
||||||
"engines": { "node": ">=18" }
|
"engines": { "node": ">=18" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'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 heartbeat(h,data,resourceId=RID){h.api.receive({id:ID,tail:'sessions/s/heartbeat',body:{resourceId}},{bizCode:0,data},200);}
|
||||||
|
async function start(h){
|
||||||
|
h.api.details.get(ID).course.progressPercent=0;
|
||||||
|
h.api.details.get(ID).resources.forEach(r=>{r.currentPositionSeconds=0;r.durationSeconds=1200;});
|
||||||
|
h.video.currentTime=0;h.video.paused=true;h.api.begin();await h.api.tickCourse(ID);
|
||||||
|
h.advance(1000);await h.api.tickCourse(ID);await Promise.resolve();
|
||||||
|
h.advance(1000);h.video.currentTime=2;h.api.monitor();
|
||||||
|
}
|
||||||
|
function cycles(h,count,data){for(let i=0;i<count;i++){
|
||||||
|
h.advance(15000);h.video.currentTime+=30;heartbeat(h,typeof data==='function'?data(i):data);h.api.monitor();
|
||||||
|
if(!h.api.state.running)break;
|
||||||
|
}}
|
||||||
|
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();}
|
||||||
|
(async()=>{
|
||||||
|
await test('no save confirmation refreshes once and stores enabled intent and quota',async()=>{
|
||||||
|
const h=harness();await start(h);h.advance(76000);h.video.currentTime+=152;h.api.monitor();
|
||||||
|
assert.equal(h.context.location.reloadCount,1);assert.equal(h.api.state.armed,true);assert.equal(h.video.paused,true);
|
||||||
|
const checkpoint=JSON.parse(h.session.get('icampus.study.v1.recovery'));assert.deepEqual(checkpoint.used,[`${ID}/${RID}`]);
|
||||||
|
assert.equal(JSON.parse(h.session.get('icampus.study.v1.enabled')).enabled,true);
|
||||||
|
});
|
||||||
|
await test('repeated successful heartbeats with frozen saved counters trigger recovery',async()=>{
|
||||||
|
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
cycles(h,9,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
assert.equal(h.context.location.reloadCount,1);assert.match(h.api.state.status,/120秒未推进/);
|
||||||
|
});
|
||||||
|
await test('rounded course percentage can stay fixed while saved video position advances',async()=>{
|
||||||
|
const h=harness();await start(h);cycles(h,20,i=>({currentPositionSeconds:(i+1)*30,courseProgressPercent:0}));
|
||||||
|
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
});
|
||||||
|
await test('advancing platform learning progress prevents a false position stall',async()=>{
|
||||||
|
const h=harness();await start(h);cycles(h,15,i=>({currentPositionSeconds:0,courseProgressPercent:i+1}));
|
||||||
|
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
});
|
||||||
|
await test('missing or invalid counter fields are not interpreted as zero progress',async()=>{
|
||||||
|
const h=harness();await start(h);cycles(h,15,i=>i%2?{}:{currentPositionSeconds:null,courseProgressPercent:'invalid'});
|
||||||
|
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
});
|
||||||
|
await test('background allows 180 seconds before saved counter stall recovery',async()=>{
|
||||||
|
const h=harness();await start(h);h.context.document.visibilityState='hidden';h.context.document.dispatchEvent(new h.context.Event('visibilitychange'));
|
||||||
|
heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});cycles(h,12,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
assert.equal(h.api.state.running,true);cycles(h,1,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
assert.equal(h.context.location.reloadCount,1);assert.match(h.api.state.status,/180秒未推进/);
|
||||||
|
});
|
||||||
|
await test('returning to foreground grants time for server counters to resume',async()=>{
|
||||||
|
const h=harness();await start(h);h.context.document.visibilityState='hidden';h.context.document.dispatchEvent(new h.context.Event('visibilitychange'));
|
||||||
|
heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});cycles(h,9,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
h.context.document.visibilityState='visible';h.context.document.dispatchEvent(new h.context.Event('visibilitychange'));
|
||||||
|
h.api.monitor();assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
h.advance(21000);h.video.currentTime+=42;heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});h.api.monitor();
|
||||||
|
assert.equal(h.context.location.reloadCount,1);
|
||||||
|
});
|
||||||
|
await test('natural end without server completion refreshes without skipping the resource',async()=>{
|
||||||
|
const h=harness();await start(h);h.video.ended=true;await h.api.tickCourse(ID);h.advance(26000);await h.api.tickCourse(ID);
|
||||||
|
assert.equal(h.context.location.reloadCount,1);assert.equal(h.buttons[1].clicks,0);
|
||||||
|
});
|
||||||
|
await test('server-confirmed completion avoids stalled-counter refresh and permits next video',async()=>{
|
||||||
|
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:1200,courseProgressPercent:50,resourceCompleted:true});
|
||||||
|
h.video.ended=true;await h.api.tickCourse(ID);assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.buttons[1].clicks,1);
|
||||||
|
});
|
||||||
|
await test('manual pause and manual cancellation never activate the progress refresh',async()=>{
|
||||||
|
const h=harness();await start(h);h.video.paused=true;h.advance(200000);h.api.monitor();
|
||||||
|
assert.equal(h.context.location.reloadCount,undefined);assert.equal(h.api.state.waiting.passive,true);
|
||||||
|
h.api.stop();h.advance(200000);await h.api.tick();assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
});
|
||||||
|
await test('ordinary refresh resumes but missing-save and stall recovery share one resource quota',async()=>{
|
||||||
|
const h=harness();await start(h);h.advance(76000);h.video.currentTime+=152;h.api.monitor();
|
||||||
|
const next=harness({sessionEntries:[...h.session]});mount(next);assert.equal(next.api.state.armed,true);
|
||||||
|
await start(next);heartbeat(next,{currentPositionSeconds:0,courseProgressPercent:0});cycles(next,9,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
assert.equal(next.context.location.reloadCount,undefined);assert.match(next.api.state.status,/恢复一次后仍失败/);
|
||||||
|
assert.equal(next.api.state.waiting.passive,true);assert.equal(next.api.state.armed,true);
|
||||||
|
});
|
||||||
|
await test('heartbeats for another resource cannot conceal missing saves on the active resource',async()=>{
|
||||||
|
const h=harness();await start(h);h.advance(76000);h.video.currentTime+=152;
|
||||||
|
heartbeat(h,{currentPositionSeconds:150,courseProgressPercent:5},'2093583972926885888');h.api.monitor();
|
||||||
|
assert.equal(h.context.location.reloadCount,1);
|
||||||
|
});
|
||||||
|
await test('an already confirmed resource can finish playing with static server counters',async()=>{
|
||||||
|
const h=harness();await start(h);cycles(h,10,{currentPositionSeconds:1200,courseProgressPercent:50,resourceCompleted:true});
|
||||||
|
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
});
|
||||||
|
await test('oscillating stale counters do not restart the stall timer indefinitely',async()=>{
|
||||||
|
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
cycles(h,10,i=>({currentPositionSeconds:i%2?0:2,courseProgressPercent:0}));assert.equal(h.context.location.reloadCount,1);
|
||||||
|
});
|
||||||
|
await test('a temporary counter plateau below the timeout keeps playing',async()=>{
|
||||||
|
const h=harness();await start(h);heartbeat(h,{currentPositionSeconds:0,courseProgressPercent:0});
|
||||||
|
cycles(h,7,{currentPositionSeconds:0,courseProgressPercent:0});assert.equal(h.api.state.running,true);
|
||||||
|
heartbeat(h,{currentPositionSeconds:220,courseProgressPercent:10});cycles(h,7,{currentPositionSeconds:220,courseProgressPercent:10});
|
||||||
|
assert.equal(h.api.state.running,true);assert.equal(h.context.location.reloadCount,undefined);
|
||||||
|
});
|
||||||
|
console.log(`${passed} progress recovery tests passed.`);
|
||||||
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||||
@@ -112,11 +112,11 @@ if (require.main === module) (async () => {
|
|||||||
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.playbackRate=1; h.api.monitor();
|
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.playbackRate=1; h.api.monitor();
|
||||||
assert.equal(h.api.state.running,false); assert.equal(h.video.playbackRate,1); assert.equal(h.video.paused,true);
|
assert.equal(h.api.state.running,false); assert.equal(h.video.playbackRate,1); assert.equal(h.video.paused,true);
|
||||||
});
|
});
|
||||||
await test('ended without server completion never clicks next, then stops on timeout', async () => {
|
await test('ended without server completion never clicks next, then refreshes on timeout', async () => {
|
||||||
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.ended=true;
|
const h=harness(); h.api.begin(); h.api.track(h.video); h.video.ended=true;
|
||||||
await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,0);
|
await h.api.tickCourse(ID); assert.equal(h.buttons[1].clicks,0);
|
||||||
h.advance(26000); await h.api.tickCourse(ID);
|
h.advance(26000); await h.api.tickCourse(ID);
|
||||||
assert.equal(h.api.state.running,false); assert.equal(h.buttons[1].clicks,0);
|
assert.equal(h.api.state.running,false); assert.equal(h.buttons[1].clicks,0);assert.equal(h.context.location.reloadCount,1);
|
||||||
});
|
});
|
||||||
await test('matching genuine heartbeat plus natural end allows next video', async () => {
|
await test('matching genuine heartbeat plus natural end allows next video', async () => {
|
||||||
const h=harness(); h.api.begin(); h.api.track(h.video);
|
const h=harness(); h.api.begin(); h.api.track(h.video);
|
||||||
@@ -135,9 +135,9 @@ if (require.main === module) (async () => {
|
|||||||
h.api.receive({id:ID,tail:'sessions'},{bizCode:47235,message:'上一个视频尚未完成'},200);
|
h.api.receive({id:ID,tail:'sessions'},{bizCode:47235,message:'上一个视频尚未完成'},200);
|
||||||
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/47235/);
|
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/47235/);
|
||||||
});
|
});
|
||||||
await test('missing heartbeat stops even if media continues advancing', () => {
|
await test('missing heartbeat refreshes even if media continues advancing', () => {
|
||||||
const h=harness(); h.api.begin(); h.api.track(h.video); h.advance(76000); h.video.currentTime+=100; h.api.monitor();
|
const h=harness(); h.api.begin(); h.api.track(h.video); h.advance(76000); h.video.currentTime+=100; h.api.monitor();
|
||||||
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/75秒/);
|
assert.equal(h.api.state.running,false); assert.match(h.api.state.status,/75秒/);assert.equal(h.context.location.reloadCount,1);
|
||||||
});
|
});
|
||||||
await test('failed course navigation is bounded', async () => {
|
await test('failed course navigation is bounded', async () => {
|
||||||
const h=harness(); h.api.begin(); h.api.pending(); await h.api.tick();
|
const h=harness(); h.api.begin(); h.api.pending(); await h.api.tick();
|
||||||
|
|||||||
Reference in New Issue
Block a user