补画布生成入口宿主生命周期验收:占位提交、同 operation 重试与成功落点
- 新增测试文件 resourceCanvasGenerationHostLifecycle.test.tsx,渲染真实 ProjectDevelopmentView - 覆盖音频入口:点工具出占位、提交后占位进入 submitted(后台仍在跑时的中间态) - 覆盖失败后原请求重试:复用同一 operationId 与幂等键,不会变成新的付费生成 - 覆盖成功后落点:结果卡按占位位置落盘(手动接管),占位随之被撤掉 - 原生命令全部走假实现,不触发任何真实 Provider 调用
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user