Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCardCurrentVersionStyle.test.ts
T
suzmii e088ede217 让「当前使用」的资源卡真的发光,并修掉版本高亮的两处既有缺陷
- .game-resource-card.is-current-version 的外向扩散层从 0 0 0 2px /14%(那是描边)改成 0 0 0 3px /32% + 0 0 20px /34% 的真发光,边框口径仍是 1px
- 新增 game-resource-card-current-version-breathe 关键帧与 ::after 光环(只呼吸 opacity,照本页 .ui-editor-status-attention 的既有写法):声明档位 38%,呼吸区间 0.58 ↔ 1,视觉上约 22% ↔ 38%
- prefers-reduced-motion 下关掉动画但保留可见档位(opacity 0.75),收敛动画不收敛可见性
- 缺陷①:.game-resource-card.is-relation-version-binding 原本只写 border-color,而卡片本体 border: 0,规则从未生效(「点击版本卡后高亮绑定资源」这条 PRD 语义一直看不见);改为真实的 1px 边框 + ring,并把该规则移到 .is-current-version 之前,让常驻光环在两者同时命中时仍然赢
- 缺陷②:当前版本规则与 hover/focus/selected 同权重且在后,导致当前版本卡几乎看不出被选中;新增 .is-current-version.is-selected / :hover / :focus-within / .is-dragging 变体,显式提权到 (0,3,0) 并带上选中态自己那层阴影
- 测试:新增 resourceCardCurrentVersionStyle.test.ts(外向扩散层档位、呼吸实现方式与关键帧区间、选中态变体权重更高、reduced-motion 下仍可见、版本聚焦规则不再是死规则)
- 测试:resourceVersionSwitch 补 data-used-by-current-version 的 DOM 断言(命中为 "true"、未命中不含该属性、切版本后跟着换手)
- 测试夹具修正:project-development.suite 的版本夹具把 slotId 从 'player' 改成恒等映射 'asset:asset-player'('historical' → 'asset:asset-removed'),否则「当前版本」判定永远不命中、照它写断言必成假守卫;并补一条自证断言钉住它真的命中
2026-09-11 16:42:58 +08:00

147 lines
6.0 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.
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);
});
});