// @vitest-environment jsdom import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { cleanup, render } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ImageCanvasCharacterAnimationPanelView } from '../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView'; import type { CanvasLayer, CharacterAnimationPanelState, QuickEditPanelState, } from '../../../src/components/image-editor/ImageCanvasEditorTypes'; import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { createResourceQuickEditPanelDraft } from '../src/features/resource-canvas/resourceCanvasQuickEditModel'; /** * 共享画布面板在 AGC 的样式覆盖契约(动画面板 / 快速编辑面板 / 生图面板)。 * * 现象(客户端验收现场,两次):①「生成动画」面板只剩通用 composer 外观——素材缩略图、动作预设、 * `生成NN泥点` 按钮与关闭键全部落回默认流布局,关闭键还跑到面板正中;② 快速编辑 composer 的同族 * 样式也缺过。根因是这套 `image-canvas-editor__*` 规则只写在网页端整站表 `src/index.css` 里, * 而 AGC 是独立宿主,宿主的 `resourceCanvasChrome.css` 只能逐条照抄。 * * jsdom 不计算外部 CSS,所以这里不做"渲染出来看观感",而是把两半分别钉死: * 1) DOM 契约:真渲染出这三块面板(含 `reference-chip--${tone}` 这种运行时拼出来的类); * 2) 样式表契约:从 AGC 入口 `src/main.tsx` 按真实 import 关系解析出"AGC 到底加载了哪些样式表", * 逐条比对**网页端命中这份 DOM 的规则(选择器分支 + 声明)AGC 是否都有一条**。 * * 比对必须按「选择器分支 + 声明」而不是类名或整串选择器:同名规则在网页端会出现两次 * (`__generation-close` 先给基础外观、再给浮层内的绝对定位),只搬第一条照样"类名有规则"却跑偏; * 而 `A, B { … }` 里只命中 A 的那一支,也不该逼宿主连 B 一起搬。 * * 这是**弱验证**(声明级,不是真机观感)。真机判据:资源卡 → 生成动画 → 缩略图、动作预设一排、 * 按钮落在面板右下角、右上角有关闭键;窄屏动画面板落到底部整宽、按钮整宽。 */ /** * 显式放行的宿主差异(每条都写明原因,避免"守卫红了就加白名单"式的静默漂移): * * 1. 快速编辑浮层**窄屏**:网页端在 `@media (max-width: 760px)` 把它与动画面板一起变成 * `position: fixed` 的整宽底栏(下一条是配套的 bottom 计算)。AGC 的快速编辑浮层是 * 「居中贴资源卡」的绝对定位(`transform: translateX(-50%)` + 内联 left/top),窄屏宽度由 * `calc(100vw - 1.5rem)` 自己收;改成底栏会压住栏目画布左下角工具栏与左侧「生成任务」侧栏。 * 2. 快速编辑浮层**桌面档**:网页端 `overflow: auto`,AGC 必须 `overflow: visible`——AGC 没有 * portal,共享组件的下拉弹层就地渲染,面板一旦滚动就会把弹层裁掉(见 `resourceCanvasChrome.css` * 快速编辑面板那段注释)。 */ const ACCEPTED_AGENT_HOST_DIVERGENCES = new Set([ '.image-canvas-editor__quick-edit-panel { position: fixed; left: 0.75rem !important; right: 0.75rem; top: auto !important; bottom: 0.75rem; width: auto; max-height: min(72vh, 34rem); transform: none; }', '.image-canvas-editor__quick-edit-panel.image-canvas-editor__generation-composer { width: auto; bottom: calc(9rem + env(safe-area-inset-bottom, 0px)); }', '.image-canvas-editor__quick-edit-panel { position: absolute; z-index: 14; display: grid; width: min(24rem, calc(100vw - 1.5rem)); max-height: min(32rem, calc(100% - 1.5rem)); gap: 0.58rem; overflow: auto; border: 1px solid rgba(148, 163, 184, 0.36); border-radius: 1.1rem; background: rgba(255, 255, 255, 0.96); padding: 0.72rem; color: #1f2937; box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22); transform: translateX(-50%); }', ]); const TEST_DIR = dirname(fileURLToPath(import.meta.url)); const AGC_ROOT = resolve(TEST_DIR, '..'); const REPO_ROOT = resolve(AGC_ROOT, '..', '..'); const PANEL_CLASS = 'image-canvas-editor__character-animation-panel'; type CssRule = { selector: string; body: string }; /** `@genarrative//` → `packages//package.json` 的 exports 指向的真实文件。 */ 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; }; 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 入口出发按 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!; // 由 Vite / Tailwind 插件接管的入口没有实体文件。 if (specifier === 'tailwindcss') { continue; } pending.push(resolveStyleSpecifier(specifier, sheet)); } } return loaded.sort(); } /** 够用的规则读取:取每一段 `选择器 { 声明 }`,跳过 `@media` 这类容器本身。 */ function readRules(file: string): CssRule[] { const source = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//gu, ''); const rules: CssRule[] = []; for (const match of source.matchAll(/([^{}]+)\{([^{}]*)\}/gu)) { const selector = match[1]!.trim().replace(/\s+/gu, ' '); if (!selector || selector.startsWith('@')) { continue; } rules.push({ selector, body: match[2]!.trim().replace(/\s+/gu, ' ') }); } return rules; } /** * 选择器能不能命中这一份 DOM:逐条 `matches()` 试。 * * 只做「元素是否可能匹配」这一层,所以把状态类伪类(`:hover` / `:focus-within` / `:disabled`…) * 与伪元素摘掉再试;`::before` / `::after` 这类由真实浏览器生成的内容不参与判断。 */ function ruleMatchesPanel(selector: string, elements: readonly Element[]) { const stripped = selector .replace(/::[a-z-]+/gu, '') .replace(/:[a-z-]+(\([^)]*\))?/gu, '') .trim(); if (!stripped) { return false; } return elements.some((element) => { try { return element.matches(stripped); } catch { return false; } }); } function declarationOf( rules: readonly CssRule[], className: string, property: string, ): string | null { const dot = `.${className}`; for (const rule of rules) { const selectors = rule.selector.split(',').map((item) => item.trim()); // 只认「这个类自己那一档」的声明:`.cls` 精确出现,或 `.cls 后代`。 const owns = selectors.some( (selector) => selector === dot || selector.startsWith(`${dot} `) || selector.startsWith(`${dot}:`) || selector.startsWith(`${dot}[`) || selector.endsWith(` ${dot}`), ); if (!owns) { continue; } for (const chunk of rule.body.split(';')) { const separator = chunk.indexOf(':'); if (separator < 0) { continue; } if (chunk.slice(0, separator).trim() === property) { return chunk .slice(separator + 1) .trim() .replace(/\s+/gu, ' '); } } } return null; } function animationPanel() { const sourceLayer: CanvasLayer = { id: 'layer-a', resourceId: 'resource-a', title: 'char_hero_back.png', src: 'data:image/png;base64,Y2hhcmFjdGVy', x: 0, y: 0, width: 128, height: 128, originalWidth: 128, originalHeight: 128, zIndex: 1, sourceType: 'uploaded', }; const initialPanel: CharacterAnimationPanelState = { sourceLayerId: 'layer-a', promptText: '待机动作', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, status: 'idle', }; function Harness() { const [panel, setPanel] = useState( initialPanel, ); return panel ? ( ) : null; } const { container } = render(); return container; } function quickEditPanel() { const sourceLayer: CanvasLayer = { id: 'layer-q', resourceId: 'resource-q', title: 'source-art.png', src: 'data:image/png;base64,c291cmNl', x: 0, y: 0, width: 128, height: 128, originalWidth: 128, originalHeight: 128, zIndex: 1, sourceType: 'uploaded', }; function Harness() { const [panel, setPanel] = useState(() => createResourceQuickEditPanelDraft(sourceLayer), ); return panel ? ( ) : null; } const { container } = render(); return container; } afterEach(() => { cleanup(); }); /** * 网页端命中这份 DOM、但 AGC 一条都没有的(选择器分支 + 声明)。 * * 比对键是**分支 + 声明**而不是类名或整串选择器:同名规则在网页端会出现两次,只搬第一条也 * "类名有规则"却跑偏;`A, B { … }` 里只命中 A 的那一支,也不该逼宿主连 B 一起搬。 */ function missingWebRulesFor(container: HTMLElement): { missing: string[]; matchedCount: number; } { const elements = [container, ...Array.from(container.querySelectorAll('*'))]; const matched = readRules(resolve(REPO_ROOT, 'src/index.css')).flatMap( (rule) => rule.selector .split(',') .map((selector) => selector.trim().replace(/\s+/gu, ' ')) .filter( (selector) => selector.includes('image-canvas-editor__') && ruleMatchesPanel(selector, elements), ) .map((selector) => `${selector} { ${rule.body} }`), ); const agcRuleKeys = new Set( collectAgcLoadedStyleSheets() .flatMap(readRules) .flatMap((rule) => rule.selector .split(',') .map( (selector) => `${selector.trim().replace(/\s+/gu, ' ')} { ${rule.body} }`, ), ), ); return { missing: [ ...new Set( matched.filter( (key) => !agcRuleKeys.has(key) && !ACCEPTED_AGENT_HOST_DIVERGENCES.has(key), ), ), ], matchedCount: matched.length, }; } describe('共享画布面板的 AGC 样式覆盖', () => { /* 只探**消费共享 `ImageCanvas*` 组件**的两块面板(动画 / 快速编辑)——它们的类名来自网页端整站表, 宿主必须逐条照抄。生图(资源生成)面板走的是 AGC 自有类名(`game-resource-generation-*` 与 `resource-canvas-asset-generation-*`),不属于这条守卫的对象;它的排布由 `resourceCanvasAssetGenerationReferences.test.tsx` 的结构用例与基类 token 覆盖。 */ for (const probe of [ { name: '「生成动画」面板', render: animationPanel }, { name: '快速编辑面板', render: quickEditPanel }, ]) { it(`${probe.name}:网页端命中它的每条规则,AGC 都有同名同声明的一条`, () => { const { missing, matchedCount } = missingWebRulesFor(probe.render()); expect( matchedCount, `${probe.name}一条网页端规则都没匹配上:判据退化成空集,守卫失效`, ).toBeGreaterThan(0); expect( missing, `${probe.name}缺这些规则(网页端有、AGC 没有):\n${missing.join('\n')}`, ).toEqual([]); }); } it('撑开面板布局的关键声明逐条在位', () => { const rules = collectAgcLoadedStyleSheets().flatMap(readRules); expect(declarationOf(rules, PANEL_CLASS, 'position')).toBe('absolute'); expect(declarationOf(rules, PANEL_CLASS, 'width')).toContain('42rem'); expect( declarationOf( rules, 'image-canvas-editor__generation-composer--character-animation', 'grid-template-columns', ), ).toBe('minmax(0, 1fr)'); expect( declarationOf( rules, 'image-canvas-editor__character-animation-presets', 'display', ), ).toBe('flex'); expect( declarationOf( rules, 'image-canvas-editor__character-animation-preset', 'border-radius', ), ).toBe('999px'); expect( declarationOf( rules, 'image-canvas-editor__character-animation-footer', 'grid-template-columns', ), ).toBe('auto minmax(0, 1fr) auto'); expect( declarationOf( rules, 'image-canvas-editor__character-animation-submit', 'grid-column', ), ).toBe('3'); expect( declarationOf(rules, 'image-canvas-editor__generation-close', 'display'), ).toBe('inline-flex'); expect( declarationOf( rules, 'image-canvas-editor__reference-strip', 'grid-column', ), ).toBe('1 / -1'); expect( declarationOf(rules, 'image-canvas-editor__reference-chip', 'width'), ).toBe('3.95rem'); }); it('判据本身可信:动画面板至少要匹配上十几条网页端规则', () => { const container = animationPanel(); const elements = [ container, ...Array.from(container.querySelectorAll('*')), ]; const matched = readRules(resolve(REPO_ROOT, 'src/index.css')).filter( (rule) => rule.selector.includes('image-canvas-editor__') && rule.selector .split(',') .some((selector) => ruleMatchesPanel(selector, elements)), ); expect(matched.length).toBeGreaterThan(10); }); });