/** @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 { 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'; function candidate( resourceId: string, compatibility: Partial = {}, overrides: Partial = {}, ): 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('候选择序与资源映射', () => { /** * 排序口径:**可替换(无硬门禁失败)的排最前,其余按后端原名次跟随**,不可替换项不隐藏。 * * 变异验证:把 `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 })); 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('替换素材需要在客户端内执行'); }); });