test(desktop): 个性化全参数 + 退出无残留的自动化验证(verify:desktop 40 → 47 项)
新增 7 项真实断言(全部读真实窗口属性,而非界面显示): - 透明度 → win.getOpacity 实际 0.62 - 主题色 → 渲染进程 CSS 变量 --island-accent = #ff7043 - 停靠位置 → 左上 8,8 / 右上 1164,8 / 底部居中 586,800 / 顶部居中 586,8 - 置顶开关 → isAlwaysOnTop 关/开 - 动画开关 → 关闭后 60ms 内即到位(404x316),无逐帧过渡 - 动画速度 → speed=0.5 收回 435ms vs speed=2 收回 128ms - 持久化 → saveConfig/getConfig 全字段往返一致 需求 5 的'退出无残留'改为进程退出后实测: - 删除 userData 目录(SingletonLock/IndexedDB 文件锁)成功 → 无句柄残留 - 用同一配置目录二次启动成功并再次全绿 → 单实例锁与进程均已释放 验收: verify:desktop 47/47(二次启动 41/41), verify:e2e 125/125, verify:web 18/18 文档: README 客户端冒烟验证表刷新为最新实测值 + 新增退出无残留说明
This commit is contained in:
1 parent
d11f8e43a1
commit
b07ee4f8d7
3 files changed
+225
-15
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
|
||||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 40/40) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 47/47) |
|
||||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||||
@@ -173,13 +173,13 @@ pnpm verify:desktop
|
|||||||
# 另开一个终端先启动后端:pnpm dev:server
|
# 另开一个终端先启动后端:pnpm dev:server
|
||||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:125 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:125 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项
|
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):47 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 125 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 125 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 40/40 通过。
|
(**班级账号登录** + 四类数据 + Socket.IO),实测 47/47 通过;退出后再启动一次校验单实例锁与文件锁已释放。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ------------------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
|
||||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
@@ -222,7 +222,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收(125 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(125 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,18 项,含权限入口隐藏与导入弹窗) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,18 项,含权限入口隐藏与导入弹窗) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(40 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(47 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴、个性化全参数) |
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||||
@@ -423,6 +423,12 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
|||||||
任务管理器中不留残留进程。冒烟实测:关闭后 `visible=false / destroyed=false / 进程存活=true`,
|
任务管理器中不留残留进程。冒烟实测:关闭后 `visible=false / destroyed=false / 进程存活=true`,
|
||||||
清理后 `托盘=false / 灵动岛=false`。
|
清理后 `托盘=false / 灵动岛=false`。
|
||||||
|
|
||||||
|
**"退出无残留"是被实测出来的,不是只写在文档里**:冒烟脚本在 Electron 进程真正退出之后再做两件事 ——
|
||||||
|
① 删除该次运行独占的 `userData` 目录(含 Chromium `SingletonLock` 与 IndexedDB 文件句柄),
|
||||||
|
删得掉就说明没有进程还占着句柄;② 用**同一个配置目录再启动一次**,若还有残留进程占着单实例锁,
|
||||||
|
第二次启动会静默退出且不产出结果文件——实测第二次启动正常跑完并再次全绿
|
||||||
|
(`[smoke] [PASS] 退出后无残留:配置文件锁已释放,二次启动成功(41/41)`)。
|
||||||
|
|
||||||
### 离线缓存与自动同步
|
### 离线缓存与自动同步
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -465,7 +471,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| 校验项 | 实测结果 |
|
| 校验项 | 实测结果 |
|
||||||
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
||||||
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
|
| 登录页渲染(三个输入框) | ✅ 服务器地址 / **班级码** / 班级密码 |
|
||||||
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
|
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
|
||||||
| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 |
|
| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 |
|
||||||
| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` |
|
| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` |
|
||||||
@@ -484,10 +490,18 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| **点击右上角收起按钮回缩为胶囊** | ✅ 真实点击 `.icon-btn` → `mode=pill` |
|
| **点击右上角收起按钮回缩为胶囊** | ✅ 真实点击 `.icon-btn` → `mode=pill` |
|
||||||
| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;形态尺寸递增 pill < expanded < urgent;紧急态偏红 **20584 px**(卡片内部光晕) |
|
| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;形态尺寸递增 pill < expanded < urgent;紧急态偏红 **20584 px**(卡片内部光晕) |
|
||||||
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
||||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 7 作业 / 20 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 |
|
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
|
||||||
|
| **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** |
|
||||||
|
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
|
||||||
|
| **个性化:停靠位置(4 个锚点)** | ✅ 左上 `8,8` / 右上 `1164,8` / 底部居中 `586,800` / 顶部居中 `586,8` |
|
||||||
|
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
|
||||||
|
| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即为 `404x316`(无过渡);speed=0.5 收回 435ms vs speed=2 收回 128ms |
|
||||||
|
| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶) |
|
||||||
|
| **退出清理:托盘 / 灵动岛 / 窗口** | ✅ `isTrayReady()=false`、`island.isReady()=false`、窗口全部销毁 |
|
||||||
|
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||||
|
|
||||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||||
已实测 `packaged: true`、28/28 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
已实测 `packaged: true`、47/47 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||||
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
||||||
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
||||||
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
||||||
@@ -836,7 +850,7 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| ----------------------------------------------------------------------------------------- | ----------------------------------- |
|
| ----------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **125/125** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **125/125** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回 + 个性化全参数 + 托盘与退出无残留) | **47/47** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ if (online === '1') {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 启动一次冒烟(返回退出码) */
|
||||||
|
function launchSmoke(extraEnv) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
const child = spawn(electronPath, ['.'], {
|
const child = spawn(electronPath, ['.'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
@@ -128,10 +131,62 @@ const child = spawn(electronPath, ['.'], {
|
|||||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||||
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
||||||
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
||||||
|
...extraEnv,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
child.on('exit', (code) => resolve(code ?? 1));
|
||||||
child.on('exit', (code) => {
|
|
||||||
console.log(`\n[smoke] Electron 退出,code=${code ?? 1}`);
|
|
||||||
process.exit(code ?? 1);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await launchSmoke();
|
||||||
|
console.log(`\n[smoke] Electron 退出,code=${code}`);
|
||||||
|
if (code !== 0) process.exit(code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需求 5 的"退出不留残留"实测(在进程真正退出之后做,才有意义):
|
||||||
|
* 1) 配置目录(含 Chromium 的 SingletonLock / IndexedDB 文件锁)必须能被删除
|
||||||
|
* —— 删不掉说明还有句柄被占用,即存在残留进程;
|
||||||
|
* 2) 用同一个配置目录再启动一次:单实例锁与端口/句柄都已释放才会真正跑起来
|
||||||
|
* (若还有残留进程占着锁,第二次启动会静默退出且不产出结果文件)。
|
||||||
|
*/
|
||||||
|
let lockReleased = true;
|
||||||
|
try {
|
||||||
|
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
lockReleased = false;
|
||||||
|
console.error(`[smoke] [FAIL] 退出后配置文件锁未释放:${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relaunchResult = path.join(repoRoot, '.cache', `desktop-smoke-relaunch-${process.pid}.json`);
|
||||||
|
try {
|
||||||
|
fs.rmSync(relaunchResult, { force: true });
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
|
||||||
|
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult });
|
||||||
|
|
||||||
|
let relaunchPassed = 0;
|
||||||
|
let relaunchTotal = 0;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(relaunchResult, 'utf8'));
|
||||||
|
relaunchPassed = payload.passed ?? 0;
|
||||||
|
relaunchTotal = payload.total ?? 0;
|
||||||
|
} catch {
|
||||||
|
relaunchPassed = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourcesOk =
|
||||||
|
lockReleased && relaunchCode === 0 && relaunchTotal > 0 && relaunchPassed === relaunchTotal;
|
||||||
|
console.log(
|
||||||
|
resourcesOk
|
||||||
|
? `[smoke] [PASS] 退出后无残留:配置文件锁已释放,二次启动成功(${relaunchPassed}/${relaunchTotal})`
|
||||||
|
: `[smoke] [FAIL] 退出后可能仍有残留:文件锁释放=${lockReleased} 二次启动 code=${relaunchCode} 结果=${relaunchPassed}/${relaunchTotal}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
|
||||||
|
} catch {
|
||||||
|
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
|
||||||
|
}
|
||||||
|
process.exit(resourcesOk ? 0 : 1);
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app } from 'electron';
|
import { app, screen } from 'electron';
|
||||||
import type { BrowserWindow, NativeImage } from 'electron';
|
import type { BrowserWindow, NativeImage } from 'electron';
|
||||||
import type { IslandNotification } from '@classhelper/shared';
|
import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
|
||||||
|
import { getConfig, saveConfig } from './config.js';
|
||||||
import { getDiagnostics } from './ipc.js';
|
import { getDiagnostics } from './ipc.js';
|
||||||
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||||
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
||||||
@@ -653,6 +654,146 @@ async function runIslandChecks(
|
|||||||
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
|
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
|
||||||
|
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
|
||||||
|
const workArea = screen.getPrimaryDisplay().workArea;
|
||||||
|
|
||||||
|
// 11.1 透明度 → 窗口 setOpacity
|
||||||
|
island.setAppearance({ ...appearanceBefore, opacity: 0.62 });
|
||||||
|
await sleep(400);
|
||||||
|
const opacityActual = islandWindow?.getOpacity() ?? -1;
|
||||||
|
record(
|
||||||
|
'个性设置:透明度生效(窗口 setOpacity)',
|
||||||
|
Math.abs(opacityActual - 0.62) < 0.03,
|
||||||
|
`设置=0.62 实际=${opacityActual.toFixed(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 11.2 主题色 → 渲染进程 CSS 变量
|
||||||
|
island.setAppearance({ ...appearanceBefore, accent: '#ff7043' });
|
||||||
|
await sleep(200);
|
||||||
|
const accentVar = await islandWindow?.webContents.executeJavaScript(
|
||||||
|
`getComputedStyle(document.documentElement).getPropertyValue('--island-accent').trim()`,
|
||||||
|
);
|
||||||
|
record('个性设置:主题色生效(CSS 变量)', accentVar === '#ff7043', `CSS --island-accent=${accentVar}`);
|
||||||
|
|
||||||
|
// 11.3 位置:四个停靠点的窗口坐标必须落在对应锚点
|
||||||
|
const positionCases: {
|
||||||
|
position: IslandAppearance['position'];
|
||||||
|
verify: (bounds: Electron.Rectangle) => boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
position: 'top-left',
|
||||||
|
verify: (bounds) =>
|
||||||
|
Math.abs(bounds.x - (workArea.x + 8)) <= 1 && Math.abs(bounds.y - (workArea.y + 8)) <= 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: 'top-right',
|
||||||
|
verify: (bounds) =>
|
||||||
|
Math.abs(bounds.x + bounds.width - (workArea.x + workArea.width - 8)) <= 1 &&
|
||||||
|
Math.abs(bounds.y - (workArea.y + 8)) <= 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: 'bottom-center',
|
||||||
|
verify: (bounds) => Math.abs(bounds.y + bounds.height - (workArea.y + workArea.height - 8)) <= 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: 'top-center',
|
||||||
|
verify: (bounds) => Math.abs(bounds.x + bounds.width / 2 - (workArea.x + workArea.width / 2)) <= 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const positionDetails: string[] = [];
|
||||||
|
let positionOk = true;
|
||||||
|
for (const item of positionCases) {
|
||||||
|
island.setAppearance({ ...appearanceBefore, position: item.position });
|
||||||
|
await sleep(450);
|
||||||
|
const bounds = islandWindow?.getBounds();
|
||||||
|
const matched = Boolean(bounds) && item.verify(bounds as Electron.Rectangle);
|
||||||
|
if (!matched) positionOk = false;
|
||||||
|
positionDetails.push(
|
||||||
|
`${item.position}=${bounds ? `${bounds.x},${bounds.y}` : '-'}${matched ? '✓' : '✗'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record('个性设置:停靠位置生效(左上/右上/底部居中/顶部居中)', positionOk, positionDetails.join(' '));
|
||||||
|
island.setAppearance(appearanceBefore);
|
||||||
|
|
||||||
|
// 11.4 置顶开关 → 窗口 alwaysOnTop
|
||||||
|
island.setAppearance({ ...appearanceBefore, alwaysOnTop: false });
|
||||||
|
await sleep(250);
|
||||||
|
const topOff = islandWindow?.isAlwaysOnTop() ?? true;
|
||||||
|
island.setAppearance({ ...appearanceBefore, alwaysOnTop: true });
|
||||||
|
await sleep(250);
|
||||||
|
const topOn = islandWindow?.isAlwaysOnTop() ?? false;
|
||||||
|
record(
|
||||||
|
'个性设置:置顶开关生效',
|
||||||
|
topOff === false && topOn === true,
|
||||||
|
`关闭后 isAlwaysOnTop=${topOff},开启后 isAlwaysOnTop=${topOn}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 11.5 动画开关:关闭动画时展开必须"立即到位"(不做逐帧过渡)
|
||||||
|
island.setAppearance({ ...appearanceBefore, animations: false });
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(250);
|
||||||
|
island.pushNotification(makeNotification('smoke-anim-off', 'NORMAL', '关闭动画校验'), { inClass: false });
|
||||||
|
await sleep(300);
|
||||||
|
island.handleAction({ action: 'expand' });
|
||||||
|
await sleep(60);
|
||||||
|
const instantBounds = islandWindow?.getBounds();
|
||||||
|
const expectedExpanded = ISLAND_SIZES.expanded;
|
||||||
|
const animationsOff = island.getAppearance().animations === false;
|
||||||
|
record(
|
||||||
|
'个性设置:关闭动画后展开立即到位',
|
||||||
|
!animationsOff ||
|
||||||
|
(instantBounds?.width === expectedExpanded.width && instantBounds?.height === expectedExpanded.height),
|
||||||
|
`animations=${island.getAppearance().animations} 60ms 后窗口=${instantBounds?.width ?? '-'}x${instantBounds?.height ?? '-'}(目标 ${expectedExpanded.width}x${expectedExpanded.height})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 11.6 动画速度:speed=2 的收回耗时必须明显短于 speed=0.5
|
||||||
|
const measureCollapse = async (speed: number): Promise<number> => {
|
||||||
|
island.setAppearance({ ...appearanceBefore, animations: true, speed });
|
||||||
|
island.handleAction({ action: 'expand' });
|
||||||
|
await sleep(500);
|
||||||
|
const started = Date.now();
|
||||||
|
island.handleAction({ action: 'collapse' });
|
||||||
|
const target = island.getAppearance().width;
|
||||||
|
const deadline = started + 2500;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const width = islandWindow?.getBounds().width ?? 0;
|
||||||
|
if (Math.abs(width - target) <= 1) break;
|
||||||
|
await sleep(16);
|
||||||
|
}
|
||||||
|
return Date.now() - started;
|
||||||
|
};
|
||||||
|
const slowMs = await measureCollapse(0.5);
|
||||||
|
const fastMs = await measureCollapse(2);
|
||||||
|
record(
|
||||||
|
'个性设置:动画速度生效(2 倍速明显快于 0.5 倍速)',
|
||||||
|
fastMs + 60 < slowMs,
|
||||||
|
`speed=0.5 收回耗时=${slowMs}ms,speed=2 收回耗时=${fastMs}ms`,
|
||||||
|
);
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(250);
|
||||||
|
|
||||||
|
// 11.7 持久化:写入主进程配置后重新读取必须一致(重启客户端后仍生效)
|
||||||
|
const persisted = island.getAppearance();
|
||||||
|
saveConfig({ island: persisted });
|
||||||
|
const restored = getConfig().island ?? null;
|
||||||
|
record(
|
||||||
|
'个性设置:持久化到客户端配置(重启后仍生效)',
|
||||||
|
Boolean(restored) &&
|
||||||
|
restored?.height === persisted.height &&
|
||||||
|
restored?.width === persisted.width &&
|
||||||
|
restored?.radius === persisted.radius &&
|
||||||
|
restored?.opacity === persisted.opacity &&
|
||||||
|
restored?.accent === persisted.accent &&
|
||||||
|
restored?.fontSize === persisted.fontSize &&
|
||||||
|
restored?.animations === persisted.animations &&
|
||||||
|
restored?.speed === persisted.speed &&
|
||||||
|
restored?.position === persisted.position &&
|
||||||
|
restored?.alwaysOnTop === persisted.alwaysOnTop,
|
||||||
|
`写入=${persisted.width}x${persisted.height} 读取=${restored?.width ?? '-'}x${restored?.height ?? '-'} ` +
|
||||||
|
`位置=${restored?.position} 置顶=${restored?.alwaysOnTop} 动画=${restored?.animations}`,
|
||||||
|
);
|
||||||
|
|
||||||
island.setAppearance(appearanceBefore);
|
island.setAppearance(appearanceBefore);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user