import { describe, expect, test } from 'vitest'; import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel'; import { projectResourceTypeLabel } from '../src/view/project-development/resourceProjectionModel'; /** * 派发决策必须由**可验证的内容证据**(mediaType,缺失时退路径扩展名)决定, * 登记标签 `kind` 只能在两者都给不出结论时兜底。 * * 这条不变量是本轮「标着图片却只有占位图标」缺陷的直接护栏:当时 `kind:"ui"` 与 * mediaType 同权,界面规格的 `application/json` 被 `artKind`(含 `ui`)顶成 art, * 于是角标按 art 显示「图片」,而预览调度走图像分支、又因 mediaType 不是图像兜底成 * `placeholder` 且**永不发起读取** —— 卡片既不报错也没有图。 * 只要有人再让 `kind` 抢在 mediaType 前面生效,这里就会红。 */ describe('派发决策的内容证据优先级不变量', () => { const MEDIA_TYPES: readonly string[] = [ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml', 'video/mp4', 'audio/mpeg', 'application/json', 'application/yaml', 'text/html', 'text/markdown', 'text/typescript', 'application/octet-stream', 'application/zip', ]; const KINDS: readonly string[] = [ 'ui', 'UI', 'ui-design', 'ui-prototype', 'image', 'scene', 'character', 'character-animation', 'art-spritesheet', 'icon-spec', 'background-music', 'spec', 'game-entry', 'game-background', 'game-code', 'code', 'source', 'video', 'font', 'binary', ]; const EXTENSIONS: readonly string[] = [ 'png', 'jpg', 'webp', 'gif', 'svg', 'mp4', 'mp3', 'json', 'yaml', 'html', 'md', 'ts', 'bundle', 'zip', ]; const MEDIA_TYPE_EXTENSION_PAIRS: ReadonlyArray = [ ['image/png', 'png'], ['image/jpeg', 'jpg'], ['image/webp', 'webp'], ['audio/mpeg', 'mp3'], ['video/mp4', 'mp4'], ['application/json', 'json'], ['text/markdown', 'md'], ]; /** 由 mediaType 单独推出的合法预览分支集合;空 mediaType 表示"没有内容证据"。 */ const allowedPreviewKindsForMediaType = ( mediaType: string, ): readonly string[] | null => { if (mediaType === '') return null; if (mediaType.startsWith('audio/')) return ['audio']; if (mediaType.startsWith('image/')) { return mediaType === 'image/png' || mediaType === 'image/jpeg' || mediaType === 'image/webp' ? ['raster-image'] : ['media-image']; } if (mediaType.startsWith('video/')) return ['video']; if ( mediaType === 'text/html' || mediaType === 'text/css' || mediaType.includes('javascript') || mediaType.includes('typescript') ) { return ['code']; } if ( mediaType.includes('json') || mediaType.includes('yaml') || mediaType.startsWith('text/') ) { return ['document']; } // 明确但非视觉的二进制类型:既不能进画布也不能有预览。 return ['placeholder']; }; const probeResource = (kind: string, path: string, mediaType: string) => ({ id: 'asset:probe', category: 'unclassified' as const, subtype: kind, label: 'probe', path, mediaType, sourceLabel: '', taskTitle: null, manifestAssetId: 'probe', producerTaskId: null, externalResourceId: null, referenceResourceIds: [], dependencies: [], dependencyDepth: 0, }); test('只要 mediaType 与路径扩展名同属一个通道,kind 不能改写预览分支', () => { const violations: string[] = []; let combinations = 0; // 路径扩展名是**独立**的判定证据,与 mediaType 冲突时按既有设计由扩展名压过 // (见文件末尾那条显式用例)。要隔离出「kind 是否抢在内容证据前面」, // 就必须让扩展名与 mediaType 同通道,另外再用无扩展名路径跑一遍。 for (const [mediaType, matchingExtension] of MEDIA_TYPE_EXTENSION_PAIRS) { const allowed = allowedPreviewKindsForMediaType(mediaType)!; for (const kind of KINDS) { for (const extension of [matchingExtension, '']) { combinations += 1; const path = extension ? `assets/probe.${extension}` : 'assets/probe'; const previewKind = projectResourceCardPreviewKind( probeResource(kind, path, mediaType), ); if (!allowed.includes(previewKind)) { violations.push( `kind=${kind} mediaType=${mediaType} ext=${extension || '(无)'} → 预览分支「${previewKind}」,但内容证据只允许 ${allowed.join('/')}`, ); } } } } // 组合数必须是全量笛卡尔积,避免将来有人收窄枚举让这条断言悄悄变弱。 expect(combinations).toBe( MEDIA_TYPE_EXTENSION_PAIRS.length * KINDS.length * 2, ); expect(violations).toEqual([]); }); test('扩展名与 mediaType 冲突时按既有设计由扩展名决定,不是缺陷', () => { // 显示类型本来就先看扩展名:登记元数据与扩展名不一致时以路径为准。 // 这里把该行为钉成显式合同,避免后人误当成本轮那条 `kind` 抢权的缺陷。 expect( projectResourceCardPreviewKind( probeResource('ui', 'assets/probe.mp3', 'image/png'), ), ).toBe('audio'); expect( projectResourceCardPreviewKind( probeResource('ui', 'assets/probe.mp4', 'image/png'), ), ).toBe('video'); }); test('本轮缺陷形状:JSON 规格无论 kind 写成什么都不得落占位或走图片分支', () => { for (const kind of [ 'UI', 'ui', 'ui-design', 'ui-prototype', 'image', 'icon', ]) { const previewKind = projectResourceCardPreviewKind( probeResource(kind, 'ui/UI 设计 1.json', 'application/json'), ); expect(previewKind, `kind=${kind}`).toBe('document'); expect( projectResourceTypeLabel({ subtype: kind, path: 'ui/UI 设计 1.json', mediaType: 'application/json', }), `kind=${kind}`, ).toBe('文档'); } }); test('mediaType 缺失时才由扩展名与 kind 决定分支', () => { expect( projectResourceCardPreviewKind(probeResource('ui', 'assets/a.png', '')), ).toBe('raster-image'); expect( projectResourceCardPreviewKind(probeResource('UI', 'assets/a.json', '')), ).toBe('document'); expect( projectResourceCardPreviewKind( probeResource('spec', 'assets/a.json', ''), ), ).toBe('document'); expect( projectResourceCardPreviewKind( probeResource('game-entry', 'game/index.html', ''), ), ).toBe('code'); // `kind` 只能给出粗类型(art),无法确定具体渲染族;扩展名也判不出时落占位, // 这是"不进画布的未知二进制"而非本轮那条待读永不发起的缺陷。 expect( projectResourceCardPreviewKind( probeResource('ui', 'assets/a.bundle', ''), ), ).toBe('placeholder'); expect( projectResourceCardPreviewKind( probeResource('binary', 'assets/a.bundle', ''), ), ).toBe('placeholder'); }); });