370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
// @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 边框与投影。
|
||
*/
|