Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts
suzmii d0b15dbc54
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m58s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m40s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Failing after 6m29s
Project CI / Repository checks (pull_request) Failing after 4m3s
Project CI / Backend tests (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m13s
Project CI / Frontend tests (pull_request) Has been cancelled
AGC 生成任务侧栏:收起也补上退场动画,退场期间内容不跳
- 进场本来就有动画(`…-enter`),收起却是瞬间卸载,观感上"闪一下没了"。现在收起先播退场再卸载:`open` 变 false 后进入 `leaving`,根节点挂 `is-leaving` 播 `…-leave`(与进场同向反向,160ms,`pointer-events: none`),播完才卸载
- 时长在组件与 CSS 两处各写一次,组件导出 `RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS` 并由用例钉住两处一致
- 减动效偏好下 CSS 已关掉动画,组件那边同步按 0ms 处理,收起立即卸载,不白等 160ms
- 退场期间沿用收起前那一份列表(`lastRenderedRef`):宿主会在同一帧里收起侧栏并把在途任务收口,直接吃新 props 会让退场动画里的内容跳一下;滚动区的空态分支也改读这份冻结快照,不能读实时 `ordered`(这是本批调试时用假时钟用例抓到的真问题)
- 用例:面板用例新增「收起时先播完收起动画再卸载,动画期间列表内容不跳」;样式用例新增「is-leaving 挂上收起动画、pointer-events: none、减动效同样关掉」;既有生成任务相关性用例改为等退场动画走完再断言(新增 `waitForTasksSidebarLeaveAnimationToFinish`)
- 文档:AGC 入口矩阵与 decision-log 同步退场动画口径

验证:tsc --noEmit 通过;appSurface 全量 441 条通过;侧栏面板 9 条、样式 10 条、开合与入口 3 条通过;变异验证:把早返回改回「立即卸载」与把 CSS 的 `is-leaving` 选择器写错,对应断言分别变红
2026-09-14 23:01:35 +08:00

239 lines
9.2 KiB
TypeScript

// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, test } from 'vitest';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
type StyleRule,
} from './styleCascade';
/**
* 侧栏样式的声明级断言。
*
* jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**:
* 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。
*/
const SIDEBAR_CSS_PATH = resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css',
);
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));
/**
* `styleCascade` 的求值器只认识宽度媒体查询,遇到 `prefers-reduced-motion` 会主动报错;
* 那条规则的生效与否单独在下面按声明断言,所以求值时先把它排除掉。
*/
const widthRules = rules.filter(
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
);
function resolved(
selectors: readonly string[],
viewportWidth = 1280,
): Map<string, string> {
return resolveDeclarations(widthRules, selectors, viewportWidth);
}
function toneRule(tone: string): StyleRule | undefined {
return rules.find((rule) =>
rule.selectors.includes(
`.game-resource-generation-task-badge[data-tone='${tone}']`,
),
);
}
describe('「生成任务」侧栏样式', () => {
test('四种状态各有一档 tone,取值全部来自平台 token 而不硬编码颜色', () => {
const tones = ['queued', 'running', 'completed', 'failed'] as const;
const resolvedTones = tones.map((tone) =>
resolved([
'.game-resource-generation-task-badge',
`.game-resource-generation-task-badge[data-tone='${tone}']`,
]),
);
const signature = (map: Map<string, string>) =>
['border-color', 'background', 'color']
.map((property) => declaration(map, property))
.join(' | ');
const signatures = resolvedTones.map(signature);
// 四档必须互不相同,否则「状态徽标成体系」这条就退化成同一个样子。
expect(new Set(signatures).size).toBe(4);
expect(signatures[0]).toContain('--platform-neutral');
expect(signatures[1]).toContain('--platform-accent');
expect(signatures[2]).toContain('--platform-success');
expect(signatures[3]).toContain('--platform-button-danger');
for (const one of signatures) {
expect(one).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu);
}
// 四档 tone 必须在 CSS 里真实存在(少一档这条断言就红)。
for (const tone of tones) {
expect(toneRule(tone)).toBeDefined();
}
});
test('生成中只有「在动」的呼吸感,没有伪造百分比', () => {
const running = resolved([
'.game-resource-generation-task-badge',
".game-resource-generation-task-badge[data-tone='running']",
]);
// 徽标本身不承载进度数值:文案由组件按后端状态给,样式里不出现百分比工具。
expect(declaration(running, 'color')).toBe('var(--platform-accent)');
const pulse = rules.find((rule) =>
rule.selectors.includes(
".game-resource-generation-task-badge[data-tone='running']::before",
),
);
expect(pulse?.declarations.get('animation')).toContain('pulse');
});
test('阶段是次要信息、已耗时等宽数字右对齐', () => {
const phase = resolved(['.game-resource-generation-task-card-phase']);
expect(declaration(phase, 'color')).toBe('var(--platform-text-muted)');
expect(declaration(phase, 'font-size')).toBe('0.7rem');
const elapsed = resolved(['.game-resource-generation-task-card-elapsed']);
expect(declaration(elapsed, 'font-variant-numeric')).toBe('tabular-nums');
expect(declaration(elapsed, 'text-align')).toBe('right');
});
test('条目卡片有圆角与悬停反馈,失败原因可折行不截断', () => {
const card = resolved(['.game-resource-generation-task-card']);
expect(declaration(card, 'border-radius')).toBe('0.5rem');
const hover = resolved([
'.game-resource-generation-task-card',
'.game-resource-generation-task-card:hover',
]);
expect(declaration(hover, 'box-shadow')).toBe(
'var(--platform-desktop-hover-shadow)',
);
expect(declaration(hover, 'transform')).toBe('translateY(-1px)');
const error = resolved(['.game-resource-generation-task-card-error']);
expect(declaration(error, 'white-space')).toBe('normal');
expect(declaration(error, 'overflow-wrap')).toBe('anywhere');
const locate = resolved(['.game-resource-generation-task-locate']);
expect(declaration(locate, 'text-decoration')).toContain('underline');
});
test('进场动画留在侧栏上,删除的折叠把手不再有样式残留', () => {
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
expect(declaration(sidebar, 'animation')).toContain(
'game-resource-generation-tasks-enter',
);
expect(declaration(sidebar, 'border-radius')).toBe('0.75rem');
expect(declaration(sidebar, 'backdrop-filter')).toBe('blur(10px)');
// 折叠把手与底部关闭条都已删除:对应类名不得再出现在样式表里。
for (const removed of [
'.game-resource-generation-tasks-handle',
'.game-resource-generation-tasks-handle-label',
'.game-resource-generation-tasks-handle-badge',
'.game-resource-generation-tasks-sidebar-footer',
]) {
expect(rules.some((rule) => rule.selectors.includes(removed))).toBe(
false,
);
}
});
test('收起与进场同源反向:is-leaving 挂上收起动画,减动效下同样关掉', () => {
const leaving = resolved([
'.game-resource-generation-tasks-sidebar',
'.game-resource-generation-tasks-sidebar.is-leaving',
]);
const animation = declaration(leaving, 'animation');
expect(animation).toContain('game-resource-generation-tasks-leave');
expect(animation).toContain('160ms');
// 动画期间不该还能点到里面(此刻它在播退场,点它只会在卸载前留下半截状态)。
expect(declaration(leaving, 'pointer-events')).toBe('none');
expect(
rules.some((rule) =>
rule.selectors.includes(
'.game-resource-generation-tasks-sidebar.is-leaving',
),
),
).toBe(true);
// 减少动效:收起动画同样关掉,组件那边会立即卸载,不白等 160ms。
const reduced = rules.filter((rule) =>
(rule.media ?? '').includes('prefers-reduced-motion'),
);
expect(
reduced.some(
(rule) =>
rule.selectors.includes(
'.game-resource-generation-tasks-sidebar.is-leaving',
) && rule.declarations.get('animation') === 'none',
),
).toBe(true);
});
test('列表滚动条是细样式,滚动区域有界', () => {
const scroll = resolved(['.game-resource-generation-tasks-scroll']);
expect(declaration(scroll, 'overflow-y')).toBe('auto');
expect(declaration(scroll, 'scrollbar-width')).toBe('thin');
expect(declaration(scroll, 'overscroll-behavior')).toBe('contain');
const thumb = rules.find((rule) =>
rule.selectors.includes(
'.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb',
),
);
expect(thumb?.declarations.get('background')).toBe(
'var(--platform-line-soft)',
);
// 侧栏高度有界:上下边界都给死,不靠内容撑高(内容再多也只滚列表)。
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
expect(declaration(sidebar, 'top')).toBe('4rem');
expect(declaration(sidebar, 'bottom')).toBe('6rem');
expect(declaration(sidebar, 'width')).toBe('min(300px, 80vw)');
});
test('窄屏(360px)下侧栏不溢出不挡把手', () => {
const narrow = resolved(['.game-resource-generation-tasks-sidebar'], 360);
expect(declaration(narrow, 'width')).toBe('auto');
expect(declaration(narrow, 'left')).toBe('0.5rem');
expect(declaration(narrow, 'right')).toBe('0.5rem');
});
test('prefers-reduced-motion 下进场动画与过渡都被关掉', () => {
const reduced = rules.filter((rule) =>
(rule.media ?? '').includes('prefers-reduced-motion'),
);
expect(reduced.length).toBeGreaterThan(0);
const motionless = (selector: string, property: string) =>
reduced.some(
(rule) =>
rule.selectors.includes(selector) &&
rule.declarations.get(property) === 'none',
);
expect(
motionless('.game-resource-generation-tasks-sidebar', 'animation'),
).toBe(true);
expect(
motionless('.game-resource-generation-task-card', 'transition'),
).toBe(true);
});
test('焦点环可见(键盘可见焦点用平台 token)', () => {
const focusRule = rules.find((rule) =>
rule.selectors.includes(
'.game-resource-generation-tasks-sidebar-icon-button:focus-visible',
),
);
expect(focusRule?.declarations.get('outline')).toBe(
'2px solid var(--platform-accent)',
);
expect(focusRule?.declarations.get('box-shadow')).toBe(
'0 0 0 3px var(--platform-input-focus-ring)',
);
});
});