Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasCharacterAnimationPanelStyle.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

459 lines
16 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, 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 { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
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/<pkg>/<subpath>` → `packages/<pkg>/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<string, string>;
};
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+'(?<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!;
// 由 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<CharacterAnimationPanelState | null>(
initialPanel,
);
return panel ? (
<ImageCanvasCharacterAnimationPanelView
panel={panel}
sourceLayer={sourceLayer}
style={{ left: 12, top: 24 }}
price={80}
setCharacterAnimationPanel={setPanel}
onUpdateDuration={vi.fn()}
onSubmit={vi.fn()}
/>
) : null;
}
const { container } = render(<Harness />);
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<QuickEditPanelState | null>(() =>
createResourceQuickEditPanelDraft(sourceLayer),
);
return panel ? (
<ImageCanvasQuickEditPanelView
panel={panel}
style={{ left: 12, top: 24 }}
setQuickEditPanel={setPanel}
onSubmit={vi.fn()}
/>
) : null;
}
const { container } = render(<Harness />);
return container;
}
function assetGenerationPanel() {
const action: ResourceCanvasAssetToolAction = {
route: 'asset',
audioKind: null,
id: 'generate-image',
label: '生成图片',
assetKind: 'image',
assetName: '素材 生成图片',
promptPlaceholder: '描述要生成什么',
adjustableDimensions: true,
aspectRatio: '1:1',
imageSize: '1K',
requiresIconSpecReference: false,
writesIconSpecReference: false,
};
const referenceAsset: GameCreationAppAssetManifestEntry = {
id: 'asset-a',
kind: 'image',
mediaType: 'image/png',
localPath: 'assets/素材-a.png',
source: { kind: 'canvas' },
};
const { container } = render(
<ResourceCanvasAssetGenerationPanelView
action={action}
assets={[referenceAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => undefined}
/>,
);
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 样式覆盖', () => {
for (const probe of [
{ name: '「生成动画」面板', render: animationPanel },
{ name: '快速编辑面板', render: quickEditPanel },
{ name: '生图(资源生成)面板', render: assetGenerationPanel },
]) {
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);
});
});