放行资源卡工具条的「替换素材」入口并补前端用例

- 资源卡选中工具条放行「替换素材」按钮:判据复用现役的 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 身份"→「不给假按钮」用例转红;失败路径改成静默关弹窗→「保留弹窗显示原因」用例转红;均已还原
This commit is contained in:
2026-09-11 17:51:52 +08:00
parent 097d86d337
commit 0d0bba2ec2
3 changed files with 949 additions and 126 deletions
@@ -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({
<span></span>
</CanvasChromeButton>
) : null}
{/*
「替换素材」只在真链路能跑通时才渲染:素材必须是 manifest 资产,
且被**当前版本**绑定(`currentVersionBindingIds` 就是版本绑定口径的
唯一判定,见 `resourceCanvasVersionBindingModel.ts`)。不满足时不渲染
按钮,避免出现"点了没反应"的假按钮。
*/}
{selectedResource &&
isResourceUsedByCurrentVersion(
selectedResource,
currentVersionBindingIds,
) ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="替换素材"
title="替换素材"
icon={<Replace className="h-4 w-4" />}
onClick={() =>
void openResourceVersionReplacement(
selectedResource,
)
}
>
<span></span>
</CanvasChromeButton>
) : null}
</>
}
onOpenQuickEditPanel={openResourceQuickEditPanel}
@@ -6576,7 +6612,9 @@ export default function ProjectDevelopmentView({
</span>
)}
onCancel={closeResourceVersionReplacement}
onConfirm={(assetIds) => void confirmResourceVersionReplacement(assetIds)}
onConfirm={(assetIds) =>
void confirmResourceVersionReplacement(assetIds)
}
/>
{resourceRecoveryPanelOpen ? (
<div
File diff suppressed because it is too large Load Diff
@@ -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<ProjectVersionResourceCompatibility> = {},
overrides: Partial<LocalProjectVersionReplacementCandidate> = {},
): 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('替换素材需要在客户端内执行');
});
});