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
+194 -65

No files matched your search

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