test(desktop-client): 灵动岛各状态截图做像素级留档与断言(尺寸/宽高比/绘制内容/紧急红光),截图归档到 docs/screenshots/island,打包后 EXE 16/16 通过

This commit is contained in:
LoveChengke committed 2026-09-12 08:54:33 +08:00
1 parent d42b0b2a1b
commit 3ab3cb1eb1
6 files changed
+152 -23

No files matched your search

+29 -12
View File
@@ -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/`,并做像素级校验):
| 形态 | 截图 | 尺寸(逻辑 / 截图) | 说明 |
| ---------------------- | ------------------------------------------------------------------- | ------------------- | ----------------------------------------- |
| 下课后自动弹出详情 | ![下课后自动弹出](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 跑自动化验证时产出的同一份文件。
### 离线缓存与自动同步 ### 离线缓存与自动同步
``` ```
@@ -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

+123 -11
View File
@@ -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);