补画布生成入口宿主生命周期验收:占位提交、同 operation 重试与成功落点

- 新增测试文件 resourceCanvasGenerationHostLifecycle.test.tsx,渲染真实 ProjectDevelopmentView
- 覆盖音频入口:点工具出占位、提交后占位进入 submitted(后台仍在跑时的中间态)
- 覆盖失败后原请求重试:复用同一 operationId 与幂等键,不会变成新的付费生成
- 覆盖成功后落点:结果卡按占位位置落盘(手动接管),占位随之被撤掉
- 原生命令全部走假实现,不触发任何真实 Provider 调用
This commit is contained in:
2026-09-17 19:20:31 +08:00
parent 58992330aa
commit 03b5c1c9f4
@@ -0,0 +1,461 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
import {
act,
cleanup,
createGameCreationAppManifest,
fireEvent,
React,
render,
screen,
waitFor,
} from './appSurface/harness';
/**
* 画布生成入口的**宿主生命周期**验收(真实 `ProjectDevelopmentView`,不做 props mock)。
*
* 覆盖三件事——它们都在宿主里(占位、提交身份、落点 effect),单测模型或组件看不到:
* 1. 音频 / 背景音乐入口:点工具立刻出占位,提交后那张占位进入 `submitted`
* 2. 失败后用同一份请求重试:复用**同一个 operationId / 幂等键**,不会变成新的付费生成;
* 3. 成功后结果卡落到占位**最新位置**、占位被撤掉(结果接管它的位置)。
*
* 原生命令全部走本文件的假实现,不触发任何真实 Provider 调用。
*/
const PROJECT_ID = 'generation-host-project';
const PROJECT_PATH = '/tmp/generation-host-project';
const NEW_ASSET_ID = 'asset-bgm-1';
const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`;
type AssetFixture = GameCreationAppAssetManifestEntry;
type LayoutWrite = {
projectPath: string;
mode: string;
positions: ProjectResourceCanvasPosition[];
};
function imageAsset(id: string, fileName: string): AssetFixture {
return {
id,
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: `assets/${fileName}`,
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
function bgmAsset(id: string): AssetFixture {
return {
id,
kind: 'background-music',
category: 'audio',
mediaType: 'audio/mpeg',
localPath: 'assets/bgm.mp3',
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
function seedBgmAsset(id: string): AssetFixture {
return { ...bgmAsset(id), localPath: `assets/${id}.mp3` };
}
function manifestFor(
projectId: string,
assets: AssetFixture[],
): GameCreationAppManifest {
return {
...createGameCreationAppManifest(projectId, `${projectId} 项目`),
assets: assets.map((asset) => structuredClone(asset)),
};
}
/**
* 只铺这条链路真正用到的本地命令;未知命令返回 `undefined` 并记账(不抛错),
* 免得画布里别的入口多调一个命令就把这组用例整体带红。
*/
function installHostTauri(options: {
assets: AssetFixture[];
deriveResults: Array<
| { ok: true; assetId: string; hold?: boolean }
| { ok: false; message: string }
>;
}) {
const layoutWrites: LayoutWrite[] = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const unexpectedCommands: string[] = [];
let revision = 0;
let releaseHeldDerive: (() => void) | null = null;
const positions: ProjectResourceCanvasPosition[] = [];
const invoke = vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: 1 };
}
if (command === 'read_local_project_resource_graph') {
return {
nodes: [],
taskFlowIds: [],
producerAssignments: [],
dependencyDepths: options.assets.map((asset) => ({
resourceId: `asset:${asset.id}`,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: String(args?.mode ?? ''),
revision,
positions: structuredClone(positions),
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const next = structuredClone(
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
);
positions.length = 0;
positions.push(...next);
revision += 1;
layoutWrites.push({
projectPath: String(args?.projectPath ?? ''),
mode: String(args?.mode ?? ''),
positions: next,
});
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: String(args?.mode ?? ''),
revision,
positions: next,
updatedAt: revision,
},
};
}
if (command === 'derive_local_project_resource') {
const input = args?.input as Record<string, unknown>;
deriveCalls.push(input);
const planned = options.deriveResults.shift();
if (!planned || !planned.ok) {
throw new Error(planned?.message ?? '测试:没有预置生成结果');
}
// `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。
if (planned.hold) {
await new Promise<void>((resolve) => {
releaseHeldDerive = resolve;
});
}
const asset = bgmAsset(planned.assetId);
return {
asset,
manifest: manifestFor(PROJECT_ID, [...options.assets, asset]),
committedProjectRevision: 2,
operationId: input.operationId,
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'list_local_project_asset_generations') {
return [];
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 2,
content: '#',
};
}
unexpectedCommands.push(command);
return undefined;
});
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
return {
invoke,
layoutWrites,
deriveCalls,
unexpectedCommands,
releaseHeldDerive: () => releaseHeldDerive?.(),
};
}
function HostWorkbench({ assets }: { assets: AssetFixture[] }) {
const [manifest, setManifest] = React.useState<GameCreationAppManifest>(() =>
manifestFor(PROJECT_ID, assets),
);
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={PROJECT_PATH}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
async function settle() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function placeholderElement(draftId: string) {
return document.querySelector<HTMLElement>(
`[data-resource-canvas-generation-placeholder="${draftId}"]`,
);
}
function allPlaceholders() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'[data-resource-canvas-generation-placeholder]',
),
);
}
/**
* 音频入口只在「音频」栏目页出现(`RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY.audio`),
* 所以先打开音频栏目、再点「生成背景音乐」:占位立刻出现在该栏目里。
*/
async function openBgmEntry() {
const opener = screen
.getAllByRole('button')
.find((button) => /^打开音频/.test(button.textContent?.trim() ?? ''));
if (!opener) {
throw new Error(
`没找到音频栏目入口,现有入口:${screen
.getAllByRole('button')
.map((button) => button.textContent?.trim())
.filter((text) => text?.startsWith('打开'))
.join(' / ')}`,
);
}
fireEvent.click(opener);
await settle();
// 切到「按类型」:落点 effect 走的是**当前排序模式**那一侧的布局 hook,
// 类型侧的 sidecar 读完之后才 ready(依赖侧还要等关系图就绪)。
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await settle();
fireEvent.click(screen.getByRole('button', { name: '生成背景音乐' }));
await settle();
const placeholder = allPlaceholders()[0];
if (!placeholder) {
throw new Error('点音频入口后没有出现占位卡');
}
const draftId =
placeholder.dataset.resourceCanvasGenerationPlaceholder ?? '';
const prompt = document.querySelector<HTMLTextAreaElement>(
'textarea',
) as HTMLTextAreaElement;
if (prompt) {
fireEvent.change(prompt, { target: { value: '一段平静的夜晚钢琴曲' } });
}
return { draftId, placeholder };
}
/**
* 占位卡在画布世界坐标里的落点。宿主不是把它写进 DOM 数据集,而是由外层包装的
* transform 决定位置——按 ManualLayout 用例的同一口径从 style 里取数。
*/
function placeholderWorldPoint(element: HTMLElement) {
const styled = element.closest<HTMLElement>('[style*="translate"]');
const source = styled?.style.transform ?? '';
const [x, y] = Array.from(
source.matchAll(/-?\d+(?:\.\d+)?/g),
(match) => Number(match[0]),
);
return { x, y };
}
function cardElement(resourceId: string) {
return document.querySelector<HTMLElement>(
`.game-resource-card[data-resource-card-id="${resourceId}"]`,
);
}
/**
* 占位的**局部落点**由占位模型给定:这一栏已有素材(种子卡在原点)时,占位排到它下方
* 一行(`placeResourceCanvasGenerationPlaceholder`x 贴左边、y = 最高下沿 + 间距)。
*
* 尺寸从种子卡画出来的盒子里读,避免在用例里再抄一份卡片尺寸常量。
*/
function placeholderLocalPoint(placeholder: HTMLElement) {
const seed = cardElement('asset:seed-bgm');
if (!seed) {
throw new Error('没找到音频栏目里的种子卡');
}
const wrapper = seed.closest<HTMLElement>('[style*="translate"]');
const style = wrapper?.getAttribute('style') ?? '';
const height = Number(
style.match(/height:\s*(-?\d+(?:\.\d+)?)px/)?.[1] ?? Number.NaN,
);
if (!Number.isFinite(height)) {
throw new Error(`种子卡没有可读的高度:${style}`);
}
expect(placeholder).not.toBeNull();
return { x: 0, y: height + 16 };
}
/**
* 面板里的提交按钮:它与底部工具栏的工具按钮**同名**(都是「生成背景音乐」),
* 所以要排掉工具栏那一支,否则点的是工具本身(再点一次入口,不会提交)。
*/
function panelSubmitButton(label: string) {
const submit = screen
.getAllByRole('button', { name: label })
.find((button) => !button.closest('.game-resource-bottom-toolbar'));
if (!submit) {
throw new Error(`没找到面板里的提交按钮「${label}`);
}
return submit;
}
afterEach(() => {
cleanup();
delete (
window as unknown as { __TAURI__?: unknown }
).__TAURI__;
vi.restoreAllMocks();
});
describe('画布生成入口的宿主生命周期', () => {
test('音频入口:提交后占位进入 submitted,成功后结果落到占位最新位置并撤掉占位', async () => {
const tauri = installHostTauri({
assets: [seedBgmAsset('seed-bgm')],
deriveResults: [{ ok: true, assetId: NEW_ASSET_ID, hold: true }],
});
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
await settle();
const { draftId, placeholder } = await openBgmEntry();
const placeholderPoint = placeholderLocalPoint(placeholder);
// 点入口只造占位:还没提交,也没发起任何生成。
expect(placeholder.dataset.resourceCanvasGenerationPlaceholderStatus).toBe(
'draft',
);
expect(tauri.deriveCalls).toEqual([]);
fireEvent.click(panelSubmitButton('生成背景音乐'));
await settle();
// 提交一次:走无源生成(create)链路;后台还在跑,占位收口为 submitted。
expect(tauri.deriveCalls).toHaveLength(1);
expect(tauri.deriveCalls[0]).toMatchObject({
editKind: 'background-music',
generationMode: 'create',
expectedProjectId: PROJECT_ID,
});
await waitFor(() =>
expect(
placeholderElement(draftId)?.dataset
.resourceCanvasGenerationPlaceholderStatus,
).toBe('submitted'),
);
// 放行后台任务:结果入库。
await act(async () => {
tauri.releaseHeldDerive();
await Promise.resolve();
});
// 结果入库后:新卡落在占位坐标上(占位在空栏目里落在原点),占位被撤掉。
await waitFor(() => {
expect(
tauri.layoutWrites
.filter((write) =>
write.positions.some(
(position) => position.resourceId === NEW_RESOURCE_ID,
),
)
.map(
(write) => {
const landed = write.positions.find(
(position) => position.resourceId === NEW_RESOURCE_ID,
)!;
return `${write.mode}:${landed.section}@${landed.x},${landed.y}${
landed.manuallyPlaced ? 'M' : 'A'
}`;
},
)
.join(' || '),
).toContain(
`type:audio@${placeholderPoint.x},${placeholderPoint.y}M`,
);
});
await waitFor(() => expect(allPlaceholders()).toHaveLength(0));
});
test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => {
const tauri = installHostTauri({
assets: [seedBgmAsset('seed-bgm')],
deriveResults: [
{ ok: false, message: 'result-unknown: 测试网络中断' },
{ ok: true, assetId: NEW_ASSET_ID },
],
});
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
await settle();
const { draftId } = await openBgmEntry();
fireEvent.click(panelSubmitButton('生成背景音乐'));
await settle();
expect(tauri.deriveCalls).toHaveLength(1);
// 失败:占位留着(输入与操作身份都还在),状态收口为 failed。
await waitFor(() =>
expect(
placeholderElement(draftId)?.dataset
.resourceCanvasGenerationPlaceholderStatus,
).toBe('failed'),
);
// 面板上的原请求重试:同一次生成,不该变成第二次付费请求。
const retry = screen
.getAllByRole('button')
.find((button) => button.textContent?.includes('重试'));
expect(retry).toBeDefined();
fireEvent.click(retry!);
await settle();
await waitFor(() => expect(tauri.deriveCalls).toHaveLength(2));
expect(tauri.deriveCalls[1]).toMatchObject({
operationId: tauri.deriveCalls[0]!.operationId,
idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey,
prompt: tauri.deriveCalls[0]!.prompt,
});
});
});