Files
Genarrative/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx
T
suzmii 2a2e4c1cea 按 review 修:动画在途拦截、样式守卫扩到三面板、窄屏差异显式放行、用例不再依赖 cwd
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 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 导出
2026-09-21 01:23:35 +08:00

375 lines
14 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.
// @vitest-environment jsdom
import { existsSync, readFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { ImageCanvasProjectAssetPickerDialog } from '../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
import { REPO_ROOT } from './repoPath';
/**
* 「选择替换素材」弹窗在 AGC 的面板底色契约。
*
* 现象:AGC 资源工作台里这个弹窗的面板本体全透明,背后模糊的资源画布直接透出来。
* 面板用的是共享 `UnifiedModal` 产出的 `platform-modal-shell`,而这条规则原先只写在网页端
* 整站样式表 `src/index.css` 里;AGC 是独立宿主,只引入 `@genarrative/shared/styles.css`
* 与 `theme.css`,于是这个类一条声明都命中不到——没有底色、没有边框、没有阴影。
*
* jsdom 不计算外部 CSS,所以这里不做"渲染出来看颜色",而是把两半分别钉死:
* 1) DOM 契约:组件真的把 `platform-modal-shell` / `platform-overlay` 挂上去了,且遮罩
* 自带 `platform-theme--light`(否则 `var(--platform-modal-fill)` 解析为空,同样透明);
* 2) 样式表契约:从 AGC 入口文件按真实 import 关系解析出"AGC 到底加载了哪些样式表",
* 断言这批表里有该类规则、声明引用主题 token,且 token 在 light 块里是接近不透明的暖白。
*
* 这是**弱验证**(声明级,不是真机观感)。真机判据见文件末尾注释。
*/
const AGC_ROOT = resolve(REPO_ROOT, 'apps/ai-game-creator-shell');
const PLATFORM_MODAL_SHELL = '.platform-modal-shell';
const PLATFORM_MODAL_BACKDROP = '.platform-modal-backdrop';
const PLATFORM_OVERLAY = '.platform-overlay';
/**
* 宿主的样式表清单:webkit 端整站表、AGC 自己的业务表与宿主 chrome 表、以及共享表。
* 「同一个类只准在一处定义」这条契约要跨这几张表比。
*/
const HOST_STYLE_SHEETS = [
'src/index.css',
'apps/ai-game-creator-shell/src/styles.css',
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css',
'packages/shared/src/components/styles.css',
];
const SHARED_STYLE_SHEET = 'packages/shared/src/components/styles.css';
/** 由 Vite/Tailwind 插件接管的样式入口,没有对应文件。 */
const PLUGIN_STYLE_ENTRIES = new Set(['tailwindcss']);
type CssRule = {
file: string;
selector: string;
declarations: Map<string, string>;
};
function absoluteSheetPath(sheetPath: string) {
return resolve(REPO_ROOT, sheetPath);
}
/** 清单与断言一律用仓库相对的正斜杠路径,免得 Windows 上比对不上。 */
function toRepoPath(absolutePath: string) {
return relative(REPO_ROOT, absolutePath).replace(/\\/gu, '/');
}
/* ============================================================
AGC 实际加载了哪些样式表:按入口文件的 import 关系解析,不手写清单。
============================================================ */
function resolveStyleSpecifier(specifier: string, fromFile: string): string {
if (specifier.startsWith('.')) {
return resolve(dirname(fromFile), specifier);
}
const match = /^(@[^/]+\/[^/]+|[^@/][^/]*)(\/.*)?$/u.exec(specifier);
expect(match, `样式入口无法解析:${specifier}`).not.toBeNull();
const packageName = match![1]!
.replace(/^@genarrative\//u, '')
.replace(/\//gu, '-');
const packageDir = resolve(REPO_ROOT, 'packages', packageName);
const manifestPath = resolve(packageDir, 'package.json');
if (!existsSync(manifestPath)) {
throw new Error(
`AGC 新增了未知样式入口「${specifier}」:请把它的解析方式补进本用例的清单。`,
);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
exports?: Record<string, string>;
};
// `@genarrative/shared/styles.css` → 包名 + exports 的键形态 `./styles.css`。
const subpath = match![2] ? `.${match![2]}` : '.';
const target = manifest.exports?.[subpath];
if (!target) {
throw new Error(
`样式入口「${specifier}」在 packages/${packageName}/package.json 的 exports 里没有对应项。`,
);
}
return resolve(packageDir, target);
}
/** 从 AGC 入口(`src/main.tsx`)出发,按 import / @import 关系收集它加载的样式表。 */
function collectAgcLoadedStyleSheets(): string[] {
const entryFile = resolve(AGC_ROOT, 'src/main.tsx');
const pending = [
...readFileSync(entryFile, 'utf8').matchAll(
/import\s+'(?<specifier>[^']+\.css)'/gu,
),
].map((match) => resolveStyleSpecifier(match.groups!.specifier!, entryFile));
const loaded: string[] = [];
while (pending.length > 0) {
const sheet = pending.pop()!;
if (loaded.includes(sheet)) {
continue;
}
loaded.push(sheet);
const source = readFileSync(sheet, 'utf8');
for (const match of source.matchAll(/@import\s+'(?<specifier>[^']+)'/gu)) {
const specifier = match.groups!.specifier!;
if (PLUGIN_STYLE_ENTRIES.has(specifier)) {
continue;
}
pending.push(resolveStyleSpecifier(specifier, sheet));
}
}
return loaded.sort();
}
/* ============================================================
声明级求值:够读本用例关心的类规则(顶层 + 一层媒体查询容器)。
============================================================ */
function splitSelectorList(selectorText: string): string[] {
const selectors: string[] = [];
let depth = 0;
let current = '';
for (const char of selectorText) {
if (char === '(' || char === '[') {
depth += 1;
} else if (char === ')' || char === ']') {
depth -= 1;
}
if (char === ',' && depth === 0) {
selectors.push(current.trim().replace(/\s+/gu, ' '));
current = '';
continue;
}
current += char;
}
if (current.trim()) {
selectors.push(current.trim().replace(/\s+/gu, ' '));
}
return selectors.filter(Boolean);
}
function parseDeclarations(body: string): Map<string, string> {
const declarations = new Map<string, string>();
for (const chunk of body.split(';')) {
const separator = chunk.indexOf(':');
if (separator < 0) {
continue;
}
const property = chunk.slice(0, separator).trim();
const value = chunk
.slice(separator + 1)
.trim()
.replace(/\s+/gu, ' ');
if (property) {
declarations.set(property, value);
}
}
return declarations;
}
function readRules(file: string): CssRule[] {
const rules: CssRule[] = [];
const source = readFileSync(file, 'utf8').replace(
/\/\*[\s\S]*?\*\//gu,
(comment) => comment.replace(/[^\n]/gu, ' '),
);
const readBlock = (text: string, start: number) => {
let depth = 1;
let index = start;
while (index < text.length && depth > 0) {
if (text[index] === '{') {
depth += 1;
} else if (text[index] === '}') {
depth -= 1;
}
index += 1;
}
return { body: text.slice(start, index - 1), end: index };
};
const walk = (text: string) => {
let cursor = 0;
let buffer = '';
while (cursor < text.length) {
const char = text[cursor]!;
if (char === '{') {
const prelude = buffer.trim().replace(/\s+/gu, ' ');
buffer = '';
const { body, end } = readBlock(text, cursor + 1);
cursor = end;
if (prelude.startsWith('@')) {
// 媒体查询/关键帧等容器:继续往里找声明块。
walk(body);
continue;
}
rules.push({
file,
selector: prelude,
declarations: parseDeclarations(body),
});
continue;
}
if (char === ';' && !buffer.includes('{')) {
// `@import …;` / `@source …;` / `@charset …;` 这类语句。
buffer = '';
cursor += 1;
continue;
}
buffer += char;
cursor += 1;
}
};
walk(source);
return rules;
}
/** 找"某个类自己就是一条独立选择器"的规则(不带后代/伪类限定)。 */
function findClassRule(rules: CssRule[], className: string): CssRule | null {
return (
rules.find((rule) =>
splitSelectorList(rule.selector).includes(className),
) ?? null
);
}
/** 取颜色值里每个色停的 alpha`rgb(...)` 视为 1。 */
function colorStopAlphas(value: string): number[] {
return [...value.matchAll(/rgba?\((?<body>[^)]*)\)/gu)].map((match) => {
const parts = match.groups!.body!.split(/[\s,/]+/u).filter(Boolean);
return parts.length >= 4 ? Number(parts[3]) : 1;
});
}
afterEach(() => {
cleanup();
});
describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
const loadedSheets = collectAgcLoadedStyleSheets();
const loadedRules = loadedSheets.flatMap(readRules);
const loadedLabels = loadedSheets.map((sheet) => toRepoPath(sheet));
it('AGC 真的加载到了共享样式表与主题表(清单本身可信)', () => {
expect(loadedLabels).toContain(SHARED_STYLE_SHEET);
expect(loadedLabels).toContain('packages/shared/src/theme.css');
expect(loadedLabels).toContain('apps/ai-game-creator-shell/src/styles.css');
// 整站样式表不在 AGC 的加载清单里——这正是这个类原先"有类名没样式"的原因。
expect(loadedLabels).not.toContain('src/index.css');
// 反向对照:清单里确实有别的共享类规则,说明解析没有落空。
expect(
findClassRule(loadedRules, '.platform-button'),
'AGC 加载的样式表解析结果缺少共享 .platform-button,说明清单解析不可信',
).not.toBeNull();
});
it('AGC 加载的样式表带 platform-modal-shell 的面板底色、边框与阴影', () => {
const rule = findClassRule(loadedRules, PLATFORM_MODAL_SHELL);
expect(
rule,
`${PLATFORM_MODAL_SHELL} 不在 AGC 加载的样式表里(${loadedLabels.join(', ')}):面板会变透明`,
).not.toBeNull();
expect(rule!.declarations.get('background')).toBe(
'var(--platform-modal-fill)',
);
expect(rule!.declarations.get('border')).toBe(
'1px solid var(--platform-modal-border)',
);
expect(rule!.declarations.get('box-shadow')).toBeTruthy();
// 依赖的 token 必须由同一批样式表提供,否则 var() 解析为空、面板依旧透明。
const lightTokens = findClassRule(loadedRules, '.platform-theme--light');
expect(
lightTokens?.declarations.get('--platform-modal-fill'),
'AGC 加载的主题表里必须有 light 主题的 --platform-modal-fill',
).toBeTruthy();
// 「不透明暖白底」是这次的验收判据:所有色停的 alpha 都要接近 1。
const alphas = colorStopAlphas(
lightTokens!.declarations.get('--platform-modal-fill')!,
);
expect(alphas.length).toBeGreaterThan(0);
expect(Math.min(...alphas)).toBeGreaterThanOrEqual(0.9);
});
it('遮罩 platform-overlay 在 AGC 也有底色,不再只剩一层 blur', () => {
const rule = findClassRule(loadedRules, PLATFORM_OVERLAY);
expect(
rule,
`${PLATFORM_OVERLAY} 不在 AGC 加载的样式表里:遮罩没有底色`,
).not.toBeNull();
expect(rule!.declarations.get('background')).toBe(
'var(--platform-overlay-fill)',
);
const lightTokens = findClassRule(loadedRules, '.platform-theme--light');
expect(
lightTokens?.declarations.get('--platform-overlay-fill'),
).toBeTruthy();
});
it('组件挂上的宿主类与上面的样式表契约同源,且遮罩自带主题类', () => {
render(
<ImageCanvasProjectAssetPickerDialog
open
assets={[]}
selectedAssetIds={[]}
singleSelect
selectionNoun="替换素材"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '选择替换素材' });
expect(dialog.className).toContain(PLATFORM_MODAL_SHELL.slice(1));
// 弹窗 portal 到 body:主题类必须挂在遮罩上,面板才拿得到 --platform-modal-fill。
const overlay = dialog.parentElement;
expect(overlay, '弹窗面板应当挂在遮罩里').not.toBeNull();
expect(overlay!.className).toContain(PLATFORM_OVERLAY.slice(1));
expect(overlay!.className).toMatch(/platform-theme--(light|dark)/u);
});
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
/*
这里必须**先转绝对路径再判断存在**`filter(existsSync)` 拿到的是仓库相对路径,`existsSync`
按 `process.cwd()` 解析——从 `apps/ai-game-creator-shell` 跑时四张表全被判成不存在,
断言退化成「谁都没定义」的假绿/假红(本次就是从这条假红里揪出来的)。
*/
const hostRules = HOST_STYLE_SHEETS.filter((sheet) =>
existsSync(absoluteSheetPath(sheet)),
).flatMap((sheet) => readRules(absoluteSheetPath(sheet)));
for (const className of [
PLATFORM_MODAL_SHELL,
PLATFORM_MODAL_BACKDROP,
PLATFORM_OVERLAY,
]) {
const definingSheets = [
...new Set(
hostRules
.filter((rule) =>
splitSelectorList(rule.selector).includes(className),
)
.map((rule) => toRepoPath(rule.file)),
),
];
expect(
definingSheets,
`${className} 只应定义在共享样式表里,别在宿主业务样式里再抄一份`,
).toEqual([SHARED_STYLE_SHEET]);
}
});
});
/**
* 真机判据(弱验证之外的确认方式):
* 打开 AGC 资源工作台 → 卡片工具条「替换素材」→「选择替换素材」弹窗:
* 面板本体应当是不透明的暖白底(与「标签」「重命名」等平台弹窗一致),
* 背后模糊的画布不应该透过卡片、搜索框、底部操作条;同时面板有 1px 边框与投影。
*/