0d0bba2ec2
- 资源卡选中工具条放行「替换素材」按钮:判据复用现役的 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 身份"→「不给假按钮」用例转红;失败路径改成静默关弹窗→「保留弹窗显示原因」用例转红;均已还原
259 lines
8.2 KiB
TypeScript
259 lines
8.2 KiB
TypeScript
/** @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('替换素材需要在客户端内执行');
|
|
});
|
|
});
|