Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts
T
suzmii 3245739385 前端改成直接替换:不切版本、尺寸规格降级为提示
- 口径跟进:前端从「版本级替换」改成「直接替换」——成功后**不再切换版本**(没有新版本可切),只重读 manifest;「当前使用」高亮会随新绑定自动移动(改的仍是同一个源版本)
- resourceVersionReplacementModel:兼容性判据的展示口径收窄为「硬门禁只有分类与类型」,尺寸规格不再产生禁用原因,改为非阻断提示「格式与源素材不同」;新增 resourceReplacementWarning 与 resourceReplacementAssetHints;类型去掉 parentVersionId、加上 warning
- index.tsx:confirmResourceVersionReplacement 去掉 selectActiveVersion 调用,失败仍保留弹窗与选择并显示原因(不切版本、不动高亮、不提示成功);新增 hint 映射并传给弹窗
- ImageCanvasProjectAssetPickerDialog:以可选 prop assetHints 扩展(默认 undefined → 不渲染),用于展示"可选但有差异"的提示;与 assetBlockedReasons 的区别是提示不改变可点性,默认行为仍逐字不变
- 失败文案补一条:版本写入边界拒绝(「不可修改、删除或重排」)翻成「替换被项目版本写入边界拒绝,请刷新项目后重试」,不让用户看到内部口径原文
- 用例改造:模型 9 条(尺寸规格不再禁用、提示表只收可选候选、提示不覆盖真正的不兼容原因、边界拒绝文案)、真链路 4 条(候选弹窗禁用硬门禁项 + 格式提示仍可选、写入载荷精确、成功后不切版本且不重载预览、失败保留弹窗且不重读 manifest、候选读取失败不弹空壳弹窗)
- 验证:两个前端用例文件 13 passed / 0 failed;npm run ai-game-creator-shell:typecheck(含 check-config)exit 0
2026-09-11 20:03:44 +08:00

291 lines
9.3 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 {
resourceReplacementAssetHints,
resourceReplacementBlockedReason,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceReplacementWarning,
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,
compatibility: resolved,
blockedReason: null,
warning: 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',
{ categoryEqual: false },
{ blockedReason: '分类不同' },
),
),
).toBe('分类不同');
});
it('后端没给原因时按硬门禁顺序派生(分类 → 类型),尺寸规格不参与禁用', () => {
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 }),
),
).toBeNull();
});
it('被标记为不可选但硬门禁都通过时仍给出兜底原因,不静默放行', () => {
expect(
resourceReplacementBlockedReason(
candidate('asset-unknown', {}, { compatible: false }),
),
).toBe('替换兼容性未通过');
});
it('尺寸规格差异只做提示,且不覆盖真正的不兼容原因', () => {
expect(
resourceReplacementWarning(
candidate('asset-format', { sizeSpecEqual: false }),
),
).toBe('格式与源素材不同');
expect(resourceReplacementWarning(candidate('asset-ok'))).toBeNull();
expect(
resourceReplacementWarning(
candidate(
'asset-blocked',
{ categoryEqual: false },
{ compatible: false },
),
),
).toBeNull();
});
it('禁用原因表只收录不可选候选;提示表只收录可选但有差异的候选', () => {
const candidates = [
candidate('asset-ok'),
candidate('asset-format', { sizeSpecEqual: false }),
candidate('asset-scene', { categoryEqual: false }),
];
expect(resourceReplacementBlockedReasons(candidates)).toEqual({
'asset-scene': '分类不同',
});
expect(resourceReplacementAssetHints(candidates)).toEqual({
'asset-format': '格式与源素材不同',
});
});
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(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('替换素材需要在客户端内执行');
});
});