WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -4347,7 +4347,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
|
||||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!focusedResource\s*&&\s*!resourceEditorRoute\s*&&\s*!uiEditorRoute/s,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
|
||||
@@ -4357,7 +4357,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
);
|
||||
});
|
||||
|
||||
it('invalidates only the committed asset preview after an in-place refine', () => {
|
||||
it('keeps the resource preview version cache wired and cleared on project switch', () => {
|
||||
const projectDevelopmentSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
@@ -4369,9 +4369,6 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/,
|
||||
);
|
||||
expect(projectDevelopmentSource).toMatch(
|
||||
/setResourcePreviewVersionByResourceId\(new Map\(\)\)/,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,142 +7,8 @@ import type { GameCreationAppManifest } from '../../../packages/shared/src/contr
|
||||
const canvasFixture = vi.hoisted(() => ({
|
||||
manifest: null as GameCreationAppManifest | null,
|
||||
revision: 0,
|
||||
sequence: 0,
|
||||
recoveredDraftIds: [] as string[],
|
||||
}));
|
||||
|
||||
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;
|
||||
host: {
|
||||
generation: {
|
||||
recoverImages: (input: {
|
||||
scope: {
|
||||
projectId: string;
|
||||
draftId: string;
|
||||
intent: 'create' | 'refine';
|
||||
sourceAssetId: string | null;
|
||||
};
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
ReactModule.useEffect(() => {
|
||||
void props.host.generation.recoverImages({ scope: props.scope });
|
||||
}, [props.host, props.scope]);
|
||||
return 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 =
|
||||
props.scope.intent === 'refine' && props.scope.sourceAssetId
|
||||
? props.scope.sourceAssetId
|
||||
: `canvas-output-${canvasFixture.sequence}`;
|
||||
const commitId = `commit-${canvasFixture.sequence}`;
|
||||
const existingAsset = base.assets.find(
|
||||
(asset) => asset.id === assetId,
|
||||
);
|
||||
const committedAsset = {
|
||||
id: assetId,
|
||||
kind: existingAsset?.kind ?? 'art-image',
|
||||
mediaType: 'image/png',
|
||||
localPath:
|
||||
props.scope.intent === 'refine'
|
||||
? `assets/canvas/${assetId}--${canvasFixture.sequence}.png`
|
||||
: `assets/${assetId}.png`,
|
||||
source:
|
||||
existingAsset?.source ??
|
||||
({
|
||||
kind: 'canvas' as const,
|
||||
taskId: null,
|
||||
resourceId: `canvas-resource-${canvasFixture.sequence}`,
|
||||
referenceResourceIds: [],
|
||||
} as const),
|
||||
};
|
||||
const nextManifest = {
|
||||
...base,
|
||||
assets: existingAsset
|
||||
? base.assets.map((asset) =>
|
||||
asset.id === assetId ? committedAsset : asset,
|
||||
)
|
||||
: [...base.assets, committedAsset],
|
||||
};
|
||||
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 {
|
||||
act,
|
||||
@@ -343,43 +209,11 @@ function SwitchableDerivedWorkbench({
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
canvasFixture.recoveredDraftIds = [];
|
||||
});
|
||||
|
||||
function installTauri(
|
||||
@@ -406,14 +240,12 @@ describe('project resource live canvas integration', () => {
|
||||
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 resumeCalls: Array<Record<string, unknown>> = [];
|
||||
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
|
||||
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
|
||||
const archiveCalls: Array<Record<string, unknown>> = [];
|
||||
const pendingReadCalls: Array<Record<string, unknown>> = [];
|
||||
let pendingReadCount = 0;
|
||||
let discoverableDraftId: string | null = null;
|
||||
let pendingEdits =
|
||||
options.pendingResourceEdits ??
|
||||
(options.pendingResourceEdit
|
||||
@@ -430,20 +262,6 @@ describe('project resource live canvas integration', () => {
|
||||
: []);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'discover_local_project_asset_canvas_draft') {
|
||||
const input = (args?.input ?? {}) as Record<string, unknown>;
|
||||
return discoverableDraftId
|
||||
? {
|
||||
status: 'found',
|
||||
draft: {
|
||||
draftId: discoverableDraftId,
|
||||
projectId: String(input.expectedProjectId),
|
||||
intent: 'refine',
|
||||
sourceAssetId: String(input.sourceAssetId),
|
||||
},
|
||||
}
|
||||
: { status: 'not-found', draft: null };
|
||||
}
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: canvasFixture.revision };
|
||||
}
|
||||
@@ -494,17 +312,6 @@ describe('project resource live canvas integration', () => {
|
||||
dataUrl: 'data:image/png;base64,AA==',
|
||||
};
|
||||
}
|
||||
if (command === 'recover_local_project_asset_canvas_generations') {
|
||||
const input = structuredClone(
|
||||
(args?.input ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
discoverableDraftId = String(input.draftId);
|
||||
canvasFixture.recoveredDraftIds.push(String(input.draftId));
|
||||
return {
|
||||
resumedGenerationIds: [`generation-for-${String(input.draftId)}`],
|
||||
serviceIdentityConfirmations: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_text_preview') {
|
||||
return {
|
||||
path: String(args?.relativePath ?? ''),
|
||||
@@ -646,33 +453,6 @@ describe('project resource live canvas integration', () => {
|
||||
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}`);
|
||||
},
|
||||
);
|
||||
@@ -684,7 +464,6 @@ describe('project resource live canvas integration', () => {
|
||||
deriveCalls,
|
||||
graphReads,
|
||||
layoutWrites,
|
||||
normalizeCalls,
|
||||
pendingReadCalls,
|
||||
resumeCalls,
|
||||
archiveCalls,
|
||||
@@ -693,147 +472,6 @@ describe('project resource live canvas integration', () => {
|
||||
};
|
||||
}
|
||||
|
||||
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/,
|
||||
});
|
||||
for (const label of [
|
||||
'生成视频',
|
||||
'生成音效',
|
||||
'生成背景音乐',
|
||||
'新增 UI 设计',
|
||||
]) {
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull();
|
||||
}
|
||||
fireEvent.click(sourceCard);
|
||||
const sourceDetails = screen.getByRole('dialog', {
|
||||
name: /source-art\.png/,
|
||||
});
|
||||
expect(sourceDetails.closest('.game-workbench-stage')).toBe(
|
||||
screen.getByLabelText('项目主视窗'),
|
||||
);
|
||||
expect(sourceDetails.hasAttribute('aria-modal')).toBe(false);
|
||||
expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull();
|
||||
for (const label of [
|
||||
'生成视频',
|
||||
'生成音效',
|
||||
'生成背景音乐',
|
||||
'新增 UI 设计',
|
||||
]) {
|
||||
expect(screen.queryByRole('button', { name: label })).toBeNull();
|
||||
}
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '按依赖' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.getByRole('button', { name: '按类型' })).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '复位资源视图' })).not.toBeNull();
|
||||
expect(screen.getByLabelText('资源依赖视图').hasAttribute('inert')).toBe(
|
||||
false,
|
||||
);
|
||||
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('dialog', { name: /source-art\.png/ }),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '完成测试保存' }),
|
||||
);
|
||||
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: /canvas-output/ })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
const updatedSource = await screen.findByRole('button', {
|
||||
name: /source-art--1.png/,
|
||||
});
|
||||
fireEvent.click(updatedSource);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: /source-art--1.png/ }),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||||
expect(
|
||||
layoutWrites.every(
|
||||
(write) =>
|
||||
!(write.positions as Array<{ resourceId: string }>).some(
|
||||
(position) => position.resourceId === 'asset:canvas-output-1',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
graphReads.every((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('reuses a kept refine draft and recovers its generation after a real workbench remount', async () => {
|
||||
installTauri();
|
||||
const firstWorkbench = render(<LiveWorkbench />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
await waitFor(() => {
|
||||
expect(canvasFixture.recoveredDraftIds).toHaveLength(1);
|
||||
});
|
||||
const keptDraftId = canvasFixture.recoveredDraftIds[0];
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
|
||||
firstWorkbench.unmount();
|
||||
render(<LiveWorkbench />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvasFixture.recoveredDraftIds).toEqual([
|
||||
keptDraftId,
|
||||
keptDraftId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an existing search while replacing the same final-image resource', 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.findByLabelText('测试素材创作画布')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
const restoredSearch = (await screen.findByLabelText(
|
||||
'搜索项目资源',
|
||||
)) as HTMLInputElement;
|
||||
expect(restoredSearch.value).toBe('source-art');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /source-art--1.png/ }),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull();
|
||||
});
|
||||
|
||||
it('derives a document non-destructively and retries with the original operation identity', async () => {
|
||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||
render(<DerivedWorkbench />);
|
||||
@@ -869,22 +507,6 @@ describe('project resource live canvas integration', () => {
|
||||
).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');
|
||||
});
|
||||
|
||||
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
|
||||
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
@@ -29,12 +29,11 @@ function resource(
|
||||
}
|
||||
|
||||
describe('全类型资源编辑能力模型', () => {
|
||||
it('把现役资源稳定分流到图片画布、媒体派生、文本派生和版本分支', () => {
|
||||
it('把现役资源稳定分流到图片派生、媒体派生、文本派生和版本分支', () => {
|
||||
const cases: Array<{
|
||||
resource: ProjectResource;
|
||||
route: 'image-canvas' | 'derive';
|
||||
route: 'derive';
|
||||
editKind?: string;
|
||||
normalizeSource?: boolean;
|
||||
}> = [
|
||||
{
|
||||
resource: resource({
|
||||
@@ -44,8 +43,8 @@ describe('全类型资源编辑能力模型', () => {
|
||||
mediaType: 'image/png',
|
||||
manifestAssetId: 'png',
|
||||
}),
|
||||
route: 'image-canvas',
|
||||
normalizeSource: false,
|
||||
route: 'derive',
|
||||
editKind: 'image-reference',
|
||||
},
|
||||
{
|
||||
resource: resource({
|
||||
@@ -54,8 +53,8 @@ describe('全类型资源编辑能力模型', () => {
|
||||
path: 'hero.webp',
|
||||
mediaType: '美术产物',
|
||||
}),
|
||||
route: 'image-canvas',
|
||||
normalizeSource: true,
|
||||
route: 'derive',
|
||||
editKind: 'image-reference',
|
||||
},
|
||||
{
|
||||
resource: resource({
|
||||
@@ -163,15 +162,7 @@ describe('全类型资源编辑能力模型', () => {
|
||||
expected.resource,
|
||||
);
|
||||
expect(capability.route, expected.resource.id).toBe(expected.route);
|
||||
if (capability.route === 'derive') {
|
||||
expect(capability.editKind, expected.resource.id).toBe(
|
||||
expected.editKind,
|
||||
);
|
||||
} else {
|
||||
expect(capability.normalizeSource, expected.resource.id).toBe(
|
||||
expected.normalizeSource,
|
||||
);
|
||||
}
|
||||
expect(capability.editKind, expected.resource.id).toBe(expected.editKind);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user