Files
ClassHelper/packages/desktop-client/src/main/smoke.ts
T
LoveChengke 7536abfc95 feat(homeworks,island,classisland,desktop,web,server): 作业按天查看与教室机器录入 + 提醒上课时段暂存规则 + 灵动岛边距/跟随鼠标屏幕
- 作业:新增 Homework.assignDate(本地日期 YYYY-MM-DD;UTC 切片在晚上会把今天的作业算成明天),列表按天过滤与排序,新增 GET /homeworks?date= 与 GET /homeworks/days(日期高亮)供客户端与 Web 共用;客户端作业页改为"看作业 + 录作业"(不再显示完成状态),支持快捷短语(存本机配置,「设置 → 作业录入」可增删/恢复默认)
- 教室机器录入:POST /homeworks 去掉 requireRole,权限下沉到 homeworks.service(staff 或本班班级账号);班级账号的 sub 是班级 id,写 createdBy 前换成该班班主任,避免撞 User 外键
- 提醒类型:ClassIslandPush.kind(notification / call)。通知类上课时段暂存、下课补弹,叫人/紧急立即弹;客户端灵动岛与 ClassIsland 插件共用同一套规则
- 灵动岛:新增 marginX / marginY(默认仍 8px,不动老外观)与「跟随鼠标屏幕」;文案统一成"灵动岛 / ClassIsland"
- 设置页:新增「ClassIsland 联动」(联动状态 + 通知模式)与「作业录入」分区
- 校验:verify:e2e 增补 assignDate 默认当天 / ?date= 过滤 / days / 非法日期 422 / 班级账号录入 201 与跨班 403;verify:classisland 与插件静态契约增补上课时段暂存断言(57/57)
- 文档:README 与 AGENTS.md 同步(作业按天、上课时段策略、两个方向的开关表、新增踩坑)

门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 57/57 通过。
未跑:verify:e2e / verify:classisland(需后端启动)、verify:web / verify:desktop(需先构建产物)。
2026-09-27 16:34:01 +08:00

2986 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { app, screen } from 'electron';
import type { BrowserWindow, NativeImage } from 'electron';
import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { getDiagnostics } from './ipc.js';
import {
getSliverSize,
island,
ISLAND_CALL_SIZE,
ISLAND_SIZES,
ISLAND_TIMEOUTS,
ISLAND_URGENT_SIZE,
} from './island.js';
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
interface SmokeResult {
name: string;
ok: boolean;
detail: string;
}
type Recorder = (name: string, ok: boolean, detail?: string) => void;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 灵动岛每种形态的截图像素统计(用于"截图像素级留档"断言) */
/** 岛几何:固定包围盒窗口 + 岛的真实矩形 + 形状路径尺寸 */
interface IslandGeometry {
window: { x: number; y: number; width: number; height: number };
island: IslandRect;
}
/** 岛在屏幕上的矩形(固定包围盒窗口内形变 → 断言都量这个) */
interface IslandRect {
x: number;
y: number;
width: number;
height: number;
radius?: number;
pathWidth?: number;
pathHeight?: number;
/** 形状路径绕行总角度(凸、不自交时 |winding| ≈ 2π) */
winding?: number;
/** 绕行单调性:角的采样参数写反时会在角上“往回折”,这里会变 false */
windingOk?: boolean;
}
interface IslandShotStats {
name: string;
/** 期望的窗口逻辑尺寸(CSS 像素) */
expected: { width: number; height: number };
/** 实际截图像素尺寸(受 DPI 缩放影响,通常是逻辑尺寸的整数倍) */
width: number;
height: number;
opaque: number;
transparent: number;
uniqueColors: number;
/** 偏红像素数(紧急形态的红色描边/角标/内部光晕) */
reddish: number;
/** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */
edgeReddish: number;
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
outsideReddish?: number;
filePath: string;
/** 四角圆角几何检查:四个角的边界步进必须一致(抓“角缺一块 / 角画反”) */
corners?: { steps: number[]; expectedInset: number; spread: number; limit: number };
}
const islandShots: IslandShotStats[] = [];
/** 统计截图位图:不透明像素、颜色种类、偏红像素(Electron 位图格式为 BGRA) */
function analyzeBitmap(image: NativeImage): {
opaque: number;
transparent: number;
uniqueColors: number;
reddish: number;
edgeReddish: number;
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
outsideReddish?: number;
} {
const bitmap = image.toBitmap();
const { width, height } = image.getSize();
const total = Math.floor(bitmap.length / 4);
let opaque = 0;
let transparent = 0;
let reddish = 0;
let edgeReddish = 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;
// 最外圈 2px 是窗口留白(卡片之外),不允许出现红色外溢光晕
const x = index % width;
const y = Math.floor(index / width);
if (x < 2 || y < 2 || x >= width - 2 || y >= height - 2) edgeReddish += 1;
}
}
return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish };
}
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */
async function captureIsland(
name: string,
expected: { width: number; height: number },
): Promise<IslandShotStats | null> {
// 截图统计总是执行(像素断言不依赖环境变量);ISLAND_SHOTS_DIR 只控制是否落盘留档
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
const win = island.getWindow();
if (!win || win.isDestroyed()) return null;
// 窗口可能正处在"隐藏 → 重新显示"的过渡里(例如下课后自动弹出):
// 先等它可见,避免偶发丢掉一张留档图导致"形态尺寸递增"这类断言缺数据。
for (let attempt = 0; attempt < 15 && !win.isVisible(); attempt += 1) {
await sleep(100);
}
if (!win.isVisible()) return null;
try {
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
await win.webContents.executeJavaScript(
`document.documentElement.style.background = '#1f2733'; document.body.style.background = '#1f2733'; true`,
);
await sleep(160);
// 等岛形变收敛:像素级断言与留档都必须在"稳定帧"上做,
// 否则会拍到形变中间帧(岛比目标略大/小),"岛外留白"统计随之误报。
for (let attempt = 0; attempt < 40; attempt += 1) {
const first = await readIslandGeometryFrom(win);
await sleep(50);
const second = await readIslandGeometryFrom(win);
if (
first &&
second &&
Math.abs(first.island.width - second.island.width) < 0.6 &&
Math.abs(first.island.height - second.island.height) < 0.6
) {
break;
}
}
// 先取岛的矩形 → 连拍两帧(第一帧可能是形变中的旧帧)→ 再取一次几何。
// capturePage 返回的是"最近一次合成的帧",形变很快时会与 DOM 状态差一帧,
// 因此用前后两次几何的**并集**做"岛之外"的判定,避免把岛边缘误算成外溢。
await win.webContents.capturePage();
await sleep(60);
const image = await win.webContents.capturePage();
// 紧接截图之后读几何:让"矩形"与"画面"尽可能来自同一时刻
const geometry = await readIslandGeometryFrom(win);
const union = unionIslandRect(geometry, geometry);
await win.webContents.executeJavaScript(
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
);
const cropped = geometry ? cropToIsland(image, geometry, win) : image;
// 岛之外的窗口留白区不允许出现红色(紧急光晕不外溢)——抗锯齿边界向内缩 2px
const outsideReddish = union ? countReddishOutsideIsland(image, union, win, 3) : 0;
const filePath = dir ? path.join(dir, `${name}.png`) : '';
if (filePath) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, cropped.toPNG());
// 同时留档"未裁剪的整窗"图,便于人工核对岛外留白
if (geometry) fs.writeFileSync(path.join(dir, `${name}-window.png`), image.toPNG());
}
const size = cropped.getSize();
// 四角圆角像素检查:半径取自卡片 CSS 变量 --card-r(与 SVG path 同源)
const scaleRatio = image.getSize().width / Math.max(1, geometry?.window.width ?? image.getSize().width);
const cardRadius = await win.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return 0;
return Number.parseFloat(getComputedStyle(card).getPropertyValue('--card-r')) || 0;
})()`,
)
.catch(() => 0);
const corners = cardRadius > 0 ? analyzeCorners(cropped, cardRadius, scaleRatio) : undefined;
const stats: IslandShotStats = {
name,
expected,
width: size.width,
height: size.height,
filePath,
...analyzeBitmap(cropped),
corners,
outsideReddish,
};
islandShots.push(stats);
const layout = await win.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const dump = (selector) => {
const node = card.querySelector(selector);
if (!node) return null;
const r = node.getBoundingClientRect();
const s = getComputedStyle(node);
return {
selector,
rect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)],
display: s.display,
dir: s.flexDirection,
justify: s.justifyContent,
opacity: s.opacity,
};
};
return {
card: (() => {
const r = card.getBoundingClientRect();
return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)];
})(),
items: [
dump('.content'),
dump('.pill-layer'),
dump('.expanded-layer'),
dump('.expanded-layer .head'),
dump('.expanded-layer .title'),
dump('.expanded-layer .body'),
dump('.expanded-layer .foot'),
].filter(Boolean),
};
})()`,
)
.catch(() => null);
if (layout) {
console.log(
`[SMOKE] 布局诊断 ${name}:卡片=${layout.card.join(',')} ` +
layout.items
.map(
(item: { selector: string; rect: number[]; display: string; dir: string; opacity: string }) =>
`${item.selector}[${item.rect.join(',')} ${item.display}/${item.dir} op=${item.opacity}]`,
)
.join(' '),
);
}
const redBox = reddishBBox(image);
console.log(
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})` +
` 诊断[红像素框=${redBox.x},${redBox.y} ${redBox.width}x${redBox.height} ` +
`岛=${geometry ? `${geometry.island.x - geometry.window.x},${geometry.island.y - geometry.window.y} ${geometry.island.width}x${geometry.island.height}` : '-'} ` +
`窗口=${geometry ? `${geometry.window.width}x${geometry.window.height}` : '-'}]`,
);
return stats;
} catch (error) {
console.warn('[SMOKE] 灵动岛截图失败', error);
return null;
}
}
/** 按岛的矩形裁剪截图(含比例换算:capturePage 返回物理像素) */
function cropToIsland(
image: Electron.NativeImage,
geometry: IslandGeometry,
win: BrowserWindow,
): Electron.NativeImage {
const scale = win.webContents.getZoomFactor() || 1;
const ratio = image.getSize().width / Math.max(1, geometry.window.width);
void scale;
const x = Math.max(0, Math.round(geometry.island.x - geometry.window.x) * (ratio / 1));
const y = Math.max(0, Math.round(geometry.island.y - geometry.window.y) * ratio);
const width = Math.min(image.getSize().width - x, Math.round(geometry.island.width * ratio));
const height = Math.min(image.getSize().height - y, Math.round(geometry.island.height * ratio));
if (width <= 0 || height <= 0) return image;
return image.crop({ x, y, width, height });
}
/**
* 圆角像素级检查(专抓“圆角缺一块 / 角画反”这类几何错误)。
*
* 做法:从裁剪图的四个角沿对角线向内逐像素走,找到第一个“连续 3 像素属于岛体”的位置,
* 记为该角的边界步进 `steps`。连续圆角(superellipse n=4.2)理论边界在 0.153r 处,
* 四个角必须互相一致——这正是之前的回归点:旧实现四个角共用同一个偏移向量,
* 左上/右下被切掉一块,两两之间步进会明显不同。该指标只看角尖端 2~7px,与卡片内文字无关。
*/
function analyzeCorners(
image: NativeImage,
radius: number,
ratio: number,
): { steps: number[]; expectedInset: number; spread: number; limit: number } {
const bitmap = image.toBitmap();
const size = image.getSize();
const r = Math.max(3, radius * ratio);
// “岛体存在”判定:与截图时注入的窗口背景色 #1f2733 差异明显即算岛体(纯黑填充、红色描边、
// 白色文字都算),这样紧急态顶部的红色渐层不会把四角判定带偏。
const bg = { r: 0x1f, g: 0x27, b: 0x33 };
const isFill = (x: number, y: number): boolean => {
if (x < 0 || y < 0 || x >= size.width || y >= size.height) return false;
const index = (y * size.width + x) * 4;
const blue = bitmap[index] ?? 255;
const green = bitmap[index + 1] ?? 255;
const red = bitmap[index + 2] ?? 255;
return Math.abs(red - bg.r) > 16 || Math.abs(green - bg.g) > 16 || Math.abs(blue - bg.b) > 16;
};
const signs: [number, number][] = [
[1, 1],
[-1, 1],
[1, -1],
[-1, -1],
];
const maxSteps = Math.ceil(r) + 2;
const steps: number[] = [];
for (const [sx, sy] of signs) {
let found = maxSteps;
for (let k = 0; k <= maxSteps; k += 1) {
const x = sx > 0 ? k : size.width - 1 - k;
const y = sy > 0 ? k : size.height - 1 - k;
if (isFill(x, y) && isFill(x + sx, y + sy) && isFill(x + 2 * sx, y + 2 * sy)) {
found = k;
break;
}
}
steps.push(found);
}
const spread = Math.max(...steps) - Math.min(...steps);
return {
steps,
expectedInset: Number((0.153 * r).toFixed(2)),
spread,
limit: Math.round(0.3 * r) + 1,
};
}
/** 偏红像素的包围盒(诊断形状错位:与几何读数对比即可定位偏差来源) */
function reddishBBox(image: Electron.NativeImage): { x: number; y: number; width: number; height: number } {
const bitmap = image.toBitmap();
const size = image.getSize();
let left = size.width;
let top = size.height;
let right = -1;
let bottom = -1;
for (let y = 0; y < size.height; y += 1) {
for (let x = 0; x < size.width; x += 1) {
const index = (y * size.width + x) * 4;
const blue = bitmap[index] ?? 0;
const green = bitmap[index + 1] ?? 0;
const red = bitmap[index + 2] ?? 0;
const alpha = bitmap[index + 3] ?? 0;
if (alpha > 40 && red > green + 12 && red > blue + 12) {
if (x < left) left = x;
if (y < top) top = y;
if (x > right) right = x;
if (y > bottom) bottom = y;
}
}
}
return right < 0
? { x: 0, y: 0, width: 0, height: 0 }
: { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
}
/** 两次几何读数的并集(形变过程中宁可多算一点,也不会把岛边缘误判成"岛外") */
function unionIslandRect(a: IslandGeometry | null, b: IslandGeometry | null): IslandGeometry | null {
if (!a) return b;
if (!b) return a;
const left = Math.min(a.island.x, b.island.x);
const top = Math.min(a.island.y, b.island.y);
const right = Math.max(a.island.x + a.island.width, b.island.x + b.island.width);
const bottom = Math.max(a.island.y + a.island.height, b.island.y + b.island.height);
return {
window: b.window,
island: { x: left, y: top, width: right - left, height: bottom - top },
};
}
/**
* 统计"岛矩形之外"的偏红像素(窗口留白区)。
* 新架构下窗口是固定包围盒,岛只占其中一部分:光晕一旦外溢就会落在这些留白里。
*/
function countReddishOutsideIsland(
image: Electron.NativeImage,
geometry: IslandGeometry,
win: BrowserWindow,
margin: number,
): number {
const bitmap = image.toBitmap();
const size = image.getSize();
const ratio = size.width / Math.max(1, geometry.window.width);
void win;
// margin 是**向外**的容差:岛的描边(1px 居中)与抗锯齿边都算"岛内",
// 只有明显落在岛之外(> margin)的红色才算外溢。此前把它当"向内缩",
// 结果把岛自身的红色描边统计成了"岛外偏红"。
const left = (geometry.island.x - geometry.window.x - margin) * ratio;
const top = (geometry.island.y - geometry.window.y - margin) * ratio;
const right = (geometry.island.x - geometry.window.x + geometry.island.width + margin) * ratio;
const bottom = (geometry.island.y - geometry.window.y + geometry.island.height + margin) * ratio;
let count = 0;
for (let y = 0; y < size.height; y += 1) {
for (let x = 0; x < size.width; x += 1) {
if (x >= left && x <= right && y >= top && y <= bottom) continue;
const index = (y * size.width + x) * 4;
const blue = bitmap[index] ?? 0;
const green = bitmap[index + 1] ?? 0;
const red = bitmap[index + 2] ?? 0;
const alpha = bitmap[index + 3] ?? 0;
if (alpha > 40 && red > green + 12 && red > blue + 12) count += 1;
}
}
return count;
}
/** 读取岛的几何(截图用;与 runIslandChecks 内的同名逻辑保持一致的语义) */
async function readIslandGeometryFrom(win: BrowserWindow): Promise<IslandGeometry | null> {
const bounds = island.getWindowBounds();
const rect = await win.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const r = card.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()`,
)
.catch(() => null);
if (!rect) return null;
return {
window: bounds,
island: {
x: bounds.x + rect.left,
y: bounds.y + rect.top,
width: rect.width,
height: rect.height,
},
};
}
/** 打印灵动岛渲染进程的真实 DOM 结构(排查空白窗口) */
async function dumpIslandDom(_label: string): Promise<string> {
const win = island.getWindow();
if (!win || win.isDestroyed()) return 'no-window';
try {
return await win.webContents.executeJavaScript(
`(() => {
const app = document.querySelector('#app');
const card = document.querySelector('.island-card');
return JSON.stringify({
appChildren: app ? app.children.length : -1,
cardClass: card ? card.className : null,
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 60),
});
})()`,
);
} catch (error) {
return `dump-error: ${(error as Error).message}`;
}
}
/**
* 冒烟用:用教师账号发一条普通通知,返回通知 id(失败返回空串)。
* 用于"未读红点位置"这类需要真实未读数据的断言。
*/
async function createSmokeNotification(): Promise<string> {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
try {
const login = (await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json())) as { data?: { token?: string } };
const token = login?.data?.token;
if (!token) return '';
const classes = (await fetch(`${base}/classes`, {
headers: { authorization: `Bearer ${token}` },
}).then((response) => response.json())) as { data?: Array<{ id?: string }> };
const classId = classes?.data?.[0]?.id ?? '';
if (!classId) return '';
const created = (await fetch(`${base}/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
classId,
title: '红点位置校验',
content: '自动化验证用通知(断言未读红点位置后会删除)。',
priority: 'NORMAL',
}),
}).then((response) => response.json())) as { data?: { id?: string } };
return created?.data?.id ?? '';
} catch {
return '';
}
}
/** 冒烟用:删除上面造的通知(失败忽略) */
async function deleteSmokeNotification(id: string): Promise<void> {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
try {
const login = (await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json())) as { data?: { token?: string } };
const token = login?.data?.token;
if (!token) return;
await fetch(`${base}/notifications/${id}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${token}` },
});
} catch {
// 清理失败不影响冒烟结论
}
}
function makeNotification(
id: string,
priority: IslandNotification['priority'],
title: string,
): IslandNotification {
return {
id,
title,
content:
'明天下午的数学课改为在实验楼 B205 上课,请同学们提前 10 分钟到达,并携带上次发的练习册。如有疑问请联系课代表。',
priority,
createdAt: new Date().toISOString(),
courseName: '数学',
teacherName: '张老师',
};
}
/**
* 岛体矩形上的相对点(fx/fy ∈ [0,1])→ 屏幕坐标。
* 主进程的命中兜底轮询读的是屏幕坐标,冒烟里用注入点代替"挪动用户鼠标"。
*/
function islandCardScreenPoint(fx: number, fy: number): { x: number; y: number } {
const bounds = island.getWindowBounds();
const rect = island.getHitRect();
if (!rect) {
return {
x: bounds.x + Math.round(bounds.width / 2),
y: bounds.y + Math.round(bounds.height / 2),
};
}
return {
x: Math.round(bounds.x + rect.x + rect.width * fx),
y: Math.round(bounds.y + rect.y + rect.height * fy),
};
}
/**
* 灵动岛行为验证(对应产品需求):
* 1. 上课时间段收到普通通知 → 不显示灵动岛(窗口真正隐藏)并暂存
* 2. 下课后 → 自动在桌面中上方弹出详情
* 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击),且带展开动画
* 4. 非上课时段收到普通通知 → 直接显示"新消息"胶囊(无入场动画),点击后展开
* 5. 点击卡片空白处 / 右上角收起按钮 → 回缩为胶囊
* 6. 上课时间段内点击胶囊不会展开(上课不显示灵动岛)
*/
async function runIslandChecks(
mainWin: BrowserWindow,
record: Recorder,
results: SmokeResult[],
): Promise<void> {
record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
if (!island.isReady()) return;
const islandWindow = island.getWindow();
// 默认关掉"失焦自动收起":冒烟是自动化环境,系统/其它窗口抢焦点会随时触发 blur,
// 把展开态收回会让断言随机失败。专门验证该行为的用例(4.5)会临时打开它。
island.setBlurCollapseEnabled(false);
// 1) 上课期间:普通通知必须完全不显示(窗口隐藏)并进入队列
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
island.pushNotification(makeNotification('smoke-normal-in-class', 'NORMAL', '上课期间的普通通知'), {
inClass: true,
});
await sleep(500);
const hiddenState = island.getState();
const hiddenVisible = islandWindow?.isVisible() ?? true;
record(
'上课期间普通通知自动隐藏(窗口隐藏 + 暂存待下课弹出)',
hiddenState.mode === 'hidden' && hiddenState.queued.length === 1 && hiddenVisible === false,
`mode=${hiddenState.mode} queued=${hiddenState.queued.length} visible=${hiddenVisible}`,
);
console.log(`[SMOKE] 隐藏态 DOM:${await dumpIslandDom('hidden')}`);
// 1.1) 上课期间请求展开也不应显示(上课不显示灵动岛)
island.handleAction({ action: 'expand' });
await sleep(400);
const expandBlocked = island.getState().mode === 'hidden' && !(islandWindow?.isVisible() ?? true);
record(
'上课期间点击不会展开灵动岛(严格不显示)',
expandBlocked,
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
);
/**
* 读取"岛"的几何:窗口矩形(固定包围盒)+ 岛在屏幕上的真实矩形(渲染层里的 .island-card)。
* 新架构下**窗口恒定、岛在窗口内形变**,因此所有尺寸类断言都必须量 `island` 而不是窗口。
*/
const readIslandGeometry = async (): Promise<IslandGeometry | null> => {
const win = island.getWindowBounds();
const rect = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const r = card.getBoundingClientRect();
const raw = getComputedStyle(card).getPropertyValue('--card-r').trim();
const path = card.querySelector('.shape path');
const box = path && typeof path.getBBox === 'function' ? path.getBBox() : null;
// 路径绕行方向检查:沿路径等距采样,相邻点的方位角必须单调递增(凸、不自交)。
// 角的采样参数若写反,路径会在角上“往回折”,这里会立刻暴露。
let winding = 0;
let windingOk = true;
if (path && typeof path.getTotalLength === 'function') {
const total = path.getTotalLength();
const points = [];
for (let index = 0; index < 96; index += 1) {
const point = path.getPointAtLength((index / 96) * total);
points.push([point.x, point.y]);
}
const cx = points.reduce((sum, p) => sum + p[0], 0) / points.length;
const cy = points.reduce((sum, p) => sum + p[1], 0) / points.length;
let previousAngle = null;
for (const [x, y] of points) {
const angle = Math.atan2(y - cy, x - cx);
if (previousAngle !== null) {
let delta = angle - previousAngle;
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
if (delta < -0.05) windingOk = false;
winding += delta;
}
previousAngle = angle;
}
}
return {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
radius: parseFloat(raw) || 0,
pathWidth: box ? box.width : 0,
pathHeight: box ? box.height : 0,
winding,
windingOk,
};
})()`,
)
.catch(() => null);
if (!rect) return null;
return {
window: win,
island: {
x: win.x + rect.left,
y: win.y + rect.top,
width: rect.width,
height: rect.height,
radius: rect.radius,
pathWidth: rect.pathWidth,
pathHeight: rect.pathHeight,
winding: rect.winding,
windingOk: rect.windingOk,
},
};
};
// 2) 下课:自动弹出暂存通知的详情
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(900);
const afterClassState = island.getState();
record(
'下课后自动弹出通知详情',
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', ISLAND_SIZES.expanded);
// 3) 上课期间紧急通知:立即展开,无需点击;并采样窗口尺寸证明有"展开动画"
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(300);
const sampledWidths: number[] = [];
const sampler = setInterval(() => {
// 新架构:量"岛"在屏幕上的真实宽度(窗口是固定包围盒,形变发生在窗口内)
void readIslandGeometry()
.then((geometry) => {
if (geometry) sampledWidths.push(Math.round(geometry.island.width));
})
.catch(() => undefined);
}, 16);
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
inClass: true,
});
await sleep(900);
clearInterval(sampler);
const urgentState = island.getState();
record(
'上课期间紧急通知立即展开显示详情(无需点击)',
urgentState.mode === 'expanded' &&
urgentState.active?.id === 'smoke-urgent-in-class' &&
urgentState.reason === 'urgent',
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
);
const urgentGeometry = await readIslandGeometry();
const urgentBounds = urgentGeometry?.island ?? null;
const rampWidths = [
...new Set(sampledWidths.filter((width) => width > 0 && width < ISLAND_URGENT_SIZE.width)),
];
record(
'紧急通知带展开动画(岛从胶囊尺寸弹簧展开到紧急尺寸)',
rampWidths.length >= 3 && Math.abs((urgentBounds?.width ?? 0) - ISLAND_URGENT_SIZE.width) <= 2,
`采样=${sampledWidths.length} 中间尺寸=${rampWidths.length} 最终=${urgentBounds?.width ?? '-'}x${urgentBounds?.height ?? '-'}`,
);
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
// 3.1) 上课期间“收起”紧急通知:只回缩为胶囊且保持可见,并且还能再次展开(回归)
island.handleAction({ action: 'collapse' });
await sleep(450);
const collapsedUrgent = island.getState();
const collapsedGeometry = await readIslandGeometry();
record(
'上课期间紧急通知收起后回缩为胶囊且保持可见',
collapsedUrgent.mode === 'pill' &&
(islandWindow?.isVisible() ?? false) &&
Math.abs((collapsedGeometry?.island.width ?? 0) - ISLAND_SIZES.pill.width) <= 2,
`mode=${collapsedUrgent.mode} visible=${islandWindow?.isVisible() ?? '-'} ` +
`岛=${collapsedGeometry?.island.width?.toFixed(0) ?? '-'}x${collapsedGeometry?.island.height?.toFixed(0) ?? '-'}`,
);
island.handleAction({ action: 'expand' });
await sleep(600);
const reExpanded = island.getState();
const reExpandedGeometry = await readIslandGeometry();
record(
'上课期间紧急通知回收后可再次展开(回归)',
reExpanded.mode === 'expanded' &&
reExpanded.active?.id === 'smoke-urgent-in-class' &&
Math.abs((reExpandedGeometry?.island.width ?? 0) - ISLAND_URGENT_SIZE.width) <= 2,
`mode=${reExpanded.mode} active=${reExpanded.active?.id ?? '-'} ` +
`岛=${reExpandedGeometry?.island.width?.toFixed(0) ?? '-'}x${reExpandedGeometry?.island.height?.toFixed(0) ?? '-'}` +
`(期望 ${ISLAND_URGENT_SIZE.width})`,
);
island.pushNotification(makeNotification('smoke-normal-in-class-2', 'NORMAL', '上课中的普通通知'), {
inClass: true,
});
await sleep(350);
island.handleAction({ action: 'collapse' });
await sleep(300);
const afterNormalPush = island.getState();
record(
'上课期间普通通知只进队列(不顶掉正在展示的紧急消息)',
afterNormalPush.active?.id === 'smoke-urgent-in-class' &&
afterNormalPush.queued.some((item) => item.id === 'smoke-normal-in-class-2'),
`active=${afterNormalPush.active?.id ?? '-'} queued=[${afterNormalPush.queued.map((item) => item.id).join(',')}]`,
);
// 4) 非上课时段普通通知 → 直接显示胶囊(无入场动画);点击后展开
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(800);
island.hide();
await sleep(400);
const hiddenBeforePush = !(islandWindow?.isVisible() ?? true);
island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), {
inClass: false,
});
await sleep(60);
const immediateGeometry = await readIslandGeometry();
const initialIsland = immediateGeometry?.island ?? null;
await sleep(640);
const pillState = island.getState();
record(
'普通通知直接显示胶囊(无"上岛"入场动画)',
hiddenBeforePush &&
pillState.mode === 'pill' &&
Math.abs((initialIsland?.width ?? 0) - ISLAND_SIZES.pill.width) <= 2 &&
Math.abs((initialIsland?.height ?? 0) - ISLAND_SIZES.pill.height) <= 2,
`推送前隐藏=${hiddenBeforePush} 60ms 内岛=${initialIsland?.width?.toFixed(1) ?? '-'}x${initialIsland?.height?.toFixed(1) ?? '-'}` +
`(期望 ${ISLAND_SIZES.pill.width}x${ISLAND_SIZES.pill.height})mode=${pillState.mode}`,
);
await captureIsland('island-4-pill', ISLAND_SIZES.pill);
console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`);
const clicked = await islandWindow?.webContents.executeJavaScript(
`(() => {
const pill = document.querySelector('.island-card.pill:not(.hidden-placeholder)');
if (!pill) return false;
pill.click();
return true;
})()`,
);
await sleep(800);
const expandedState = island.getState();
record(
'普通通知先变"新消息"胶囊,点击后展开详情',
pillState.mode === 'pill' && clicked === true && expandedState.mode === 'expanded',
`pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`,
);
await captureIsland('island-5-clicked', ISLAND_SIZES.expanded);
// 4.1) 展开态的 DOM 结构自检(卡片/标题/操作按钮都要真的渲染出来)
const domInfo = await islandWindow?.webContents.executeJavaScript(
`(() => ({
hasCard: Boolean(document.querySelector('.island-card')),
title: document.querySelector('.title')?.textContent ?? '',
hasActions: Boolean(document.querySelector('.solid-btn')),
}))()`,
);
record(
'灵动岛渲染进程正常绘制(卡片/标题/操作按钮)',
Boolean(domInfo?.hasCard && domInfo?.title && domInfo?.hasActions),
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
);
// 4.2) 点击卡片空白处 → 回缩为胶囊
const blankClicked = await islandWindow?.webContents.executeJavaScript(
`(() => {
const blank = document.querySelector('.island-card.expanded .body');
if (!blank) return false;
blank.click();
return true;
})()`,
);
await sleep(600);
const afterBlank = island.getState();
record(
'点击卡片空白处回缩为胶囊',
blankClicked === true && afterBlank.mode === 'pill',
`blankClicked=${blankClicked} mode=${afterBlank.mode}`,
);
// 4.3) 点击右上角收起按钮 → 回缩为胶囊
island.handleAction({ action: 'expand' });
await sleep(600);
const chevronClicked = await islandWindow?.webContents.executeJavaScript(
`(() => {
const button = document.querySelector('.island-card.expanded .icon-btn');
if (!button) return false;
button.click();
return true;
})()`,
);
await sleep(600);
const afterChevron = island.getState();
record(
'点击右上角收起按钮回缩为胶囊',
chevronClicked === true && afterChevron.mode === 'pill',
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
);
// 4.4) 新架构(照搬 WinIsland):窗口是固定包围盒,开合过程中**窗口一帧都不动**,
// 岛在窗口内做弹簧形变。这里逐帧采样"窗口 + 岛":窗口必须完全恒定,岛形变必须连续。
island.handleAction({ action: 'expand' });
await sleep(700);
const shrinkSamples: {
window: string;
island: string;
radius: number;
pathWidth: number;
pathHeight: number;
winding?: number;
windingOk?: boolean;
}[] = [];
const shrinkSampler = setInterval(() => {
void readIslandGeometry()
.then((geometry) => {
if (!geometry) return;
shrinkSamples.push({
window: `${geometry.window.width}x${geometry.window.height}@${geometry.window.x},${geometry.window.y}`,
island: `${Math.round(geometry.island.width)}x${Math.round(geometry.island.height)}`,
radius: Number(geometry.island.radius ?? 0),
pathWidth: Number(geometry.island.pathWidth ?? 0),
pathHeight: Number(geometry.island.pathHeight ?? 0),
winding: Number(geometry.island.winding ?? 0),
windingOk: geometry.island.windingOk === true,
});
})
.catch(() => undefined);
}, 40);
island.handleAction({ action: 'collapse' });
await sleep(700);
clearInterval(shrinkSampler);
await sleep(120);
const windowSignatures = [...new Set(shrinkSamples.map((item) => item.window))];
const islandSizes = shrinkSamples.map((item) => item.island);
const islandWidths = shrinkSamples.map((item) => Number(item.island.split('x')[0]));
const islandMonotonic = islandWidths.every(
(width, index) => index === 0 || width <= (islandWidths[index - 1] ?? width) + 1,
);
// 画出来的形状必须与卡片框一致:SVG 路径 bbox ≈ 卡片宽高(±2px)。
// 这能一次性抓住“viewBox/preserveAspectRatio 错位导致形状被缩放或残留角”这类问题。
const pathFits = shrinkSamples.every(
(item) =>
Math.abs(item.pathWidth - Number(item.island.split('x')[0])) <= 2 &&
Math.abs(item.pathHeight - Number(item.island.split('x')[1])) <= 2,
);
// 圆角绕行方向:连续圆角路径必须凸且不自交(|绕行| ≈ 2π);角的采样写反会在形变中被抓到
const windingSamples = shrinkSamples.filter((item) => item.windingOk !== undefined);
const windingOk =
windingSamples.length > 0 &&
windingSamples.every((item) => item.windingOk === true) &&
windingSamples.every((item) => Math.abs(Math.abs(item.winding ?? 0) - Math.PI * 2) < 0.6);
record(
'开合过程中窗口全程恒定、岛在窗口内连续形变(照搬 WinIsland 架构)',
shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits && windingOk,
`采样=${shrinkSamples.length} 窗口签名=[${windowSignatures.join(' ')}](要求只有 1 种) ` +
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} ` +
`形状与卡片框一致=${pathFits} 圆角绕行正确=${windingOk}(|绕行|=${(windingSamples[0]?.winding ?? 0).toFixed(2)})`,
);
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
island.handleAction({ action: 'expand' });
await sleep(700);
const focusable = islandWindow?.isFocusable() ?? false;
// "点到别处"的另一半含义是**指针不在岛上**:这里把注入光标放到屏幕左上角(远离岛体),
// 否则会被下面 4.5.1 的"指针还在岛上 = 假失焦"保护逻辑判为无效失焦。
island.setHitTestCursor({ x: 1, y: 1 });
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
island.setBlurCollapseEnabled(true); // 这一条专门验证"失焦收起",先打开该行为
islandWindow?.emit('blur');
await sleep(700);
const afterBlur = island.getState();
record(
'点击屏幕任意位置回缩为胶囊(失焦收起;窗口始终可激活)',
focusable && islandWindow?.isFocusable() === true && afterBlur.mode === 'pill',
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
);
// 4.5.2) 胶囊态窗口**必须始终可激活**:Windows 下 WS_EX_NOACTIVATE 且非活动窗口会丢掉
// 鼠标**按下**事件(实测只到 mouseup、没有 mousedown/click)→ 胶囊"点不动"。
// 这是用户反馈的根因,用"胶囊态 isFocusable() 必须为 true"固化下来。
for (let index = 0; index < 10; index += 1) {
const current = island.getState();
if (!current.active && current.queued.length === 0) break;
island.handleAction({ action: 'dismiss' });
await sleep(120);
}
island.pushNotification(makeNotification('smoke-pill-focusable', 'NORMAL', '胶囊可激活校验'), {
inClass: false,
});
await sleep(500);
const pillFocusState = island.getState();
record(
'胶囊态窗口仍可激活(否则 Windows 丢掉 mousedown,胶囊"点不动")',
pillFocusState.mode === 'pill' && (islandWindow?.isFocusable() ?? false) === true,
`mode=${pillFocusState.mode} focusable=${islandWindow?.isFocusable() ?? '-'} 当前是否聚焦=${islandWindow?.isFocused() ?? '-'}`,
);
island.handleAction({ action: 'dismiss' });
await sleep(300);
// 4.5.1) 展开动作自身引发的**假失焦**不能把刚展开的岛缩回胶囊。
// 用户复现:"收起后再次点击灵动岛没反应" —— 点开胶囊后约 0.5s 被系统收回焦点,
// 岛立刻缩回胶囊,看起来就像"点了没反应"。此时指针必然还在岛上,必须忽略该失焦。
island.pushNotification(makeNotification('smoke-phantom-blur', 'NORMAL', '假失焦回归'), {
inClass: false,
});
await sleep(400);
island.handleAction({ action: 'expand' });
await sleep(300);
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5)); // 指针停在岛上(用户刚点的位置)
await sleep(160);
islandWindow?.emit('blur'); // 模拟展开后到达的那次假失焦
await sleep(500);
const afterPhantomBlur = island.getState();
const phantomStillInteractive = island.getInteractive();
record(
'假失焦(指针仍在岛上)不回缩:展开后必须保持展开',
afterPhantomBlur.mode === 'expanded' &&
afterPhantomBlur.active?.id === 'smoke-phantom-blur' &&
phantomStillInteractive === true,
`mode=${afterPhantomBlur.mode} active=${afterPhantomBlur.active?.id ?? '-'} 命中=${phantomStillInteractive}`,
);
island.setHitTestCursor(null);
// 清掉这一条通知(队列为空 → 岛自行隐藏),避免影响后续用例的"空闲态"断言
island.handleAction({ action: 'dismiss' });
await sleep(400);
// 其余用例关掉"失焦收起":避免无关的焦点变化(系统抢焦点等)把展开态收回,干扰断言
island.setBlurCollapseEnabled(false);
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
// 先排空队列:单条消息时胶囊标题应是类型名("新作业"),多条时才汇总成
// 「新消息:叫人/作业/通知(共 N 条)」——两种情况都要确定性覆盖。
for (let index = 0; index < 20; index += 1) {
const current = island.getState();
if (!current.active && current.queued.length === 0) break;
island.handleAction({ action: 'dismiss' });
await sleep(120);
}
await sleep(200);
island.pushNotification(
{
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
kind: 'homework',
teacherName: '张老师',
},
{ inClass: false },
);
await sleep(700);
const homeworkState = island.getState();
const homeworkDom = await islandWindow?.webContents.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card.pill.homework');
return { cls: card?.className ?? '', title: card?.querySelector('.pill-title')?.textContent?.trim() ?? '' };
})()`,
);
record(
'新作业发布也上岛(胶囊带"新作业"样式)',
homeworkState.mode === 'pill' &&
homeworkState.active?.kind === 'homework' &&
(homeworkDom?.title ?? '').includes('新作业'),
`mode=${homeworkState.mode} kind=${homeworkState.active?.kind} 胶囊标题="${homeworkDom?.title ?? '-'}" 类名=${homeworkDom?.cls ?? '-'}`,
);
island.handleAction({ action: 'expand' });
await sleep(700);
const homeworkCardText = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.expanded')?.innerText?.replace(/\\s+/g, ' ').trim() ?? ''`,
);
record(
'作业详情只显示作业要求(截止时间已下线)',
(homeworkCardText ?? '').includes('第 3 章课后练习') && !(homeworkCardText ?? '').includes('截止时间'),
`作业卡文案="${homeworkCardText ?? '-'}"`,
);
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
// 4.6b) 收起后的胶囊要说明"这批消息里都有什么":
// 单条 → 类型名(上面的"新作业");多条 → 「新消息:叫人/作业/通知(共 3 条)」+「点击查看」
island.pushNotification(
{
...makeNotification('smoke-summary-call', 'NORMAL', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
},
{ inClass: false },
);
await sleep(400);
island.pushNotification(makeNotification('smoke-summary-notice', 'NORMAL', '类型汇总校验'), {
inClass: false,
});
await sleep(600);
const summaryDom = await islandWindow?.webContents.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card.pill');
return {
title: card?.querySelector('.pill-title')?.textContent?.trim() ?? '',
sub: card?.querySelector('.pill-sub')?.textContent?.trim() ?? '',
types: Array.from(card?.querySelectorAll('.pill-type') ?? []).map((node) => node.textContent.trim()),
};
})()`,
);
const summaryState = island.getState();
record(
'收起胶囊显示通知类型汇总(新消息:叫人/作业/通知(共 N 条))',
(summaryDom?.title ?? '').includes('新消息:') &&
(summaryDom?.title ?? '').includes('叫人') &&
(summaryDom?.title ?? '').includes('作业') &&
(summaryDom?.title ?? '').includes('通知') &&
(summaryDom?.title ?? '').includes('共 3 条') &&
(summaryDom?.sub ?? '').includes('点击查看') &&
(summaryDom?.types ?? []).length === 3 &&
summaryState.mode === 'pill',
`标题="${summaryDom?.title ?? '-'}" 副标题="${summaryDom?.sub ?? '-'}" ` +
`类型标签=[${(summaryDom?.types ?? []).join(',')}] mode=${summaryState.mode}`,
);
island.handleAction({ action: 'dismiss' });
await sleep(300);
// 4.7) "紧急叫人":URGENT + kind=call → 上课时段也立即展开
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-call', 'URGENT', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
teacherName: '张老师',
},
{ inClass: true },
);
// 采样状态轨迹,便于定位"出现了又被隐藏"这类时序问题
const callTrace: string[] = [];
for (let index = 0; index < 9; index += 1) {
await sleep(100);
const snapshot = island.getState();
callTrace.push(`${snapshot.mode}/${snapshot.reason ?? '-'}`);
}
const callState = island.getState();
const callDom = await islandWindow?.webContents.executeJavaScript(
`(() => ({
badge: document.querySelector('.island-card.expanded .badge')?.textContent?.trim() ?? '',
title: document.querySelector('.island-card.expanded .title')?.textContent?.trim() ?? '',
solid: document.querySelector('.island-card.expanded .solid-btn')?.textContent?.trim() ?? '',
hint: document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? '',
cls: document.querySelector('.island-card.expanded')?.className ?? '',
}))()`,
);
record(
'紧急叫人消息上岛(上课时段也立即展开,无需点击)',
callState.mode === 'expanded' &&
callState.active?.kind === 'call' &&
callState.reason === 'call' &&
callState.inClass === true &&
(callDom?.title ?? '').includes('请') &&
(callDom?.title ?? '').includes('找'),
`mode=${callState.mode} kind=${callState.active?.kind} reason=${callState.reason} 上课中=${callState.inClass} ` +
`徽标=${callDom?.badge} 标题="${callDom?.title}" 按钮=${callDom?.solid}`,
);
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
// 4.7b) 紧急叫人"收起 → 再次点开"(用户复现路径:点击缩回后也无法点开)
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(600);
const urgentCallCollapsed = island.getState();
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(700);
const urgentCallReopened = island.getState();
record(
'紧急叫人收起后可再次点开(用户复现路径)',
urgentCallCollapsed.mode === 'pill' &&
urgentCallReopened.mode === 'expanded' &&
urgentCallReopened.active?.id === 'smoke-call',
`收起后=${urgentCallCollapsed.mode} 再次点开=${urgentCallReopened.mode} ` +
`active=${urgentCallReopened.active?.id ?? '-'}`,
);
// 4.8) "普通叫人":kind=call 但 priority=HIGH(服务端 urgent 缺省)→ **不自动展开**(不打断课堂),
// 但上课时段仍以胶囊保持可见,并且必须能点开(用户反馈:"非紧急叫人无法点开灵动岛")。
island.handleAction({ action: 'dismiss' });
await sleep(300);
island.pushNotification(
{
...makeNotification('smoke-call-normal', 'HIGH', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
},
{ inClass: true },
);
await sleep(500);
const normalCallState = island.getState();
const normalCallVisible = islandWindow?.isVisible() ?? false;
record(
'普通叫人上课时段只显示胶囊(不自动展开、不打断课堂)',
normalCallState.mode === 'pill' &&
normalCallState.active?.id === 'smoke-call-normal' &&
normalCallState.reason === 'call' &&
normalCallVisible,
`mode=${normalCallState.mode} reason=${normalCallState.reason} 可见=${normalCallVisible} ` +
`active=${normalCallState.active?.id ?? '-'}`,
);
// 上课时段点开普通叫人:必须能展开(这正是用户复现的那条路径)
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(700);
const normalCallOpened = island.getState();
record(
'普通叫人上课时段可以点开(用户复现路径)',
normalCallOpened.mode === 'expanded' && normalCallOpened.active?.id === 'smoke-call-normal',
`点击后 mode=${normalCallOpened.mode} active=${normalCallOpened.active?.id ?? '-'}`,
);
// 再收起 → 仍是胶囊(保持可见、可再次点开),下课后自动展开
await island.handleAction({ action: 'collapse' });
await sleep(500);
const normalCallCollapsed = island.getState();
record(
'普通叫人收起后仍保留胶囊(可再次点开)',
normalCallCollapsed.mode === 'pill' && normalCallCollapsed.active?.id === 'smoke-call-normal',
`mode=${normalCallCollapsed.mode} active=${normalCallCollapsed.active?.id ?? '-'}`,
);
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(600);
const afterClassCall = island.getState();
record(
'下课后普通叫人自动弹出详情(叫人文案不丢)',
afterClassCall.mode === 'expanded' && afterClassCall.active?.id === 'smoke-call-normal',
`mode=${afterClassCall.mode} active=${afterClassCall.active?.id ?? '-'} kind=${afterClassCall.active?.kind}`,
);
island.handleAction({ action: 'dismiss' });
await sleep(400);
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(400);
// 4.9) 收起态回归(用户反馈"收起后部分情况无法再次打开"):
// a) 胶囊不再因超时消失(有未处理通知时常驻);
// b) 收起后胶囊仍然可命中(窗口保持可交互)→ 点击可再次展开;
// c) 空闲细缝态同样是"可见即可点"(细缝 6x22 也能命中)。
// 注:此处不使用后面的 drainIsland(它在本函数更靠下的位置定义),就地排空队列。
const drainForCollapseCheck = async (): Promise<void> => {
for (let index = 0; index < 20; index += 1) {
const current = island.getState();
if (!current.active && current.queued.length === 0) return;
island.handleAction({ action: 'dismiss' });
await sleep(120);
}
};
/** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */
const hoverCard = async (): Promise<boolean> =>
Boolean(
await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return false;
const r = card.getBoundingClientRect();
window.dispatchEvent(
new MouseEvent('mousemove', {
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
bubbles: true,
}),
);
return true;
})()`,
)
.catch(() => false),
);
await drainForCollapseCheck();
await sleep(300);
island.pushNotification(makeNotification('smoke-collapse-reopen', 'NORMAL', '收起态回归校验'), {
inClass: false,
});
await sleep(500);
const collapseBefore = island.getState();
await hoverCard();
await sleep(200);
await sleep(ISLAND_TIMEOUTS.pill + 700);
const lingered = island.getState();
const lingeredVisible = islandWindow?.isVisible() ?? false;
// 断言"没有消失"即可:真人可能在这 15 秒里点了岛(那就从胶囊变成展开态),
// 但只要还看得见、还是同一条通知,就说明"常驻"成立。
record(
'收起后胶囊常驻(超时不再消失,随时可再次点开)',
collapseBefore.mode === 'pill' &&
lingered.mode !== 'hidden' &&
lingeredVisible &&
lingered.active?.id === 'smoke-collapse-reopen',
`收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible} ` +
`active=${lingered.active?.id ?? '-'}`,
);
// 先确保处于胶囊态(真人在等待期间可能点开过),再验证"命中恢复 + 点击展开"
island.handleAction({ action: 'collapse' });
await sleep(500);
// 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 ——
// 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。
// 先把光标注入到屏幕左上角(等价于指针离开岛体),否则轮询会立刻把命中又打开。
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
island.setInteractive(false);
await sleep(60);
const forcedThrough = island.getInteractive();
const pillPoint = islandCardScreenPoint(0.5, 0.5);
island.setHitTestCursor(pillPoint);
await sleep(260);
const interactiveWhilePill = island.getInteractive();
// 点击前记录渲染进程当前形态:若渲染进程停在 expanded(状态推送滞后),
// 合成点击会被解释成"点空白处 → 收起",表现为"点了没反应",这里留证据便于定位。
const classBeforeClick = await islandWindow?.webContents
.executeJavaScript(`document.querySelector('.island-card')?.className ?? '-'`)
.catch(() => '-');
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
// 轮询等待主进程状态落到 expanded(不同环境 IPC/动画耗时不同,固定 sleep 会误判)
let reopened = island.getState();
for (let attempt = 0; attempt < 12 && reopened.mode !== 'expanded'; attempt += 1) {
await sleep(120);
reopened = island.getState();
}
const classAfterClick = await islandWindow?.webContents
.executeJavaScript(`document.querySelector('.island-card')?.className ?? '-'`)
.catch(() => '-');
record(
'收起态点击可再次展开(胶囊命中恢复 + 点击后展开)',
forcedThrough === false &&
interactiveWhilePill === true &&
reopened.mode === 'expanded' &&
reopened.active?.id === 'smoke-collapse-reopen',
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} ` +
`点击前渲染类名=${classBeforeClick} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'} 点击后渲染类名=${classAfterClick}`,
);
const sliverForInteractive = getSliverSize();
island.setAppearance({ ...island.getAppearance(), idleSliver: true });
await sleep(200);
await drainForCollapseCheck();
await sleep(500);
const sliverState = island.getState();
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(260);
const sliverInteractive = island.getInteractive();
const sliverVisible = islandWindow?.isVisible() ?? false;
// 光标移开细缝(屏幕左上角)→ 命中必须撤销,避免细缝窗口吞掉桌面点击
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
const sliverInteractiveOutside = island.getInteractive();
record(
'空闲细缝态命中跟随光标(细缝上可点、移开即穿透)',
sliverState.mode === 'hidden' &&
sliverVisible &&
sliverInteractive === true &&
sliverInteractiveOutside === false,
`mode=${sliverState.mode} 可见=${sliverVisible} 光标在细缝上=${sliverInteractive} ` +
`光标移开=${sliverInteractiveOutside} 细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`,
);
island.setHitTestCursor(null);
island.setAppearance({ ...island.getAppearance(), idleSliver: false });
await drainForCollapseCheck();
await sleep(300);
// 4.10) 用户复现路径:"点开灵动岛再收起就无法再次打开了"
// 之前是一条"展开 → 收起 → 再也点不开"的死路(窗口停在穿透状态)。
// 这里用真实 DOM 点击走完 展开 → 收起 → 再次展开,并断言每个形态都有命中框上报。
const clickIsland = async (selector = '.island-card'): Promise<boolean> =>
Boolean(
await islandWindow?.webContents
.executeJavaScript(
`(() => {
const node = document.querySelector(${JSON.stringify(selector)});
if (!node) return false;
node.click();
return true;
})()`,
)
.catch(() => false),
);
const rectText = (rect: { width: number; height: number } | null): string =>
rect ? `${Math.round(rect.width)}x${Math.round(rect.height)}` : '无';
await drainForCollapseCheck();
await sleep(300);
island.pushNotification(makeNotification('smoke-reopen-roundtrip', 'NORMAL', '展开收起再展开'), {
inClass: false,
});
await sleep(500);
const clickedFirst = await clickIsland();
await sleep(700);
const afterFirstOpen = island.getState();
const hitRectExpanded = island.getHitRect();
const clickedCollapse = await clickIsland('.island-card.expanded .body');
await sleep(700);
const afterCollapse = island.getState();
const hitRectPill = island.getHitRect();
const clickedSecond = await clickIsland();
await sleep(750);
const afterSecondOpen = island.getState();
record(
'展开 → 收起 → 再次展开(用户复现路径)',
clickedFirst &&
clickedCollapse &&
clickedSecond &&
afterFirstOpen.mode === 'expanded' &&
afterCollapse.mode === 'pill' &&
afterSecondOpen.mode === 'expanded' &&
afterSecondOpen.active?.id === 'smoke-reopen-roundtrip' &&
hitRectExpanded !== null &&
hitRectPill !== null,
`${afterFirstOpen.mode}(命中框 ${rectText(hitRectExpanded)}) → ${afterCollapse.mode}` +
`(命中框 ${rectText(hitRectPill)}) → ${afterSecondOpen.mode}` +
` active=${afterSecondOpen.active?.id ?? '-'}`,
);
// 主进程的命中兜底轮询:岛体矩形必须随形态更新(指针交给系统,这里只断言"依据"是对的)
await island.handleAction({ action: 'collapse' });
await sleep(500);
const hitRectCollapsed = island.getHitRect();
record(
'命中兜底:岛体矩形随形态更新(供主进程按光标校正)',
hitRectCollapsed !== null &&
Math.abs(hitRectCollapsed.width - ISLAND_SIZES.pill.width) <= 2 &&
hitRectExpanded !== null &&
hitRectExpanded.width > hitRectCollapsed.width,
`展开 ${rectText(hitRectExpanded)} → 收起 ${rectText(hitRectCollapsed)}(期望 ${ISLAND_SIZES.pill.width})`,
);
// 彻底隐藏(无细缝)时必须撤销命中框,避免"看不见的窗口吞桌面点击"
await drainForCollapseCheck();
await sleep(600);
const hitRectHidden = island.getHitRect();
record(
'彻底隐藏时撤销命中框(不吞桌面点击)',
island.getState().mode === 'hidden' && hitRectHidden === null,
`mode=${island.getState().mode} 命中框=${rectText(hitRectHidden)}`,
);
// 4.11) 设置页"预览效果"(用户反馈:预览无法正常展开示例岛)
// 预览必须直接展开、失焦不收起(用户调滑块时主窗口一直是焦点),并在到点后自行消失。
await drainForCollapseCheck();
await sleep(300);
island.pushNotification(
{
...makeNotification('appearance-test-smoke', 'NORMAL', '灵动岛外观预览'),
content: '拖动滑块即可实时预览。',
teacherName: '本地预览',
},
{ inClass: false, preview: true },
);
await sleep(700);
const previewState = island.getState();
const previewVisible = islandWindow?.isVisible() ?? false;
const previewGeometry = await readIslandGeometry();
// 失焦(等价于用户回到设置页拖滑块)不应把示例岛收起来
islandWindow?.emit('blur');
await sleep(700);
const previewAfterBlur = island.getState();
record(
'设置页预览:示例岛直接展开且失焦不收起',
previewState.mode === 'expanded' &&
previewState.active?.id === 'appearance-test-smoke' &&
previewVisible &&
(previewGeometry?.island.width ?? 0) > ISLAND_SIZES.pill.width &&
previewAfterBlur.mode === 'expanded',
`预览态=${previewState.mode} 可见=${previewVisible} 岛宽=${previewGeometry?.island.width?.toFixed(0) ?? '-'}` +
` 失焦后=${previewAfterBlur.mode}`,
);
// 预览通知不该被"常驻胶囊"逻辑留下:点"知道了"后必须彻底消失
island.handleAction({ action: 'dismiss' });
await sleep(600);
const previewDismissed = island.getState();
record(
'设置页预览:关掉后不残留(不会变成常驻胶囊)',
previewDismissed.mode === 'hidden' && previewDismissed.active === null,
`mode=${previewDismissed.mode} active=${previewDismissed.active?.id ?? '-'}`,
);
// 4.12) 用户复现路径:紧急通知 + 新作业(联合通知)时,收起后点击必须能打开**新作业**
// 之前 active 仍是紧急那条,点击只会反复打开紧急,表现为"点不开新作业"。
await drainForCollapseCheck();
await sleep(300);
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(300);
island.pushNotification(makeNotification('smoke-mixed-urgent', 'URGENT', '上课期间的紧急通知'), {
inClass: true,
});
await sleep(700);
const mixedUrgent = island.getState();
// 收起(学生点空白处)
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(600);
const mixedCollapsed = island.getState();
// 又来了新作业(上课时段,属普通通知 → 进队列,但胶囊已把这个类型汇总出来了)
island.pushNotification(
{
...makeNotification('smoke-mixed-homework', 'NORMAL', '今天的新作业'),
kind: 'homework',
},
{ inClass: true },
);
await sleep(600);
const mixedPillDom = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.pill .pill-title')?.textContent?.trim() ?? ''`,
);
// 点击胶囊:必须打开**新作业**这条
await islandWindow?.webContents
.executeJavaScript(
`(() => {
document.querySelector('.island-card')?.click();
return true;
})()`,
)
.catch(() => undefined);
await sleep(750);
const mixedOpened = island.getState();
record(
'紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)',
mixedUrgent.mode === 'expanded' &&
mixedCollapsed.mode === 'pill' &&
(mixedPillDom ?? '').includes('作业') &&
mixedOpened.mode === 'expanded' &&
mixedOpened.active?.id === 'smoke-mixed-homework',
`紧急态=${mixedUrgent.mode} 收起=${mixedCollapsed.mode} 胶囊标题="${mixedPillDom ?? '-'}" ` +
`点击后=${mixedOpened.mode} active=${mixedOpened.active?.id ?? '-'}`,
);
island.handleAction({ action: 'dismiss' });
await sleep(300);
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(300);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(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} 岛外偏红=${shot.outsideReddish ?? 0}`,
};
});
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 outsideReddish = urgentShot?.outsideReddish ?? 0;
const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
// "形态尺寸递增":胶囊 < 某个展开形态 < 紧急形态。
// 展开形态优先取 after-class 留档,缺图时退回其它展开态(避免一张可选留档缺失就误判)。
const expandedShot = [
shotDetails.find((shot) => shot.name.includes('after-class')),
shotDetails.find((shot) => shot.name.includes('clicked')),
shotDetails.find((shot) => shot.name.includes('homework')),
].find(Boolean);
const sizesOrdered = Boolean(
pillShot &&
expandedShot &&
urgentShot &&
pillShot.height < expandedShot.height &&
expandedShot.height < 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}`,
);
// 7) 光晕只在岛内部:固定的窗口包围盒里,岛之外的留白区不允许出现红色外溢像素
record(
'紧急光晕不外溢(岛之外的窗口留白区无红色像素)',
urgentShot !== undefined && outsideReddish === 0 && urgentReddish > 200,
`紧急态岛内偏红=${urgentReddish} 岛外留白偏红=${outsideReddish}(要求 0)`,
);
// 7b) 圆角几何:从四角沿对角线向内找边界,四个角的步进必须一致(旧实现四角共用同一个
// 偏移向量,左上/右下被切掉一块,两两步进差异明显)。只看角尖端 2~7px,与文字无关。
const cornerShots = shotDetails.filter((shot) => shot.corners);
const badCorners = cornerShots.filter((shot) => {
const corner = shot.corners;
if (!corner) return false;
return corner.spread > 2 || corner.steps.some((step) => step < 1 || step > corner.limit);
});
const cornerReport = cornerShots
.map((shot) => {
const corner = shot.corners;
if (!corner) return shot.name;
const flag = corner.spread > 2 ? ' ←离散超限' : '';
return `${shot.name} 步进=[${corner.steps.join(',')}] 理论=${corner.expectedInset} 离散=${corner.spread}${flag}`;
})
.join(' | ');
record(
'圆角四角一致(无“缺一块 / 角画反”:四角边界步进一致且落在连续圆角理论值内)',
cornerShots.length >= 4 && badCorners.length === 0,
cornerReport,
);
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
const appearanceBefore = island.getAppearance();
// 8.1) 设置页真实链路(渲染进程 → IPC → 主进程):用户反馈"设置不生效",
// 因此必须走设置页用的 `window.desktop.islandSetAppearance`,而不是直接调主进程 API。
const rendererApplied = await mainWin?.webContents
.executeJavaScript(
`(() => {
if (!window.desktop?.islandSetAppearance) return false;
window.desktop.islandSetAppearance({
height: 62,
width: 320,
radius: 28,
fontSize: 16,
opacity: 0.9,
accent: '#ff7043',
});
return true;
})()`,
)
.catch(() => false);
await sleep(450);
const rendererAppearance = island.getAppearance();
const rendererCssVars = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const style = getComputedStyle(document.documentElement);
return {
h: style.getPropertyValue('--island-h').trim(),
w: style.getPropertyValue('--island-w').trim(),
font: style.getPropertyValue('--island-font').trim(),
accent: style.getPropertyValue('--island-accent').trim(),
};
})()`,
)
.catch(() => null);
record(
'设置页链路:渲染进程 islandSetAppearance 实时生效(尺寸/字号/主题色)',
rendererApplied === true &&
rendererAppearance.height === 62 &&
rendererAppearance.width === 320 &&
rendererAppearance.fontSize === 16 &&
rendererAppearance.accent.toLowerCase() === '#ff7043' &&
rendererCssVars?.h === '62px' &&
rendererCssVars?.w === '320px' &&
rendererCssVars?.font === '16px' &&
rendererCssVars?.accent.toLowerCase() === '#ff7043',
`渲染进程调用=${rendererApplied} 主进程外观=${rendererAppearance.width}x${rendererAppearance.height} ` +
`字号=${rendererAppearance.fontSize} 主题色=${rendererAppearance.accent} ` +
`CSS 变量 h=${rendererCssVars?.h} w=${rendererCssVars?.w} font=${rendererCssVars?.font} accent=${rendererCssVars?.accent}`,
);
// 岛**可见**时改尺寸也要立刻生效(用户在设置页调滑块时岛正显示着)
island.pushNotification(makeNotification('smoke-appearance-live', 'NORMAL', '实时预览校验'), {
inClass: false,
});
await sleep(500);
await mainWin?.webContents
.executeJavaScript(
`(() => {
window.desktop?.islandSetAppearance({ height: 66, width: 336, fontSize: 17 });
return true;
})()`,
)
.catch(() => undefined);
await sleep(450);
const liveBounds = (await readIslandGeometry())?.island ?? null;
record(
'设置页链路:岛显示中改尺寸也立刻生效(实时预览)',
liveBounds?.height === 66 && Math.abs((liveBounds?.width ?? 0) - 336) <= 1,
`岛=${liveBounds?.width?.toFixed(0) ?? '-'}x${liveBounds?.height?.toFixed(0) ?? '-'}(期望 336x66)`,
);
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
island.handleAction({ action: 'dismiss' });
await sleep(300);
island.pushNotification(makeNotification('smoke-appearance', 'NORMAL', '外观设置校验'), {
inClass: false,
});
await sleep(600);
const pillBounds = (await readIslandGeometry())?.island ?? null;
const cssVars = await islandWindow?.webContents.executeJavaScript(
`(() => {
const style = getComputedStyle(document.documentElement);
const card = document.querySelector('.island-card');
return {
h: style.getPropertyValue('--island-h').trim(),
w: style.getPropertyValue('--island-w').trim(),
radius: style.getPropertyValue('--island-radius').trim(),
font: style.getPropertyValue('--island-font').trim(),
accent: style.getPropertyValue('--island-accent').trim(),
cardHeight: card ? Math.round(card.getBoundingClientRect().height) : 0,
};
})()`,
);
record(
'个性化外观生效(高度/宽度/圆角/字号 → 窗口与卡片同步)',
pillBounds?.width === 300 &&
pillBounds?.height === 56 &&
cssVars?.h === '56px' &&
cssVars?.w === '300px' &&
cssVars?.radius === '26px' &&
cssVars?.font === '14px',
`窗口=${pillBounds?.width ?? '-'}x${pillBounds?.height ?? '-'} CSS变量 h=${cssVars?.h} w=${cssVars?.w} ` +
`圆角=${cssVars?.radius} 字号=${cssVars?.font} 卡片高=${cssVars?.cardHeight}`,
);
// 展开后内容不溢出(高度调大也不能出现内容超出卡片)
island.handleAction({ action: 'expand' });
await sleep(700);
const overflowCheck = await islandWindow?.webContents.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card.expanded');
if (!card) return null;
return {
overflow: card.scrollHeight - card.clientHeight,
width: Math.round(card.getBoundingClientRect().width),
height: Math.round(card.getBoundingClientRect().height),
};
})()`,
);
record(
'调整尺寸后内容不溢出',
Boolean(overflowCheck) && overflowCheck.overflow <= 1,
`卡片=${overflowCheck?.width ?? '-'}x${overflowCheck?.height ?? '-'} 溢出=${overflowCheck?.overflow ?? '-'}px`,
);
// 9) 抖动修复(需求 4):只量"收回"过程
// - 窗口宽度必须单调收缩(由主进程 rAF 循环驱动,不回跳)
// - 卡片布局尺寸用 offsetWidth/offsetTop(不含 CSS transform),只允许在"详情/胶囊"两种固定值间切换
island.setAppearance(appearanceBefore);
await sleep(300);
// 前面几节的收尾可能已经清空队列:这里保证有"正在展示的通知",否则 expand 无可展开,
// 采样只会拿到隐藏态的空数据(这条断言就失去意义)。
if (!island.getState().active) {
island.pushNotification(makeNotification('smoke-jitter', 'NORMAL', '开合抖动校验'), {
inClass: false,
});
await sleep(500);
}
island.handleAction({ action: 'expand' });
await sleep(700);
const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = [];
const jitterSampler = setInterval(() => {
void readIslandGeometry()
.then((geometry) => {
if (!geometry) return;
jitterSamples.push({
win: geometry.window.width,
cardWidth: Math.round(geometry.island.width),
cardTop: Math.round(geometry.island.y),
});
})
.catch(() => undefined);
}, 30);
island.handleAction({ action: 'collapse' });
await sleep(800);
clearInterval(jitterSampler);
const winSequence = jitterSamples.map((item) => item.win);
const windowConstant = new Set(winSequence).size <= 1;
const islandSequence = jitterSamples.map((item) => item.cardWidth);
const monotonicShrink = islandSequence.every(
(width, index) => index === 0 || width <= (islandSequence[index - 1] ?? width) + 1,
);
const cardTops = jitterSamples.map((item) => item.cardTop);
const topSpread = cardTops.length > 0 ? Math.max(...cardTops) - Math.min(...cardTops) : 99;
record(
'收回过程不抖动(窗口恒定 + 岛宽度单调收缩 + 岛顶部不动)',
jitterSamples.length >= 4 && windowConstant && monotonicShrink && topSpread <= 1,
`采样=${jitterSamples.length} 窗口宽度集合=[${[...new Set(winSequence)].join(',')}](要求 1 个) ` +
`岛宽度序列=[${islandSequence.join(',')}] 单调收缩=${monotonicShrink} 岛上边缘波动=${topSpread}px`,
);
// 10) 连续快速开合:最终状态与尺寸必须稳定
for (let index = 0; index < 5; index += 1) {
island.handleAction({ action: 'expand' });
await sleep(45);
island.handleAction({ action: 'collapse' });
await sleep(45);
}
await sleep(600);
const rapidState = island.getState();
const rapidBounds = (await readIslandGeometry())?.island ?? null;
const currentAppearance = island.getAppearance();
record(
'连续快速展开/收起后状态与尺寸稳定',
rapidState.mode === 'pill' &&
Math.abs((rapidBounds?.width ?? 0) - currentAppearance.width) <= 2 &&
Math.abs((rapidBounds?.height ?? 0) - currentAppearance.height) <= 2,
`mode=${rapidState.mode} 岛=${rapidBounds?.width?.toFixed(1) ?? '-'}x${rapidBounds?.height?.toFixed(1) ?? '-'}` +
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
);
// 10.5) 「开合不震动」实测:把窗口矩形与卡片的**真实屏幕矩形**逐帧采样,
// 断言 ①卡片的水平中心与上边缘绝对不动(锚点稳定,不左右/上下跳动)
// ②卡片与窗口的尺寸单调变化且**从不过冲**(不再有"弹一下"的回弹)
const motionSamples: {
winW: number;
winH: number;
cardCx: number;
cardTop: number;
cardW: number;
cardH: number;
}[] = [];
const sampleCardRect = async (): Promise<void> => {
const bounds = islandWindow?.getBounds();
const rect = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const r = card.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()`,
)
.catch(() => null);
if (!bounds || !rect) return;
motionSamples.push({
winW: bounds.width,
winH: bounds.height,
cardCx: bounds.x + rect.left + rect.width / 2,
cardTop: bounds.y + rect.top,
cardW: rect.width,
cardH: rect.height,
});
};
const runMotionProbe = async (action: 'expand' | 'collapse'): Promise<void> => {
motionSamples.length = 0;
island.handleAction({ action });
const deadline = Date.now() + 900;
while (Date.now() < deadline) {
await sampleCardRect();
await sleep(12);
}
};
island.handleAction({ action: 'dismiss' });
await sleep(250);
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
await sleep(350);
const expandedCard = { width: ISLAND_SIZES.expanded.width, height: ISLAND_SIZES.expanded.height };
const pillCard = { width: ISLAND_SIZES.pill.width, height: ISLAND_SIZES.pill.height };
await runMotionProbe('expand');
const expandSamples = [...motionSamples];
const cxSpread = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardCx)) -
Math.min(...expandSamples.map((item) => item.cardCx))
: 99;
const topSpreadExpand = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardTop)) -
Math.min(...expandSamples.map((item) => item.cardTop))
: 99;
const maxCardW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardW)) : 0;
const maxCardH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardH)) : 0;
const maxWinW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winW)) : 0;
const maxWinH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winH)) : 0;
const minWinW = expandSamples.length ? Math.min(...expandSamples.map((item) => item.winW)) : 0;
const minWinH = expandSamples.length ? Math.min(...expandSamples.map((item) => item.winH)) : 0;
const windowFrozen = maxWinW === minWinW && maxWinH === minWinH;
const expandMonotonic = expandSamples.every(
(item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1,
);
const expandNoOvershoot = maxCardW <= expandedCard.width + 0.5 && maxCardH <= expandedCard.height + 0.5;
record(
'展开过程不震动(窗口冻结 + 岛锚点不动 + 尺寸不过冲)',
expandSamples.length >= 5 &&
windowFrozen &&
cxSpread <= 0.5 &&
topSpreadExpand <= 0.5 &&
expandMonotonic &&
expandNoOvershoot,
`采样=${expandSamples.length} 窗口=${maxWinW}x${maxWinH}(全程冻结=${windowFrozen}) ` +
`岛中心波动=${cxSpread.toFixed(2)}px 岛上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
`最大岛=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height}) 单调=${expandMonotonic}`,
);
await runMotionProbe('collapse');
const collapseSamples = [...motionSamples];
const cxSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardCx)) -
Math.min(...collapseSamples.map((item) => item.cardCx))
: 99;
const topSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardTop)) -
Math.min(...collapseSamples.map((item) => item.cardTop))
: 99;
const minCardW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.cardW)) : 0;
const collapseWindowFrozen =
new Set(collapseSamples.map((item) => item.winW)).size <= 1 &&
new Set(collapseSamples.map((item) => item.winH)).size <= 1;
const collapseMonotonic = collapseSamples.every(
(item, index) =>
index === 0 || item.cardW <= (collapseSamples[index - 1]?.cardW ?? Number.MAX_SAFE_INTEGER) + 1,
);
const collapseNoOvershoot = minCardW >= pillCard.width - 1;
record(
'收回过程不震动(窗口冻结 + 岛锚点不动 + 尺寸不过冲)',
collapseSamples.length >= 5 &&
collapseWindowFrozen &&
cxSpreadCollapse <= 0.5 &&
topSpreadCollapse <= 0.5 &&
collapseMonotonic &&
collapseNoOvershoot,
`采样=${collapseSamples.length} 窗口全程冻结=${collapseWindowFrozen} ` +
`岛中心波动=${cxSpreadCollapse.toFixed(2)}px 岛上边缘波动=${topSpreadCollapse.toFixed(2)}px ` +
`最小岛宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 单调收缩=${collapseMonotonic}`,
);
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
await sleep(250);
/** 排空灵动岛队列:连续收起直到没有活动通知与排队通知(否则"空闲态"断言会被下一条顶掉) */
const drainIsland = async (): Promise<void> => {
for (let index = 0; index < 20; index += 1) {
const current = island.getState();
if (!current.active && current.queued.length === 0) return;
island.handleAction({ action: 'dismiss' });
await sleep(120);
}
};
/** 灵动岛状态摘要(失败时用于定位是"没排空队列"还是"几何没生效") */
const islandStateSummary = (): string => {
const current = island.getState();
return `mode=${current.mode} active=${current.active?.id ?? 'null'} queued=${current.queued.length} visible=${
islandWindow?.isVisible() ?? '-'
}`;
};
/** 等待灵动岛渲染出指定选择器(最多约 2s) */
const waitForIslandDom = async (selector: string, timeoutMs = 2000): Promise<boolean> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const found = await islandWindow?.webContents
.executeJavaScript(`Boolean(document.querySelector(${JSON.stringify(selector)}))`)
.catch(() => false);
if (found) return true;
await sleep(60);
}
return false;
};
// 10.6) 字号:所有文本都由基础字号推导,改设置必须"立刻看到"(WinIsland 的 font_size 模型)
const readFontSizes = async (): Promise<Record<string, number>> =>
(await islandWindow?.webContents
.executeJavaScript(
`(() => {
const pick = (selector) => {
const node = document.querySelector(selector);
if (!node) return 0;
return Math.round(parseFloat(getComputedStyle(node).fontSize) * 100) / 100;
};
return {
'pill-title': pick('.pill-title'),
'pill-sub': pick('.pill-sub'),
title: pick('.title'),
content: pick('.content-text'),
ghost: pick('.ghost-btn'),
};
})()`,
)
.catch(() => ({}))) ?? {};
const probeFontScale = async (fontSize: number): Promise<Record<string, number>> => {
island.setAppearance({ ...appearanceBefore, fontSize });
await drainIsland();
island.pushNotification(makeNotification('smoke-font', 'NORMAL', '字号实时生效校验'), {
inClass: false,
});
await waitForIslandDom('.island-card.pill .pill-title');
await sleep(120);
const pillSizes = await readFontSizes();
island.handleAction({ action: 'expand' });
await waitForIslandDom('.island-card.expanded .title');
await sleep(160);
const expandedSizes = await readFontSizes();
// 两次读取各有一半元素不存在(返回 0),合并时只采纳 >0 的有效值
const merged: Record<string, number> = { ...pillSizes };
for (const [key, value] of Object.entries(expandedSizes)) {
if (value > 0) merged[key] = value;
}
return merged;
};
await drainIsland();
const fontSmall = await probeFontScale(13);
const fontLarge = await probeFontScale(19);
const fontKeys = Object.keys(fontSmall).filter((key) => (fontSmall[key] ?? 0) > 0);
const expectedRatio = 19 / 13;
const fontRatios = fontKeys.map((key) => ({
key,
ratio: (fontLarge[key] ?? 0) / (fontSmall[key] ?? 1),
}));
const fontUniform = fontRatios.every((item) => Math.abs(item.ratio - expectedRatio) <= 0.03);
record(
'字号设置立刻生效(岛内所有文本按基础字号等比缩放)',
fontKeys.length >= 5 && fontUniform,
`13px→[${fontKeys.map((key) => `${key}=${fontSmall[key]}`).join(' ')}] ` +
`19px→[${fontKeys.map((key) => `${key}=${fontLarge[key]}`).join(' ')}] ` +
`期望倍率=${expectedRatio.toFixed(3)} 实测=[${fontRatios.map((item) => `${item.key}:${item.ratio.toFixed(3)}`).join(' ')}] ` +
`${islandStateSummary()}`,
);
// 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效
const probeStyle = async (
style: IslandAppearance['style'],
): Promise<{ fill: string; stroke: string; material: string }> => {
island.setAppearance({ ...appearanceBefore, style });
await sleep(260);
const measured = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const path = document.querySelector('.island-card .shape path');
if (!path) return null;
const style = getComputedStyle(path);
return { fill: style.fill, stroke: style.stroke };
})()`,
)
.catch(() => null);
return {
fill: String(measured?.fill ?? '-'),
stroke: String(measured?.stroke ?? '-'),
material: island.getBackgroundMaterial(),
};
};
const blackStyle = await probeStyle('black');
const glassStyle = await probeStyle('glass');
const tintedStyle = await probeStyle('tinted');
record(
'视觉风格切换立刻生效(纯黑 / 毛玻璃亚克力 / 主题色)',
blackStyle.fill === 'rgb(0, 0, 0)' &&
blackStyle.material === 'none' &&
glassStyle.fill.startsWith('rgba(10, 10, 14') &&
glassStyle.material === 'acrylic' &&
tintedStyle.fill === 'rgb(11, 11, 15)',
`纯黑 fill=${blackStyle.fill} 材质=${blackStyle.material};` +
`毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` +
`主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`,
);
// 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest):
// 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。
// 指针进入岛体前先保证岛真的画出来了(隐藏态曾让这条断言偶发失败)
await drainIsland();
island.pushNotification(makeNotification('smoke-interactive', 'NORMAL', '鼠标穿透校验'), {
inClass: false,
});
await waitForIslandDom('.island-card.pill .pill-title');
await sleep(200);
// 命中兜底轮询读的是"岛体矩形":等它上报后再注入光标(渲染进程上报有限流)
for (let index = 0; index < 20 && !island.getHitRect(); index += 1) await sleep(80);
await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
const r = card.getBoundingClientRect();
window.dispatchEvent(
new MouseEvent('mousemove', {
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
bubbles: true,
}),
);
return true;
})()`,
)
.catch(() => undefined);
await sleep(180);
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"。
// 命中判定链路是异步的(渲染进程上报矩形 → 主进程 120ms 轮询校正),
// 因此这里**轮询等待**而不是固定 sleep 后读一次,避免偶发误判。
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
let interactiveInside = island.getInteractive();
for (let index = 0; index < 10 && interactiveInside !== true; index += 1) {
await sleep(120);
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
interactiveInside = island.getInteractive();
}
await islandWindow?.webContents
.executeJavaScript(
`(() => {
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 2, clientY: 2, bubbles: true }));
return true;
})()`,
)
.catch(() => undefined);
// 光标的权威判定在主进程(兜底轮询):这里把光标注入到屏幕左上角,等价于"指针离开岛体"
island.setHitTestCursor({ x: 1, y: 1 });
let interactiveOutside = island.getInteractive();
for (let index = 0; index < 10 && interactiveOutside !== false; index += 1) {
await sleep(120);
island.setHitTestCursor({ x: 1, y: 1 });
interactiveOutside = island.getInteractive();
}
const hitCheckGeometry = island.getHitRect();
island.setHitTestCursor(null);
record(
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
interactiveInside === true && interactiveOutside === false,
`指针在岛内 interactive=${interactiveInside},指针在岛外 interactive=${interactiveOutside}(要求 true / false)` +
` 窗口可见=${islandWindow?.isVisible() ?? false} 状态=${island.getState().mode}` +
` 岛体矩形=${hitCheckGeometry ? `${Math.round(hitCheckGeometry.x)},${Math.round(hitCheckGeometry.y)} ${Math.round(hitCheckGeometry.width)}x${Math.round(hitCheckGeometry.height)}` : 'null'}`,
);
island.setAppearance({ ...appearanceBefore, style: 'black' });
await sleep(200);
// 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开
const sliver = getSliverSize();
/**
* 等岛体几何收敛到目标尺寸(不等固定时长)。
* 原因:Windows 有时会把置顶透明小窗判定为"被遮挡/后台",Chromium 会把 rAF 压到 1 帧/秒
* (实测打包版细缝态出现过 1.1fps),此时形变会明显变慢;渲染进程 1.2s 后会自动 snap 到目标,
* 所以这里轮询等待即可,固定 sleep 会误判成"细缝尺寸不对"。
*/
const waitIslandSize = async (
target: { width: number; height: number },
timeoutMs = 4000,
): Promise<{ width: number; height: number } | null> => {
const deadline = Date.now() + timeoutMs;
let last: { width: number; height: number } | null = null;
while (Date.now() < deadline) {
last = (await readIslandGeometry())?.island ?? null;
if (last && Math.abs(last.width - target.width) <= 1 && Math.abs(last.height - target.height) <= 1) {
return last;
}
await sleep(120);
}
return last;
};
island.setAppearance({ ...appearanceBefore, idleSliver: true });
await drainIsland();
const sliverBounds = await waitIslandSize(sliver);
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
const afterSliverNotification = await waitIslandSize({
width: island.getAppearance().width,
height: island.getAppearance().height,
});
record(
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 &&
Math.abs((sliverBounds?.height ?? 0) - sliver.height) <= 1 &&
Math.abs((afterSliverNotification?.width ?? 0) - island.getAppearance().width) <= 2,
`细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` +
` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`,
);
island.setAppearance({ ...appearanceBefore, idleSliver: false });
island.handleAction({ action: 'dismiss' });
await sleep(450);
record(
'关闭空闲细缝后空闲再次完全隐藏(保持原行为)',
islandWindow?.isVisible() === false,
`isVisible=${islandWindow?.isVisible() ?? '-'}`,
);
island.pushNotification(makeNotification('smoke-after-sliver', 'NORMAL', '细缝关闭后恢复'), {
inClass: false,
});
await sleep(320);
// 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 = (await readIslandGeometry())?.island ?? null;
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('个性设置:停靠位置生效(6 个锚点)', positionOk, positionDetails.join(' '));
// 11.3b) 边距:左右/上下边距必须真的把岛挪走(用户反馈「调边距没反应」:
// 滑块在渲染层、定位在主进程,主进程没重跑就会出现「数字在变、岛不动」)
island.setAppearance({ ...appearanceBefore, position: 'top-left', marginX: 48, marginY: 40 });
await sleep(450);
const marginLeft = (await readIslandGeometry())?.island ?? null;
const marginLeftOk =
Boolean(marginLeft) &&
Math.abs((marginLeft as Electron.Rectangle).x - (workArea.x + 48)) <= 1 &&
Math.abs((marginLeft as Electron.Rectangle).y - (workArea.y + 40)) <= 1;
island.setAppearance({ ...appearanceBefore, position: 'top-right', marginX: 48, marginY: 8 });
await sleep(450);
const marginRight = (await readIslandGeometry())?.island ?? null;
const marginRightOk =
Boolean(marginRight) &&
Math.abs(
(marginRight as Electron.Rectangle).x +
(marginRight as Electron.Rectangle).width -
(workArea.x + workArea.width - 48),
) <= 1;
record(
'个性设置:边距生效(左右 + 上下)',
marginLeftOk && marginRightOk,
'top-left(marginX=48,marginY=40)=' +
(marginLeft ? marginLeft.x + ',' + marginLeft.y : '-') +
(marginLeftOk ? '✓' : '✗') +
' top-right(marginX=48)=' +
(marginRight ? marginRight.x + ',' + marginRight.y : '-') +
(marginRightOk ? '✓' : '✗'),
);
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 = (await readIslandGeometry())?.island ?? null;
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(900);
const started = Date.now();
island.handleAction({ action: 'collapse' });
const target = island.getAppearance().width;
const deadline = started + 3000;
while (Date.now() < deadline) {
const geometry = await readIslandGeometry();
const width = geometry?.island.width ?? 0;
if (Math.abs(width - target) <= 1.5) 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);
await sleep(300);
// 4.12) 同一条消息的"双广播"不能把"叫人"降级成普通通知。
// 服务端 createCall 既发 notification:new(班级房间)又发 call:new(user 房间),
// 两条 DTO 同 id;后到的那条若直接覆盖,胶囊会显示成"新消息"(用户实测截图正是如此),
// 紧急叫人还会被降级成"课中只排队、不立即展开"。
const mergeCallId = 'smoke-call-merge';
island.pushNotification(
{ ...makeNotification(mergeCallId, 'HIGH', '请 王小明 同学找 张老师'), kind: 'call' },
{ inClass: false },
);
await sleep(250);
island.pushNotification(makeNotification(mergeCallId, 'HIGH', '请 王小明 同学找 张老师'), {
inClass: false,
});
await sleep(450);
const mergedCall = island.getState();
record(
'双广播(叫人 + 通知)不降级:同 id 合并后仍按"叫人"处理',
mergedCall.active?.id === mergeCallId && mergedCall.active?.kind === 'call' && mergedCall.mode === 'pill',
`mode=${mergedCall.mode} kind=${mergedCall.active?.kind ?? '-'} priority=${mergedCall.active?.priority ?? '-'}`,
);
const mergeUrgentId = 'smoke-call-merge-urgent';
island.pushNotification(makeNotification(mergeUrgentId, 'NORMAL', '请 王小明 同学找 张老师'), {
inClass: true,
});
await sleep(250);
island.pushNotification(
{ ...makeNotification(mergeUrgentId, 'URGENT', '请 王小明 同学找 张老师'), kind: 'call' },
{ inClass: true },
);
await sleep(450);
const mergedUrgent = island.getState();
record(
'紧急叫人双广播:合并后仍 URGENT 立即展开(课中不被降级为暂存)',
mergedUrgent.active?.id === mergeUrgentId &&
mergedUrgent.mode === 'expanded' &&
mergedUrgent.active?.priority === 'URGENT',
`mode=${mergedUrgent.mode} priority=${mergedUrgent.active?.priority ?? '-'} kind=${mergedUrgent.active?.kind ?? '-'}`,
);
island.setClassState({ inClass: false, currentPeriodEnd: null });
island.handleAction({ action: 'dismiss' });
await sleep(300);
// 4.13) 渲染进程崩溃自愈:岛窗口绝不能变成"屏幕上一帧点不动的幽灵胶囊"。
// 真实现象(本机实测):崩掉岛渲染进程后,Windows 保留最后一帧 —— 胶囊看着还在,
// 窗口也还在,但点击全部丢失(主进程日志里再无"收到用户动作")。
// 这里断言主进程会隐藏死窗口并重建,重建后状态回来了、也能重新命中。
island.pushNotification(makeNotification('smoke-renderer-recover', 'NORMAL', '渲染进程自愈'), {
inClass: false,
});
await sleep(500);
const windowBeforeCrash = island.getWindow();
islandWindow?.webContents.forcefullyCrashRenderer();
await sleep(3000);
const windowAfterCrash = island.getWindow();
const recovered = island.isReady() && Boolean(windowAfterCrash) && windowAfterCrash !== windowBeforeCrash;
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(250);
const recoveredInteractive = island.getInteractive();
const recoveredState = island.getState();
record(
'渲染进程崩溃后自愈(隐藏死窗口 + 重建 + 恢复命中,不再留"点不动的幽灵胶囊")',
recovered && recoveredInteractive && recoveredState.mode === 'pill',
`窗口已重建=${windowAfterCrash !== windowBeforeCrash} ready=${island.isReady()} ` +
`mode=${recoveredState.mode} 命中=${recoveredInteractive}`,
);
island.setHitTestCursor(null);
island.handleAction({ action: 'dismiss' });
await sleep(300);
// 收尾:隐藏灵动岛,避免影响后续用例
island.handleAction({ action: 'dismiss' });
await sleep(300);
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
}
/**
* 真实通知链路自检(含"标为已读"同步)。
*
* 与 runIslandChecks 的区别:那里直接调 IPC 验证状态机;这里走完
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
*/
async function runIslandRealtimeCheck(
win: BrowserWindow,
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
const scenario = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeScenario) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.islandRealtimeScenario();
})()`,
);
if (!scenario?.ok) {
return {
delivered: false,
deliveryDetail: `投递失败:${scenario?.detail ?? '未知原因'}`,
markRead: false,
markReadDetail: '未投递成功,跳过',
};
}
// 等通知经实时通道到达并弹出胶囊
const deadline = Date.now() + 10_000;
let state = island.getState();
while (Date.now() < deadline) {
state = island.getState();
if (state.mode === 'pill' && state.active?.id === scenario.notificationId) break;
await sleep(120);
}
const visible = island.getWindow()?.isVisible() ?? false;
const delivered = state.mode === 'pill' && state.active?.id === scenario.notificationId && visible;
const deliveryDetail =
`通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` +
`灵动岛 mode=${state.mode} 可见=${visible}`;
// 扩展灵动岛 → 点"标为已读" → 通知中心应同步为已读
let markRead = false;
let markReadDetail = '未执行';
if (delivered) {
const before = await win.webContents.executeJavaScript(
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
);
island.handleAction({ action: 'expand' });
await sleep(700);
const clicked = await island.getWindow()?.webContents.executeJavaScript(
`(() => {
const button = Array.from(document.querySelectorAll('.island-card.expanded .ghost-btn')).find((node) =>
(node.textContent ?? '').includes('标为已读'),
);
if (!button) return false;
button.click();
return true;
})()`,
);
await sleep(900);
const after = await win.webContents.executeJavaScript(
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
);
markRead = clicked === true && after?.found === true && after?.read === true;
markReadDetail =
`点击标为已读=${clicked} 点击前 read=${before?.read}(未读 ${before?.unreadCount})` +
` 点击后 read=${after?.read}(未读 ${after?.unreadCount})标题="${after?.title ?? '-'}"`;
}
// 清理:删除本次自检产生的通知,避免污染演示数据
const cleanup = await win.webContents
.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeCleanup) return { ok: false, detail: '缺少清理钩子' };
return await window.__classhelperSmoke__.islandRealtimeCleanup(
${JSON.stringify(scenario.notificationId)},
${JSON.stringify(scenario.teacherToken)},
);
})()`,
)
.catch(() => null);
console.log(`[SMOKE] 真实链路清理:${cleanup?.detail ?? '未执行'}`);
island.handleAction({ action: 'dismiss' });
await sleep(300);
return { delivered, deliveryDetail, markRead, markReadDetail };
}
/**
* 设置页「真实 UI 链路」自检(用户反馈:**灵动岛设置无法生效并且无法实时预览**)。
*
* 为什么必须单独来一条:上面的用例都是直接调 `window.desktop.islandSetAppearance(...)`,
* 绕过了设置页的控件,于是漏掉了真正的失败模式 —— 设置页把 Vue 的响应式 Proxy(`ref.value`)
* 直接交给 `ipcRenderer`,结构化克隆会失败并抛出 `An object could not be cloned.`。
* 这种失败是"半静默"的:滑块数字照常变化、界面不报错,只有主进程里的外观一动不动,
* 用户看到的就是"拖了滑块没反应、预览也没变化"。
*
* 因此这里在真实设置页里合成一次拖拽(mousedown → mousemove → mouseup,与用户操作同一路径),
* 并同时断言「滑块自身的数值」与「主进程里的外观」都变了 —— 只断言前者会漏掉这个 bug。
*/
async function runSettingsPageAppearanceCheck(
win: BrowserWindow,
): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = await window.desktop.islandGetAppearance();
// 先固定基准高度:避免"当前高度恰好等于拖拽落点"导致假通过或假失败
window.desktop.islandSetAppearance({ ...before, height: 44 });
await wait(300);
location.hash = '#/settings';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
runway = document.querySelector('.el-slider__runway');
}
if (!runway) return { ok: false, detail: '设置页未渲染出滑块(未登录或页面未挂载)' };
const rect = runway.getBoundingClientRect();
if (!rect.width) return { ok: false, detail: '滑块宽度为 0,无法拖拽' };
const x = rect.left + rect.width * 0.9;
const y = rect.top + rect.height / 2;
const fire = (target, type, extra) =>
target.dispatchEvent(
new MouseEvent(
type,
Object.assign({ bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }, extra),
),
);
fire(runway, 'mousedown', { buttons: 1 });
await wait(60);
fire(window, 'mousemove', { buttons: 1 });
await wait(60);
fire(window, 'mouseup', {});
await wait(600);
const dragged = await window.desktop.islandGetAppearance();
const label =
Array.from(document.querySelectorAll('.el-form-item__label'))
.map((node) => (node.textContent ?? '').trim())
.find((text) => text.startsWith('高度')) ?? '';
// 复原外观与路由,不影响后续用例
window.desktop.islandSetAppearance({ ...before });
location.hash = '#/schedule';
return { ok: true, baseline: 44, dragged: dragged.height, label };
})()`,
)
.catch((error: unknown) => ({
ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as
| { ok: true; baseline: number; dragged: number; label: string }
| { ok: false; detail: string }
| null;
if (result?.ok) {
return {
ok: result.dragged !== result.baseline && result.label === `高度 ${result.dragged}px`,
detail:
`拖动高度滑块到 90%:页面标签="${result.label}",` +
`主进程外观高度 ${result.baseline} → ${result.dragged}`,
};
}
return { ok: false, detail: result?.detail ?? '未执行' };
}
/**
* 设置页「通知显示位置」自检(需求:提醒弹在 ClassHelper 还是 ClassIsland,由客户端自己选)。
*
* 走真实 UI:切到设置页 → 点单选项 → 断言「本地配置已写入」,再切回去。
* 服务端同步(写回班级记录)由 verify:classisland 覆盖,这里只管客户端这一半。
*/
async function runSettingsPageChannelCheck(
win: BrowserWindow,
): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/settings';
// 找到「通知显示位置」那张卡片里的单选组
let group = null;
for (let i = 0; i < 40 && !group; i += 1) {
await wait(100);
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
(card.querySelector('.el-card__header')?.textContent ?? '').includes('ClassIsland 联动'),
);
}
if (!group) return { ok: false, detail: '设置页没有「ClassIsland 联动」卡片' };
const radios = Array.from(group.querySelectorAll('.el-radio'));
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
// 点第二个(只在 ClassHelper 客户端弹),断言配置真的写下去了
radios[1].click();
await wait(600);
const afterClient = (await window.desktop.getConfig()).notificationChannel;
const clientChecked = radios[1].classList.contains('is-checked');
// 再点回第一个(两端都弹),避免影响后续用例
radios[0].click();
await wait(600);
const afterBoth = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/schedule';
return { ok: true, before, afterClient, afterBoth, clientChecked, labels: radios.map((n) => n.textContent.trim()) };
})()`,
)
.catch((error: unknown) => ({
ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as
| { ok: true; before: string; afterClient: string; afterBoth: string; clientChecked: boolean; labels: string[] }
| { ok: false; detail: string }
| null;
if (result?.ok) {
return {
ok: result.afterClient === 'client' && result.clientChecked && result.afterBoth === 'both',
detail:
`单选项=[${result.labels.join(' / ')}],` +
`选择「只在 ClassHelper 客户端弹」后配置 ${result.before} → ${result.afterClient}(选中=${result.clientChecked}),` +
`切回后 ${result.afterBoth}`,
};
}
return { ok: false, detail: result?.detail ?? '未执行' };
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
* 以及可选的联网集成(ELECTRON_SMOKE_ONLINE=1)。
*
* 结果同时输出到 stdout,并在设置 ELECTRON_SMOKE_RESULT 时写入 JSON 文件 ——
* 打包后的 Windows GUI 进程没有 stdout,用结果文件才能在 CI/脚本里验证安装包。
*/
/**
* 托盘与退出清理校验(需求 5)。
* 这两个用例会隐藏主窗口并销毁托盘/灵动岛,因此必须放在所有依赖它们的用例之后。
*/
async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder): Promise<void> {
// 11) 关闭到托盘:窗口关闭后进程存活、托盘仍在;清理后资源归零
const trayBefore = getTrayState();
mainWin.close();
await sleep(500);
const afterClose = { visible: mainWin.isVisible(), destroyed: mainWin.isDestroyed(), alive: app.isReady() };
record(
'关闭窗口隐藏到托盘(进程继续后台运行)',
trayBefore.ready === true &&
afterClose.visible === false &&
afterClose.destroyed === false &&
afterClose.alive === true,
`托盘就绪=${trayBefore.ready} 有图标=${trayBefore.hasIcon} 关闭后 visible=${afterClose.visible} destroyed=${afterClose.destroyed} 进程存活=${afterClose.alive}`,
);
destroyTray();
island.destroy();
await sleep(200);
record(
'退出前资源清理(托盘/灵动岛已释放)',
isTrayReady() === false && island.isReady() === false,
`托盘=${isTrayReady()} 灵动岛=${island.isReady()}`,
);
}
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const results: SmokeResult[] = [];
const record = (name: string, ok: boolean, detail = ''): void => {
results.push({ name, ok, detail });
console.log(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ` - ${detail}` : ''}`);
};
// 等待 Vue 挂载并完成启动流程(离线时最多等 14 秒)
let dom: {
title: string;
appChildren: number;
hasLoginPage: boolean;
hasInput: boolean;
hasLayout: boolean;
text: string;
} | null = null;
const snapshot = async (): Promise<typeof dom> =>
win.webContents.executeJavaScript(
`(() => {
const app = document.querySelector('#app');
return {
title: document.title,
appChildren: app ? app.children.length : 0,
hasLoginPage: Boolean(document.querySelector('.login-page')),
hasInput: Boolean(document.querySelector('input')),
hasLayout: document.querySelectorAll('.el-menu-item').length > 0,
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 80),
};
})()`,
);
for (let attempt = 0; attempt < 56; attempt += 1) {
dom = await snapshot();
// 等到登录页或主布局真正渲染出来(而不是停在启动过渡页)
if ((dom?.hasLoginPage && dom?.hasInput) || dom?.hasLayout) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
// 上次运行可能残留登录态(客户端会直接进主界面)——先退出登录,再校验登录页
if (dom?.hasLayout && !dom?.hasLoginPage) {
console.log('[SMOKE] 检测到已保存的登录态,先退出登录再校验登录页');
await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return false;
await window.__classhelperSmoke__.sessionCleanup();
location.hash = '#/login';
return true;
})()`,
);
for (let attempt = 0; attempt < 40; attempt += 1) {
dom = await snapshot();
if (dom?.hasLoginPage && dom?.hasInput) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
record(
'窗口标题正确',
dom?.title === '班级小助手' || (dom?.title ?? '').includes('班级小助手'),
`title=${dom?.title}`,
);
record(
'登录页渲染(服务器地址/用户名/密码输入框)',
Boolean(dom?.hasLoginPage && dom?.hasInput),
`text=${dom?.text ?? ''}`,
);
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号
const loginExampleLeak = await win.webContents.executeJavaScript(
`(() => {
const text = (document.body.innerText || '') + ' ' + Array.from(document.querySelectorAll('input'))
.map((node) => node.getAttribute('placeholder') || '')
.join(' ');
const tokens = ['例如 G101', 'G101', '例如G101', 'admin123', 'teacher123', '演示', '示例'];
return { leaked: tokens.filter((item) => text.includes(item)) };
})()`,
);
record(
'登录页不含示例内容(无示例班级码/演示账号)',
(loginExampleLeak?.leaked ?? ['?']).length === 0,
`越界文案=[${(loginExampleLeak?.leaked ?? []).join(',')}]`,
);
// preload 桥接
const bridge = await win.webContents.executeJavaScript(
`(() => ({
hasBridge: typeof window.desktop === 'object' && window.desktop !== null,
methods: Object.keys(window.desktop ?? {}).sort(),
}))()`,
);
record(
'preload contextBridge 注入',
Boolean(bridge?.hasBridge) && Array.isArray(bridge?.methods) && bridge.methods.includes('getConfig'),
`methods=${(bridge?.methods ?? []).join(',')}`,
);
// IPC 往返(渲染进程 <-> 主进程)
const ipcRoundTrip = await win.webContents.executeJavaScript(
`(async () => {
const info = await window.desktop.getAppInfo();
const saved = await window.desktop.saveConfig({ serverUrl: 'http://127.0.0.1:4000' });
return { electron: info.electron, hasSmokeFlag: info.smokeTest === true, serverUrl: saved.serverUrl, userData: info.userDataPath };
})()`,
);
record(
'IPC 调用主进程(getAppInfo / saveConfig)',
Boolean(ipcRoundTrip?.electron) && ipcRoundTrip?.serverUrl === 'http://127.0.0.1:4000',
`electron=${ipcRoundTrip?.electron} serverUrl=${ipcRoundTrip?.serverUrl}`,
);
const diagnostics = getDiagnostics();
record('配置文件已写入用户目录', diagnostics.config.serverUrl.length > 0, diagnostics.configPath);
// IndexedDB 缓存自检
const cacheSelfTest = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.cacheSelfTest();
})()`,
);
record('IndexedDB 缓存读写', Boolean(cacheSelfTest?.ok), String(cacheSelfTest?.detail ?? ''));
// 离线回退场景
const offline = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.offlineScenario();
})()`,
);
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
// ---------------------------------------------------------------- 灵动岛
await runIslandChecks(win, record, results);
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
const online = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })});
})()`,
);
record(
'联网集成(登录 + 布局挂载 + 四类数据 + Socket.IO + 缓存写入)',
Boolean(online?.ok),
String(online?.detail ?? ''),
);
// 设置页真实 UI 链路(用户反馈:"灵动岛设置无法生效并且无法实时预览"):
// 必须真的去拖设置页里的滑块 —— 直接调 window.desktop.* 会绕过
// 「Vue 响应式 Proxy 过不了 IPC 结构化克隆」这个失败模式。
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
// 通知显示位置(客户端自己选):提醒弹在 ClassHelper 还是 ClassIsland
const settingsChannel = await runSettingsPageChannelCheck(win);
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail);
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
const realtime = await runIslandRealtimeCheck(win);
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail);
record('灵动岛"标为已读"同步通知中心', realtime.markRead, realtime.markReadDetail);
// ClassIsland 风格「今天」时间轴:造课 → 断言高亮/倒计时/大时钟 → 清理
const timeline = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.scheduleTimelineSelfTest) return { ok: false, detail: '缺少时间轴自检钩子' };
return await window.__classhelperSmoke__.scheduleTimelineSelfTest();
})()`,
);
record(
'课表「今天」时间轴(当前课高亮/倒计时/大时钟)',
Boolean(timeline?.ok),
String(timeline?.detail ?? ''),
);
// 作业看板全屏自适应(用户反馈:全屏看板没有铺满屏幕):
// 进作业页 → 切看板 → 打开全屏 → 量布局宽度/可视底部,要求铺满且不需要滚动
const boardFullscreen = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.homeworkBoardSelfTest) return { ok: false, detail: '缺少看板自适应自检钩子' };
return await window.__classhelperSmoke__.homeworkBoardSelfTest();
})()`,
);
record(
'作业看板全屏自适应(铺满屏幕、内容不溢出)',
Boolean(boardFullscreen?.ok),
String(boardFullscreen?.detail ?? ''),
);
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
const navigation = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.layoutNavigationSelfTest();
})()`,
);
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
const readBadgeGeometry = async (): Promise<{
ok: boolean;
skipped?: string;
reason?: string;
dx?: number;
dy?: number;
text?: string;
} | null> =>
await win.webContents
.executeJavaScript(
`(() => {
const item = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').includes('通知'),
);
if (!item) return { ok: false, reason: '找不到通知菜单项' };
const badge = item.querySelector('.menu-badge');
const icon = item.querySelector('.menu-icon-slot .el-icon');
if (!badge) return { ok: true, skipped: '当前没有未读消息' };
if (!icon) return { ok: false, reason: '通知图标没有 .menu-icon-slot 定位父级' };
const b = badge.getBoundingClientRect();
const i = icon.getBoundingClientRect();
return {
ok: true,
dx: Math.round(b.left + b.width / 2 - (i.left + i.width / 2)),
dy: Math.round(b.top + b.height / 2 - (i.top + i.height / 2)),
text: (badge.textContent ?? '').trim(),
};
})()`,
)
.catch(() => null);
let badgeCheck = await readBadgeGeometry();
let injectedId = '';
if (badgeCheck?.skipped) {
// 没有未读消息时红点不会渲染:造一条未读通知(教师账号),等实时推送刷新后再断言位置
const created = await createSmokeNotification();
if (created) {
injectedId = created;
await sleep(1400);
badgeCheck = await readBadgeGeometry();
}
}
record(
'未读红点位置正确(挂在"通知"图标右上方)',
badgeCheck?.ok === true &&
(badgeCheck?.skipped !== undefined || ((badgeCheck?.dx ?? 0) > 0 && (badgeCheck?.dy ?? 0) < 0)),
badgeCheck?.skipped
? `${badgeCheck.skipped}(未能构造未读消息,已跳过位置断言)`
: `红点=${badgeCheck?.text} 相对图标中心 dx=${badgeCheck?.dx} dy=${badgeCheck?.dy}(要求 dx>0 且 dy<0)` +
`${badgeCheck?.reason ? ` 原因=${badgeCheck.reason}` : ''}`,
);
if (injectedId) await deleteSmokeNotification(injectedId);
const cleanup = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.sessionCleanup();
})()`,
);
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
}
/**
* 把当前结果落盘 + 打印汇总。
* 为什么要写两次:托盘/退出清理用例会真的走一遍"退出应用",进程可能在收尾代码执行前就退出,
* 那样 ELECTRON_SMOKE_RESULT 就不会生成(重启校验会误报"结果=-1/0")。
* 所以在跑托盘用例之前先写一次,收尾再写一次(覆盖全部用例)。
*/
const writeResult = (): void => {
const failedItems = results.filter((item) => !item.ok);
const passedCount = results.length - failedItems.length;
console.log(`\n[SMOKE] ${passedCount}/${results.length} 项通过`);
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
if (!resultFile) return;
try {
fs.mkdirSync(path.dirname(resultFile), { recursive: true });
fs.writeFileSync(
resultFile,
JSON.stringify(
{
passed: passedCount,
total: results.length,
ok: failedItems.length === 0,
results,
finishedAt: new Date().toISOString(),
appVersion: app.getVersion(),
packaged: app.isPackaged,
},
null,
2,
),
'utf8',
);
console.log(`[SMOKE] 结果已写入 ${resultFile}`);
} catch (error) {
console.error('[SMOKE] 写入结果文件失败', error);
}
};
writeResult();
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
await runTrayAndShutdownChecks(win, record);
writeResult();
const failed = results.filter((item) => !item.ok);
app.exit(failed.length === 0 ? 0 : 1);
}