import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; /** * 「当前使用」资源卡的发光档案:声明级断言。 * * jsdom 不加载样式表、也不做动画,肉眼效果只能由真机确认;这里钉住的是这次改动的实质—— * 外向扩散层的档位(旧值 14% 是描边不是发光)、呼吸的实现方式(伪元素只动 opacity)、 * 选中态提权的形态,以及 `prefers-reduced-motion` 下仍然可见。 */ function stylesSource() { return readFileSync( resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); } /** 取 CSS 源文件里某条规则的声明体。 */ function ruleBody(styles: string, selector: string) { const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); expect(match, `${selector} 规则缺失`).not.toBeNull(); return match![1]!; } /** * 取 `box-shadow` 里「外向扩散层」的透明度档位(百分比数字)。 * * 外向扩散层 = 偏移为 `0 0` 的层(`0 0 0 Npx` 的 ring 或 `0 0 Npx` 的发光), * 与 `0 6px 18px` 这类带偏移的投影区分开。只在层边界(下一个层以 `0 ` 开头)拆逗号, * 避免把 `rgb(216 115 66 / 14%)` 里的空格当分隔符。 */ function outwardGlowAlphas(ruleBodyText: string) { // 先只取 `box-shadow` 的值(到第一个分号),否则第一层会粘着属性名而认不出来。 const value = /box-shadow:\s*([^;]+);/u.exec(ruleBodyText)?.[1] ?? ''; return value .split(/,(?=\s*0 )/u) .map((layer) => layer.trim()) .filter((layer) => layer.startsWith('0 0 ')) .map((layer) => Number(/\/\s*(\d+)%/u.exec(layer)?.[1] ?? 0)); } /** 选择器权重里可数的那部分:类与伪类的个数(只在同类选择器之间比高低)。 */ function selectorClassCount(selector: string) { return (selector.match(/[.:]/gu) ?? []).length; } /** 按花括号配平取出包含指定选择器的 `@media (prefers-reduced-motion: reduce)` 块。 */ function reducedMotionBlockFor(styles: string, selectorFragment: string) { const marker = '@media (prefers-reduced-motion: reduce)'; let index = styles.indexOf(marker); while (index >= 0) { const open = styles.indexOf('{', index); let depth = 0; let end = open; for (let cursor = open; cursor < styles.length; cursor += 1) { if (styles[cursor] === '{') { depth += 1; } else if (styles[cursor] === '}') { depth -= 1; if (depth === 0) { end = cursor; break; } } } const block = styles.slice(open + 1, end); if (block.includes(selectorFragment)) { return block; } index = styles.indexOf(marker, index + marker.length); } return null; } describe('当前使用资源卡的发光档案', () => { it('当前版本资源卡带常驻呼吸光环,选中态提权压过它', () => { const styles = stylesSource(); // 「当前使用」必须在卡片本体的 box-shadow 上有真的外向扩散层。旧值是 // `0 0 0 2px rgb(216 115 66 / 14%)`——那是描边不是发光,本用例就是钉这次改动的。 const base = ruleBody(styles, '\\.game-resource-card\\.is-current-version'); const glowAlphas = outwardGlowAlphas(base); expect(glowAlphas.length).toBeGreaterThanOrEqual(2); expect(Math.max(...glowAlphas)).toBeGreaterThanOrEqual(30); expect(base).toMatch(/0 0 20px rgb\(216 115 66 \/ 3[0-9]%\)/u); // 发光不靠加粗边框:边框口径仍是 1px。 expect(base).toMatch(/border:\s*1px solid/u); // 「呼吸」挂在伪元素上,只动 opacity(本页既有写法),不碰布局也不吃点击。 const ring = ruleBody( styles, '\\.game-resource-card\\.is-current-version::after', ); expect(ring).toMatch( /animation:\s*game-resource-card-current-version-breathe/u, ); expect(ring).toMatch(/pointer-events:\s*none/u); expect(ring).toMatch(/border-radius:\s*inherit/u); const breathe = /@keyframes game-resource-card-current-version-breathe\s*\{([\s\S]*?)\n\}/u.exec( styles, )?.[1] ?? ''; // 声明档位 38%,呼吸区间 0.58 ↔ 1 ⇒ 视觉上约 22% ↔ 38%。 expect(outwardGlowAlphas(ring)).toEqual([38, 38]); expect(breathe).toMatch(/0%,\s*100%\s*\{\s*opacity:\s*0\.58;/u); expect(breathe).toMatch(/50%\s*\{\s*opacity:\s*1;/u); // 选中 / 悬停 / 拖拽必须赢过常驻光环:显式提权到 (0,3,0), // 而不是继续和 `.is-selected` 拼源文件顺序(旧写法下当前版本卡看不出被选中)。 const selectedVariant = /(\.game-resource-card\.is-current-version\.is-selected,[\s\S]*?)\{([^}]*)\}/u.exec( styles, ); expect(selectedVariant).not.toBeNull(); expect(selectorClassCount(selectedVariant![1]!)).toBeGreaterThan( selectorClassCount('.game-resource-card.is-current-version'), ); // 选中态自己那层阴影真的写进了这个变体。 expect(selectedVariant![2]).toContain('0 8px 22px rgb(195 105 62 / 15%)'); expect( Math.max(...outwardGlowAlphas(selectedVariant![2]!)), ).toBeGreaterThanOrEqual(30); // 收敛动画但不收敛可见性:静态档位必须仍然看得见。 const reduced = reducedMotionBlockFor( styles, '.game-resource-card.is-current-version::after', ); expect(reduced).not.toBeNull(); expect(reduced).toMatch(/animation:\s*none/u); expect( Number(/opacity:\s*(0?\.\d+)/u.exec(reduced!)?.[1]), ).toBeGreaterThanOrEqual(0.5); // 「点击版本卡后高亮绑定资源」的旧规则只写 `border-color`,而卡片本体 `border: 0`, // 从未生效;现在必须是能落地的边框 + ring。 const relation = ruleBody( styles, '\\.game-resource-card\\.is-relation-version-binding', ); expect(relation).toMatch(/border:\s*1px solid/u); expect(Math.max(...outwardGlowAlphas(relation))).toBeGreaterThanOrEqual(30); }); });