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('替换素材需要在客户端内执行');
+ });
+});