Files
Genarrative/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx
T
suzmii 0749241908 修复 AGC「选择替换素材」弹窗面板透明:平台弹窗外壳规则移入共享样式表
- 根因:弹窗面板用的 `platform-modal-shell`(遮罩 `platform-overlay`)规则原先只写在网页端整站样式表 src/index.css;AGC 只加载 packages/shared/src/components/styles.css 与 theme.css,类名挂上了但一条声明都没命中,面板因此无底色、无边框、无阴影,背后模糊的资源画布直接透出
- 把 platform-modal-shell / platform-modal-backdrop / platform-overlay 三条宿主类规则从 src/index.css 移进 packages/shared/src/components/styles.css:两个宿主都 import 这张表,规则只保留一份,不在 AGC 业务样式里复制平行副本;三处声明逐字照搬,网页端层叠结果不变
- 新增 apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx:从 src/main.tsx 按真实 import 关系解析出「AGC 到底加载了哪些样式表」,断言清单里有这三条规则、背景引用主题 token、light 主题下 --platform-modal-fill 各色停 alpha ≥ 0.9;DOM 契约断言面板挂 platform-modal-shell、遮罩带 platform-overlay 与 platform-theme--light;并断言这三条规则只在共享表里各定义一次(禁止平行拷贝)
- docs/project-memory/shared-memory/pitfalls.md 记录「共享弹窗的宿主类必须落在所有宿主都会加载的样式表里」的排障经验
2026-09-12 18:48:31 +08:00

368 lines
13 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';
/**
* 「选择替换素材」弹窗在 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 REPO_ROOT = process.cwd();
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('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
const hostRules = HOST_STYLE_SHEETS.filter(existsSync).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 边框与投影。
*/