资源 kind 用例与 fixture 跟随严格口径(非 canonical 值一律停在待归类)

- packages/shared/src/contracts/gameCreationApp.test.ts:新增严格解析用例(认不出的原值 console.warn 留痕、返回 unknown),分类映射与自愈用例改用 canonical ui-design / icon,并断言 kind:'ui' 收口成 unknown 后落 unclassified。
- tests/resourceTypePanel.test.tsx / tests/resourceClassificationPanel.test.tsx:自愈用例的 fixture kind 由 ui 改为 ui-design,让「显示口径 ≠ 落盘口径」这条判据继续真的被验证。
- tests/projectResourceLiveIntegration.test.tsx:fixture kind 改为 ui-design;用例按 canonical UI 设计图带「(待视觉验收)」的标签定位按钮,并把用例名与注释改回真实口径。
- tests/resourceCardPreviewRealManifest.test.ts:真机 61 条资产的取证结论改为严格口径——57 条 ui 与 game-entry 停在待归类,明确栏目只由落盘 category 决定;自愈断言改用 ui-design。
- tests/appSurface/home.suite.ts / project-development.suite.ts:fixture 里的 art-spritesheet / game-background / animation / ui-prototype / ui 改为 canonical icon-spritesheet / scene / character-animation / ui-design / icon。
- tests/projectResourceProjectionModel.test.ts / projectResourceLiveUpdateModel.test.ts:fixture kind 由 game-entry / asset 改为 code / image。
This commit is contained in:
2026-09-17 15:46:43 +08:00
parent a86ead93e4
commit 6ed3772ff5
9 changed files with 156 additions and 116 deletions
@@ -227,7 +227,7 @@ export function registerClientHomeTests() {
assets: [
{
id: 'live-hero',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/live-hero.png',
source: { kind: 'generated' as const, taskId: 'art-asset-plan' },
@@ -375,7 +375,7 @@ export function registerClientHomeTests() {
assets: [
{
id: 'runtime-live-hero',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/runtime-live-hero.png',
source: {
@@ -538,7 +538,7 @@ export function registerClientHomeTests() {
assets: [
{
id: 'stale-first-asset',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/stale-first.png',
source: { kind: 'generated' as const },
@@ -552,7 +552,7 @@ export function registerClientHomeTests() {
secondManifest.assets = [
{
id: 'second-asset',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/second.png',
source: { kind: 'generated' },
@@ -2346,7 +2346,7 @@ export function registerHomeProjectCreationTests() {
},
{
id: 'direct-game-background',
kind: 'game-background',
kind: 'scene',
mediaType: 'image/png',
localPath: 'assets/direct-game-background.png',
source: {
@@ -2356,7 +2356,7 @@ export function registerHomeProjectCreationTests() {
},
{
id: 'direct-art-spritesheet',
kind: 'art-spritesheet',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/art-spritesheet.png',
source: {
@@ -2728,7 +2728,9 @@ export function registerProjectWorkbenchFoundationTests() {
assets: [
{
id: 'imported-image',
kind: 'ui',
// canonical `icon` → 「UI 交互」:本用例钉的是"同数量 manifest 更新后栏目跟着变"
// 不掺 UI 设计图「(待视觉验收)」标签那套,所以选一个不会带来额外后缀的 kind。
kind: 'icon',
mediaType: 'image/png',
localPath: 'assets/uploads/local-imported-image.png',
source: {
@@ -3913,7 +3915,7 @@ export function registerProjectWorkbenchFoundationTests() {
},
{
id: 'art-video',
kind: 'animation',
kind: 'character-animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated' },
@@ -4056,8 +4058,8 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toBe(true);
// 视频(animation → character-animation)落在「角色与对象」,
// 音频(bgm 不 canonical 目录里)落在「待归类」。
// 视频(canonical `character-animation`)落在「角色与对象」,
// 音频(`bgm` canonical kind)落在「待归类」。
await openResourceBookCategory('角色与对象');
fireEvent.click(
screen.getByRole('button', {
@@ -5668,17 +5670,17 @@ export function registerProjectWorkbenchFoundationTests() {
);
manifest.assets.push(
{
id: 'ui-prototype-first-by-label',
kind: 'ui-prototype',
id: 'ui-design-last-by-label',
kind: 'ui-design',
mediaType: 'image/png',
localPath: 'assets/a-ui-prototype.png',
localPath: 'assets/a-ui-design.png',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'art-spritesheet-last-by-label',
kind: 'art-spritesheet',
id: 'icon-spritesheet-first-by-label',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/z-art-spritesheet.png',
localPath: 'assets/z-icon-spritesheet.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
);
@@ -5749,12 +5751,12 @@ export function registerProjectWorkbenchFoundationTests() {
expect(typePositions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:art-spritesheet-last-by-label',
resourceId: 'asset:icon-spritesheet-first-by-label',
x: 0,
y: 0,
}),
expect.objectContaining({
resourceId: 'asset:ui-prototype-first-by-label',
resourceId: 'asset:ui-design-last-by-label',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
}),
@@ -226,9 +226,9 @@ function ClassificationWorkbench() {
},
{
id: 'asset-ui',
// `ui` 不在 canonical 目录里:`kind` 派生值经别名落到 `ui-interaction`
// 而落盘值仍是 `unclassified`。用来钉住"角标与栏目都走读显示口径"。
kind: 'ui',
// canonical `ui-design` 派生 `ui-interaction`,而落盘值仍是 `unclassified`
// 用来钉住"角标与栏目都走读显示口径"(写 `ui` 只会被收口成 `unknown`,不再派生)
kind: 'ui-design',
category: 'unclassified',
mediaType: 'image/png',
localPath: 'assets/panel.png',
@@ -1874,8 +1874,10 @@ describe('project resource live canvas integration', () => {
*
* 两条判据缺一不可:
* - `asset-hero`(落盘 `character`):角标「角色与对象」,不可能是媒体类型「图片」;
* - `asset-ui`(落盘 `unclassified` + `kind:"ui"`):读显示口径自愈成 `ui-interaction`
* 所以角标与栏目都是「UI 交互」——不是落盘值推出的「待归类」。
* - `asset-ui`(落盘 `unclassified` + canonical `kind:"ui-design"`):读显示口径自愈成
* `ui-interaction`所以角标与栏目都是「UI 交互」——不是落盘值推出的「待归类」。
* 它的 `kind` 是 canonical UI 设计图,`design-foundation` 未完成时卡片标签带
* 「(待视觉验收)」,定位按钮时要带上这一截。
*
* 变异验证:把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`),
* 两条都会变红(分别是「图片」与「图片」)。
@@ -1888,7 +1890,7 @@ describe('project resource live canvas integration', () => {
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
await openResourceBookCategory('UI 交互');
expect(await cardBadgeText('panel.png')).toBe('UI 交互');
expect(await cardBadgeText('panel.png(待视觉验收)')).toBe('UI 交互');
});
/**
@@ -2025,20 +2027,21 @@ describe('project resource live canvas integration', () => {
});
/**
* 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + `kind:"ui"` 的资产
* 在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,manifest 上的 `category`
* 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时出现过两种落盘值。
* 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + canonical
* `kind:"ui-design"` 的资产在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,
* manifest 上的 `category` 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时
* 出现过两种落盘值。
*
* 变异验证(已实测):把标签面板的载荷改回读显示口径
* `gameCreationAppAssetCategory(asset)`),manifest 上的值会变成 `ui-interaction`
* 本用例必须失败。
*/
it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified + kind ui 的资产)', async () => {
it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified 的 UI 设计图资产)', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('UI 交互');
fireEvent.click(await findResourceSelectButton('panel.png'));
fireEvent.click(await findResourceSelectButton('panel.png(待视觉验收)'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' });
@@ -24,7 +24,7 @@ function manifest(projectId: string, assetIds: string[] = []) {
tasks: [],
assets: assetIds.map((id) => ({
id,
kind: 'asset',
kind: 'image',
mediaType: 'image/png',
localPath: `assets/${id}.png`,
source: { kind: 'canvas' as const, taskId: null },
@@ -38,7 +38,7 @@ describe('项目资源投影', () => {
manifest.assets = [
{
id: 'game-entry',
kind: 'game-entry',
kind: 'code',
mediaType: 'text/html',
localPath: 'game/index.html',
source: { kind: 'generated' },
@@ -118,7 +118,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
expect(resources).toHaveLength(61);
});
test('存量 57 条 ui 的落盘 unclassified 被读时重派生自愈', () => {
test('存量 57 条 ui 的落盘 unclassified 停在「待归类」:kind 只接受 canonical,不归一也不迁移', () => {
const assets = realManifest().assets;
const uiAssets = assets.filter((asset) => asset.kind === 'ui');
@@ -127,26 +127,47 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
expect(uiAssets.every((asset) => asset.category === 'unclassified')).toBe(
true,
);
// 读取侧已经把它重派生成 ui-interaction:读时自愈,不回写 manifest。
// `ui` 不是 canonical kind → 收口成 `unknown` → 派生分类仍是 unclassified
// 读时自愈对这类存量**不会触发**,它们继续留在「待归类」,由 `app_log!` 的
// 原始串留痕去定位写入方(无别名表、无迁移)。
expect(
uiAssets.every(
(asset) => gameCreationAppAssetCategory(asset) === 'ui-interaction',
(asset) => gameCreationAppAssetCategory(asset) === 'unclassified',
),
).toBe(true);
// 直接钉住「读时重派生」这一行为本身:落盘 unclassified + kind 能派生明确分类
// → 用派生值;这正是修复前那 57 条留在「待归类」的原因
// 读时自愈本身仍然成立,但只对 canonical kind 生效:落盘 unclassified +
// canonical kind 能派生明确分类 → 用派生值。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }),
gameCreationAppAssetCategory({
kind: 'ui-design',
category: 'unclassified',
}),
).toBe('ui-interaction');
// 落盘值本身就是明确分类时仍然信任落盘值(用户手动设置的权威性)。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'scene' }),
gameCreationAppAssetCategory({ kind: 'ui-design', category: 'scene' }),
).toBe('scene');
// 落盘值缺失时按 kind 派生(历史 manifest 兼容)
// 落盘值缺失时按 canonical kind 派生。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: undefined }),
gameCreationAppAssetCategory({
kind: 'ui-design',
category: undefined,
}),
).toBe('ui-interaction');
// 非 canonical 原值不查别名表:一律 unknown → unclassified。
for (const raw of [
'ui',
'UI',
'ui-prototype',
'art-spritesheet',
'game-background',
'game-entry',
]) {
expect(
gameCreationAppAssetCategory({ kind: raw, category: 'unclassified' }),
).toBe('unclassified');
}
});
test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => {
@@ -159,16 +180,18 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
}
});
test('读时重派生后57 ui 归位 UI 交互,待归类只剩 code 类 game-entry', () => {
test('严格口径下58非 canonical kind 停在待归类,明确栏目只由落盘 category 决定', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const byCategory = countBy(resources, (resource) =>
projectResourceCanvasCategory(resource),
);
// 存量自愈后的真实分布:57 条 ui 归位到 UI 交互,只剩 game-entrycode)在待归类。
// ui ×57、game-entry ×1 的 kind 都认不出 → unknown → unclassified
// art-spritesheet / game-background 虽然 kind 认不出,但落盘 category 是明确值,
// 读显示口径信任落盘值,因此仍留在各自栏目。
expect(byCategory).toEqual({
unclassified: 1,
'ui-interaction': 59,
unclassified: 58,
'ui-interaction': 2,
scene: 1,
});
const uiResources = resources.filter(
@@ -178,18 +201,9 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
expect(
uiResources.every(
(resource) =>
projectResourceCanvasCategory(resource) === 'ui-interaction',
projectResourceCanvasCategory(resource) === 'unclassified',
),
).toBe(true);
// 唯一留在待归类的就是 game-entrycode → unclassified 是分类表的设计口径。
expect(
resources
.filter(
(resource) =>
projectResourceCanvasCategory(resource) === 'unclassified',
)
.map((resource) => resource.subtype),
).toEqual(['game-entry']);
});
test('按预览分支计数:52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位', () => {
@@ -234,15 +248,15 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
}
});
test('按画布栏目计数:待归类从 58 降到 1,UI 交互升到 59', () => {
test('按画布栏目计数:非 canonical kind 全部停在待归类(待归类 58 / UI 交互 2 / 场景 1', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const byCategory = countBy(resources, (resource) =>
projectResourceCanvasCategory(resource),
);
expect(byCategory).toEqual({
unclassified: 1,
'ui-interaction': 59,
unclassified: 58,
'ui-interaction': 2,
scene: 1,
});
});
@@ -250,14 +250,14 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
return { revision: 7 };
}
return {
asset: { ...asset, kind: 'ui', category: 'unclassified' },
asset: { ...asset, kind: 'ui-design', category: 'unclassified' },
committedProjectRevision: 8,
};
});
renderPanel({
asset: {
...asset,
kind: 'ui',
kind: 'ui-design',
category: 'unclassified',
tags: [],
},
@@ -476,13 +476,14 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
*/
describe('ResourceClassificationPanel 不再编辑素材类型', () => {
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
* 落盘 `unclassified` 而 canonical `kind:"ui-design"` 能派生出 `ui-interaction`:这是读时
* 自愈的触发条件,也正是"显示值 ≠ 落盘值"的现场。kind 只接受 canonical 值——写 `"ui"`
* 会被收口成 `unknown`,派生仍是 `unclassified`,自愈不触发。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
id: 'asset-ui',
kind: 'ui',
kind: 'ui-design',
category: 'unclassified',
tags: [],
};
@@ -31,13 +31,16 @@ const asset: GameCreationAppAssetManifestEntry = {
};
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"选择器显示值 ≠ 落盘值"的现场(真机 57 条 `kind:"ui"` 资产就是这个形状)
* 落盘 `unclassified` 而 canonical `kind:"ui-design"` 能派生出 `ui-interaction`:这是读时
* 自愈的触发条件,也正是"选择器显示值 ≠ 落盘值"的现场。
*
* kind 只接受 canonical 值:写成 `"ui"` / `"UI"` 只会被收口成 `unknown`(派生仍是
* `unclassified`),自愈不触发,用例就失去要钉的行为。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
id: 'asset-ui',
kind: 'ui',
kind: 'ui-design',
category: 'unclassified',
tags: [],
};
@@ -1,10 +1,9 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
canonicalGameCreationAppAssetKind,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
GAME_CREATION_AGENT_CAPABILITIES,
@@ -13,7 +12,7 @@ import {
GAME_CREATION_AGENT_TOOL_CALL_MAX,
GAME_CREATION_APP_ASSET_CATEGORIES,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
GAME_CREATION_APP_ASSET_KINDS,
GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,
@@ -26,11 +25,22 @@ import {
type GameCreationAppManifest,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
parseGameCreationAppAssetKind,
PROJECT_RESOURCE_CANVAS_SECTIONS,
selectGameCreationAppReadyTasks,
} from './gameCreationApp';
describe('AI 游戏创作 App 共享契约', () => {
// 严格解析会给每个非 canonical 原值打一条留痕日志;契约用例大量喂 legacy 值,
// 这里只关心返回值,不把留痕刷进测试输出。
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('keeps command permissions explicit', () => {
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
@@ -736,37 +746,38 @@ describe('AI 游戏创作 App 共享契约', () => {
});
});
it('maps legacy canvas asset kinds to canonical kinds', () => {
expect(GAME_CREATION_APP_CANONICAL_ASSET_KINDS).toContain(
'character-animation',
);
expect(canonicalGameCreationAppAssetKind('game-background')).toBe('scene');
expect(canonicalGameCreationAppAssetKind('character-art')).toBe(
it('parses kind strictly and never aliases non-canonical values', () => {
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('character-animation');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('font');
expect(GAME_CREATION_APP_ASSET_KINDS).toContain('unknown');
// 口径是严格接受:不 trim、不做大小写归一、不查别名表、不做迁移。
for (const raw of [
'game-background',
'character-art',
'ui-prototype',
'art-spritesheet',
'art-spritesheet-slice',
'illustration',
'UI',
'UI-Prototype',
'FONT',
' image ',
]) {
expect(parseGameCreationAppAssetKind(raw, 'test.legacy')).toBe('unknown');
expect(gameCreationAppAssetCategoryForKind(raw)).toBe('unclassified');
}
expect(parseGameCreationAppAssetKind('font', 'test.legacy')).toBe('font');
expect(parseGameCreationAppAssetKind('character', 'test.legacy')).toBe(
'character',
);
expect(canonicalGameCreationAppAssetKind('ui-prototype')).toBe('ui-design');
expect(canonicalGameCreationAppAssetKind('art-spritesheet')).toBe(
'icon-spritesheet',
expect(parseGameCreationAppAssetKind('unknown', 'test.legacy')).toBe(
'unknown',
);
expect(canonicalGameCreationAppAssetKind('art-spritesheet-slice')).toBe(
'icon',
);
expect(canonicalGameCreationAppAssetKind('illustration')).toBe('image');
expect(canonicalGameCreationAppAssetKind('character')).toBe('character');
// 别名表大小写不敏感:UI 设计资产的现役写入侧写的是大写 `"UI"`。
expect(canonicalGameCreationAppAssetKind('UI')).toBe('ui-design');
expect(canonicalGameCreationAppAssetKind('UI-Prototype')).toBe('ui-design');
// 字体是 canonical 成员(现役写入侧就直接写 `font`),不再走 `font → document` 别名,
// 否则「落盘值集合」与「canonical 集合」会分叉。大小写仍不敏感。
expect(canonicalGameCreationAppAssetKind('font')).toBe('font');
expect(canonicalGameCreationAppAssetKind('FONT')).toBe('font');
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
/**
* `Object.prototype` 上的键不是别名:别名表是对象字面量,直接下标取值会拿到原型上的
* 函数/对象(truthy)并被当成 canonical kind 返回;Rust 侧是 `match` 字面量,只会落
* `image`。必须用 `Object.hasOwn` 挡掉,两侧对这类输入的归类才一致
* `image → unclassified`)。跨语言对照另见 `assetKindCanonicalMapping.test.ts` 的
* `decided` 表。
* `Object.prototype` 上的键不再是特例:kind 查表用 `Set`,不存在原型命中,
* 与普通未知串一样收口成 `unknown`(派生 `unclassified`)。
*/
for (const prototypeKey of [
'constructor',
@@ -775,20 +786,21 @@ describe('AI 游戏创作 App 共享契约', () => {
'valueOf',
'hasOwnProperty',
]) {
expect(canonicalGameCreationAppAssetKind(prototypeKey)).toBe('image');
expect(parseGameCreationAppAssetKind(prototypeKey, 'test.legacy')).toBe(
'unknown',
);
expect(gameCreationAppAssetCategoryForKind(prototypeKey)).toBe(
'unclassified',
);
}
expect(canonicalGameCreationAppAssetKind('unknown-kind')).toBe('image');
});
it('maps every canonical asset kind to a functional category', () => {
it('maps every asset kind to a functional category', () => {
expect(GAME_CREATION_APP_ASSET_CATEGORIES).toHaveLength(6);
expect(Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND)).toHaveLength(
GAME_CREATION_APP_CANONICAL_ASSET_KINDS.length,
GAME_CREATION_APP_ASSET_KINDS.length,
);
for (const kind of GAME_CREATION_APP_CANONICAL_ASSET_KINDS) {
for (const kind of GAME_CREATION_APP_ASSET_KINDS) {
expect(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind]).toBeDefined();
expect(gameCreationAppAssetCategoryForKind(kind)).toBe(
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[kind],
@@ -807,16 +819,12 @@ describe('AI 游戏创作 App 共享契约', () => {
for (const kind of ['image', 'video', 'code', 'publication-material']) {
expect(gameCreationAppAssetCategoryForKind(kind)).toBe('unclassified');
}
expect(gameCreationAppAssetCategoryForKind('game-background')).toBe(
'scene',
);
// 现役写入侧写的是大写 `"UI"`(画板导出的 UI 设计 JSON 资产)与 `font`(字体上传):
// 两者都必须落进明确栏目,否则 8 条真机 UI 资产会永远停在「待归类」。
expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction');
// `font` 是 canonical 成员(现役写入侧直接写 `font`),分类是 `document`。
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
expect(gameCreationAppAssetCategoryForKind('unknown-kind')).toBe(
'unclassified',
);
expect(gameCreationAppAssetCategoryForKind('unknown')).toBe('unclassified');
});
it('resolves asset category and tags with legacy and forward compatibility', () => {
@@ -883,8 +891,11 @@ describe('AI 游戏创作 App 共享契约', () => {
*
* 规则:落盘 `category === 'unclassified'` 且该资产 `kind` 能派生出明确的非
* `unclassified` 分类时采用派生值;其余情况信任落盘值。目的是自愈历史上被系统误写成
* `unclassified` 的存量(真机 57 条 `kind:"ui"` 资产因此从「待归类」归位「UI 交互」),
* 不写迁移脚本且永久生效。
* `unclassified` 的存量(真机出现过 UI 资产落盘 `unclassified` 而 kind 已是
* `ui-design` 的情况),不写迁移脚本且永久生效。
*
* 这条规则**不含**任何 kind 别名归一:认不出的 kind 收口成 `unknown`,派生结果同样是
* `unclassified`,规则不触发。
*/
it('自愈规则覆盖落盘 unclassified:这是显式接受的盲区', () => {
// 落盘 unclassified + kind 可明确分类 → 派生值覆盖落盘值。
@@ -897,15 +908,22 @@ describe('AI 游戏创作 App 共享契约', () => {
category: 'unclassified',
}),
).toBe('character');
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }),
).toBe('ui-interaction');
expect(
gameCreationAppAssetCategory({
kind: 'art-spritesheet',
kind: 'ui-design',
category: 'unclassified',
}),
).toBe('ui-interaction');
expect(
gameCreationAppAssetCategory({
kind: 'icon',
category: 'unclassified',
}),
).toBe('ui-interaction');
// legacy 原值不在自愈窗口内:它连 canonical kind 都不是。
expect(
gameCreationAppAssetCategory({ kind: 'ui', category: 'unclassified' }),
).toBe('unclassified');
});
it('派生结果本身就是 unclassified 的 kind 不受自愈规则影响,保持落盘值', () => {
@@ -945,7 +963,7 @@ describe('AI 游戏创作 App 共享契约', () => {
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
*/
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
const uiAsset = { kind: 'ui', category: 'unclassified' as const };
const uiAsset = { kind: 'ui-design', category: 'unclassified' as const };
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
@@ -974,9 +992,8 @@ describe('AI 游戏创作 App 共享契约', () => {
/**
* 分区顺序必须跨语言一致:Rust 侧 doc 明写 `PROJECT_RESOURCE_CANVAS_SECTIONS`
* 「必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致」,但 Rust 单测只钉住自己那份的
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐
* 由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust
* `EFFECTIVE_CATEGORY_CONTRACT` 的同一手法:两侧各写一份就一定会分叉。
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐——
* 分区顺序不是 ts-rs 生成物,只能靠这条用例跨语言钉住;两侧各写一份就一定会分叉。
*/
it('资源画布分区顺序与 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 对齐', () => {
const rustSource = readFileSync(