Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts
T
kdletters 2e0bc3aeb1 保留资源替换冲突具体详情
已知冲突分类继续给出动作文案并附带脱敏对象详情
2026-10-06 14:38:15 +08:00

426 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @vitest-environment jsdom */
import { describe, expect, it, vi } from 'vitest';
import type {
GameCreationAppAssetKind,
GameCreationAppManifest,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import type {
LocalProjectVersionReplacementCandidate,
ProjectVersionResourceCompatibility,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
projectResourcesByManifestAssetId,
resourceReplacementAssetHints,
resourceReplacementBlockedReason,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceReplacementWarning,
resourceVersionReplacementErrorMessage,
sortResourceReplacementCandidates,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
readVersionResourceReplacementCandidates,
replaceVersionResource,
} from '../src/features/resource-canvas/resourceVersionReplacementTransport';
import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel';
import { installTauriRuntime } from './tauriRuntimeFake';
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: GameCreationAppAssetKind;
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('当前版本已不存在,请刷新项目后重试:initial-1');
expect(
resourceVersionReplacementErrorMessage(
new Error('源版本未绑定该素材:initial-1 · asset-1'),
),
).toBe('该素材不在当前版本的绑定里,不能替换:initial-1 · asset-1');
expect(
resourceVersionReplacementErrorMessage(
new Error('项目资源不存在:asset-x'),
),
).toBe('替换素材未登记或已被删除:asset-x');
expect(
resourceVersionReplacementErrorMessage(new Error('替换素材与源素材相同')),
).toBe('替换素材与源素材相同');
expect(
resourceVersionReplacementErrorMessage(
new Error('项目版本记录写入后不可修改、删除或重排'),
),
).toBe('替换被项目版本写入边界拒绝,请刷新项目后重试');
expect(
resourceVersionReplacementErrorMessage(
new Error('替换素材需要在客户端内执行'),
),
).toBe('替换素材需要在客户端内执行');
expect(resourceVersionReplacementErrorMessage(new Error('磁盘满了'))).toBe(
'磁盘满了',
);
const sensitive = resourceVersionReplacementErrorMessage(
new Error(
'HTTP 502 upstream overloaded;Authorization: Bearer fixture-secret;C:\\Users\\demo\\game',
),
);
expect(sensitive).toContain('HTTP 502 upstream overloaded');
expect(sensitive).not.toContain('fixture-secret');
expect(sensitive).not.toContain('C:\\Users\\demo');
expect(resourceVersionReplacementErrorMessage(undefined)).toBe(
'替换素材失败',
);
});
});
describe('候选择序与资源映射', () => {
/**
* 排序口径:**可替换(无硬门禁失败)的排最前,其余按后端原名次跟随**,不可替换项不隐藏。
*
* 变异验证:把 `sortResourceReplacementCandidates` 改成 identity(直接返回原数组),
* 本用例必须失败。
*/
it('可替换的排最前,不可替换的按原名次跟随,且不改写调用方数组', () => {
const candidates = [
candidate(
'asset-scene',
{ categoryEqual: false },
{ blockedReason: '分类不同' },
),
candidate('asset-ok'),
candidate(
'asset-subtype',
{ subtypeEqual: false },
{ blockedReason: '类型不同' },
),
candidate('asset-format', { sizeSpecEqual: false }),
// 后端说不可替换,但三项兼容性都是 true:兜底原因也是"不可选"。
candidate('asset-unknown', {}, { compatible: false }),
];
const sorted = sortResourceReplacementCandidates(candidates);
expect(sorted.map((entry) => entry.resourceId)).toEqual([
'asset-ok',
'asset-format',
'asset-scene',
'asset-subtype',
'asset-unknown',
]);
// 原名次不变:排序只改展示顺序,返回的是新数组。
expect(candidates.map((entry) => entry.resourceId)).toEqual([
'asset-scene',
'asset-ok',
'asset-subtype',
'asset-format',
'asset-unknown',
]);
expect(sorted).not.toBe(candidates);
});
/**
* 排序与弹窗的禁用状态必须同源:排在前面的候选**一定**没有禁用原因。
*
* 否则会出现"排在最前却点不动"的候选,排序就成了误导。
*
* 变异验证:把排序判据换成 `candidate.compatible === true`(而不是复用
* `resourceReplacementBlockedReason`),`asset-unknown` 会被排到前面,本用例必须失败。
*/
it('排在前面的候选一定没有禁用原因', () => {
const candidates = [
candidate('asset-ok'),
candidate('asset-unknown', {}, { compatible: false }),
candidate('asset-scene', { categoryEqual: false }),
candidate('asset-format', { sizeSpecEqual: false }),
];
const blockedReasons = resourceReplacementBlockedReasons(candidates);
const selectableCount = candidates.filter(
(entry) => blockedReasons[entry.resourceId] === undefined,
).length;
const sorted = sortResourceReplacementCandidates(candidates);
expect(selectableCount).toBe(2);
expect(
sorted
.slice(0, selectableCount)
.every((entry) => blockedReasons[entry.resourceId] === undefined),
).toBe(true);
expect(
sorted
.slice(selectableCount)
.every((entry) => blockedReasons[entry.resourceId] !== undefined),
).toBe(true);
});
it('manifest 资产 → 资源投影映射:跳过没有 manifest 身份的投影,同一资产取第一个', () => {
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,
[
{
fileName: '草稿.png',
mediaType: 'image/png',
localPath: 'uploads/draft.png',
status: 'imported',
},
],
[],
);
const resourceByAssetId = projectResourcesByManifestAssetId(resources);
expect(Array.from(resourceByAssetId.keys()).sort()).toEqual([
'asset-audio',
'asset-image',
]);
expect(resourceByAssetId.get('asset-image')?.path).toBe('assets/hero.png');
// 同一份映射喂给弹窗素材与候选缩略图,所以它必须与附件这类非 manifest 投影无关。
expect(
resources.some((entry) => entry.id === 'attachment:uploads/draft.png'),
).toBe(true);
});
});
describe('资源替换的 IPC 边界', () => {
it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => {
const invoke = vi.fn(async () => ({ ok: true }));
installTauriRuntime({ 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('替换素材需要在客户端内执行');
});
});