2e0bc3aeb1
已知冲突分类继续给出动作文案并附带脱敏对象详情
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
/** @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('替换素材需要在客户端内执行');
|
||
});
|
||
});
|