diff --git a/2倍速脚本使用说明.md b/2倍速脚本使用说明.md
index bcf8986..8e02c03 100644
--- a/2倍速脚本使用说明.md
+++ b/2倍速脚本使用说明.md
@@ -1,6 +1,6 @@
# 学员中心 · 2倍速学习助手使用说明
-适用版本:**1.4.1**。浏览器设置核对日期:**2026-10-06**。
+适用版本:**1.4.2**。浏览器设置核对日期:**2026-10-06**。
[仓库首页](README.md) · [脚本文件](icampus-study-helper.user.js) · [更新记录](CHANGELOG.md)
@@ -37,9 +37,9 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
2. 点击文件页面的“下载”,或打开“原始文件 / Raw”后保存。文件应名为 `icampus-study-helper.user.js`,不能是仓库HTML网页或误存的 `.txt`。下载整个仓库压缩包的用户先解压。
3. 点击篡改猴图标 →“管理面板 / Dashboard”→“实用工具 / Utilities”。
4. 找到“从文件导入 / Import from file”文件选择按钮,选中该 `.user.js`。
-5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.1**,按提示确认安装或更新。仅选择文件不等于安装完成。
+5. 在安装/导入页面核对名称“学员中心 · 2倍速学习助手”和版本 **1.4.2**,按提示确认安装或更新。仅选择文件不等于安装完成。
6. 回到“已安装脚本”,确认开关开启。
-7. 刷新课程页,确认左下角版本为 **1.4.1**。
+7. 刷新课程页,确认左下角版本为 **1.4.2**。
篡改猴导入入口见 [官方导入导出说明](https://www.tampermonkey.net/faq.php?locale=zh&q=Q106)。界面文字可能因版本不同而变化;若该入口不接受 `.user.js`,直接用下一节复制方式,无需将脚本改成JSON。
@@ -77,10 +77,20 @@ Chrome不能在无痕窗口内安装扩展,应先普通窗口安装再允许
| 播放受限时尝试静音 | 有声自动播放被拒绝时尝试一次静音;成功后面板显示“静音” |
| 已启动 · 等待恢复 | 仍启用,查看提示;普通等待会定期检查或随页面变化接续 |
| 人工取消 | 关闭自动运行并暂停助手控制的视频 |
+| 收起/展开 | 右上角收起面板成为状态条,点击状态条展开;收纳不停止任务 |
+| 拖动标题/收纳条 | 按住并拖动来移动面板;其他按钮仍按正常点击操作 |
| 运行记录 → 更新记录 | 显示当前页的近期运行记录 |
更换运行范围或“只做本门测验”前,先人工取消。启动状态保存在当前标签页会话中,同一页普通刷新和自动恢复刷新都可接续。新标签页、浏览器重启、结束无痕会话不保证保留。手动暂停视频时,助手等待手动播放,不抢占暂停操作。
+### 面板收纳和拖拽
+
+点击标题右侧 **“收起”**,完整面板变成小型状态条,显示“运行中”“等待恢复”或“未启动”。收纳仅改变显示,不会暂停视频、取消任务或修改运行选项。鼠标停在状态条上可查看当前提示,点击 **“展开”** 恢复完整面板;需要停止时先展开再点“人工取消”。也可用Tab聚焦收起/展开按钮,按Enter操作。
+
+按住展开面板的标题区域,或按住整个收纳条,拖动到合适位置即可。拖动收纳条后放开不会误展开,普通点击才展开。表单、收起和其他操作按钮不作为拖拽区域。
+
+拖动结束、收起/展开时保存位置和状态,刷新后恢复。窗口缩小或展开后空间不足,会自动调整到可见范围;内容较多时面板内部可滚动。偏好保存在课程站点本地存储,普通/无痕环境分别记忆。浏览器禁止存储时仍可本页拖动和收纳,但不能保证刷新保留。
+
### 全过程静音
勾选 **“全过程静音”** 后,当前课程播放器立即静音,不用等待点击开始;每次启动、加载新视频或更换课程播放器都会应用静音,网页或用户在播放器中取消静音时也会重新静音。播放位置、音量滑块和播放速度不因该开关改变。
@@ -167,7 +177,7 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
| 保存位置 | 内容 |
| --- | --- |
-| 网站本地存储 | 答案记忆、最近提交快照、后台、全过程静音和静音回退偏好、标签页运行锁 |
+| 网站本地存储 | 答案记忆、最近提交快照、后台、全过程静音和静音回退偏好、面板位置及收纳状态、标签页运行锁 |
| 当前标签页会话存储 | 启用状态、运行范围和刷新恢复记录 |
| 当前页内存 | 运行记录、已读取课程列表和时长样本 |
@@ -193,12 +203,16 @@ Chrome经常回收课程页时,可在“设置 → 性能 → 始终保持活
| 课程重名 | 手动进入目标课程,选仅当前课程 |
| 登录、前置限制、无保存确认 | 按平台提示处理后刷新,同一标签页可接续 |
| 视频没有声音 | 如勾选了全过程静音,先关闭该开关;再检查播放器原有静音状态或受限播放的静音回退 |
+| 只看到小型助手状态条 | 面板已收纳,点击状态条展开;收纳期间任务仍继续 |
+| 想移动面板 | 拖动标题或收纳条;不要拖动表单或操作按钮 |
反馈时展开“运行记录”并点“更新记录”,提供脚本版本、普通/无痕模式、运行范围、面板文字和必要记录,不要附凭据或完整敏感抓包。
## 12. 验证与恢复边界
-91项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音成功和仍被拒绝,以及静音开关、播放器更换、声音重置、后台与原始状态恢复等情况。
+101项本地行为测试:16播放/进度、13测验、13媒体恢复、12后台、10持续运行/完成条件、17统计/自动播放回退、10全过程静音、10面板操作。覆盖低于100分但已合格换课、刷新接续、缺失时长、暂停预测、静音回退、面板位置保存、状态恢复、拖动误点击抑制和存储失败等情况。
+
+面板另在本地模拟课程页面使用Playwright与预装Chrome验证:1280×900桌面及360×640窗口下的收起、展开、标题/状态条拖动、刷新保留、运行意图保留、屏幕边界和键盘展开均通过,无相关控制台错误。该验证只覆盖本地面板交互,没有更新用户浏览器中的已安装脚本;真实课程页和实际触屏设备尚未验证。
现场此前已观察到网页原生2倍速、平台保存响应及点击播放后恢复。全过程静音经过本地模拟测试,尚未在真实课程上验证。整体统计、估算和静音回退也尚未完成所有真实课程和长时间后台端到端验证,不能保证最终学分或全部课程结构兼容。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68afb41..d8d9f90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
[仓库首页](README.md) · [安装与使用](2倍速脚本使用说明.md)
+## 1.4.2(2026-10-12)
+
+- 新增面板“收起/展开”,收起后显示运行状态条,自动任务继续。
+- 支持拖动面板标题和收纳条移动,避免拖动后误触展开。
+- 保存面板位置和收纳状态,刷新后恢复;窗口缩小及展开时限制在可见范围内。
+- 新增10项行为测试,总计101项;本地Chrome模拟页面通过桌面和窄窗口的交互验证。
+- 更新安装版本和使用说明。
+
## 1.4.1(2026-10-10)
- 新增“全过程静音”开关,默认关闭,可随时开启,立即静音当前课程播放器。
diff --git a/README.md b/README.md
index 6894b5b..c6eb041 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
用于 [学员中心课程页](https://icampus.yunyinsh.com/course/) 的 Tampermonkey(篡改猴/油猴)脚本。支持 **2倍速播放、全过程静音、自动续播、测验作答和交卷、按平台合格状态换课、后台播放,以及整体进度和完成时间估算**。
-当前版本:**1.4.1**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
+当前版本:**1.4.2**。普通使用只需要桌面 Chrome 和篡改猴,无需安装 Node.js、Git 或运行命令。
**[获取脚本](icampus-study-helper.user.js) · [完整安装与使用说明](2倍速脚本使用说明.md) · [更新记录](CHANGELOG.md)**
@@ -12,7 +12,7 @@
2. 地址栏输入 `chrome://extensions`,找到 Tampermonkey →“详情”,按下表检查设置。
3. 在仓库打开 [icampus-study-helper.user.js](icampus-study-helper.user.js),使用文件页面的“下载”或“原始文件 / Raw”保存完整脚本。
4. 点击篡改猴图标 →“管理面板”→“实用工具”→“从文件导入 / Import from file”,选择下载的 `.user.js`,按提示安装。找不到该入口或不接受文件时,使用下方复制方法。
-5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.1”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
+5. 登录课程网站并刷新,确认左下角显示 **“2倍速学习助手 · 1.4.2”**。打开课程列表,展开目标分组,选择范围并点击“开始”。停止时点击“人工取消”。
扩展应先在普通窗口安装,再按需允许在无痕窗口运行;参考 [Chrome 官方扩展帮助](https://support.google.com/chrome_webstore/answer/2664769?hl=en)。
@@ -47,7 +47,8 @@
| 课程完成判断 | **学习进度100%+平台判定测验合格**才换课;无需测验的课程只核对学习完成 |
| 持续启用 | 开始后保持启用,同一标签页刷新可接续;“人工取消”清除启用状态 |
| 后台播放 | 默认开启“切标签页继续播放”,保留网站正常播放和保存流程 |
-| 全过程静音(新增) | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 |
+| 面板收纳与拖拽(新增) | 点击“收起”变为状态条,点击“展开”恢复;拖动标题或收纳条移动,刷新记住位置和状态 |
+| 全过程静音 | 勾选后立即静音,切换课程/视频和播放器重建后继续静音,刷新保留偏好 |
| 自动播放恢复 | 有声自动播放被拒绝时可尝试一次静音播放;仍失败则等待点击,避免定时空转 |
| 整体进度 | 完成百分比、已完成/总课程数、平均学习进度、待合格测验数 |
| 时间预测 | 显示预计剩余时长和完成日期时间;缺失时长用已知课程均值粗估并标明 |
@@ -72,6 +73,8 @@
需要全程无声音时,勾选新增的 **“全过程静音”**(默认关闭)。该开关独立于自动运行,在开始前、播放中、等待和后台均保持课程播放器静音;人工取消只停止自动运行,不关闭静音偏好。关闭静音开关后恢复播放器开启前的静音状态;原本已静音的播放器仍保持静音。普通与无痕环境分别保存偏好。
+面板右上角点击 **“收起”**,会变成显示运行状态的小条,自动任务继续;点击小条 **“展开”** 恢复控制面板。按住面板标题或收纳条拖动即可移动。位置和收纳状态在同一站点存储环境中保存,刷新后恢复;窗口缩小时自动限制在可见范围内。需要人工取消时先展开面板。
+
“已启动 · 等待恢复”仍是启用状态;普通等待会定期检查或随页面变化接续。不支持的题型、登录失效、自动播放仍受限等情况需人工处理。手动暂停视频后会等待手动恢复播放;更换范围前先“人工取消”。
**仓库推送不会自动更新浏览器已安装脚本。** 更新时重新导入最新文件,或在原脚本中替换全部代码;然后分别刷新实际使用的普通/无痕课程标签页并检查面板版本。只启用一份助手,停用旧的“学员中心 · 本地网络诊断”等测试脚本。
@@ -86,7 +89,7 @@
## 验证与开发
-目前 **91项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复和全过程静音。实际卡住页面此前已通过点击播放恢复2倍速,并重新收到平台保存确认。全过程静音新增10项本地测试,尚未在真实课程上验证;统计、预测和静音回退也尚未完成所有课程及长期后台的真实端到端验证。
+目前 **101项本地行为测试**,覆盖播放、测验、恢复、后台、完成判断、统计、自动播放恢复、全过程静音及面板操作。面板收纳、拖动、刷新恢复和窗口边界另通过本地模拟页面的Chrome浏览器验证(1280×900、360×640),无相关控制台错误。尚未在真实课程页验证新版面板;全过程静音、统计、预测和静音回退也尚未完成所有课程及长期后台的真实端到端验证。
开发验证需要 Node.js 18+,无需安装第三方依赖:
diff --git a/icampus-study-helper.user.js b/icampus-study-helper.user.js
index 47e3958..5de5f5d 100644
--- a/icampus-study-helper.user.js
+++ b/icampus-study-helper.user.js
@@ -1,8 +1,8 @@
// ==UserScript==
// @name 学员中心 · 2倍速学习助手
// @namespace local.icampus.study
-// @version 1.4.1
-// @description 支持全过程静音、切换标签页继续2倍速播放与保存进度,自动测验及错题重试。
+// @version 1.4.2
+// @description 支持面板收纳与拖拽、全过程静音、后台2倍速播放、自动测验及错题重试。
// @match https://icampus.yunyinsh.com/course/*
// @run-at document-start
// @grant none
@@ -73,6 +73,11 @@
const minutes=Math.ceil(Math.max(0,seconds)/60);
return minutes>=60?`${Math.floor(minutes/60)}小时${minutes%60}分`:`${minutes}分钟`;
}
+ function panelPosition(x,y,width,height,viewportWidth,viewportHeight) {
+ const maxX=Math.max(8,viewportWidth-width-8),maxY=Math.max(8,viewportHeight-height-8);
+ return {x:Math.max(8,Math.min(maxX,Number.isFinite(x)?x:16)),
+ y:Math.max(8,Math.min(maxY,Number.isFinite(y)?y:16))};
+ }
function videoPlan(detail) {
if (!detail?.course || !Array.isArray(detail.resources)) return {kind: 'wait'};
const resources = [...detail.resources].sort((a, b) => a.sortNo - b.sortNo);
@@ -136,7 +141,7 @@
} catch { return null; }
}
const core = {normalize, route, endpoint, bodyJSON, courseDone, examPassed, videoPlan, resolveCards, leaseAvailable,
- questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText};
+ questionKey, selectionKey, chooseAnswer, learnCard, quizEndpoint, learningPercent, overallProgress, durationText, panelPosition};
if (typeof module === 'object' && module.exports && typeof window === 'undefined') { module.exports = core; return; }
if (window.top !== window.self || document.getElementById('icampus-study-helper')) return;
@@ -148,6 +153,7 @@
const INTENT_KEY = 'icampus.study.v1.enabled';
const MUTED_KEY = 'icampus.study.v1.mutedFallback';
const FULL_MUTED_KEY = 'icampus.study.v1.fullMuted';
+ const PANEL_KEY = 'icampus.study.v1.panel';
let fullMutedEnabled = false;
try { fullMutedEnabled = localStorage.getItem(FULL_MUTED_KEY) === 'on'; } catch {}
const mutedPlayers = new Map();
@@ -180,6 +186,7 @@
let runNumber = 0;
let status = '就绪:刷新后,在课程列表或学习页点击开始。';
let panel;
+ let fitPanel = () => {};
let mode;
let active = null;
let routeSince = Date.now();
@@ -817,7 +824,10 @@
panel.querySelector('[data-quiz-only]').disabled = armed;
panel.querySelector('[data-background-state]').textContent = backgroundEnabled
? `${isHidden()?'后台':'前台'} · 切标签页继续播放已开启` : '后台模式已关闭';
+ panel.querySelector('[data-mini-state]').textContent=armed?(running?'运行中':'等待恢复'):'未启动';
+ panel.querySelector('[data-expand]').title=`${status}\n拖动可移动,点击展开面板`;
mode.disabled = armed;
+ fitPanel();
}
function renderOverall() {
const current=details.get(route(location.hash).id)?.course;
@@ -840,14 +850,75 @@
}
panel.querySelector('[data-estimate]').textContent=estimate;
}
+ function setupPanelMovement(host) {
+ const section=panel.querySelector('[data-main-panel]');
+ const header=panel.querySelector('[data-drag-handle]');
+ const compact=panel.querySelector('[data-expand]');
+ let collapsed=false,position=null,drag=null,suppressClick=false;
+ try {
+ const raw=localStorage.getItem(PANEL_KEY);
+ const saved=raw && raw.length<2048?JSON.parse(raw):null;
+ collapsed=saved?.collapsed===true;
+ if(Number.isFinite(saved?.x) && Number.isFinite(saved?.y)) position={x:saved.x,y:saved.y};
+ } catch { /* Invalid layout settings use the default location. */ }
+ function save() {
+ try { localStorage.setItem(PANEL_KEY,JSON.stringify({...position,collapsed})); } catch {}
+ }
+ function rect() {return host.getBoundingClientRect?.() || {left:16,top:16,width:collapsed?240:322,height:collapsed?44:600};}
+ fitPanel=()=>{
+ const box=rect();
+ position=panelPosition(position?.x ?? box.left,position?.y ?? box.top,box.width,box.height,
+ window.innerWidth || 1024,window.innerHeight || 768);
+ host.style.left=`${position.x}px`;host.style.top=`${position.y}px`;host.style.bottom='auto';
+ };
+ function setCollapsed(value) {
+ collapsed=value;section.hidden=value;compact.hidden=!value;
+ compact.ariaExpanded=String(!value);
+ fitPanel();save();
+ }
+ section.hidden=collapsed;compact.hidden=!collapsed;compact.ariaExpanded=String(!collapsed);
+ fitPanel();
+ panel.querySelector('[data-collapse]').onclick=()=>setCollapsed(true);
+ compact.onclick=event=>{
+ const dragged=suppressClick;suppressClick=false;
+ if(dragged && event?.detail!==0) return;
+ setCollapsed(false);
+ };
+ for(const handle of [header,compact]) {
+ handle.onpointerdown=event=>{
+ if(event.button!==0 || event.isPrimary===false || drag) return;
+ if(handle===header && event.target.closest?.('button, input, select, a, summary')) return;
+ suppressClick=false;
+ const box=rect();
+ drag={id:event.pointerId,handle,startX:event.clientX,startY:event.clientY,x:box.left,y:box.top,moved:false};
+ handle.setPointerCapture?.(event.pointerId);
+ };
+ handle.onpointermove=event=>{
+ if(!drag || drag.id!==event.pointerId) return;
+ const dx=event.clientX-drag.startX,dy=event.clientY-drag.startY;
+ if(!drag.moved && Math.hypot(dx,dy)<4) return;
+ drag.moved=true;
+ position={x:drag.x+dx,y:drag.y+dy};fitPanel();event.preventDefault();
+ };
+ function endDrag(event) {
+ if(!drag || drag.id!==event.pointerId) return;
+ if(drag.moved) {suppressClick=handle===compact;fitPanel();save();}
+ drag=null;
+ if(handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId);
+ }
+ handle.onpointerup=endDrag;handle.onpointercancel=endDrag;handle.onlostpointercapture=endDrag;
+ }
+ window.addEventListener('resize',()=>{fitPanel();save();});
+ }
function mount() {
if (!document.body) return;
const host = document.createElement('div'); host.id = 'icampus-study-helper';
host.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:2147483646';
- host.style.maxHeight='calc(100vh - 32px)'; host.style.overflowY='auto';
+ host.style.maxHeight='calc(100vh - 16px)'; host.style.overflowY='auto';
panel = host.attachShadow({mode: 'open'});
- panel.innerHTML = `
- 2倍速学习助手 · 1.4.1
+ panel.innerHTML = `
+
+
@@ -894,7 +965,7 @@
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'); };
- document.body.append(host); render();
+ document.body.append(host);setupPanelMovement(host);render();
try {
const resume=JSON.parse(sessionStorage.getItem(RESUME_KEY) || 'null');
sessionStorage.removeItem(RESUME_KEY);
diff --git a/package.json b/package.json
index 2e3ae52..f7023ea 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,10 @@
{
"name": "e-learning-assistant",
- "version": "1.4.1",
+ "version": "1.4.2",
"private": true,
"description": "学员中心2倍速播放和测验辅助脚本",
"scripts": {
- "test": "node --check icampus-study-helper.user.js && node study-helper.test.cjs && node quiz-helper.test.cjs && node playback-recovery.test.cjs && node background-playback.test.cjs && node persistent-run.test.cjs && node overall-progress.test.cjs && node full-mute.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"
},
"engines": { "node": ">=18" }
}
diff --git a/panel-controls.test.cjs b/panel-controls.test.cjs
new file mode 100644
index 0000000..bab7cde
--- /dev/null
+++ b/panel-controls.test.cjs
@@ -0,0 +1,83 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {panelPosition}=require('./icampus-study-helper.user.js');
+const {harness}=require('./study-helper.test.cjs');
+const KEY='icampus.study.v1.panel';
+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);}};
+ const host={style:{},attachShadow:()=>panel,getBoundingClientRect(){return {
+ left:parseFloat(this.style.left)||16,top:parseFloat(this.style.top)||100,
+ width:nodes.get('[data-main-panel]')?.hidden?240:322,height:nodes.get('[data-main-panel]')?.hidden?44:620};}};
+ h.context.window.innerWidth=1400;h.context.window.innerHeight=900;
+ h.context.document.body={append(){}};h.context.document.createElement=()=>host;h.api.mount();
+ return {nodes,host};
+}
+function pointer(id,x,y,target={closest:()=>null}){return {pointerId:id,clientX:x,clientY:y,button:0,isPrimary:true,target,preventDefault(){}};}
+(async()=>{
+ await test('collapse and expand preserve active playback, intent and options',()=>{
+ const h=harness(),{nodes}=mount(h);h.api.begin();h.api.track(h.video);
+ nodes.get('[data-collapse]').onclick();assert.equal(nodes.get('[data-main-panel]').hidden,true);assert.equal(nodes.get('[data-expand]').hidden,false);
+ assert.equal(h.api.state.running,true);assert.equal(h.api.state.armed,true);assert.equal(h.video.pauseCount,0);
+ nodes.get('[data-expand]').onclick({detail:0});assert.equal(nodes.get('[data-main-panel]').hidden,false);assert.equal(h.api.state.running,true);
+ });
+ await test('dragging the title changes and stores position',()=>{
+ const h=harness(),{nodes,host}=mount(h),handle=nodes.get('[data-drag-handle]');
+ handle.onpointerdown(pointer(1,20,104));handle.onpointermove(pointer(1,220,204));handle.onpointerup(pointer(1,220,204));
+ assert.equal(host.style.left,'216px');assert.equal(host.style.top,'200px');
+ assert.deepEqual(JSON.parse(h.storage.get(KEY)),{x:216,y:200,collapsed:false});
+ });
+ await test('dragged compact button remains collapsed and a later click expands',()=>{
+ const h=harness(),{nodes}=mount(h);nodes.get('[data-collapse]').onclick();const compact=nodes.get('[data-expand]');
+ compact.onpointerdown(pointer(1,20,104));compact.onpointermove(pointer(1,120,204));compact.onpointerup(pointer(1,120,204));
+ compact.onclick({detail:1});assert.equal(nodes.get('[data-main-panel]').hidden,true);
+ compact.onpointerdown(pointer(2,120,204));compact.onpointerup(pointer(2,120,204));compact.onclick({detail:1});
+ assert.equal(nodes.get('[data-main-panel]').hidden,false);
+ });
+ await test('refresh restores compact state and saved position',()=>{
+ const h=harness(),{nodes}=mount(h);const header=nodes.get('[data-drag-handle]');
+ header.onpointerdown(pointer(1,20,104));header.onpointermove(pointer(1,220,204));header.onpointerup(pointer(1,220,204));nodes.get('[data-collapse]').onclick();
+ const restarted=harness({storageEntries:[...h.storage]}),ui=mount(restarted);
+ assert.equal(ui.nodes.get('[data-main-panel]').hidden,true);assert.equal(ui.host.style.left,'216px');assert.equal(ui.host.style.top,'200px');
+ assert.equal(restarted.api.state.armed,false);
+ });
+ await test('resize keeps the header and expanded panel inside the smaller viewport',()=>{
+ const h=harness(),{nodes,host}=mount(h);const header=nodes.get('[data-drag-handle]');
+ header.onpointerdown(pointer(1,20,104));header.onpointermove(pointer(1,1000,250));header.onpointerup(pointer(1,1000,250));
+ h.window.innerWidth=400;h.window.innerHeight=700;h.window.dispatchEvent({type:'resize'});
+ assert.equal(host.style.left,'70px');assert.equal(host.style.top,'72px');
+ });
+ await test('tiny movements and button presses do not start a drag or change settings',()=>{
+ const h=harness(),{nodes,host}=mount(h),header=nodes.get('[data-drag-handle]');
+ const before={...host.style};header.onpointerdown(pointer(1,20,104));header.onpointermove(pointer(1,21,105));header.onpointerup(pointer(1,21,105));
+ assert.equal(host.style.left,before.left);assert.equal(h.storage.has(KEY),false);
+ header.onpointerdown(pointer(2,20,104,{closest:()=>({})}));header.onpointermove(pointer(2,500,500));header.onpointerup(pointer(2,500,500));
+ assert.equal(host.style.left,before.left);assert.equal(h.api.state.armed,false);
+ });
+ await test('cancelled pointer capture allows the next drag to work',()=>{
+ const h=harness(),{nodes,host}=mount(h),header=nodes.get('[data-drag-handle]');
+ header.onpointerdown(pointer(1,20,104));header.onpointercancel(pointer(1,20,104));
+ header.onpointerdown(pointer(2,20,104));header.onpointermove(pointer(2,120,104));header.onpointerup(pointer(2,120,104));
+ assert.equal(host.style.left,'116px');
+ });
+ await test('invalid and off-screen preferences safely fall back or clamp',()=>{
+ const invalid=harness({storageEntries:[[KEY,'broken json']]});assert.equal(mount(invalid).nodes.get('[data-main-panel]').hidden,false);
+ const outside=harness({storageEntries:[[KEY,JSON.stringify({x:100000,y:-100,collapsed:false})]]});const ui=mount(outside);
+ assert.equal(ui.host.style.left,'1070px');assert.equal(ui.host.style.top,'8px');
+ assert.deepEqual(panelPosition(NaN,Infinity,322,620,1400,900),{x:16,y:16});
+ });
+ await test('compact status reflects waiting and cancellation',()=>{
+ const h=harness(),{nodes}=mount(h);nodes.get('[data-start]').onclick();nodes.get('[data-collapse]').onclick();
+ h.api.park('等待网络');assert.equal(nodes.get('[data-mini-state]').textContent,'等待恢复');
+ assert.match(nodes.get('[data-expand]').title,/等待网络/);h.api.stop();assert.equal(nodes.get('[data-mini-state]').textContent,'未启动');
+ });
+ await test('blocked local storage does not prevent collapsing or dragging',()=>{
+ const h=harness(),{nodes,host}=mount(h);h.context.localStorage.setItem=()=>{throw new Error('blocked');};
+ nodes.get('[data-collapse]').onclick();const compact=nodes.get('[data-expand]');
+ compact.onpointerdown(pointer(1,20,104));compact.onpointermove(pointer(1,120,204));compact.onpointerup(pointer(1,120,204));
+ assert.equal(nodes.get('[data-main-panel]').hidden,true);assert.equal(host.style.left,'116px');
+ });
+ console.log(`${passed} panel control tests passed.`);
+})().catch(e=>{console.error(e);process.exitCode=1;});