Files
Genarrative/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
T
menghao 44d362c587
Project CI / Repository checks (pull_request) Failing after 48s
Project CI / Frontend tests (pull_request) Failing after 2m6s
Project CI / Native shell tests (pull_request) Failing after 2m57s
Project CI / Backend tests (pull_request) Successful in 3m46s
切换客户端全类型资源编辑至External API
移除WebView登录Token读取及Tauri命令透传
统一图片视频音效和背景音乐的External v1调用链
严格校验202受理语义并保护历史站内账本
补齐凭据隔离非破坏性派生和视频端到端测试
同步客户端创作合同与项目共享记忆
2026-08-10 17:17:12 +08:00

567 lines
19 KiB
TypeScript

/** @vitest-environment jsdom */
import React, { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
const canvasFixture = vi.hoisted(() => ({
manifest: null as GameCreationAppManifest | null,
revision: 0,
sequence: 0,
}));
vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
const ReactModule = await import('react');
return {
AssetCanvasSurface: (props: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
sessionId: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: (result: {
draftId: string;
disposition: 'kept' | 'discarded';
}) => void;
onSaveAttempt?: (attempt: {
saveAttemptId: string;
sessionId: string;
projectId: string;
draftId: string;
commitId: string;
}) => void;
onCommitted?: (notification: Record<string, unknown>) => void;
}) =>
ReactModule.createElement(
'section',
{ 'aria-label': '测试素材创作画布' },
ReactModule.createElement(
'span',
null,
`${props.scope.intent}:${props.scope.sourceAssetId ?? 'none'}:${props.initialAssetName ?? 'unset'}:${props.initialAssetKind ?? 'unset'}`,
),
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () =>
props.onCancel?.({
draftId: props.scope.draftId,
disposition: 'kept',
}),
},
'取消并返回',
),
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () => {
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
canvasFixture.sequence += 1;
canvasFixture.revision += 1;
const assetId = `canvas-output-${canvasFixture.sequence}`;
const commitId = `commit-${canvasFixture.sequence}`;
const sourceResourceId = props.scope.sourceAssetId
? `asset:${props.scope.sourceAssetId}`
: null;
const nextManifest = {
...base,
assets: [
...base.assets,
{
id: assetId,
kind: 'art-image',
mediaType: 'image/png',
localPath: `assets/${assetId}.png`,
source: {
kind: 'canvas' as const,
taskId: null,
resourceId: `canvas-resource-${canvasFixture.sequence}`,
referenceResourceIds: sourceResourceId
? [sourceResourceId]
: [],
},
},
],
};
canvasFixture.manifest = nextManifest;
props.onSaveAttempt?.({
saveAttemptId: `save-${canvasFixture.sequence}`,
sessionId: props.sessionId,
projectId: props.scope.projectId,
draftId: props.scope.draftId,
commitId,
});
props.onCommitted?.({
source: 'command',
projectPath: '/tmp/live-canvas-integration',
projectId: props.scope.projectId,
draftId: props.scope.draftId,
commitId,
assetId,
manifest: nextManifest,
projectRevision: canvasFixture.revision,
committedProjectRevision: canvasFixture.revision,
eventId: `event-${canvasFixture.sequence}`,
});
},
},
'完成测试保存',
),
),
};
});
import ProjectDevelopmentView from '../src/view/project-development';
import {
createGameCreationAppManifest,
fireEvent,
render,
screen,
waitFor,
} from './appSurface/harness';
const projectPath = '/tmp/live-canvas-integration';
function graphFor(
resources: Array<{ resourceId: string }>,
manifest: GameCreationAppManifest,
) {
const resourceIds = resources.map((resource) => resource.resourceId);
const referenceEdges = manifest.assets.flatMap((asset) =>
(asset.source.referenceResourceIds ?? []).map((sourceResourceId) => ({
id: `reference:${sourceResourceId}:${asset.id}`,
kind: 'asset-reference' as const,
sourceResourceId,
targetResourceId: `asset:${asset.id}`,
cyclic: false,
})),
);
return {
resourceIds,
referenceEdges,
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: referenceEdges
.filter((edge) => edge.targetResourceId === resourceId)
.map((edge) => edge.sourceResourceId),
downstreamReferenceResourceIds: [],
referenceEdgeIds: referenceEdges
.filter(
(edge) =>
edge.targetResourceId === resourceId ||
edge.sourceResourceId === resourceId,
)
.map((edge) => edge.id),
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: referenceEdges.some(
(edge) => edge.targetResourceId === resourceId,
)
? 1
: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function LiveWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'source-art',
kind: 'art-image',
mediaType: 'image/png',
localPath: 'assets/source-art.png',
source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' },
},
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
function DerivedWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'source-rules',
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: 'docs/rules.md',
source: { kind: 'generated', resourceId: 'rules-resource' },
},
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
function TaskImageWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
const task = initial.tasks.find(
(candidate) => candidate.id === 'art-asset-plan',
);
if (!task) throw new Error('missing art task fixture');
task.status = 'completed';
task.artifacts = ['assets/task-hero.png'];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
describe('project resource live canvas integration', () => {
afterEach(() => {
delete window.__TAURI__;
canvasFixture.manifest = null;
canvasFixture.revision = 0;
canvasFixture.sequence = 0;
});
function installTauri(options: { failFirstDerive?: boolean } = {}) {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: canvasFixture.revision };
}
if (command === 'read_local_project_resource_graph') {
graphReads.push(structuredClone(args ?? {}));
return graphFor(
(args?.resources ?? []) as Array<{ resourceId: string }>,
canvasFixture.manifest!,
);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-canvas-project',
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutWrites.push(structuredClone(args ?? {}));
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'live-canvas-project',
mode: args?.mode,
revision: Number(args?.expectedRevision ?? 0) + 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
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: 8,
content: '# 玩法规则',
};
}
if (command === 'derive_local_project_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
deriveCalls.push(input);
if (options.failFirstDerive && deriveCalls.length === 1) {
throw new Error('result-unknown: 测试网络中断');
}
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const operationId = String(input.operationId);
const assetId = `edit-${operationId}`;
const asset = {
id: assetId,
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: `assets/edits/${operationId}-rules.md`,
source: {
kind: 'generated' as const,
resourceId: `local-asset:${assetId}`,
referenceResourceIds: ['rules-resource'],
},
};
canvasFixture.revision += 1;
const nextManifest = {
...base,
assets: [...base.assets, asset],
};
canvasFixture.manifest = nextManifest;
return {
operationId,
editKind: input.editKind,
sourceResourceId: 'rules-resource',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest: nextManifest,
};
}
if (command === 'normalize_local_project_raster_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
normalizeCalls.push(input);
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const asset = {
id: 'normalized-task-hero',
kind: 'art-image',
mediaType: 'image/png',
localPath: String(input.sourcePath),
source: {
kind: 'generated' as const,
taskId: String(input.producerTaskId),
resourceId: String(input.sourceResourceId),
},
};
canvasFixture.revision += 1;
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
return {
committedProjectRevision: canvasFixture.revision,
asset,
manifest: nextManifest,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: async () => () => undefined },
};
return { deriveCalls, graphReads, layoutWrites, normalizeCalls };
}
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
const { graphReads, layoutWrites } = installTauri();
render(<LiveWorkbench />);
const sourceCard = await screen.findByRole('button', {
name: /source-art\.png/,
});
expect(
(
screen.getByRole('button', {
name: '新增资源',
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.click(sourceCard);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:source-art:source-art:art-image');
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(
await screen.findByRole('region', { name: /source-art\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(
await screen.findByRole('region', { name: /canvas-output-1\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', { name: /source-art\.png/ }),
).not.toBeNull();
expect(
await screen.findByText(/canvas-output-1\.png 引用 source-art\.png/),
).not.toBeNull();
await waitFor(() => {
expect(
layoutWrites.some(
(write) =>
write.mode === 'dependency' &&
(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
expect(
layoutWrites.some(
(write) =>
write.mode === 'type' &&
(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
});
expect(
graphReads.some((read) => {
const resourceIds = (
read.resources as Array<{ resourceId: string }>
).map((resource) => resource.resourceId);
return (
resourceIds.includes('asset:source-art') &&
resourceIds.includes('asset:canvas-output-1')
);
}),
).toBe(true);
});
it('keeps an existing search when an edited result is hidden and locates only after the explicit action', async () => {
installTauri();
render(<LiveWorkbench />);
const search = await screen.findByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'source-art' } });
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(
await screen.findByText('新资源已保存,但被当前搜索条件隐藏'),
).not.toBeNull();
expect((search as HTMLInputElement).value).toBe('source-art');
fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' }));
expect(
await screen.findByRole('region', { name: /canvas-output-1\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
((await screen.findByLabelText('搜索项目资源')) as HTMLInputElement)
.value,
).toBe('');
});
it('derives a document non-destructively and retries with the original operation identity', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
fireEvent.click(await screen.findByRole('button', { name: /rules\.md/ }));
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(await screen.findByText('编辑现有资源')).not.toBeNull();
fireEvent.change(screen.getByLabelText('编辑提示词'), {
target: { value: '把角色头发设定改为红色' },
});
fireEvent.click(screen.getByRole('button', { name: '生成派生资源' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
const operationId = String(deriveCalls[1]?.operationId);
expect(
await screen.findByRole('region', {
name: new RegExp(operationId, 'u'),
}),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', { name: /docs\/rules\.md/ }),
).not.toBeNull();
});
it('normalizes a completed task image before opening the existing refine canvas', async () => {
const { normalizeCalls } = installTauri();
render(<TaskImageWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /task-hero\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:normalized-task-hero');
expect(normalizeCalls).toHaveLength(1);
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
});
});