Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts
T
suzmii 7148ff9eaf
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 7m22s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 7m32s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m46s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 7m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
验收修正:任务锚点改挂画布那一格,展开后开关让位
- 重叠:右上角锚点不再用 position: absolute + 写死的 top(工具条是 stage 第一行、高度随按钮换行变化),改为 stage 网格里与工作面同一个单元格的条目:grid-row 按 placement 分档 + grid-column 1 + justify-self end + align-self start + margin;同格的三处容器也显式钉住第 1 列,避免画布被自动列放置挤进隐式第二列。
- 并排:开关只在完全收起时渲染(收起动画期间也不画),展开态画布右上角只留面板,收起走面板头部 × 或点画布外部。
- 测试:面板用例新增「展开时开关让位」、计数用例拆成收起查开关 / 展开查面板;样式用例改判网格条目坐标与窄屏 justify-self: stretch;dismiss 用例改走面板 × 收起、收起后开关回来能再打开。
- 文档:PRD 3.10 与更新时间、decision-log 新增 2026-09-21 验收修正条目、待办文档补登记该修正。
2026-09-21 04:33:26 +08:00

303 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']);
// 锚点是 stage 网格里与工作面同一个单元格的条目:贴右贴顶由网格给,不再用写死 top 的绝对定位
//(写死的 top 会被工具条换行顶穿,验收现场那条「生成任务和菜单栏重叠」就是这么来的)。
expect(anchor.has('position')).toBe(false);
expect(declaration(anchor, 'grid-row')).toBe('3');
expect(declaration(anchor, 'grid-column')).toBe('1');
expect(declaration(anchor, 'justify-self')).toBe('end');
expect(declaration(anchor, 'align-self')).toBe('start');
expect(declaration(anchor, 'z-index')).toBe('40');
// 画布顶边内缩一小段,不压在工具条那一行上。
expect(declaration(anchor, 'margin')).toBe('0.85rem');
// 运行表现层右上角被版本入口(`game-run-version-picker`:top 20px / right 20px)占着,
// 这一档必须把开关压到那枚版本入口下面(内缩上边距 3.5rem)。
const runAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']",
]);
expect(declaration(runAnchor, 'grid-row')).toBe('2 / -1');
expect(declaration(runAnchor, 'margin')).toContain('3.5rem');
// UI 编辑器那一档与资源画布同档(第二行),不引入第三套坐标。
const editorAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor']",
]);
expect(declaration(editorAnchor, 'grid-row')).toBe('2');
expect(declaration(editorAnchor, 'margin')).toBe('0.85rem');
// 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影;
// 取值仍然全部走平台 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, 'justify-self')).toBe('stretch');
expect(declaration(narrowAnchor, 'margin')).toBe('0.5rem');
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)',
);
});
});