fix(desktop-client): 灵动岛反馈修正——红晕改为卡片内部 inset(窗口边缘零外溢)、紧急通知补展开动画、普通通知去掉上岛入场动画、点空白处/右上角按钮回缩为胶囊、上课时段严格不显示;冒烟用独立 userData 以免与运行中的客户端抢单实例锁
This commit is contained in:
1 parent
3ab3cb1eb1
commit
38c7660dd9
10 files changed
+353
-119
No files matched your search
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(root, '..', '..');
|
||||
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
|
||||
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
|
||||
const missing = required.filter((file) => !fs.existsSync(file));
|
||||
@@ -25,6 +26,11 @@ if (missing.length > 0) {
|
||||
|
||||
console.log('[smoke] 启动 Electron 冒烟验证...\n');
|
||||
|
||||
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)
|
||||
const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile');
|
||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||
console.log(`[smoke] 独立配置目录:${profileDir}`);
|
||||
|
||||
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
|
||||
let online = process.env.ELECTRON_SMOKE_ONLINE;
|
||||
if (online === undefined) {
|
||||
@@ -48,7 +54,11 @@ if (online === undefined) {
|
||||
const child = spawn(electronPath, ['.'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: resolveElectronEnv({ ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online }),
|
||||
env: resolveElectronEnv({
|
||||
ELECTRON_SMOKE_TEST: '1',
|
||||
ELECTRON_SMOKE_ONLINE: online,
|
||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||
}),
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
|
||||
@@ -40,6 +40,17 @@ const subtitle = computed(() => {
|
||||
/** 下课补发时提示"其实上课期间就到了" */
|
||||
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
|
||||
|
||||
/**
|
||||
* 入场动画:
|
||||
* - 胶囊(普通通知):**没有入场动画**,直接出现在屏幕上
|
||||
* - 展开 / 紧急:弹簧缩放入场,配合窗口尺寸缓动形成"展开"效果
|
||||
*/
|
||||
const transitionName = computed(() => (mode.value === 'pill' ? 'island-instant' : 'island-pop'));
|
||||
const transitionDuration = computed(() => ({
|
||||
enter: mode.value === 'pill' ? 0 : 260,
|
||||
leave: 140,
|
||||
}));
|
||||
|
||||
function expand(): void {
|
||||
bridge?.sendAction('expand');
|
||||
}
|
||||
@@ -48,6 +59,14 @@ function collapse(): void {
|
||||
bridge?.sendAction('collapse');
|
||||
}
|
||||
|
||||
/** 点击卡片空白处(非按钮、非选中文字)→ 回缩为灵动岛胶囊 */
|
||||
function onCardClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('button')) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
collapse();
|
||||
}
|
||||
|
||||
function dismiss(): void {
|
||||
bridge?.sendAction('dismiss');
|
||||
}
|
||||
@@ -70,8 +89,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="island-root">
|
||||
<Transition name="island-pop" :duration="{ enter: 240, leave: 150 }">
|
||||
<div class="island-root" @click.self="collapse">
|
||||
<Transition :name="transitionName" :duration="transitionDuration">
|
||||
<!-- 胶囊态:收到通知后先变成"新消息" -->
|
||||
<button
|
||||
v-if="mode === 'pill' && notification"
|
||||
@@ -111,12 +130,13 @@ onMounted(async () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 展开态:显示通知详情 -->
|
||||
<!-- 展开态:显示通知详情(点击空白处回缩为胶囊) -->
|
||||
<section
|
||||
v-else-if="mode === 'expanded' && notification"
|
||||
key="expanded"
|
||||
class="island-card expanded"
|
||||
:class="{ urgent: isUrgent }"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
|
||||
<header class="head">
|
||||
@@ -194,7 +214,10 @@ body,
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压 */
|
||||
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压。
|
||||
注意:窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
|
||||
在屏幕上表现为"卡片周围一圈奇怪的光晕/硬边"。因此这里**只用 inset 阴影**,
|
||||
所有装饰效果都画在卡片内部。 */
|
||||
.island-card {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -209,28 +232,24 @@ body,
|
||||
radial-gradient(120% 140% at 50% -20%, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 55%),
|
||||
linear-gradient(180deg, #23242a 0%, #0c0d10 100%);
|
||||
box-shadow:
|
||||
0 12px 28px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
border-color 0.28s ease,
|
||||
box-shadow 0.28s ease,
|
||||
background 0.28s ease;
|
||||
}
|
||||
|
||||
/* 紧急通知:红色描边 + 独立光晕层呼吸
|
||||
注意:无限动画必须放在内部 .glow 上,否则会让 Vue <Transition> 的
|
||||
transitionend/animationend 判定失效,导致DOM 卡在离场状态。 */
|
||||
/* 紧急通知:红色描边 + 内部光晕呼吸(不外扩,避免窗口边界裁切出光晕硬边) */
|
||||
.island-card.urgent {
|
||||
border-color: rgba(255, 92, 92, 0.55);
|
||||
border-color: rgba(255, 92, 92, 0.6);
|
||||
box-shadow:
|
||||
0 12px 30px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 92, 92, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
animation: urgent-breath 2.2s ease-in-out infinite;
|
||||
@@ -240,13 +259,13 @@ body,
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 92, 92, 0.22),
|
||||
0 0 18px rgba(255, 72, 72, 0.28);
|
||||
inset 0 0 14px rgba(255, 72, 72, 0.22),
|
||||
inset 0 0 0 1px rgba(255, 92, 92, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 92, 92, 0.38),
|
||||
0 0 34px rgba(255, 72, 72, 0.5);
|
||||
inset 0 0 26px rgba(255, 72, 72, 0.45),
|
||||
inset 0 0 0 1px rgba(255, 92, 92, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,25 +595,44 @@ body,
|
||||
|
||||
/* ---------------------------------------------------------------- 形变动画 */
|
||||
|
||||
/* 胶囊态:没有入场动画,直接出现 */
|
||||
.island-instant-enter-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.island-instant-enter-from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.island-instant-leave-active {
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.island-instant-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 展开态:弹簧缩放入场(配合窗口尺寸缓动) */
|
||||
.island-pop-enter-active {
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.island-pop-leave-active {
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
opacity 0.14s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.island-pop-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.86) translateY(-8px);
|
||||
transform: scale(0.88) translateY(-6px);
|
||||
}
|
||||
|
||||
.island-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,11 @@ const currentDir = __dirname;
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
|
||||
|
||||
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
|
||||
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
|
||||
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
|
||||
if (smokeProfile) app.setPath('userData', smokeProfile);
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
|
||||
@@ -246,6 +246,8 @@ class IslandController {
|
||||
handleAction(payload: IslandActionPayload): void {
|
||||
switch (payload.action) {
|
||||
case 'expand':
|
||||
// 上课时段一律不显示灵动岛(只有紧急通知能自动展开)
|
||||
if (this.state.inClass) break;
|
||||
if (this.state.active) {
|
||||
this.setState({ mode: 'expanded' });
|
||||
this.scheduleCollapse(
|
||||
@@ -253,16 +255,19 @@ class IslandController {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'collapse': {
|
||||
const next = this.state.queued[this.state.queued.length - 1];
|
||||
if (next) {
|
||||
case 'collapse':
|
||||
// 点击卡片空白处 / 右上角收起按钮:回缩到灵动岛(胶囊),不直接消失
|
||||
if (this.state.inClass) {
|
||||
this.hideImmediately();
|
||||
break;
|
||||
}
|
||||
if (this.state.active) {
|
||||
this.setState({ mode: 'pill', reason: this.state.reason });
|
||||
this.scheduleCollapse(TIMEOUTS.pill);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dismiss':
|
||||
this.dismissActive();
|
||||
break;
|
||||
@@ -290,6 +295,23 @@ class IslandController {
|
||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 立刻隐藏(不做淡出):
|
||||
* 上课时间段必须"完全不显示灵动岛",任何残留的淡出都会被投影/屏幕录制看到。
|
||||
*/
|
||||
hideImmediately(): void {
|
||||
this.clearTimers();
|
||||
this.fadeToken += 1;
|
||||
const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
|
||||
if (changed) {
|
||||
this.state = { ...this.state, mode: 'hidden', reason: null, updatedAt: Date.now() };
|
||||
this.emit();
|
||||
}
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
if (this.win.isVisible()) this.win.hide();
|
||||
this.win.setOpacity(1);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clearTimers();
|
||||
if (this.win && !this.win.isDestroyed()) this.win.destroy();
|
||||
@@ -397,6 +419,13 @@ class IslandController {
|
||||
private syncWindow(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
|
||||
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知"允许出现在屏幕上)
|
||||
const urgentShowing = this.state.mode === 'expanded' && this.state.active?.priority === 'URGENT';
|
||||
if (this.state.inClass && !urgentShowing) {
|
||||
this.hideImmediately();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.mode === 'hidden') {
|
||||
this.fadeOut();
|
||||
return;
|
||||
@@ -410,13 +439,30 @@ class IslandController {
|
||||
: SIZES.pill;
|
||||
|
||||
if (!this.win.isVisible()) {
|
||||
const pillBounds = this.computeBounds(SIZES.pill);
|
||||
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
|
||||
if (size === SIZES.pill) {
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(1);
|
||||
this.win.setBounds(pillBounds);
|
||||
this.win.showInactive();
|
||||
return;
|
||||
}
|
||||
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(0);
|
||||
this.win.setBounds(pillBounds);
|
||||
this.win.showInactive();
|
||||
this.animateBounds(size);
|
||||
this.animateBounds(size, { spring: true });
|
||||
this.fadeIn();
|
||||
return;
|
||||
}
|
||||
this.animateBounds(size);
|
||||
this.animateBounds(size, { spring: size !== SIZES.pill });
|
||||
}
|
||||
|
||||
/** 作废进行中的淡入淡出动画 */
|
||||
private cancelFade(): void {
|
||||
this.fadeToken += 1;
|
||||
}
|
||||
|
||||
private fadeIn(): void {
|
||||
@@ -450,26 +496,30 @@ class IslandController {
|
||||
step();
|
||||
}
|
||||
|
||||
/** 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果 */
|
||||
private animateBounds(size: { width: number; height: number }): void {
|
||||
/**
|
||||
* 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。
|
||||
* `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感。
|
||||
*/
|
||||
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const from = this.win.getBounds();
|
||||
const to = this.computeBounds(size);
|
||||
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
|
||||
|
||||
const token = ++this.boundsToken;
|
||||
const duration = 260;
|
||||
const spring = options.spring === true;
|
||||
const duration = spring ? 340 : 260;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const step = (): void => {
|
||||
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
|
||||
const progress = Math.min(1, (Date.now() - startedAt) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
||||
const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
||||
const bounds = {
|
||||
x: Math.round(from.x + (to.x - from.x) * eased),
|
||||
y: Math.round(from.y + (to.y - from.y) * eased),
|
||||
width: Math.round(from.width + (to.width - from.width) * eased),
|
||||
height: Math.round(from.height + (to.height - from.height) * eased),
|
||||
width: Math.max(1, Math.round(from.width + (to.width - from.width) * eased)),
|
||||
height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)),
|
||||
};
|
||||
this.win.setBounds(bounds);
|
||||
if (progress < 1) setTimeout(step, 12);
|
||||
@@ -478,6 +528,14 @@ class IslandController {
|
||||
}
|
||||
}
|
||||
|
||||
/** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */
|
||||
function easeOutBack(progress: number): number {
|
||||
const c1 = 1.15;
|
||||
const c3 = c1 + 1;
|
||||
const p = progress - 1;
|
||||
return 1 + c3 * p * p * p + c1 * p * p;
|
||||
}
|
||||
|
||||
export const island = new IslandController();
|
||||
|
||||
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
|
||||
|
||||
@@ -29,8 +29,10 @@ interface IslandShotStats {
|
||||
opaque: number;
|
||||
transparent: number;
|
||||
uniqueColors: number;
|
||||
/** 偏红像素数(紧急形态的红色光晕/角标) */
|
||||
/** 偏红像素数(紧急形态的红色描边/角标/内部光晕) */
|
||||
reddish: number;
|
||||
/** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */
|
||||
edgeReddish: number;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
@@ -42,12 +44,15 @@ function analyzeBitmap(image: NativeImage): {
|
||||
transparent: number;
|
||||
uniqueColors: number;
|
||||
reddish: number;
|
||||
edgeReddish: 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) {
|
||||
@@ -63,11 +68,18 @@ function analyzeBitmap(image: NativeImage): {
|
||||
}
|
||||
opaque += 1;
|
||||
if (colors.size < 4096) colors.add((red << 16) | (green << 8) | blue);
|
||||
// 红色占优:紧急态的光晕、角标、深色卡片上的暖色高光
|
||||
if (red > 110 && red > green + 40 && red > blue + 40) reddish += 1;
|
||||
|
||||
// 红色占优:紧急态的描边、角标、内部光晕
|
||||
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 };
|
||||
return { opaque, transparent, uniqueColors: colors.size, reddish, edgeReddish };
|
||||
}
|
||||
|
||||
/** 灵动岛状态截图留档 + 像素统计(便于人工复核外观与自动化断言) */
|
||||
@@ -104,7 +116,7 @@ async function captureIsland(
|
||||
islandShots.push(stats);
|
||||
console.log(
|
||||
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
|
||||
`颜色=${stats.uniqueColors} 偏红=${stats.reddish})`,
|
||||
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`,
|
||||
);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
@@ -153,10 +165,12 @@ function makeNotification(
|
||||
|
||||
/**
|
||||
* 灵动岛行为验证(对应产品需求):
|
||||
* 1. 上课时间段收到普通通知 → 自动隐藏并暂存
|
||||
* 1. 上课时间段收到普通通知 → 不显示灵动岛(窗口真正隐藏)并暂存
|
||||
* 2. 下课后 → 自动在桌面中上方弹出详情
|
||||
* 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击)
|
||||
* 4. 非上课时段收到普通通知 → 变成"新消息"胶囊,点击后展开
|
||||
* 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击),且带展开动画
|
||||
* 4. 非上课时段收到普通通知 → 直接显示"新消息"胶囊(无入场动画),点击后展开
|
||||
* 5. 点击卡片空白处 / 右上角收起按钮 → 回缩为胶囊
|
||||
* 6. 上课时间段内点击胶囊不会展开(上课不显示灵动岛)
|
||||
*/
|
||||
async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise<void> {
|
||||
record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
|
||||
@@ -164,20 +178,31 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
|
||||
const islandWindow = island.getWindow();
|
||||
|
||||
// 1) 上课期间:普通通知必须保持隐藏并进入队列
|
||||
// 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,
|
||||
`mode=${hiddenState.mode} queued=${hiddenState.queued.length}`,
|
||||
'上课期间普通通知自动隐藏(窗口隐藏 + 暂存待下课弹出)',
|
||||
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}`,
|
||||
);
|
||||
|
||||
// 2) 下课:自动弹出暂存通知的详情
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(900);
|
||||
@@ -189,13 +214,19 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
);
|
||||
await captureIsland('island-2-after-class', ISLAND_SIZES.expanded);
|
||||
|
||||
// 3) 上课期间紧急通知:立即展开,无需点击
|
||||
// 3) 上课期间紧急通知:立即展开,无需点击;并采样窗口尺寸证明有"展开动画"
|
||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||
await sleep(300);
|
||||
const sampledWidths: number[] = [];
|
||||
const sampler = setInterval(() => {
|
||||
const bounds = islandWindow?.getBounds();
|
||||
if (bounds) sampledWidths.push(bounds.width);
|
||||
}, 16);
|
||||
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
|
||||
inClass: true,
|
||||
});
|
||||
await sleep(900);
|
||||
clearInterval(sampler);
|
||||
const urgentState = island.getState();
|
||||
record(
|
||||
'上课期间紧急通知立即展开显示详情(无需点击)',
|
||||
@@ -204,17 +235,49 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
urgentState.reason === 'urgent',
|
||||
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
|
||||
);
|
||||
|
||||
const urgentBounds = islandWindow?.getBounds();
|
||||
const rampWidths = [
|
||||
...new Set(sampledWidths.filter((width) => width > 0 && width < ISLAND_URGENT_SIZE.width)),
|
||||
];
|
||||
record(
|
||||
'紧急通知带展开动画(窗口从胶囊尺寸缓动到紧急尺寸)',
|
||||
rampWidths.length >= 3 && urgentBounds?.width === ISLAND_URGENT_SIZE.width,
|
||||
`采样=${sampledWidths.length} 中间尺寸=${rampWidths.length} 最终=${urgentBounds?.width ?? '-'}x${urgentBounds?.height ?? '-'}`,
|
||||
);
|
||||
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
|
||||
|
||||
// 4) 非上课时段普通通知 → 胶囊态;点击后展开
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
// 3.1) 上课期间请求"收起"也不应显示胶囊(上课严格不显示灵动岛)
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await sleep(400);
|
||||
record(
|
||||
'上课期间收起不会回缩成胶囊(严格不显示)',
|
||||
island.getState().mode === 'hidden' && !(islandWindow?.isVisible() ?? true),
|
||||
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
|
||||
);
|
||||
|
||||
// 4) 非上课时段普通通知 → 直接显示胶囊(无入场动画);点击后展开
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(300);
|
||||
await sleep(800);
|
||||
island.hide();
|
||||
await sleep(400);
|
||||
const hiddenBeforePush = !(islandWindow?.isVisible() ?? true);
|
||||
island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), {
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(700);
|
||||
await sleep(60);
|
||||
const immediateBounds = islandWindow?.getBounds();
|
||||
await sleep(640);
|
||||
const pillState = island.getState();
|
||||
record(
|
||||
'普通通知直接显示胶囊(无"上岛"入场动画)',
|
||||
hiddenBeforePush &&
|
||||
pillState.mode === 'pill' &&
|
||||
immediateBounds?.width === ISLAND_SIZES.pill.width &&
|
||||
immediateBounds?.height === ISLAND_SIZES.pill.height,
|
||||
`推送前隐藏=${hiddenBeforePush} 60ms 内窗口=${immediateBounds?.width ?? '-'}x${immediateBounds?.height ?? '-'}` +
|
||||
`(期望 ${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')}`);
|
||||
|
||||
@@ -235,7 +298,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
);
|
||||
await captureIsland('island-5-clicked', ISLAND_SIZES.expanded);
|
||||
|
||||
// 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口)
|
||||
// 4.1) 展开态的 DOM 结构自检(卡片/标题/操作按钮都要真的渲染出来)
|
||||
const domInfo = await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => ({
|
||||
hasCard: Boolean(document.querySelector('.island-card')),
|
||||
@@ -249,8 +312,44 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
|
||||
);
|
||||
|
||||
// 6) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色光晕像素。
|
||||
// 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}`,
|
||||
);
|
||||
|
||||
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||
const shotDetails = islandShots.map((shot) => {
|
||||
const expectedRatio = shot.expected.width / shot.expected.height;
|
||||
const actualRatio = shot.width / shot.height;
|
||||
@@ -260,7 +359,8 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
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}`,
|
||||
`比例偏差 ${ratioDelta.toFixed(3)}) 不透明=${shot.opaque} 颜色=${shot.uniqueColors} ` +
|
||||
`偏红=${shot.reddish} 外圈偏红=${shot.edgeReddish}`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -268,6 +368,7 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
const wrongRatio = shotDetails.filter((shot) => shot.ratioDelta > 0.05);
|
||||
const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent'));
|
||||
const urgentReddish = urgentShot?.reddish ?? 0;
|
||||
const edgeReddish = urgentShot?.edgeReddish ?? 0;
|
||||
const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
|
||||
const sizesOrdered = Boolean(
|
||||
pillShot &&
|
||||
@@ -289,6 +390,13 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
` 形态尺寸递增=${sizesOrdered}`,
|
||||
);
|
||||
|
||||
// 7) 光晕只在卡片内部:截图最外圈(窗口留白)不允许出现红色外溢像素
|
||||
record(
|
||||
'紧急红晕不外溢(窗口边缘无红色光晕像素)',
|
||||
urgentShot !== undefined && edgeReddish === 0 && urgentReddish > 200,
|
||||
`紧急态偏红=${urgentReddish} 最外圈偏红=${edgeReddish}(要求 0)`,
|
||||
);
|
||||
|
||||
// 收尾:隐藏灵动岛,避免影响后续用例
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(300);
|
||||
|
||||
Reference in new issue
Block a user