From 0d0bba2ec2333d858085ff4cbc7338a450c0d8f1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:51:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=BE=E8=A1=8C=E8=B5=84=E6=BA=90=E5=8D=A1?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=9D=A1=E7=9A=84=E3=80=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E3=80=8D=E5=85=A5=E5=8F=A3=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源卡选中工具条放行「替换素材」按钮:判据复用现役的 isResourceUsedByCurrentVersion(manifest 身份 + 被当前版本绑定),未绑定的素材一律不渲染,避免"点了没反应"的假按钮;本仓工具条 opt-in 硬约定要求真链路先通,因此入口放在最后一批提交 - 按钮落在宿主 extraActions,与「UI 编辑器 / 编辑标签 / 重命名」同处,不动 ImageCanvasSelectedLayerToolbarAction 共享 union、不改 resourceCanvasToolbarModel 的 supportedActions - selectActiveVersion 改为 useCallback 固定身份:替换成功后要走同一条记录层版本切换通道,普通函数声明会让 useCallback 依赖数组每次渲染都变(eslint react-hooks/exhaustive-deps 报错);替换相关的处理函数随之挪到它之后声明,依赖数组求值顺序才对 - 新增 tests/resourceVersionReplacement.test.tsx(真链路 4 条):入口只在被当前版本绑定时渲染、候选弹窗禁用不兼容项并显示原因且不合成非候选素材、写入 IPC 载荷逐字精确、成功后切版本但不重载预览(onPlay 不被调用)、后端拒绝时保留弹窗显示原因且不切版本不重读 manifest、候选读取失败时不弹空壳弹窗 - 新增 tests/resourceVersionReplacementModel.test.ts(8 条):禁用原因口径(后端原因优先、按 PRD §5.3 字段顺序派生、兜底不静默放行)、候选→弹窗素材映射(复用资源投影口径、不给 img 喂空 src、缺投影不合成条目)、失败文案逐条映射与未知错误原样透出、两个命令的 IPC 载荷形状 - 用例独立成文件,不往 tests/appSurface/project-development.suite.ts 里插,避免与工具条那条线互相踩 - 变异验证:放宽入口判据为"只要有 manifest 身份"→「不给假按钮」用例转红;失败路径改成静默关弹窗→「保留弹窗显示原因」用例转红;均已还原 --- .../src/view/project-development/index.tsx | 290 +++++----- .../tests/resourceVersionReplacement.test.tsx | 527 ++++++++++++++++++ .../resourceVersionReplacementModel.test.ts | 258 +++++++++ 3 files changed, 949 insertions(+), 126 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 746b01ce5..3a8dcc622 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -34,6 +34,7 @@ import { Play, Plus, Redo2, + Replace, RotateCcw, Search, Settings2, @@ -136,10 +137,10 @@ import { isResourceUsedByCurrentVersion, } from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; import { + type LocalProjectVersionReplacementCandidate, resourceReplacementBlockedReasons, resourceReplacementPickerAssets, resourceVersionReplacementErrorMessage, - type LocalProjectVersionReplacementCandidate, } from '../../features/resource-canvas/resourceVersionReplacementModel'; import { readVersionResourceReplacementCandidates, @@ -2609,121 +2610,6 @@ export default function ProjectDevelopmentView({ }, [reloadManifestAfterAssetCommand], ); - /** - * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 - * - * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, - * 避免出现"打开了但什么也选不了"的假入口。 - */ - const openResourceVersionReplacement = useCallback( - async (resource: ProjectResource) => { - const assetId = resource.manifestAssetId; - const version = resolveActiveIterationVersion( - projectVersions, - activeVersionId, - ); - if (!assetId || !version) return; - const source = { versionId: version.versionId, assetId }; - setResourceReplacementError(null); - setResourceReplacementCandidates([]); - setResourceReplacementSource(source); - setResourceReplacementLoading(true); - try { - const result = await readVersionResourceReplacementCandidates({ - projectPath, - sourceVersionId: source.versionId, - sourceResourceId: source.assetId, - }); - setResourceReplacementCandidates(result.candidates); - setResourceReplacementOpen(true); - } catch (error) { - setResourceReplacementSource(null); - setResourceWorkbenchNotice( - resourceVersionReplacementErrorMessage(error), - ); - } finally { - setResourceReplacementLoading(false); - } - }, - [activeVersionId, projectPath, projectVersions], - ); - const closeResourceVersionReplacement = useCallback(() => { - setResourceReplacementOpen(false); - setResourceReplacementSource(null); - setResourceReplacementCandidates([]); - setResourceReplacementError(null); - }, []); - /** - * 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。 - * - * 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。 - * 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现, - * 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。 - */ - const confirmResourceVersionReplacement = useCallback( - async (assetIds: string[]) => { - const source = resourceReplacementSource; - const replacementResourceId = assetIds[0]; - if (!source || !replacementResourceId) { - setResourceReplacementError('请选择一个替换素材'); - return; - } - if (resourceReplacementLoading) return; - setResourceReplacementLoading(true); - setResourceReplacementError(null); - try { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { - throw new Error('替换素材需要在客户端内执行'); - } - const status = await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ); - if (!Number.isSafeInteger(status.revision) || status.revision < 0) { - throw new Error('项目 revision 无效'); - } - const result = await replaceVersionResource({ - projectPath, - expectedProjectId: manifest.projectId, - expectedProjectRevision: status.revision, - sourceVersionId: source.versionId, - sourceResourceId: source.assetId, - replacementResourceId, - }); - setResourceReplacementOpen(false); - setResourceReplacementSource(null); - setResourceReplacementCandidates([]); - selectActiveVersion(result.versionId); - await reloadManifestAfterAssetCommand( - result.committedProjectRevision, - `version-resource-replacement:${result.versionId}`, - ); - } catch (error) { - setResourceReplacementError( - resourceVersionReplacementErrorMessage(error), - ); - } finally { - setResourceReplacementLoading(false); - } - }, - [ - manifest.projectId, - projectPath, - reloadManifestAfterAssetCommand, - resourceReplacementLoading, - resourceReplacementSource, - ], - ); - const resourceReplacementPickerEntries = useMemo( - () => - resourceReplacementPickerAssets(resources, resourceReplacementCandidates), - [resourceReplacementCandidates, resources], - ); - const resourceReplacementBlockedReasonMap = useMemo( - () => resourceReplacementBlockedReasons(resourceReplacementCandidates), - [resourceReplacementCandidates], - ); /** * 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。 * Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。 @@ -5122,16 +5008,141 @@ export default function ProjectDevelopmentView({ * * 画面本身不需要运行时按版本重映射:素材不可变(编辑产出新素材而不是改文件), * 所以版本之间没变的资源本来就是同一份文件,重载预览即可回到该版本对应的画面。 + * + * 用 `useCallback` 固定身份:素材替换成功后也要走这条记录层通道(见 + * `confirmResourceVersionReplacement`),普通函数声明会让依赖数组每次渲染都变。 */ - function selectActiveVersion(versionId: string) { - if (versionId === activeVersionId) { - return; - } - onActiveVersionChange?.(versionId); - if (mode === 'run' && runAvailable) { - onPlay?.(); - } - } + const selectActiveVersion = useCallback( + (versionId: string) => { + if (versionId === activeVersionId) { + return; + } + onActiveVersionChange?.(versionId); + if (mode === 'run' && runAvailable) { + onPlay?.(); + } + }, + [activeVersionId, mode, onActiveVersionChange, onPlay, runAvailable], + ); + /** + * 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。 + * + * 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白, + * 避免出现"打开了但什么也选不了"的假入口。 + * + * 本组处理函数放在 `selectActiveVersion` 之后:替换成功要走同一条记录层切换通道, + * 而 `useCallback` 的依赖数组在渲染期求值,声明顺序必须先于它。 + */ + const openResourceVersionReplacement = useCallback( + async (resource: ProjectResource) => { + const assetId = resource.manifestAssetId; + const version = resolveActiveIterationVersion( + projectVersions, + activeVersionId, + ); + if (!assetId || !version) return; + const source = { versionId: version.versionId, assetId }; + setResourceReplacementError(null); + setResourceReplacementCandidates([]); + setResourceReplacementSource(source); + setResourceReplacementLoading(true); + try { + const result = await readVersionResourceReplacementCandidates({ + projectPath, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + }); + setResourceReplacementCandidates(result.candidates); + setResourceReplacementOpen(true); + } catch (error) { + setResourceReplacementSource(null); + setResourceWorkbenchNotice( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [activeVersionId, projectPath, projectVersions], + ); + const closeResourceVersionReplacement = useCallback(() => { + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + setResourceReplacementError(null); + }, []); + /** + * 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。 + * + * 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。 + * 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现, + * 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。 + */ + const confirmResourceVersionReplacement = useCallback( + async (assetIds: string[]) => { + const source = resourceReplacementSource; + const replacementResourceId = assetIds[0]; + if (!source || !replacementResourceId) { + setResourceReplacementError('请选择一个替换素材'); + return; + } + if (resourceReplacementLoading) return; + setResourceReplacementLoading(true); + setResourceReplacementError(null); + try { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + throw new Error('替换素材需要在客户端内执行'); + } + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await replaceVersionResource({ + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision: status.revision, + sourceVersionId: source.versionId, + sourceResourceId: source.assetId, + replacementResourceId, + }); + setResourceReplacementOpen(false); + setResourceReplacementSource(null); + setResourceReplacementCandidates([]); + selectActiveVersion(result.versionId); + await reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `version-resource-replacement:${result.versionId}`, + ); + } catch (error) { + setResourceReplacementError( + resourceVersionReplacementErrorMessage(error), + ); + } finally { + setResourceReplacementLoading(false); + } + }, + [ + manifest.projectId, + projectPath, + reloadManifestAfterAssetCommand, + resourceReplacementLoading, + resourceReplacementSource, + selectActiveVersion, + ], + ); + const resourceReplacementPickerEntries = useMemo( + () => + resourceReplacementPickerAssets(resources, resourceReplacementCandidates), + [resourceReplacementCandidates, resources], + ); + const resourceReplacementBlockedReasonMap = useMemo( + () => resourceReplacementBlockedReasons(resourceReplacementCandidates), + [resourceReplacementCandidates], + ); function showRunView() { if (!runAvailable || uiEditorRoute) { @@ -5919,6 +5930,31 @@ export default function ProjectDevelopmentView({ 重命名 ) : null} + {/* + 「替换素材」只在真链路能跑通时才渲染:素材必须是 manifest 资产, + 且被**当前版本**绑定(`currentVersionBindingIds` 就是版本绑定口径的 + 唯一判定,见 `resourceCanvasVersionBindingModel.ts`)。不满足时不渲染 + 按钮,避免出现"点了没反应"的假按钮。 + */} + {selectedResource && + isResourceUsedByCurrentVersion( + selectedResource, + currentVersionBindingIds, + ) ? ( + } + onClick={() => + void openResourceVersionReplacement( + selectedResource, + ) + } + > + 替换素材 + + ) : null} } onOpenQuickEditPanel={openResourceQuickEditPanel} @@ -6576,7 +6612,9 @@ export default function ProjectDevelopmentView({ )} onCancel={closeResourceVersionReplacement} - onConfirm={(assetIds) => void confirmResourceVersionReplacement(assetIds)} + onConfirm={(assetIds) => + void confirmResourceVersionReplacement(assetIds) + } /> {resourceRecoveryPanelOpen ? (
) { + const resources = + (args?.resources as Array<{ resourceId: string }> | undefined) ?? []; + return { + resourceIds: resources.map(({ resourceId }) => resourceId), + referenceEdges: [], + taskFlows: [], + connectionIndex: resources.map(({ resourceId }) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resources.map(({ resourceId }) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +function replacementManifest() { + const manifest = createGameCreationAppManifest(PROJECT_ID, '替换素材测试'); + manifest.assets = [ + { + id: 'asset-legacy', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/legacy.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-final', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/final.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-scene', + kind: 'scene', + mediaType: 'image/png', + localPath: 'assets/scene.png', + source: { kind: 'generated' as const }, + }, + { + id: 'asset-late', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/late.png', + source: { kind: 'generated' as const }, + }, + ]; + manifest.versions = [ + { + versionId: SOURCE_VERSION_ID, + parentVersionId: null, + projectRevision: 1, + resourceBindings: [ + { slotId: 'asset:asset-legacy', resourceId: 'asset-legacy' }, + { slotId: 'asset:asset-final', resourceId: 'asset-final' }, + { slotId: 'asset:asset-scene', resourceId: 'asset-scene' }, + ], + createdReason: 'initial', + createdAt: 1_700_000_000, + }, + ]; + return manifest; +} + +const REPLACEMENT_CANDIDATES = { + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + candidates: [ + { + resourceId: 'asset-final', + compatible: true, + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + }, + blockedReason: null, + }, + { + resourceId: 'asset-scene', + compatible: false, + compatibility: { + categoryEqual: false, + subtypeEqual: false, + sizeSpecEqual: true, + }, + blockedReason: '分类不同', + }, + ], +}; + +const REPLACEMENT_RESULT = { + versionId: 'replace-6', + parentVersionId: SOURCE_VERSION_ID, + committedProjectRevision: 6, + replacement: { + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + replacementResourceId: 'asset-final', + compatibility: { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + }, + }, +}; + +type RenderOptions = { + replacementCandidates?: () => Promise; + replacementWrite?: () => Promise; +}; + +let observer: ReturnType< + typeof installResourceCardIntersectionObserver +> | null = null; + +function renderReplacementWorkbench(options: RenderOptions = {}) { + observer = installResourceCardIntersectionObserver(); + const manifest = replacementManifest(); + const nextManifest = { + ...manifest, + versions: [ + ...manifest.versions, + { + versionId: 'replace-6', + parentVersionId: SOURCE_VERSION_ID, + projectRevision: 6, + resourceBindings: [ + { slotId: 'asset:asset-final', resourceId: 'asset-final' }, + { slotId: 'asset:asset-scene', resourceId: 'asset-scene' }, + ], + createdReason: 'resource-replacement' as const, + createdAt: 1_700_000_100, + }, + ], + }; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: layoutRevision, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 12, + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + }; + } + if (command === 'get_local_game_project_revision') { + return { revision: EXPECTED_REVISION }; + } + if ( + command === 'read_local_project_version_resource_replacement_candidates' + ) { + return options.replacementCandidates + ? options.replacementCandidates() + : REPLACEMENT_CANDIDATES; + } + if (command === 'replace_local_project_version_resource') { + return options.replacementWrite + ? options.replacementWrite() + : REPLACEMENT_RESULT; + } + if (command === 'get_local_game_manifest') { + return nextManifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const onActiveVersionChange = vi.fn(); + const onPlay = vi.fn(); + const onManifestChange = vi.fn(); + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + activeVersionId: null, + onActiveVersionChange, + onPlay, + onManifestChange, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + return { invoke, onActiveVersionChange, onPlay, onManifestChange }; +} + +async function selectCardAndOpenToolbar(label: string) { + await waitFor(() => + expect( + document.querySelector('.game-resource-book-thumbnail'), + ).not.toBeNull(), + ); + // 浮出工具条只在栏目页(`resourceBookView !== 'main'`)上挂载:先打开素材所在的栏目页。 + if (!document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click( + await screen.findByRole('button', { name: '打开角色与对象' }), + ); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="child"]'), + ).not.toBeNull(), + ); + } + // 卡片必须先被 IntersectionObserver 报为可见,选中后才会浮出工具条; + // 这段 stub 与 `project-development.suite.ts` 的同名 helper 同形,本文件独立成文件后才复制过来。 + act(() => { + if (!observer) throw new Error('IntersectionObserver stub 未安装'); + observer.triggerVisible(); + }); + fireEvent.click(await findResourceSelectButton(label)); + return screen.findByRole('toolbar', { name: '图片工具栏' }); +} + +/** + * 资源卡预览用的 IntersectionObserver stub。 + * + * jsdom 没有 IntersectionObserver,而卡片浮出工具条依赖"可见"这一步,所以用例自己提供它。 + */ +function installResourceCardIntersectionObserver() { + const instances: Array<{ + callback: IntersectionObserverCallback; + observed: Set; + observer: IntersectionObserver; + }> = []; + + class ResourceCardIntersectionObserver { + readonly root = null; + readonly rootMargin = '160px'; + readonly thresholds = [0]; + readonly observed = new Set(); + + constructor(readonly callback: IntersectionObserverCallback) { + instances.push({ + callback, + observed: this.observed, + observer: this as unknown as IntersectionObserver, + }); + } + + observe(element: Element) { + this.observed.add(element); + } + + unobserve(element: Element) { + this.observed.delete(element); + } + + disconnect() { + this.observed.clear(); + } + + takeRecords() { + return []; + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + configurable: true, + value: ResourceCardIntersectionObserver, + }); + + return { + triggerVisible(elements?: Element[]) { + const instance = instances.at(-1); + if (!instance) { + throw new Error('resource card IntersectionObserver was not created'); + } + const targets = elements ?? Array.from(instance.observed); + instance.callback( + targets.map( + (target) => + ({ + target, + isIntersecting: true, + intersectionRatio: 1, + }) as IntersectionObserverEntry, + ), + instance.observer, + ); + }, + }; +} + +describe('版本级资源替换', () => { + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { + const { invoke } = renderReplacementWorkbench(); + + // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 + const lateToolbar = await selectCardAndOpenToolbar('late.png'); + expect( + within(lateToolbar).queryByRole('button', { name: '替换素材' }), + ).toBeNull(); + expect( + within(lateToolbar).getByRole('button', { name: '快速编辑' }), + ).not.toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => + command === + 'read_local_project_version_resource_replacement_candidates', + ), + ).toBe(false); + + // 被当前版本绑定的素材:入口出现。 + const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); + expect( + within(sourceToolbar).getByRole('button', { name: '替换素材' }), + ).not.toBeNull(); + }); + + it('从入口一路走到写入:候选弹窗禁用不兼容项、写入载荷精确、成功后切版本但不重载预览', async () => { + const { invoke, onActiveVersionChange, onPlay, onManifestChange } = + renderReplacementWorkbench(); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command]) => + command === + 'read_local_project_version_resource_replacement_candidates', + ), + ).toBe(true), + ); + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_version_resource_replacement_candidates', + { + input: { + projectPath: PROJECT_PATH, + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + }, + }, + ); + + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + // 候选只列后端给出的素材:未绑定/未登记的素材不合成条目。 + expect( + within(dialog).queryByRole('option', { name: '选择替换素材late.png' }), + ).toBeNull(); + const blockedOption = within(dialog).getByRole('option', { + name: '选择替换素材scene.png', + }) as HTMLButtonElement; + expect(blockedOption.disabled).toBe(true); + expect(within(dialog).getByText('分类不同')).not.toBeNull(); + const compatibleOption = within(dialog).getByRole('option', { + name: '选择替换素材final.png', + }) as HTMLButtonElement; + expect(compatibleOption.disabled).toBe(false); + + fireEvent.click(compatibleOption); + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); + + await waitFor(() => + expect( + invoke.mock.calls.some( + ([command]) => command === 'replace_local_project_version_resource', + ), + ).toBe(true), + ); + expect(invoke).toHaveBeenCalledWith( + 'replace_local_project_version_resource', + { + input: { + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: EXPECTED_REVISION, + sourceVersionId: SOURCE_VERSION_ID, + sourceResourceId: 'asset-legacy', + replacementResourceId: 'asset-final', + }, + }, + ); + + // 成功后:重读 manifest 并按新 revision 提交;记录层当前版本切到新版本。 + await waitFor(() => + expect(onActiveVersionChange).toHaveBeenCalledWith('replace-6'), + ); + expect(onManifestChange).toHaveBeenCalledWith( + PROJECT_PATH, + expect.objectContaining({ projectId: PROJECT_ID }), + expect.objectContaining({ revision: 6, source: 'asset-command' }), + ); + // PRD §3.2 末条:替换不自动重载/重启运行中的预览。 + expect(onPlay).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(), + ); + }); + + it('后端拒绝时保留弹窗、显示原因,且不切版本、不重读 manifest', async () => { + const { invoke, onActiveVersionChange, onManifestChange } = + renderReplacementWorkbench({ + replacementWrite: async () => { + throw new Error('resource-replacement-incompatible:分类不同'); + }, + }); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + const dialog = await screen.findByRole('dialog', { + name: '选择替换素材', + }); + fireEvent.click( + within(dialog).getByRole('option', { name: '选择替换素材final.png' }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: '确认选择替换素材' }), + ); + + await waitFor(() => + expect(within(dialog).getByRole('alert').textContent).toBe( + '替换素材不兼容:分类不同', + ), + ); + expect(screen.getByRole('dialog', { name: '选择替换素材' })).not.toBeNull(); + expect(onActiveVersionChange).not.toHaveBeenCalled(); + expect(onManifestChange).not.toHaveBeenCalled(); + expect( + invoke.mock.calls.some( + ([command]) => command === 'get_local_game_manifest', + ), + ).toBe(false); + }); + + it('候选读取失败时不弹空壳弹窗,直接把原因说明白', async () => { + renderReplacementWorkbench({ + replacementCandidates: async () => { + throw new Error('源项目版本不存在:initial-1'); + }, + }); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + + await waitFor(() => + expect( + screen.getByText('当前版本已不存在,请刷新项目后重试'), + ).not.toBeNull(), + ); + expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts new file mode 100644 index 000000000..cd2890922 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -0,0 +1,258 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + LocalProjectVersionReplacementCandidate, + ProjectVersionResourceCompatibility, +} from '../src/features/resource-canvas/resourceVersionReplacementModel'; +import { + resourceReplacementBlockedReason, + resourceReplacementBlockedReasons, + resourceReplacementPickerAssets, + resourceVersionReplacementErrorMessage, +} from '../src/features/resource-canvas/resourceVersionReplacementModel'; +import { + readVersionResourceReplacementCandidates, + replaceVersionResource, +} from '../src/features/resource-canvas/resourceVersionReplacementTransport'; +import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; + +function candidate( + resourceId: string, + compatibility: Partial = {}, + overrides: Partial = {}, +): LocalProjectVersionReplacementCandidate { + const resolved: ProjectVersionResourceCompatibility = { + categoryEqual: true, + subtypeEqual: true, + sizeSpecEqual: true, + ...compatibility, + }; + return { + resourceId, + compatible: + resolved.categoryEqual && resolved.subtypeEqual && resolved.sizeSpecEqual, + compatibility: resolved, + blockedReason: null, + ...overrides, + }; +} + +function manifestWithAssets( + assets: Array<{ + id: string; + kind: string; + mediaType: string; + localPath: string; + }>, +): GameCreationAppManifest { + return { + schemaVersion: 'game-creator-manifest.v1', + projectId: 'project-1', + name: '资源替换模型测试', + goal: null, + tasks: [], + assets: assets.map((asset) => ({ + ...asset, + source: { kind: 'generated' as const }, + })), + }; +} + +describe('资源替换的展示层口径', () => { + it('兼容时没有禁用原因,不兼容时优先用后端给的原因', () => { + expect(resourceReplacementBlockedReason(candidate('asset-ok'))).toBeNull(); + expect( + resourceReplacementBlockedReason( + candidate( + 'asset-x', + { sizeSpecEqual: false }, + { blockedReason: '尺寸规格不同' }, + ), + ), + ).toBe('尺寸规格不同'); + }); + + it('后端没给原因时按 PRD §5.3 的字段顺序派生第一项不等的维度', () => { + expect( + resourceReplacementBlockedReason( + candidate('asset-category', { + categoryEqual: false, + subtypeEqual: false, + sizeSpecEqual: false, + }), + ), + ).toBe('分类不同'); + expect( + resourceReplacementBlockedReason( + candidate('asset-subtype', { + subtypeEqual: false, + sizeSpecEqual: false, + }), + ), + ).toBe('类型不同'); + expect( + resourceReplacementBlockedReason( + candidate('asset-size', { sizeSpecEqual: false }), + ), + ).toBe('尺寸规格不同'); + }); + + it('被标记为不兼容但三项都是 true 时仍给出兜底原因,不静默放行', () => { + expect( + resourceReplacementBlockedReason( + candidate('asset-unknown', {}, { compatible: false }), + ), + ).toBe('替换兼容性未通过'); + }); + + it('禁用原因表只收录被禁用的候选:兼容候选不得出现禁用文案', () => { + expect( + resourceReplacementBlockedReasons([ + candidate('asset-ok'), + candidate('asset-scene', { categoryEqual: false }), + ]), + ).toEqual({ 'asset-scene': '分类不同' }); + }); + + it('候选映射成弹窗素材:复用资源投影口径、不给 img 喂空 src、缺投影的候选不合成条目', () => { + const manifest = manifestWithAssets([ + { + id: 'asset-image', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + }, + { + id: 'asset-audio', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/theme.mp3', + }, + ]); + const resources = projectResourcesFromReadModels(manifest, [], []); + const assets = resourceReplacementPickerAssets(resources, [ + candidate('asset-image'), + candidate('asset-audio'), + candidate('asset-missing'), + ]); + + expect(assets.map((asset) => asset.id)).toEqual([ + 'asset-image', + 'asset-audio', + ]); + expect(assets.map((asset) => asset.label)).toEqual([ + 'hero.png', + 'theme.mp3', + ]); + expect(assets.map((asset) => asset.mediaType)).toEqual(['image', 'audio']); + // AGC 没有同步 src:交给弹窗的 renderAssetMedia 渲染类型占位,不能挂破图。 + expect(assets.every((asset) => asset.src === '')).toBe(true); + }); + + it('失败文案逐条可读,未知错误原样透出', () => { + expect( + resourceVersionReplacementErrorMessage( + new Error('project-revision-conflict'), + ), + ).toBe('项目已被其它操作改动,请重试替换'); + expect( + resourceVersionReplacementErrorMessage( + new Error('project-identity-conflict'), + ), + ).toBe('项目身份不一致,请重新打开项目后再试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('resource-replacement-incompatible:尺寸规格不同'), + ), + ).toBe('替换素材不兼容:尺寸规格不同'); + expect( + resourceVersionReplacementErrorMessage( + new Error('源项目版本不存在:initial-1'), + ), + ).toBe('当前版本已不存在,请刷新项目后重试'); + expect( + resourceVersionReplacementErrorMessage( + new Error('源版本未绑定该素材:initial-1 · asset-1'), + ), + ).toBe('该素材不在当前版本的绑定里,不能替换'); + expect( + resourceVersionReplacementErrorMessage( + new Error('项目资源不存在:asset-x'), + ), + ).toBe('替换素材未登记或已被删除'); + expect( + resourceVersionReplacementErrorMessage(new Error('替换素材与源素材相同')), + ).toBe('替换素材与源素材相同'); + expect( + resourceVersionReplacementErrorMessage( + new Error('替换素材需要在客户端内执行'), + ), + ).toBe('替换素材需要在客户端内执行'); + expect(resourceVersionReplacementErrorMessage(new Error('磁盘满了'))).toBe( + '磁盘满了', + ); + expect(resourceVersionReplacementErrorMessage(undefined)).toBe( + '替换素材失败', + ); + }); +}); + +describe('资源替换的 IPC 边界', () => { + it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => { + const invoke = vi.fn(async () => ({ ok: true })); + window.__TAURI__ = { core: { invoke } }; + + await readVersionResourceReplacementCandidates({ + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }); + expect(invoke).toHaveBeenNthCalledWith( + 1, + 'read_local_project_version_resource_replacement_candidates', + { + input: { + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }, + }, + ); + + await replaceVersionResource({ + projectPath: '/tmp/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + replacementResourceId: 'asset-target', + }); + expect(invoke).toHaveBeenNthCalledWith( + 2, + 'replace_local_project_version_resource', + { + input: { + projectPath: '/tmp/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + replacementResourceId: 'asset-target', + }, + }, + ); + }); + + it('不在客户端内执行时明确报错,不做静默成功', async () => { + (window as unknown as { __TAURI__?: unknown }).__TAURI__ = undefined; + await expect( + readVersionResourceReplacementCandidates({ + projectPath: '/tmp/project', + sourceVersionId: 'initial-1', + sourceResourceId: 'asset-source', + }), + ).rejects.toThrow('替换素材需要在客户端内执行'); + }); +});