Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx
k88936 99fb6c38c1 AGC 音频生成并入图片类那份后台任务账本
- 原生:`start_local_project_asset_generation` 新增可选入参 `idempotencyKey`,并在音频 kind 上分叉;图片类载荷与分支逐字未改
- 原生:新增音频提交期收口 `prepare_local_project_audio_generation`(kind / 提示词上限 / 素材名 / operation 身份 / 幂等键)
- 原生:新增 `run_local_project_audio_generation_at`,在派发时刻读项目 revision 并复用既有音频无源生成实现,不复制生成逻辑
- 原生:新增 `begin_local_project_audio_generation_task` / `run_local_project_audio_generation_task`,音频任务落同一份项目内账本并写 running → completed / failed,跑完没登记素材按失败收口
- 原生:补两条用例——被拒绝的提交零写入账本、音频任务落在同一账本且 kind 正确
- 前端任务模型:新增音频任务与 `idempotencyKey` 字段,恢复出来的历史任务不带它也不承接重试,入口文案扩到音频栏目
- 前端队列:按 kind 分流派发载荷,音频只发任务身份(任务 id 即 operation id)与幂等键
- 前端面板:音频生成面板改为点「生成」同步提交并立即关闭,删除「生成中…」「后台运行并关闭」与输入锁定
- 前端宿主:音频提交改为同步入队并展开「生成任务」侧栏,只有「后端从未受理」才连原草稿与原请求身份重开面板
- 前端清理:删除随本次改动失效的 `resourceCanvasGenerationSourceId` 与宿主里不再使用的 import
- 测试:面板 / 队列 / 宿主生命周期 / 落点 / appSurface 改按后台账本口径断言,并补「未受理即时失败重开」用例(已用移除重开逻辑的变异验证其非空)
- 文档:PRD §3.10 / §7.9、AGC 底部工具栏入口矩阵、V3 端到端验收 S11a 同步为音频后台化口径
- 文档:新增里程碑与实施计划(含验收证据矩阵),并在 decision-log 记下「音频并入后台任务账本」这条长期约定
2026-09-20 23:32:09 +08:00

537 lines
19 KiB
TypeScript

// @vitest-environment jsdom
/**
* 生成落点与音频提交身份的**宿主级**整合用例。
*
* 覆盖两条只能跨模块才校验得了的口径:
* 1. 结果落点用**正式归类后的 section**(普通图片落「待归类」)与占位**最新位置**,写完撤占位;
* 2. 音频入口失败后占位保留、面板带回草稿,重试复用同一 operationId(幂等,不重复付费)。
*
* Tauri 只用最小假实现:未知命令返回 `undefined` 并记账,别的入口多调一个命令不该让整条链转红。
*/
import { useState } from 'react';
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,
within,
} from './appSurface/harness';
import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
afterEach(() => {
cleanup();
delete window.__TAURI__;
vi.restoreAllMocks();
});
const PROJECT_ID = 'generation-landing';
const PROJECT_PATH = '/tmp/generation-landing';
const GENERATED_ASSET_ID = 'asset-generated-landing';
const GENERATED_RESOURCE_ID = `asset:${GENERATED_ASSET_ID}`;
function pngAsset(
id: string,
fileName: string,
category: GameCreationAppAssetManifestEntry['category'] = 'character',
): GameCreationAppAssetManifestEntry {
return {
id,
kind: 'character',
category,
mediaType: 'image/png',
localPath: `assets/${fileName}`,
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
/** 生成结果按**原生正式归类**落进「待归类」:入口栏目是 character,两者故意不同。 */
function generatedAsset(): GameCreationAppAssetManifestEntry {
return {
...pngAsset(GENERATED_ASSET_ID, 'generated.png', 'unclassified'),
kind: 'image',
};
}
function resourceGraphFor(resources: Array<{ resourceId: string }>) {
const resourceIds = resources.map((resource) => resource.resourceId);
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
type Mock = {
invoke: ReturnType<typeof vi.fn>;
layoutWrites: ProjectResourceCanvasPosition[][];
generationStarts: Record<string, unknown>[];
deriveInputs: Record<string, unknown>[];
unexpected: string[];
};
function installTauri({
assets,
assetGenerationFailed = false,
}: {
assets: GameCreationAppAssetManifestEntry[];
/** 后台账本里那条任务是不是「已受理、之后失败」的终态。 */
assetGenerationFailed?: boolean;
}): Mock {
const layoutWrites: ProjectResourceCanvasPosition[][] = [];
const generationStarts: Record<string, unknown>[] = [];
const deriveInputs: Record<string, unknown>[] = [];
const unexpected: string[] = [];
const manifest: GameCreationAppManifest = {
...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'),
/*
生成的素材在**收尾时的清单重读**里出现(原生是先写 manifest 再返回的终态),
但归类是它自己的(`unclassified`),与入口栏目 `character` 不同——落点必须按前者。
*/
assets: [...assets, generatedAsset()].map((asset) =>
structuredClone(asset),
),
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: 3 };
}
if (command === 'get_local_game_manifest') {
return structuredClone(manifest);
}
if (command === 'read_local_project_resource_graph') {
return resourceGraphFor(
(args?.resources as Array<{ resourceId: string }> | undefined) ?? [],
);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutWrites.push(
structuredClone(
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
),
);
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutWrites.length,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'list_local_project_asset_generations') {
// 账本里的终态记录:队列轮询一次就收口,成功时给出 manifest 资源 id。
return [
{
taskId: String(args?.taskId ?? '') || LAST_TASK_ID.value,
projectId: PROJECT_ID,
kind: 'image',
assetName: 'AI 生成图片',
status: assetGenerationFailed ? 'failed' : 'completed',
phaseDetail: assetGenerationFailed
? '生成失败:远端拒绝'
: '生成已完成。',
createdAtMillis: 1,
startedAtMillis: 2,
finishedAtMillis: 3,
assetId: assetGenerationFailed ? null : GENERATED_ASSET_ID,
error: assetGenerationFailed ? '远端拒绝' : null,
},
];
}
if (command === 'start_local_project_asset_generation') {
generationStarts.push(structuredClone(args ?? {}));
LAST_TASK_ID.value = String(args?.taskId ?? '');
return {
taskId: String(args?.taskId ?? ''),
projectId: PROJECT_ID,
kind: String(args?.kind ?? 'image'),
assetName: String(args?.assetName ?? ''),
status: 'running',
phaseDetail: '正在生成。',
createdAtMillis: 1,
startedAtMillis: 2,
finishedAtMillis: null,
assetId: null,
error: null,
};
}
if (command === 'derive_local_project_resource') {
/*
生成入口(音频与图片类)都走后台任务账本:同步派生通道不该再被触发。真被调到就大声
失败,而不是静默返回一个像模像样的结果。
*/
deriveInputs.push(
structuredClone((args?.input ?? {}) as Record<string, unknown>),
);
throw new Error('生成入口不应再走同步派生通道');
}
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: 1,
content: '# 文档',
};
}
unexpected.push(command);
return undefined;
},
);
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected };
}
const LAST_TASK_ID = { value: '' };
/** 打开栏目页:左侧大纲已删,走「资源总览」的栏目缩略卡。 */
async function openCategory(label: string) {
const categoryByLabel: Record<string, string> = {
'UI 交互': 'ui-interaction',
角色与对象: 'character',
音频: 'audio',
待归类: 'unclassified',
};
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() =>
expect(
document
.querySelector('[data-resource-book-view="child"]')
?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`,
),
).not.toBeNull(),
);
}
function Workbench({
assets,
}: {
assets: GameCreationAppAssetManifestEntry[];
}) {
const [manifest, setManifest] = useState<GameCreationAppManifest>(() => ({
...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'),
assets: assets.map((asset) => structuredClone(asset)),
}));
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={PROJECT_PATH}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
// 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。
onManifestChange={(_projectPath, nextManifest) =>
setManifest(nextManifest)
}
/>
);
}
async function settle() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe('图片生成落点', () => {
test('按正式归类落 section、用占位最新位置,并撤掉占位', async () => {
const tauri = installTauri({
assets: [pngAsset('asset-character', 'character.png')],
});
render(
<Workbench assets={[pngAsset('asset-character', 'character.png')]} />,
);
await openCategory('角色与对象');
fireEvent.click(await screen.findByRole('button', { name: '生成图片' }));
const panel = await screen.findByRole('dialog', { name: '生成图片' });
// 浮层必须自带面板 chrome:否则没有背景边框、header 也不成排。
expect(panel.classList.contains('game-approval-dialog')).toBe(true);
expect(
panel.classList.contains('resource-canvas-generation-floating-panel'),
).toBe(true);
// 高度上界由真实矩形算出来(jsdom 走兜底画布尺寸),不是 100dvh。
expect(Number.parseFloat(panel.style.maxHeight)).toBeGreaterThan(0);
const placeholder = document.querySelector<HTMLElement>(
'[data-resource-canvas-generation-placeholder]',
);
expect(placeholder).not.toBeNull();
const placeholderX = Number.parseFloat(placeholder!.style.left);
const placeholderY = Number.parseFloat(placeholder!.style.top);
await typeGenerationPrompt(panel, '一只披风猫');
fireEvent.click(within(panel).getByRole('button', { name: '生成图片' }));
await waitFor(() => expect(tauri.generationStarts).toHaveLength(1));
// 入口栏目随任务带给原生(可选入参),参考图为空数组。
expect(tauri.generationStarts[0]).toMatchObject({
kind: 'image',
targetCategory: 'character',
referenceAssetIds: [],
});
// 任务收口为成功 → 结果按**正式归类**(unclassified)落到占位位置,占位撤掉。
await waitFor(
() => {
expect(
tauri.layoutWrites
.flat()
.some(
(position) =>
position.resourceId === GENERATED_RESOURCE_ID &&
position.manuallyPlaced === true,
),
).toBe(true);
},
{ timeout: 5_000 },
);
const landed = tauri.layoutWrites
.flat()
.filter((position) => position.resourceId === GENERATED_RESOURCE_ID)
.at(-1);
expect(landed).toMatchObject({
section: 'unclassified',
x: placeholderX,
y: placeholderY,
manuallyPlaced: true,
});
await waitFor(() =>
expect(
document.querySelector('[data-resource-canvas-generation-placeholder]'),
).toBeNull(),
);
}, 20_000);
});
/**
* 真实矩形模拟:用 1280x720 浏览器上量到的数字(画布高 568、标题栏 48、底栏 62、占位卡 128)
* 替掉 jsdom 的空矩形,然后核对**浮层实际拿到的 maxHeight 与它相对画布的落点**——
* 只断言 maxHeight 字符串是不够的:错的口径(window.innerHeight、当前顶边、世界坐标当屏幕坐标)
* 都能拼出一个看起来合理的字符串。
*/
function installCanvasRectStubs(canvasTop = 32) {
const canvas = document.querySelector<HTMLElement>(
'.game-resource-page-canvas',
);
if (!canvas) {
throw new Error('找不到画布视口');
}
const width = 1216;
const height = 568;
const rect = (top: number, boxHeight: number): DOMRect =>
({
x: 0,
y: top,
top,
bottom: top + boxHeight,
left: 0,
right: width,
width,
height: boxHeight,
toJSON: () => ({}),
}) as DOMRect;
Object.defineProperty(canvas, 'clientWidth', {
configurable: true,
value: width,
});
Object.defineProperty(canvas, 'clientHeight', {
configurable: true,
value: height,
});
canvas.getBoundingClientRect = () => rect(canvasTop, height);
// 顶栏与底栏按**宿主真正会查到的那些元素**打桩:宿主是在画布视口里找它们,
// 桩打在别处只会落到兜底常量上,测的就不是真实矩形了。
const titlebar = document.querySelector(
'.game-resource-book-scene-titlebar.is-active',
);
if (titlebar) {
titlebar.getBoundingClientRect = () => rect(canvasTop, 48);
}
const toolbar = document.querySelector('.game-resource-bottom-toolbar');
if (toolbar) {
toolbar.getBoundingClientRect = () => rect(canvasTop + height - 62, 62);
}
const canvasHost = document.querySelector<HTMLElement>(
'.game-resource-canvas',
);
if (canvasHost && canvasHost !== canvas) {
Object.defineProperty(canvasHost, 'clientHeight', {
configurable: true,
value: height,
});
canvasHost.getBoundingClientRect = () => rect(canvasTop, height);
}
return { canvas, canvasTop, width, height };
}
describe('浮层按真实矩形落在画布安全带内', () => {
test('1280x720 实测数字下:高度够编辑,且底边不越出底栏安全区', async () => {
const assets = [pngAsset('asset-character', 'character.png')];
installTauri({ assets });
render(<Workbench assets={assets} />);
await openCategory('角色与对象');
const { canvasTop, height } = installCanvasRectStubs();
fireEvent.click(await screen.findByRole('button', { name: '生成图片' }));
const panel = await screen.findByRole('dialog', { name: '生成图片' });
await settle();
const maxHeight = Number.parseFloat(panel.style.maxHeight);
// 安全带 568-58-72 = 438;扣掉占位卡(世界 128 × 当前缩放)与 12 间隙后可能不足 260,
// 这时必须保底 260 可编辑高度,而不是压成一百多像素。
// 缩放 1.5 时卡就占 192px,卡下面放不下可编辑高度 → 改为盖住占位、拿整条安全带(>260)。
expect(maxHeight).toBeGreaterThanOrEqual(260);
const panelTop = Number.parseFloat(panel.style.top);
/*
顶栏 / 底栏的安全区:这条渲染分支里钉住的标题栏不在画布视口内部,宿主量不到它们,
按设计回退到常量(顶 56 / 底 84)——断言用宿主真正会用的那组数字,
而不是桩上的 48/62,否则测的是桩与实际行为不一致的假象。
*/
const safeTop = 56;
const safeBottom = height - 84;
expect(Number.isFinite(panelTop)).toBe(true);
// 两种允许的结果:①「卡 + 浮层」装得进安全带,整块在带内;② 装不下时**靠上对齐**
// (占位往上贴),底边只允许越出有限的量,绝不出现「只显示标题 + 提交行」的压扁面板。
expect(panelTop).toBeGreaterThanOrEqual(safeTop);
expect(panelTop).toBeGreaterThanOrEqual(safeTop - 1);
expect(panelTop + maxHeight).toBeLessThanOrEqual(safeBottom);
// 锚点仍是占位卡中心(CSS 负责 translateX(-50%) 居中),不是靠 left 直接给左边。
expect(canvasTop).toBe(32);
}, 20_000);
});
describe('音频生成身份', () => {
test('失败保留占位与草稿,重试复用同一 operationId 与幂等键', async () => {
const assets = [pngAsset('asset-character', 'character.png')];
const tauri = installTauri({ assets, assetGenerationFailed: true });
render(<Workbench assets={assets} />);
await openCategory('音频');
fireEvent.click(
await screen.findByRole('button', { name: '生成背景音乐' }),
);
const panel = await screen.findByRole('dialog', { name: '生成背景音乐' });
await typeGenerationPrompt(panel, '轻快的八音盒');
fireEvent.click(
within(panel).getByRole('button', { name: '生成背景音乐' }),
);
// 音频与图片类同一条后台通道:任务 id 就是这次生成的 operation id,载荷带幂等键。
await waitFor(() => expect(tauri.generationStarts).toHaveLength(1));
const firstTaskId = tauri.generationStarts[0]?.taskId;
expect(typeof firstTaskId).toBe('string');
expect(tauri.generationStarts[0]).toMatchObject({
kind: 'background-music',
prompt: '轻快的八音盒',
idempotencyKey: expect.any(String),
});
// 音频不再走同步派生通道。
expect(tauri.deriveInputs).toEqual([]);
// 提交即关闭:面板不留在屏幕上等结果。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '生成背景音乐' })).toBeNull(),
);
// 失败:占位收口为失败并保留(不是被删掉)。
await waitFor(() =>
expect(
document.querySelector<HTMLElement>(
'[data-resource-canvas-generation-placeholder-status="failed"]',
),
).not.toBeNull(),
);
// 点占位:草稿灌回来,重试复用同一 operationId / 幂等键(原生幂等,不重复付费)。
fireEvent.click(
document.querySelector<HTMLElement>(
'[data-resource-canvas-generation-placeholder]',
)!,
);
const reopened = await screen.findByRole('dialog', {
name: '生成背景音乐',
});
/*
重开的浮层带着**那张占位自己的**失败原因:按钮也就从「生成背景音乐」换成
「使用原请求重试」。失败原因不再随面板实例留在这儿,换到别的占位看不到它。
*/
expect(reopened.textContent).toContain('远端拒绝');
fireEvent.click(
within(reopened).getByRole('button', { name: '使用原请求重试' }),
);
await waitFor(() => expect(tauri.generationStarts).toHaveLength(2));
expect(tauri.generationStarts[1]?.taskId).toBe(firstTaskId);
expect(tauri.generationStarts[1]?.idempotencyKey).toBe(
tauri.generationStarts[0]?.idempotencyKey,
);
}, 20_000);
});