2a2e4c1cea
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 draftKey 拆成 resourcePath, `resourceCanvasResourceEditSubmissionBlocksResource` 被快速编辑与生成动画共用; 此前动画这条键对不上、拦不住,重开面板再提交会复用同一 operationId 卡在原生锁上, 或(改了提示词)直接多出一笔付费生成 - 样式缺口守卫扩到三块面板(动画面板 / 快速编辑面板 / 生图面板),判据改为按 「选择器分支 + 声明」逐条比对;两个面板的实测缺口一并照抄补上(快速编辑面板的 `overflow: auto → visible` 与窄屏底栏两条按宿主差异显式登记,不静默漂移) - 窄屏只搬动画面板那一支:网页端 ≤760px 会把快速编辑浮层也变成整宽底栏,AGC 的 快速编辑是「居中贴资源卡」的绝对定位,跟随它会压住栏目工具栏与任务侧栏 - 用例路径不再依赖 cwd:新增 tests/repoPath.ts(按 import.meta.url 反推仓库根), 19 个用例文件的 `resolve(process.cwd(), …)` 统一改用它;顺手修掉 `HOST_STYLE_SHEETS.filter(existsSync)` 把仓库相对路径交给 cwd 解析的假红 - 清掉两处死代码:不可达的 `quickEditPanel.status === 'generating'` 判据与 未被使用的 prune 导出
148 lines
6.0 KiB
TypeScript
148 lines
6.0 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
import { repoPath } from './repoPath';
|
||
|
||
/**
|
||
* 「当前使用」资源卡的发光档案:声明级断言。
|
||
*
|
||
* jsdom 不加载样式表、也不做动画,肉眼效果只能由真机确认;这里钉住的是这次改动的实质——
|
||
* 外向扩散层的档位(旧值 14% 是描边不是发光)、呼吸的实现方式(伪元素只动 opacity)、
|
||
* 选中态提权的形态,以及 `prefers-reduced-motion` 下仍然可见。
|
||
*/
|
||
function stylesSource() {
|
||
return readFileSync(
|
||
repoPath('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);
|
||
});
|
||
});
|