2a2e4c1cea
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 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 导出
459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
// @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);
|
||
});
|
||
});
|