Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx
T
k88936 25c5ecdf6e
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m29s
Project CI / Backend tests (pull_request) Failing after 4m38s
Project CI / Frontend tests (pull_request) Successful in 3m33s
Project CI / Repository checks (pull_request) Failing after 4m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m13s
Project CI / Native shell tests (pull_request) Successful in 6m35s
测试:全仓测试统一 Tauri 运行时替身安装点
- 78 处内联 window.__TAURI__ 赋值改为 installTauriRuntime,覆盖 appSurface 之外的 37 个测试文件
- 31 处 delete window.__TAURI__ / window.__TAURI__ = undefined 复位改为 resetTauriRuntime
- 删除 recentProjectsHook、projectCreationDirectory、projectResourceLiveIntegration、useProjectResourceCardPreviews、resourceVersionReplacementModel、unityProjectOpen 里的本地 asTauriInvoke / asTauriListen 适配器与相关类型别名
- designProjectRestore 的 setup 参数由 ReturnType<typeof vi.fn> 收窄为具体 invoke 签名
- 迁移后仅 tests/tauriRuntimeFake.ts 直接读写 window.__TAURI__
2026-10-03 20:19:45 +08:00

539 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';
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
afterEach(() => {
cleanup();
resetTauriRuntime();
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;
},
);
installTauriRuntime({ core: { invoke } });
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}
// ProjectDevelopmentViewProps 现在必填 chat;本用例只验资源落点,不需要对话区。
chat={null}
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);
});