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'),否则「当前版本」判定永远不命中、照它写断言必成假守卫;并补一条自证断言钉住它真的命中
147 lines
6.0 KiB
TypeScript
147 lines
6.0 KiB
TypeScript
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);
|
||
});
|
||
});
|