44d362c587
移除WebView登录Token读取及Tauri命令透传 统一图片视频音效和背景音乐的External v1调用链 严格校验202受理语义并保护历史站内账本 补齐凭据隔离非破坏性派生和视频端到端测试 同步客户端创作合同与项目共享记忆
567 lines
19 KiB
TypeScript
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');
|
|
});
|
|
});
|