/** @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, 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) => void; host: { generation: { recoverImages: (input: { scope: { projectId: string; draftId: string; intent: 'create' | 'refine'; sourceAssetId: string | null; }; }) => Promise; }; }; }) => { 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, createGameCreationAppManifest, fireEvent, render, screen, waitFor, } from './appSurface/harness'; const projectPath = '/tmp/live-canvas-integration'; type PendingResourceEditFixture = { operationId: string; editKind: string; sourceResourceId: string; assetName: string; phase: string; createdAt: number; }; function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, reject, resolve }; } 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 ( Supervisor} 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 ( Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} /> ); } function SwitchableDerivedWorkbench({ onManifestChange, }: { onManifestChange: (projectPath: string) => void; }) { const [project, setProject] = useState({ id: 'recovery-project-a', name: '恢复项目 A', path: '/tmp/recovery-project-a', }); const manifest = createGameCreationAppManifest(project.id, project.name); manifest.assets = [ { id: 'source-rules', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, }, ]; canvasFixture.manifest = manifest; return ( <> Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(changedProjectPath) => onManifestChange(changedProjectPath) } /> ); } 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 ( Supervisor} 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( options: { failFirstDerive?: boolean; pendingResourceEdit?: boolean; pendingResourceEdits?: PendingResourceEditFixture[]; failFirstPendingRead?: boolean; readPendingResourceEdits?: ( input: Record, readCount: number, ) => PendingResourceEditFixture[] | Promise; resumeResourceEdit?: ( input: Record, ) => Promise>; requestResourceEditServiceIdentityConfirmation?: ( input: Record, ) => Promise>; confirmResourceEditServiceIdentity?: ( input: Record, ) => Promise>; } = {}, ) { const layoutWrites: Array> = []; const graphReads: Array> = []; const deriveCalls: Array> = []; const normalizeCalls: Array> = []; const resumeCalls: Array> = []; const serviceIdentityRequestCalls: Array> = []; const serviceIdentityConfirmCalls: Array> = []; const archiveCalls: Array> = []; const pendingReadCalls: Array> = []; let pendingReadCount = 0; let discoverableDraftId: string | null = null; let pendingEdits = options.pendingResourceEdits ?? (options.pendingResourceEdit ? [ { operationId: '99999999-9999-4999-8999-999999999999', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '恢复的规则编辑版', phase: 'media-downloaded', createdAt: 1, }, ] : []); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'discover_local_project_asset_canvas_draft') { const input = (args?.input ?? {}) as Record; 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 }; } 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_document') { return { path: String(args?.relativePath ?? 'docs/rules.md'), mediaType: 'text/markdown', byteLen: 1, content: '# rules', }; } 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 === 'recover_local_project_asset_canvas_generations') { const input = structuredClone( (args?.input ?? {}) as Record, ); 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 ?? ''), mediaType: 'text/markdown', byteLen: 8, content: '# 玩法规则', }; } if (command === 'list_pending_local_project_resource_edits') { const input = structuredClone( (args?.input ?? {}) as Record, ); pendingReadCount += 1; pendingReadCalls.push(input); if (options.failFirstPendingRead && pendingReadCount === 1) { throw new Error('恢复队列测试读取失败'); } if (options.readPendingResourceEdits) { return structuredClone( await options.readPendingResourceEdits(input, pendingReadCount), ); } return structuredClone(pendingEdits); } if (command === 'resume_local_project_resource_edit') { const input = structuredClone( (args?.input ?? {}) as Record, ); resumeCalls.push(input); if (options.resumeResourceEdit) { return await options.resumeResourceEdit(input); } const base = canvasFixture.manifest; if (!base) throw new Error('missing manifest fixture'); const operationId = String(input.operationId); pendingEdits = pendingEdits.filter( (pending) => pending.operationId !== operationId, ); const asset = { id: `edit-${operationId}`, kind: 'game-rules', mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-recovered-rules.md`, source: { kind: 'generated' as const, resourceId: `local-asset:edit-${operationId}`, referenceResourceIds: ['rules-resource'], }, }; canvasFixture.revision += 1; const nextManifest = { ...base, assets: [...base.assets, asset] }; canvasFixture.manifest = nextManifest; return { operationId, editKind: 'text', sourceResourceId: 'rules-resource', committedProjectRevision: canvasFixture.revision, asset, version: null, manifest: nextManifest, }; } if ( command === 'request_local_project_resource_edit_service_identity_confirmation' ) { const input = structuredClone( (args?.input ?? {}) as Record, ); serviceIdentityRequestCalls.push(input); if (options.requestResourceEditServiceIdentityConfirmation) { return await options.requestResourceEditServiceIdentityConfirmation( input, ); } throw new Error('unexpected resource edit service identity request'); } if ( command === 'confirm_local_project_resource_edit_service_identity' ) { const input = structuredClone( (args?.input ?? {}) as Record, ); serviceIdentityConfirmCalls.push(input); if (options.confirmResourceEditServiceIdentity) { return await options.confirmResourceEditServiceIdentity(input); } throw new Error('unexpected resource edit service identity confirm'); } if (command === 'archive_failed_local_project_resource_edit') { const input = structuredClone( (args?.input ?? {}) as Record, ); archiveCalls.push(input); pendingEdits = pendingEdits.filter( (pending) => pending.operationId !== input.operationId, ); return { operationId: input.operationId, phase: 'archived', }; } if (command === 'derive_local_project_resource') { const input = structuredClone( (args?.input ?? {}) as Record, ); 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, ); 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, pendingReadCalls, resumeCalls, archiveCalls, serviceIdentityRequestCalls, serviceIdentityConfirmCalls, }; } 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(); 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(); 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(); 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(); 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(); 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('dialog', { name: new RegExp(operationId, 'u'), }), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '收起资源' })); expect( await screen.findByRole('button', { name: '打开资源详情:设计文档 rules.md', }), ).not.toBeNull(); }); it('normalizes a completed task image before opening the existing refine canvas', async () => { const { normalizeCalls } = installTauri(); render(); 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(); const manage = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); fireEvent.click(manage); const resume = await screen.findByRole('button', { name: '继续原编辑', }); fireEvent.click(resume); await waitFor(() => expect(resumeCalls).toHaveLength(1)); expect(resumeCalls[0]).toEqual({ projectPath, expectedProjectId: 'live-canvas-project', operationId: '99999999-9999-4999-8999-999999999999', }); expect(resumeCalls[0]).not.toHaveProperty('prompt'); expect(resumeCalls[0]).not.toHaveProperty('endpoint'); expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey'); expect( await screen.findByRole('dialog', { name: /recovered-rules\.md/, }), ).not.toBeNull(); }); it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => { const operation: PendingResourceEditFixture = { operationId: 'abababab-abab-4bab-8bab-abababababab', editKind: 'video', sourceResourceId: 'source-video', assetName: '旧 Key 视频编辑', phase: 'accepted', createdAt: 1, }; let identityConfirmed = false; let committed = false; const { resumeCalls, serviceIdentityRequestCalls, serviceIdentityConfirmCalls, } = installTauri({ readPendingResourceEdits: () => (committed ? [] : [operation]), resumeResourceEdit: async () => { if (!identityConfirmed) { throw new Error( 'service-identity-confirmation-required: 当前服务地址需要用户确认', ); } const base = canvasFixture.manifest!; const asset = { id: `edit-${operation.operationId}`, kind: 'video', mediaType: 'video/mp4', localPath: `assets/edits/${operation.operationId}.mp4`, source: { kind: 'canvas' as const, resourceId: 'remote-video-resource', referenceResourceIds: ['source-video'], }, }; committed = true; canvasFixture.revision += 1; const manifest = { ...base, assets: [...base.assets, asset] }; canvasFixture.manifest = manifest; return { operationId: operation.operationId, editKind: 'video', sourceResourceId: 'source-video', committedProjectRevision: canvasFixture.revision, asset, version: null, manifest, }; }, requestResourceEditServiceIdentityConfirmation: async (input) => ({ operationId: input.operationId, remoteOperationId: 'remote-existing-operation', operationState: 'accepted', serviceOrigin: 'https://editor.example.test', challenge: 'challenge-bound-to-the-current-ledger-snapshot', expiresAt: 9_999_999_999, }), confirmResourceEditServiceIdentity: async (input) => { expect(input).toMatchObject({ operationId: operation.operationId, remoteOperationId: 'remote-existing-operation', challenge: 'challenge-bound-to-the-current-ledger-snapshot', }); identityConfirmed = true; return { operationId: operation.operationId, remoteOperationId: 'remote-existing-operation', operationState: 'accepted', serviceOrigin: 'https://editor.example.test', identityScheme: 'service-origin-v1', }; }, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); expect( await screen.findByText('当前服务:https://editor.example.test'), ).not.toBeNull(); expect(serviceIdentityRequestCalls).toHaveLength(1); expect(resumeCalls).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: '确认当前服务并继续' })); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(serviceIdentityConfirmCalls).toHaveLength(1); expect(resumeCalls[0]).toEqual(resumeCalls[1]); expect(resumeCalls[1]).toEqual({ projectPath, expectedProjectId: 'live-canvas-project', operationId: operation.operationId, }); }); it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => { const failedOperationId = '11111111-1111-4111-8111-111111111111'; const resumableOperationId = '22222222-2222-4222-8222-222222222222'; const reconciliationOperationId = '33333333-3333-4333-8333-333333333333'; const { archiveCalls, pendingReadCalls, resumeCalls } = installTauri({ pendingResourceEdits: [ { operationId: failedOperationId, editKind: 'image', sourceResourceId: 'source-art', assetName: '已失败图片编辑', phase: 'remote-failed', createdAt: 1, }, { operationId: resumableOperationId, editKind: 'text', sourceResourceId: 'rules-resource', assetName: '可继续规则编辑', phase: 'media-downloaded', createdAt: 2, }, { operationId: reconciliationOperationId, editKind: 'agent-result', sourceResourceId: 'agent-result', assetName: '需要对账的结果', phase: 'reconciliation-required', createdAt: 3, }, ], }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (3)', }), ); const dialog = await screen.findByRole('dialog', { name: '未完成的资源编辑', }); expect(dialog.textContent).toContain('远程明确失败'); expect(dialog.textContent).toContain('需要人工对账'); expect(screen.getByText('只读对账')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(resumeCalls).toHaveLength(1)); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect(resumeCalls[0]?.operationId).toBe(resumableOperationId); expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId); fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)', }), ); fireEvent.click( await screen.findByRole('button', { name: '移出恢复队列' }), ); await waitFor(() => expect(archiveCalls).toHaveLength(1)); await waitFor(() => expect(pendingReadCalls).toHaveLength(3)); expect(archiveCalls[0]?.operationId).toBe(failedOperationId); expect(screen.getByText('只读对账')).not.toBeNull(); expect( await screen.findByRole('button', { name: '管理未完成编辑 (1)', }), ).not.toBeNull(); }); it('项目切换后丢弃旧项目迟到的恢复队列读取结果', async () => { const oldRead = createDeferred(); const oldOperation: PendingResourceEditFixture = { operationId: '44444444-4444-4444-8444-444444444444', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '旧项目迟到任务', phase: 'media-downloaded', createdAt: 1, }; const newOperation: PendingResourceEditFixture = { operationId: '55555555-5555-4555-8555-555555555555', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '新项目权威任务', phase: 'media-downloaded', createdAt: 2, }; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (input) => input.projectPath === '/tmp/recovery-project-a' ? oldRead.promise : [newOperation], }); render( undefined} />); await waitFor(() => expect(pendingReadCalls).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' })); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect(pendingReadCalls[1]).toMatchObject({ projectPath: '/tmp/recovery-project-b', expectedProjectId: 'recovery-project-b', }); expect( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ).not.toBeNull(); await act(async () => { oldRead.resolve([oldOperation]); await oldRead.promise; }); fireEvent.click(screen.getByRole('button', { name: '管理未完成编辑 (1)' })); expect(await screen.findByText('新项目权威任务')).not.toBeNull(); expect(screen.queryByText('旧项目迟到任务')).toBeNull(); }); it('项目切换后丢弃旧项目迟到的恢复结果', async () => { const staleResume = createDeferred>(); const staleOperation: PendingResourceEditFixture = { operationId: '66666666-6666-4666-8666-666666666666', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '旧项目恢复任务', phase: 'media-downloaded', createdAt: 1, }; const currentOperation: PendingResourceEditFixture = { operationId: '77777777-7777-4777-8777-777777777777', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '新项目保留任务', phase: 'media-downloaded', createdAt: 2, }; const onManifestChange = vi.fn(); const { resumeCalls } = installTauri({ readPendingResourceEdits: (input) => input.projectPath === '/tmp/recovery-project-a' ? [staleOperation] : [currentOperation], resumeResourceEdit: () => staleResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)', }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(resumeCalls).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' })); const currentTrigger = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); expect(screen.queryByText('旧项目恢复任务')).toBeNull(); const staleManifest = createGameCreationAppManifest( 'recovery-project-a', '恢复项目 A', ); const staleAsset = { id: 'stale-recovered-asset', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'assets/stale-recovered-rules.md', source: { kind: 'generated' as const, resourceId: 'local-asset:stale-recovered-asset', referenceResourceIds: ['rules-resource'], }, }; staleManifest.assets = [...staleManifest.assets, staleAsset]; await act(async () => { staleResume.resolve({ operationId: staleOperation.operationId, editKind: 'text', sourceResourceId: 'rules-resource', committedProjectRevision: 1, asset: staleAsset, version: null, manifest: staleManifest, }); await staleResume.promise; }); expect(onManifestChange).not.toHaveBeenCalled(); fireEvent.click(currentTrigger); expect(await screen.findByText('新项目保留任务')).not.toBeNull(); expect(screen.queryByText('旧项目恢复任务')).toBeNull(); expect( screen.queryByRole('button', { name: /stale-recovered/u }), ).toBeNull(); }); it('恢复成功后的权威队列重读迟到时不会关闭新项目面板', async () => { const postActionRead = createDeferred(); const oldOperation: PendingResourceEditFixture = { operationId: '88888888-8888-4888-8888-888888888888', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '旧项目已恢复任务', phase: 'media-downloaded', createdAt: 1, }; const newOperation: PendingResourceEditFixture = { operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '新项目不能被关闭的任务', phase: 'media-downloaded', createdAt: 2, }; const { pendingReadCalls, resumeCalls } = installTauri({ readPendingResourceEdits: (input, readCount) => { if (input.projectPath === '/tmp/recovery-project-b') { return [newOperation]; } return readCount === 1 ? [oldOperation] : postActionRead.promise; }, }); render( undefined} />); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(resumeCalls).toHaveLength(1)); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' })); await waitFor(() => expect(pendingReadCalls).toHaveLength(3)); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); expect(await screen.findByText('新项目不能被关闭的任务')).not.toBeNull(); await act(async () => { postActionRead.resolve([]); await postActionRead.promise; }); expect( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); expect(screen.getByText('新项目不能被关闭的任务')).not.toBeNull(); }); it('恢复失败后重读权威队列并展示后端迁移后的失败阶段', async () => { const operation: PendingResourceEditFixture = { operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '阶段迁移任务', phase: 'media-downloaded', createdAt: 1, }; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (_input, readCount) => [ { ...operation, phase: readCount === 1 ? 'media-downloaded' : 'remote-failed', }, ], resumeResourceEdit: async () => { throw new Error('远程恢复明确失败'); }, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect((await screen.findByRole('alert')).textContent).toContain( '远程恢复明确失败', ); expect(screen.getByText('远程明确失败')).not.toBeNull(); expect(screen.getByRole('button', { name: '移出恢复队列' })).not.toBeNull(); }); it('仅锁定正在操作的 operation,同项防双击且其他项可并行', async () => { const firstResume = createDeferred>(); const secondResume = createDeferred>(); const firstOperationId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const secondOperationId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const { resumeCalls } = installTauri({ pendingResourceEdits: [ { operationId: firstOperationId, editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行任务一', phase: 'media-downloaded', createdAt: 1, }, { operationId: secondOperationId, editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行任务二', phase: 'media-downloaded', createdAt: 2, }, ], resumeResourceEdit: (input) => input.operationId === firstOperationId ? firstResume.promise : secondResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)' }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); fireEvent.click(actions[0]); fireEvent.click(actions[0]); expect(actions[0].hasAttribute('disabled')).toBe(true); expect(actions[1].hasAttribute('disabled')).toBe(false); fireEvent.click(actions[1]); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(resumeCalls.map((call) => call.operationId)).toEqual([ firstOperationId, secondOperationId, ]); }); it('并行 operation 的权威重读互相判旧时仍分别保留原始失败', async () => { const firstResume = createDeferred>(); const secondResume = createDeferred>(); const firstRead = createDeferred(); const secondRead = createDeferred(); const firstOperation: PendingResourceEditFixture = { operationId: 'f1111111-1111-4111-8111-111111111111', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行失败任务一', phase: 'media-downloaded', createdAt: 1, }; const secondOperation: PendingResourceEditFixture = { operationId: 'f2222222-2222-4222-8222-222222222222', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行失败任务二', phase: 'media-downloaded', createdAt: 2, }; const operations = [firstOperation, secondOperation]; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (_input, readCount) => { if (readCount === 1) return operations; return readCount === 2 ? firstRead.promise : secondRead.promise; }, resumeResourceEdit: (input) => input.operationId === firstOperation.operationId ? firstResume.promise : secondResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)', }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); fireEvent.click(actions[0]); fireEvent.click(actions[1]); await act(async () => { firstResume.reject(new Error('并行恢复一失败')); await firstResume.promise.catch(() => undefined); }); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); await act(async () => { secondResume.reject(new Error('并行恢复二失败')); await secondResume.promise.catch(() => undefined); }); await waitFor(() => expect(pendingReadCalls).toHaveLength(3)); await act(async () => { firstRead.resolve(operations); await firstRead.promise; }); expect((await screen.findByRole('alert')).textContent).toContain( '并行恢复一失败', ); await act(async () => { secondRead.resolve(operations); await secondRead.promise; }); await waitFor(() => { const alert = screen.getByRole('alert'); expect(alert.textContent).toContain('并行恢复一失败'); expect(alert.textContent).toContain('并行恢复二失败'); }); }); it('旧弹窗动作完成时不会关闭用户后来重新打开的同项目弹窗', async () => { const pendingResume = createDeferred>(); const operation: PendingResourceEditFixture = { operationId: 'f3333333-3333-4333-8333-333333333333', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '跨弹窗恢复任务', phase: 'media-downloaded', createdAt: 1, }; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (_input, readCount) => readCount === 1 ? [operation] : [], resumeResourceEdit: () => pendingResume.promise, }); render(); const trigger = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); fireEvent.click(trigger); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); fireEvent.click( screen.getByRole('button', { name: '关闭资源编辑恢复面板' }), ); await waitFor(() => expect( screen.queryByRole('dialog', { name: '未完成的资源编辑' }), ).toBeNull(), ); fireEvent.click(trigger); expect( await screen.findByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); const base = canvasFixture.manifest!; const asset = { id: 'reopened-dialog-output', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'assets/reopened-dialog-output.md', source: { kind: 'generated' as const, resourceId: 'local-asset:reopened-dialog-output', referenceResourceIds: ['rules-resource'], }, }; const nextManifest = { ...base, assets: [...base.assets, asset] }; canvasFixture.manifest = nextManifest; await act(async () => { pendingResume.resolve({ operationId: operation.operationId, editKind: 'text', sourceResourceId: 'rules-resource', committedProjectRevision: 1, asset, version: null, manifest: nextManifest, }); await pendingResume.promise; }); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); expect(screen.getByText('当前没有未完成的资源编辑')).not.toBeNull(); }); it('恢复已提交但权威队列重读失败时保留面板并显式报错', async () => { const operation: PendingResourceEditFixture = { operationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '已提交待确认任务', phase: 'media-downloaded', createdAt: 1, }; installTauri({ readPendingResourceEdits: (_input, readCount) => { if (readCount > 1) throw new Error('提交后权威队列读取失败'); return [operation]; }, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); expect((await screen.findByRole('alert')).textContent).toContain( '提交后权威队列读取失败', ); expect( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); expect(screen.getByText('已提交待确认任务')).not.toBeNull(); }); it('恢复队列读取失败时显式提供重试,不伪装成空队列', async () => { installTauri({ failFirstPendingRead: true, pendingResourceEdit: true, }); render(); fireEvent.click( await screen.findByRole('button', { name: '恢复队列读取失败' }), ); expect((await screen.findByRole('alert')).textContent).toContain( '恢复队列测试读取失败', ); fireEvent.click(screen.getByRole('button', { name: '重试读取' })); expect(await screen.findByText('恢复的规则编辑版')).not.toBeNull(); }); it('恢复面板循环约束 Tab 焦点,Escape 关闭后归还触发器聚焦', async () => { installTauri({ pendingResourceEdit: true }); render(); const trigger = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); fireEvent.click(trigger); const close = await screen.findByRole('button', { name: '关闭资源编辑恢复面板', }); expect(document.activeElement).toBe(close); const resume = screen.getByRole('button', { name: '继续原编辑' }); fireEvent.keyDown(window, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(resume); fireEvent.keyDown(window, { key: 'Tab' }); expect(document.activeElement).toBe(close); trigger.focus(); fireEvent.keyDown(window, { key: 'Tab' }); expect(document.activeElement).toBe(close); fireEvent.keyDown(window, { key: 'Escape' }); await waitFor(() => expect( screen.queryByRole('dialog', { name: '未完成的资源编辑' }), ).toBeNull(), ); await waitFor(() => expect(document.activeElement).toBe(trigger)); }); it('恢复动作使当前按钮动态禁用后仍将 Tab 焦点约束在弹窗内', async () => { const pendingResume = createDeferred>(); installTauri({ pendingResourceEdit: true, resumeResourceEdit: () => pendingResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)', }), ); const close = await screen.findByRole('button', { name: '关闭资源编辑恢复面板', }); const resume = screen.getByRole('button', { name: '继续原编辑' }); resume.focus(); fireEvent.click(resume); await waitFor(() => expect(resume.hasAttribute('disabled')).toBe(true)); expect(document.activeElement).toBe(resume); fireEvent.keyDown(window, { key: 'Tab' }); expect(document.activeElement).toBe(close); await act(async () => { pendingResume.reject(new Error('焦点测试恢复失败')); await pendingResume.promise.catch(() => undefined); }); expect((await screen.findByRole('alert')).textContent).toContain( '焦点测试恢复失败', ); }); });