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
- 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 全通过
421 lines
15 KiB
TypeScript
421 lines
15 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 { 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);
|
||
});
|
||
});
|