Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts
T
suzmii d56b5ff15a AGC「生成任务」侧栏移到画布右上角:开关常驻、面板贴着它展开
- 新增右上角锚点 game-resource-generation-tasks-anchor,挂在 game-workbench-stage 内;资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口。
- 开关改由 ResourceCanvasAssetGenerationTasksPanelView 自己渲染(保留 data-resource-generation-task-toggle 与计数属性、可访问名唯一),收起态只剩这一枚开关;工具条上两处重复入口与宿主那份 resourceAssetGenerationInFlightCount 一并删除。
- 面板自己不再定位,改由 max-height 封顶 + 内部滚动;进场 / 退场动画位移方向跟着锚点翻到正 X。
- 开关沿用原工具条那枚次级胶囊外观,颜色 / 圆角 / 字重 / 阴影继续全部走平台 token。
- 测试:面板用例新增「折叠只剩右上角开关」「开关计数与面板头部同源」「锚点按工作面分档」;样式用例改为锚点坐标 / 运行档下移 / 面板不再自定位 / 开关走 token;dismiss 用例改为右上角入口且工具条不再有生成任务按钮。
- 文档:PRD 3.10 与更新时间、decision-log 新增 2026-09-21 条目、画布验收待办登记第四批并勾掉该条。
2026-09-21 03:25:53 +08:00

295 lines
12 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.
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
type StyleRule,
} from './styleCascade';
/**
* 侧栏样式的声明级断言。
*
* jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**:
* 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。
*/
const SIDEBAR_CSS_PATH = repoPath(
'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, 'width')).toBe('min(300px, 80vw)');
expect(declaration(sidebar, 'max-height')).toBe(
'min(30rem, calc(100vh - 12rem))',
);
// 面板自己不再定位:坐标只由右上角锚点一处决定,避免两处各锚一处互相漂移。
expect(sidebar.has('position')).toBe(false);
expect(sidebar.has('left')).toBe(false);
});
test('锚点贴在工作面右上角,运行表现层让开版本入口,开关沿用次级胶囊样式', () => {
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
expect(declaration(anchor, 'position')).toBe('absolute');
expect(declaration(anchor, 'top')).toBe('3.5rem');
expect(declaration(anchor, 'right')).toBe('0.85rem');
expect(declaration(anchor, 'z-index')).toBe('40');
expect(declaration(anchor, 'align-items')).toBe('flex-start');
// 画布与开关之间只留一档小间距,开关不会盖住面板。
expect(declaration(anchor, 'gap')).toBe('0.45rem');
// 运行表现层右上角被版本入口(`game-run-version-picker`:top 20px / right 20px)占着,
// 这一档必须把开关压到那枚版本入口下面。
const runAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']",
]);
expect(declaration(runAnchor, 'top')).toBe('7rem');
expect(
Number.parseFloat(declaration(runAnchor, 'top') as string),
).toBeGreaterThan(5);
// 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影;
// 取值仍然全部走平台 token,不引入第二套色板。
const toggle = resolved(['.game-resource-generation-tasks-toggle']);
expect(declaration(toggle, 'border-radius')).toBe('999px');
expect(declaration(toggle, 'border')).toBe(
'1px solid var(--platform-surface-border)',
);
expect(declaration(toggle, 'background')).toBe(
'var(--platform-button-secondary-fill)',
);
expect(declaration(toggle, 'color')).toBe(
'var(--platform-button-secondary-text)',
);
expect(declaration(toggle, 'box-shadow')).toBe(
'var(--platform-panel-shadow)',
);
for (const property of ['border', 'background', 'color', 'box-shadow']) {
expect(declaration(toggle, property)).not.toMatch(
/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu,
);
}
});
test('窄屏(360px)下锚点占满可用宽度,面板宽度自适应不溢出', () => {
const narrowAnchor = resolved(
['.game-resource-generation-tasks-anchor'],
360,
);
expect(declaration(narrowAnchor, 'left')).toBe('0.5rem');
expect(declaration(narrowAnchor, 'right')).toBe('0.5rem');
expect(declaration(narrowAnchor, 'justify-content')).toBe('flex-end');
const narrowSidebar = resolved(
['.game-resource-generation-tasks-sidebar'],
360,
);
expect(declaration(narrowSidebar, 'width')).toBe('auto');
});
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)',
);
});
});