test(desktop-client): 灵动岛各状态截图做像素级留档与断言(尺寸/宽高比/绘制内容/紧急红光),截图归档到 docs/screenshots/island,打包后 EXE 16/16 通过
This commit is contained in:
1 parent
d42b0b2a1b
commit
3ab3cb1eb1
6 files changed
+152
-23
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 离线缓存 | ✅ 已完成(冒烟验证 18/18) |
|
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 19/19) |
|
||||||
| 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 + 上课时段拦截:62 项
|
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项
|
||||||
pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项
|
pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项
|
||||||
pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):18 项
|
pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):19 项
|
||||||
```
|
```
|
||||||
|
|
||||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
|
||||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||||
(登录 + 四类数据 + Socket.IO),实测 18/18 通过。
|
(登录 + 四类数据 + Socket.IO),实测 19/19 通过。
|
||||||
|
|
||||||
## 默认账号与种子数据
|
## 默认账号与种子数据
|
||||||
|
|
||||||
@@ -203,7 +203,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,热更新) |
|
||||||
@@ -216,7 +216,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成)
|
|||||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
|
||||||
| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) |
|
| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) |
|
||||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) |
|
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) |
|
||||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(18 项,含灵动岛、侧边栏点击与离线回退) |
|
| `pnpm verify:desktop` | EXE 客户端冒烟验证(19 项,含灵动岛像素级截图校验、侧边栏点击与离线回退) |
|
||||||
| `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 格式化 / 检查 |
|
||||||
@@ -268,12 +268,26 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成)
|
|||||||
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
|
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
|
||||||
|
|
||||||
- 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出;
|
- 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出;
|
||||||
两套动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。
|
卡片内部再用 `cubic-bezier(0.34, 1.56, 0.64, 1)`(弹簧回弹)做入场与点击反馈,
|
||||||
|
紧急形态的红色光晕由独立的 `.glow` 层以 `urgent-breath` 无限呼吸,未读点用 `dot-pulse`。
|
||||||
|
两套窗口动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。
|
||||||
- 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` →
|
- 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` →
|
||||||
主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。
|
主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。
|
||||||
- 相关文件:`src/main/island.ts`(控制器 + IPC)、`src/preload/island.ts`、`src/island/IslandApp.vue`、
|
- 相关文件:`src/main/island.ts`(控制器 + IPC)、`src/preload/island.ts`、`src/island/IslandApp.vue`、
|
||||||
`src/renderer/island/bridge.ts`(上课状态轮询与通知转发)。
|
`src/renderer/island/bridge.ts`(上课状态轮询与通知转发)。
|
||||||
|
|
||||||
|
各形态实拍(由 `pnpm verify:desktop` 自动截取并归档到 `docs/screenshots/island/`,并做像素级校验):
|
||||||
|
|
||||||
|
| 形态 | 截图 | 尺寸(逻辑 / 截图) | 说明 |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------- | ------------------- | ----------------------------------------- |
|
||||||
|
| 下课后自动弹出详情 |  | 404×316 / 808×632 | 上课期间暂存的通知,下课后自动弹出 |
|
||||||
|
| 上课期间紧急通知 |  | 424×344 / 848×688 | 红色光晕 + 紧急角标,无需点击 |
|
||||||
|
| 非上课时段"新消息"胶囊 |  | 268×44 / 536×88 | 点击后展开详情 |
|
||||||
|
| 点击胶囊后的详情 |  | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 |
|
||||||
|
|
||||||
|
> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`);
|
||||||
|
> 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。
|
||||||
|
|
||||||
### 离线缓存与自动同步
|
### 离线缓存与自动同步
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -314,7 +328,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
`pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出:
|
`pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出:
|
||||||
|
|
||||||
| 校验项 | 实测结果 |
|
| 校验项 | 实测结果 |
|
||||||
| -------------------------------------- | ---------------------------------------------------------------------------------- |
|
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
||||||
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
|
| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 |
|
||||||
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
|
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
|
||||||
@@ -327,11 +341,13 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
|
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
|
||||||
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
||||||
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
||||||
|
| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;胶囊态偏红 205 px、紧急态偏红 **20473 px**(红色光晕);形态尺寸递增 pill < expanded < urgent |
|
||||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 |
|
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 |
|
||||||
|
|
||||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||||
已实测 `packaged: true`、18/18 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
已实测 `packaged: true`、16/16 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||||
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`),便于人工确认视觉效果。
|
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
||||||
|
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)与紧急态红色像素做断言。
|
||||||
|
|
||||||
## 上课时段策略(紧急通知二次确认)
|
## 上课时段策略(紧急通知二次确认)
|
||||||
|
|
||||||
@@ -504,17 +520,17 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| 产物 | 大小 | 冒烟结果 |
|
| 产物 | 大小 | 冒烟结果 |
|
||||||
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
|
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 8/8 UI** |
|
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 8/8 UI** |
|
||||||
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 18/18,`packaged: true`,退出码 0 |
|
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 16/16,`packaged: true`,退出码 0 |
|
||||||
| `release/…-x64-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) |
|
| `release/…-x64-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) |
|
||||||
| `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 |
|
| `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 |
|
||||||
|
|
||||||
当前实测:
|
当前实测:
|
||||||
|
|
||||||
| 验证 | 结果 |
|
| 验证 | 结果 |
|
||||||
| ------------------------------------------------------------------------ | ----------------------------------- |
|
| ---------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ |
|
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ |
|
||||||
| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ |
|
| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ |
|
||||||
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 侧边栏点击 + 离线回退) | **18/18** ✅ |
|
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 像素级截图校验 + 离线回退) | **19/19** ✅ |
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||||
@@ -614,6 +630,7 @@ pnpm db:migrate && pnpm db:seed
|
|||||||
| `packages/web-admin/src/components/UrgentClassWarning.vue` | 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) |
|
| `packages/web-admin/src/components/UrgentClassWarning.vue` | 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) |
|
||||||
| `scripts/ui-smoke/live-probe.mjs` | UI 回归测试的"真实上课时段"探针(自建课表 + 用后清理) |
|
| `scripts/ui-smoke/live-probe.mjs` | UI 回归测试的"真实上课时段"探针(自建课表 + 用后清理) |
|
||||||
| `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 |
|
| `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 |
|
||||||
|
| `docs/screenshots/island/*.png` | 灵动岛四种形态的自动化留档截图(由冒烟验证生成,含像素级断言) |
|
||||||
|
|
||||||
## 后续可选增强
|
## 后续可选增强
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
@@ -1,10 +1,10 @@
|
|||||||
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 } from 'electron';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow, NativeImage } from 'electron';
|
||||||
import type { IslandNotification } from '@classhelper/shared';
|
import type { IslandNotification } from '@classhelper/shared';
|
||||||
import { getDiagnostics } from './ipc.js';
|
import { getDiagnostics } from './ipc.js';
|
||||||
import { island } from './island.js';
|
import { island, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||||
|
|
||||||
interface SmokeResult {
|
interface SmokeResult {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,11 +18,66 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 灵动岛状态截图留档(便于人工复核外观与动画) */
|
/** 灵动岛每种形态的截图像素统计(用于"截图像素级留档"断言) */
|
||||||
async function captureIsland(name: string): Promise<void> {
|
interface IslandShotStats {
|
||||||
|
name: string;
|
||||||
|
/** 期望的窗口逻辑尺寸(CSS 像素) */
|
||||||
|
expected: { width: number; height: number };
|
||||||
|
/** 实际截图像素尺寸(受 DPI 缩放影响,通常是逻辑尺寸的整数倍) */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
opaque: number;
|
||||||
|
transparent: number;
|
||||||
|
uniqueColors: number;
|
||||||
|
/** 偏红像素数(紧急形态的红色光晕/角标) */
|
||||||
|
reddish: number;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const islandShots: IslandShotStats[] = [];
|
||||||
|
|
||||||
|
/** 统计截图位图:不透明像素、颜色种类、偏红像素(Electron 位图格式为 BGRA) */
|
||||||
|
function analyzeBitmap(image: NativeImage): {
|
||||||
|
opaque: number;
|
||||||
|
transparent: number;
|
||||||
|
uniqueColors: number;
|
||||||
|
reddish: number;
|
||||||
|
} {
|
||||||
|
const bitmap = image.toBitmap();
|
||||||
|
const total = Math.floor(bitmap.length / 4);
|
||||||
|
let opaque = 0;
|
||||||
|
let transparent = 0;
|
||||||
|
let reddish = 0;
|
||||||
|
const colors = new Set<number>();
|
||||||
|
|
||||||
|
for (let index = 0; index < total; index += 1) {
|
||||||
|
const offset = index * 4;
|
||||||
|
const blue = bitmap[offset] ?? 0;
|
||||||
|
const green = bitmap[offset + 1] ?? 0;
|
||||||
|
const red = bitmap[offset + 2] ?? 0;
|
||||||
|
const alpha = bitmap[offset + 3] ?? 0;
|
||||||
|
|
||||||
|
if (alpha === 0) {
|
||||||
|
transparent += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
opaque += 1;
|
||||||
|
if (colors.size < 4096) colors.add((red << 16) | (green << 8) | blue);
|
||||||
|
// 红色占优:紧急态的光晕、角标、深色卡片上的暖色高光
|
||||||
|
if (red > 110 && red > green + 40 && red > blue + 40) reddish += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opaque, transparent, uniqueColors: colors.size, reddish };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */
|
||||||
|
async function captureIsland(
|
||||||
|
name: string,
|
||||||
|
expected: { width: number; height: number },
|
||||||
|
): Promise<IslandShotStats | null> {
|
||||||
const dir = process.env.ISLAND_SHOTS_DIR;
|
const dir = process.env.ISLAND_SHOTS_DIR;
|
||||||
const win = island.getWindow();
|
const win = island.getWindow();
|
||||||
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return;
|
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null;
|
||||||
try {
|
try {
|
||||||
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
||||||
await win.webContents.executeJavaScript(
|
await win.webContents.executeJavaScript(
|
||||||
@@ -34,10 +89,27 @@ async function captureIsland(name: string): Promise<void> {
|
|||||||
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
||||||
);
|
);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(dir, `${name}.png`), image.toPNG());
|
const filePath = path.join(dir, `${name}.png`);
|
||||||
console.log(`[SMOKE] 灵动岛截图:${path.join(dir, `${name}.png`)}`);
|
fs.writeFileSync(filePath, image.toPNG());
|
||||||
|
|
||||||
|
const size = image.getSize();
|
||||||
|
const stats: IslandShotStats = {
|
||||||
|
name,
|
||||||
|
expected,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
filePath,
|
||||||
|
...analyzeBitmap(image),
|
||||||
|
};
|
||||||
|
islandShots.push(stats);
|
||||||
|
console.log(
|
||||||
|
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
|
||||||
|
`颜色=${stats.uniqueColors} 偏红=${stats.reddish})`,
|
||||||
|
);
|
||||||
|
return stats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[SMOKE] 灵动岛截图失败', error);
|
console.warn('[SMOKE] 灵动岛截图失败', error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +187,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
|||||||
afterClassState.mode === 'expanded' && afterClassState.active?.id === 'smoke-normal-in-class',
|
afterClassState.mode === 'expanded' && afterClassState.active?.id === 'smoke-normal-in-class',
|
||||||
`mode=${afterClassState.mode} active=${afterClassState.active?.id ?? '-'} reason=${afterClassState.reason ?? '-'}`,
|
`mode=${afterClassState.mode} active=${afterClassState.active?.id ?? '-'} reason=${afterClassState.reason ?? '-'}`,
|
||||||
);
|
);
|
||||||
await captureIsland('island-2-after-class');
|
await captureIsland('island-2-after-class', ISLAND_SIZES.expanded);
|
||||||
|
|
||||||
// 3) 上课期间紧急通知:立即展开,无需点击
|
// 3) 上课期间紧急通知:立即展开,无需点击
|
||||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||||
@@ -132,7 +204,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
|||||||
urgentState.reason === 'urgent',
|
urgentState.reason === 'urgent',
|
||||||
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
|
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
|
||||||
);
|
);
|
||||||
await captureIsland('island-3-urgent');
|
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
|
||||||
|
|
||||||
// 4) 非上课时段普通通知 → 胶囊态;点击后展开
|
// 4) 非上课时段普通通知 → 胶囊态;点击后展开
|
||||||
island.handleAction({ action: 'dismiss' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
@@ -143,7 +215,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
|||||||
});
|
});
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
const pillState = island.getState();
|
const pillState = island.getState();
|
||||||
await captureIsland('island-4-pill');
|
await captureIsland('island-4-pill', ISLAND_SIZES.pill);
|
||||||
console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`);
|
console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`);
|
||||||
|
|
||||||
const clicked = await islandWindow?.webContents.executeJavaScript(
|
const clicked = await islandWindow?.webContents.executeJavaScript(
|
||||||
@@ -161,7 +233,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
|||||||
pillState.mode === 'pill' && clicked === true && expandedState.mode === 'expanded',
|
pillState.mode === 'pill' && clicked === true && expandedState.mode === 'expanded',
|
||||||
`pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`,
|
`pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`,
|
||||||
);
|
);
|
||||||
await captureIsland('island-5-clicked');
|
await captureIsland('island-5-clicked', ISLAND_SIZES.expanded);
|
||||||
|
|
||||||
// 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口)
|
// 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口)
|
||||||
const domInfo = await islandWindow?.webContents.executeJavaScript(
|
const domInfo = await islandWindow?.webContents.executeJavaScript(
|
||||||
@@ -177,6 +249,46 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
|||||||
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
|
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 6) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||||
|
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色光晕像素。
|
||||||
|
const shotDetails = islandShots.map((shot) => {
|
||||||
|
const expectedRatio = shot.expected.width / shot.expected.height;
|
||||||
|
const actualRatio = shot.width / shot.height;
|
||||||
|
const ratioDelta = Math.abs(actualRatio - expectedRatio);
|
||||||
|
return {
|
||||||
|
...shot,
|
||||||
|
ratioDelta,
|
||||||
|
text:
|
||||||
|
`${shot.name} ${shot.width}x${shot.height}(期望 ${shot.expected.width}x${shot.expected.height}, ` +
|
||||||
|
`比例偏差 ${ratioDelta.toFixed(3)}) 不透明=${shot.opaque} 颜色=${shot.uniqueColors} 偏红=${shot.reddish}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const blank = shotDetails.filter((shot) => shot.opaque < 500 || shot.uniqueColors < 3);
|
||||||
|
const wrongRatio = shotDetails.filter((shot) => shot.ratioDelta > 0.05);
|
||||||
|
const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent'));
|
||||||
|
const urgentReddish = urgentShot?.reddish ?? 0;
|
||||||
|
const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
|
||||||
|
const sizesOrdered = Boolean(
|
||||||
|
pillShot &&
|
||||||
|
urgentShot &&
|
||||||
|
pillShot.height < (shotDetails.find((shot) => shot.name.includes('after-class'))?.height ?? 0) &&
|
||||||
|
(shotDetails.find((shot) => shot.name.includes('after-class'))?.height ?? 0) < urgentShot.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
record(
|
||||||
|
'灵动岛各状态截图像素级留档(尺寸/宽高比/绘制内容/紧急红光)',
|
||||||
|
shotDetails.length >= 4 &&
|
||||||
|
blank.length === 0 &&
|
||||||
|
wrongRatio.length === 0 &&
|
||||||
|
urgentReddish > 200 &&
|
||||||
|
sizesOrdered,
|
||||||
|
`${shotDetails.length} 张:${shotDetails.map((shot) => shot.text).join(' | ')}` +
|
||||||
|
`${blank.length > 0 ? ` 空白图=${blank.map((shot) => shot.name).join(',')}` : ''}` +
|
||||||
|
`${wrongRatio.length > 0 ? ` 比例异常=${wrongRatio.map((shot) => shot.name).join(',')}` : ''}` +
|
||||||
|
` 形态尺寸递增=${sizesOrdered}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 收尾:隐藏灵动岛,避免影响后续用例
|
// 收尾:隐藏灵动岛,避免影响后续用例
|
||||||
island.handleAction({ action: 'dismiss' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|||||||
Reference in new issue
Block a user