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 = ` + +
2倍速学习助手 · 1.4.2

@@ -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;});