Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx
T
suzmii cd98e0603e
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
合并 master:音频后台化与 Mac 构建管线进分支,冲突按新口径解
- index.tsx 的 audio/video 提交:取 master 侧(#437 已把它改成后台任务队列 + 项目内账本),
  卡片浮层那套本地提交记录在这条链路上不再需要——队列本身就是它的可见性与幂等来源
- PRD 更新时间两边的两条事实合成一行;decision-log 保留 master 的音频章节与我们的
  派生任务/提交即关章节,并把「appSurface 16 条既有失败」更正为 cwd 假红的口径
- 样式守卫里撤掉「生图面板」这条探针:它走 AGC 自有类名,本就不吃
  `image-canvas-editor__*` 那一族(守卫自身加的空集断言把这条假探针暴露了出来)
- 合并后从仓库根验证:appSurface 538 通过 / 17 跳过 / 0 失败;AGC 定向 84 条、typecheck、
  check:encoding、git diff --check 全通过
2026-09-21 01:33:06 +08:00

421 lines
15 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 { 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;
}
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);
});
});