diff --git a/README.md b/README.md index faa2c87..c118494 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 18/18) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 19/19) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | @@ -173,13 +173,13 @@ pnpm verify:desktop # 另开一个终端先启动后端:pnpm dev:server pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项 pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项 -pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):18 项 +pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):19 项 ``` 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(登录 + 四类数据 + Socket.IO),实测 18/18 通过。 +(登录 + 四类数据 + Socket.IO),实测 19/19 通过。 ## 默认账号与种子数据 @@ -202,30 +202,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成) ## 常用命令 -| 命令 | 说明 | -| ------------------------------------------- | -------------------------------------------------------------------- | -| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | -| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | -| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | -| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | -| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | -| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | -| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | -| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | -| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | -| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | -| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | -| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(18 项,含灵动岛、侧边栏点击与离线回退) | -| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | -| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | -| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | -| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | -| `pnpm db:migrate` | 创建并应用迁移 | -| `pnpm db:seed` | 写入种子数据 | -| `pnpm db:reset` | 重置数据库并重新执行 seed | -| `pnpm db:studio` | 打开 Prisma Studio | -| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | +| 命令 | 说明 | +| ------------------------------------------- | ------------------------------------------------------------------------- | +| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | +| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | +| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | +| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | +| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | +| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | +| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | +| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | +| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | +| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | +| `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(19 项,含灵动岛像素级截图校验、侧边栏点击与离线回退) | +| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | +| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | +| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | +| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | +| `pnpm db:migrate` | 创建并应用迁移 | +| `pnpm db:seed` | 写入种子数据 | +| `pnpm db:reset` | 重置数据库并重新执行 seed | +| `pnpm db:studio` | 打开 Prisma Studio | +| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | ## EXE 客户端(学生端) @@ -268,12 +268,26 @@ pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成) | 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | - 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出; - 两套动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 + 卡片内部再用 `cubic-bezier(0.34, 1.56, 0.64, 1)`(弹簧回弹)做入场与点击反馈, + 紧急形态的红色光晕由独立的 `.glow` 层以 `urgent-breath` 无限呼吸,未读点用 `dot-pulse`。 + 两套窗口动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 - 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` → 主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。 - 相关文件:`src/main/island.ts`(控制器 + IPC)、`src/preload/island.ts`、`src/island/IslandApp.vue`、 `src/renderer/island/bridge.ts`(上课状态轮询与通知转发)。 +各形态实拍(由 `pnpm verify:desktop` 自动截取并归档到 `docs/screenshots/island/`,并做像素级校验): + +| 形态 | 截图 | 尺寸(逻辑 / 截图) | 说明 | +| ---------------------- | ------------------------------------------------------------------- | ------------------- | ----------------------------------------- | +| 下课后自动弹出详情 | ![下课后自动弹出](docs/screenshots/island/island-2-after-class.png) | 404×316 / 808×632 | 上课期间暂存的通知,下课后自动弹出 | +| 上课期间紧急通知 | ![紧急通知立即展开](docs/screenshots/island/island-3-urgent.png) | 424×344 / 848×688 | 红色光晕 + 紧急角标,无需点击 | +| 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 点击后展开详情 | +| 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 | + +> 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`); +> 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。 + ### 离线缓存与自动同步 ``` @@ -313,25 +327,27 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS `pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出: -| 校验项 | 实测结果 | -| -------------------------------------- | ---------------------------------------------------------------------------------- | -| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | -| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | -| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) | -| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | -| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | -| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | -| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | -| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` | -| 上课期间普通通知自动隐藏(暂存) | ✅ `mode=hidden queued=1` | -| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | -| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | -| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | -| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 | +| 校验项 | 实测结果 | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | +| 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | +| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) | +| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | +| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | +| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | +| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | +| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` | +| 上课期间普通通知自动隐藏(暂存) | ✅ `mode=hidden queued=1` | +| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | +| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | +| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 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 已连接 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、18/18 通过、退出码 0)。灵动岛各状态的窗口截图会写到 -`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`),便于人工确认视觉效果。 +已实测 `packaged: true`、16/16 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), +并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)与紧急态红色像素做断言。 ## 上课时段策略(紧急通知二次确认) @@ -504,20 +520,20 @@ pnpm db:migrate && pnpm db:seed | 产物 | 大小 | 冒烟结果 | | -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | | 服务端安装程序(内置 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-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 | 当前实测: -| 验证 | 结果 | -| ------------------------------------------------------------------------ | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 侧边栏点击 + 离线回退) | **18/18** ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| ---------------------------------------------------------------------------- | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 像素级截图校验 + 离线回退) | **19/19** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) @@ -614,6 +630,7 @@ pnpm db:migrate && pnpm db:seed | `packages/web-admin/src/components/UrgentClassWarning.vue` | 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) | | `scripts/ui-smoke/live-probe.mjs` | UI 回归测试的"真实上课时段"探针(自建课表 + 用后清理) | | `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 | +| `docs/screenshots/island/*.png` | 灵动岛四种形态的自动化留档截图(由冒烟验证生成,含像素级断言) | ## 后续可选增强 diff --git a/docs/screenshots/island/island-2-after-class.png b/docs/screenshots/island/island-2-after-class.png new file mode 100644 index 0000000..9338e08 Binary files /dev/null and b/docs/screenshots/island/island-2-after-class.png differ diff --git a/docs/screenshots/island/island-3-urgent.png b/docs/screenshots/island/island-3-urgent.png new file mode 100644 index 0000000..69e9631 Binary files /dev/null and b/docs/screenshots/island/island-3-urgent.png differ diff --git a/docs/screenshots/island/island-4-pill.png b/docs/screenshots/island/island-4-pill.png new file mode 100644 index 0000000..8c49f6b Binary files /dev/null and b/docs/screenshots/island/island-4-pill.png differ diff --git a/docs/screenshots/island/island-5-clicked.png b/docs/screenshots/island/island-5-clicked.png new file mode 100644 index 0000000..7584d4a Binary files /dev/null and b/docs/screenshots/island/island-5-clicked.png differ diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 9119fff..d4ed248 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -1,10 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { app } from 'electron'; -import type { BrowserWindow } from 'electron'; +import type { BrowserWindow, NativeImage } from 'electron'; import type { IslandNotification } from '@classhelper/shared'; import { getDiagnostics } from './ipc.js'; -import { island } from './island.js'; +import { island, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js'; interface SmokeResult { name: string; @@ -18,11 +18,66 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** 灵动岛状态截图留档(便于人工复核外观与动画) */ -async function captureIsland(name: string): Promise { +/** 灵动岛每种形态的截图像素统计(用于"截图像素级留档"断言) */ +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(); + + 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 { const dir = process.env.ISLAND_SHOTS_DIR; const win = island.getWindow(); - if (!dir || !win || win.isDestroyed() || !win.isVisible()) return; + if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null; try { // 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图 await win.webContents.executeJavaScript( @@ -34,10 +89,27 @@ async function captureIsland(name: string): Promise { `document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`, ); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, `${name}.png`), image.toPNG()); - console.log(`[SMOKE] 灵动岛截图:${path.join(dir, `${name}.png`)}`); + const filePath = 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) { 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', `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) 上课期间紧急通知:立即展开,无需点击 island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 }); @@ -132,7 +204,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis urgentState.reason === 'urgent', `mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`, ); - await captureIsland('island-3-urgent'); + await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE); // 4) 非上课时段普通通知 → 胶囊态;点击后展开 island.handleAction({ action: 'dismiss' }); @@ -143,7 +215,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis }); await sleep(700); const pillState = island.getState(); - await captureIsland('island-4-pill'); + await captureIsland('island-4-pill', ISLAND_SIZES.pill); console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`); 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', `pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`, ); - await captureIsland('island-5-clicked'); + await captureIsland('island-5-clicked', ISLAND_SIZES.expanded); // 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口) 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}`, ); + // 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' }); await sleep(300);