356 lines
12 KiB
TypeScript
356 lines
12 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?: () => 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 },
|
|
'取消并返回',
|
|
),
|
|
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)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe('project resource live canvas integration', () => {
|
|
afterEach(() => {
|
|
delete window.__TAURI__;
|
|
canvasFixture.manifest = null;
|
|
canvasFixture.revision = 0;
|
|
canvasFixture.sequence = 0;
|
|
});
|
|
|
|
function installTauri() {
|
|
const layoutWrites: Array<Record<string, unknown>> = [];
|
|
const graphReads: 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==',
|
|
};
|
|
}
|
|
throw new Error(`unexpected invoke ${command}`);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: async () => () => undefined },
|
|
};
|
|
return { graphReads, layoutWrites };
|
|
}
|
|
|
|
it('enters create/refine in the central view, cancels to context, and coordinates both layouts after a durable refine', async () => {
|
|
const { graphReads, layoutWrites } = installTauri();
|
|
render(<LiveWorkbench />);
|
|
|
|
const sourceCard = await screen.findByRole('button', {
|
|
name: /source-art\.png/,
|
|
});
|
|
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.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 the new resource 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(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('');
|
|
});
|
|
});
|