// @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; }; 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; }; // `@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+'(?[^']+\.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+'(?[^']+)'/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 { const declarations = new Map(); 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?\((?[^)]*)\)/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( {}} 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 边框与投影。 */