// @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 { 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) => ['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)', ); }); });