补齐资源 kind 别名表:ui 归 UI 交互,game-* 归代码,animation 归角色

- TS 侧 GAME_CREATION_APP_LEGACY_ASSET_KINDS 补 5 条别名:ui → ui-design、game-entry/game-script/game-style → code、animation → character-animation、asset → image
- Rust 侧 canonical_game_creation_app_asset_kind 同步补齐同一批别名,两侧必须成对维护
- 按拍板口径不往 GAME_CREATION_APP_CANONICAL_ASSET_KINDS 加新 kind,避免出现 ui 与 ui-design 两个同义 kind
- 保持 art-spritesheet-slice → icon → ui-interaction 不动
- 补注释写明别名表的职责是「线上值 → canonical」而非仅收历史遗留,画板导出等现役写入侧同样要在这里收口
- 新增 assetKindCanonicalMapping.test.ts:解析 Rust 源码交叉钉住两侧 canonical 目录与别名映射逐条一致,并逐条断言已拍板的 kind → canonical → 栏目口径
- 新增 resourceCardPreviewRealManifest.test.ts:按真机 manifest 逐条形状可执行地数出预览分支与栏目分布(raster-image 52 / placeholder 8 / code 1;unclassified 58 / ui-interaction 2 / scene 1)
- 真机取证用例同时钉住「别名只救新登记、不救存量」:57 条 ui 的落盘 category 仍是 unclassified,需读时重派生或一次性回填才能归位
- 修正两条按旧错误栏目断言的既有用例:kind=ui 的上传图归「UI 交互」、animation 视频归「角色与对象」
This commit is contained in:
2026-09-10 21:18:55 +08:00
parent 2fefb373d1
commit c5df56d855
5 changed files with 425 additions and 5 deletions
@@ -1835,10 +1835,10 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
fireEvent.click(within(outline).getByRole('button', { name: /待归类/ }));
fireEvent.click(within(outline).getByRole('button', { name: /UI 交互/ }));
expect(
await screen.findByRole('button', {
name: '选中资源:待归类 local-imported-image.png',
name: '选中资源:UI 交互 local-imported-image.png',
}),
).not.toBeNull();
});
@@ -2705,11 +2705,12 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toBe(true);
// 视频(animation)与音频(bgm)都不在 canonical kind 目录里,落在「待归类」栏目。
await openResourceBookCategory('待归类');
// 视频(animation → character-animation)落在「角色与对象」,
// 音频(bgm 不在 canonical 目录里)落在「待归类」。
await openResourceBookCategory('角色与对象');
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:待归类 intro.mp4',
name: '选中资源:角色与对象 intro.mp4',
}),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
@@ -0,0 +1,153 @@
// kind 别名表必须跨语言一致:TS 侧 `GAME_CREATION_APP_LEGACY_ASSET_KINDS`(前端读投影用)
// 与 Rust 侧 `canonical_game_creation_app_asset_kind`(写入侧按 kind 派生 category 用)
// 是同一套口径的两份实现。任何一边漏加别名,就会让「落盘 category」与「读侧栏目」
// 互相矛盾——`ui` 漏加就正好是「57 条 UI 资产显示成待归类」的成因。
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import {
canonicalGameCreationAppAssetKind,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
gameCreationAppAssetCategoryForKind,
type GameCreationAppCanonicalAssetKind,
} from '../../../packages/shared/src/contracts/gameCreationApp';
const RUST_CONTRACT_PATH =
'server-rs/crates/shared-contracts/src/game_creation_app.rs';
/** 从 Rust 侧 `canonical_game_creation_app_asset_kind` 里解析出别名 → canonical 表。 */
function rustAssetKindAliases(): Map<string, string> {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const start = source.indexOf('pub fn canonical_game_creation_app_asset_kind');
expect(start).toBeGreaterThan(-1);
// 取到函数体结束(下一个顶层 `pub fn` / 常量前的空行大括号收口)。
const body = source.slice(start, source.indexOf('\n}\n', start));
const aliases = new Map<string, string>();
for (const line of body.split('\n')) {
const arm = /^\s*((?:"[^"]+"\s*\|?\s*)+)=>\s*"([^"]+)",\s*$/.exec(line);
if (!arm) continue;
const sources = Array.from(arm[1]!.matchAll(/"([^"]+)"/g)).map(
(match) => match[1]!,
);
for (const one of sources) {
aliases.set(one, arm[2]!);
}
}
return aliases;
}
/** 从 Rust 侧解析 canonical kind 常量目录。 */
function rustCanonicalKinds(): string[] {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const start = source.indexOf(
'pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS',
);
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf('];', start));
return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!);
}
/** TS 侧的别名表(未导出,用 canonical 目录探测「非 canonical 输入被改写成什么」)。 */
const OBSERVED_ALIAS_INPUTS = [
'game-background',
'character-art',
'ui-prototype',
'ui',
'art-spritesheet',
'art-spritesheet-slice',
'illustration',
'game-art',
'game-entry',
'game-script',
'game-style',
'animation',
'asset',
] as const;
describe('资源 kind 别名表跨语言一致性', () => {
test('两侧 canonical kind 目录一致', () => {
expect(rustCanonicalKinds()).toEqual([
...GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
]);
});
test('两侧别名映射逐条一致', () => {
const rustAliases = rustAssetKindAliases();
const mismatches: Array<{ kind: string; ts: string; rust: string }> = [];
for (const [kind, rustCanonical] of rustAliases) {
const tsCanonical = canonicalGameCreationAppAssetKind(kind);
if (tsCanonical !== rustCanonical) {
mismatches.push({ kind, ts: tsCanonical, rust: rustCanonical });
}
}
expect(mismatches).toEqual([]);
// 反向:TS 侧认得的别名,Rust 侧也必须认得(否则写入侧会写出错 category)。
const rustOnly = new Set(rustAliases.keys());
const missingInRust = OBSERVED_ALIAS_INPUTS.filter(
(kind) =>
canonicalGameCreationAppAssetKind(kind) !== kind && !rustOnly.has(kind),
);
expect(missingInRust).toEqual([]);
});
test('每个别名都指向 canonical 目录内的值', () => {
const canonical =
GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[];
for (const kind of OBSERVED_ALIAS_INPUTS) {
expect(canonical).toContain(canonicalGameCreationAppAssetKind(kind));
}
});
});
describe('真机出现的 kind 归类口径', () => {
/** 已拍板的口径:现役 kind → canonical → 栏目。 */
const decided: Array<{
kind: string;
canonical: GameCreationAppCanonicalAssetKind;
category: string;
}> = [
{ kind: 'ui', canonical: 'ui-design', category: 'ui-interaction' },
{ kind: 'game-entry', canonical: 'code', category: 'unclassified' },
{ kind: 'game-script', canonical: 'code', category: 'unclassified' },
{ kind: 'game-style', canonical: 'code', category: 'unclassified' },
{
kind: 'animation',
canonical: 'character-animation',
category: 'character',
},
{ kind: 'asset', canonical: 'image', category: 'unclassified' },
// 明确保持不动:它落到 ui-interaction 是正确口径。
{
kind: 'art-spritesheet-slice',
canonical: 'icon',
category: 'ui-interaction',
},
];
test.each(decided)(
'$kind → $canonical → $category',
({ kind, canonical, category }) => {
expect(canonicalGameCreationAppAssetKind(kind)).toBe(canonical);
expect(gameCreationAppAssetCategoryForKind(kind)).toBe(category);
},
);
test('canonical 目录里没有 ui / animation / asset,避免出现同义 kind', () => {
const canonical =
GAME_CREATION_APP_CANONICAL_ASSET_KINDS as readonly string[];
for (const forbidden of ['ui', 'animation', 'asset', 'game-entry']) {
expect(canonical).not.toContain(forbidden);
}
// ui 与 ui-design 不能同时存在,否则就是两套词汇。
expect(canonical).toContain('ui-design');
});
test('分类表穷举 canonical 目录,别名不会落空', () => {
expect(
Object.keys(GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND).sort(),
).toEqual([...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort());
});
});
@@ -0,0 +1,240 @@
// @vitest-environment jsdom
// 真机取证:用真机 `What do u wanna do kitten` manifest 的逐条真实形状,
// 可执行地数出「每张卡走哪个预览分支」「最终落哪个画布栏目」「实际发出哪些 IPC」。
//
// 真机数据(只读读出,未修改):
// assets 61 条 = ui/image/png ×49、ui/application/json ×8、art-spritesheet/image/png ×1、
// game-background/image/png ×1、game-entry/text/html ×1、icon-spec/image/png ×1
// 每条都带显式 categoryscene ×1、ui-interaction ×2、unclassified ×58
// versions 5 条
import { describe, expect, test } from 'vitest';
import {
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel';
import {
projectResourceCanvasCategory,
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from '../src/view/project-development/resourceProjectionModel';
type Shape = {
kind: string;
mediaType: string;
ext: string;
count: number;
category: 'scene' | 'ui-interaction' | 'unclassified';
};
/** 真机 manifest 的 assets 逐条形状(category 为磁盘上的持久化值)。 */
const REAL_ASSET_SHAPES: readonly Shape[] = [
{
kind: 'ui',
mediaType: 'image/png',
ext: 'png',
count: 49,
category: 'unclassified',
},
{
kind: 'ui',
mediaType: 'application/json',
ext: 'json',
count: 8,
category: 'unclassified',
},
{
kind: 'art-spritesheet',
mediaType: 'image/png',
ext: 'png',
count: 1,
category: 'ui-interaction',
},
{
kind: 'game-background',
mediaType: 'image/png',
ext: 'png',
count: 1,
category: 'scene',
},
{
kind: 'game-entry',
mediaType: 'text/html',
ext: 'html',
count: 1,
category: 'unclassified',
},
{
kind: 'icon-spec',
mediaType: 'image/png',
ext: 'png',
count: 1,
category: 'ui-interaction',
},
];
function buildRealAssets(): GameCreationAppAssetManifestEntry[] {
const assets: GameCreationAppAssetManifestEntry[] = [];
let index = 0;
for (const shape of REAL_ASSET_SHAPES) {
for (let i = 0; i < shape.count; i += 1) {
index += 1;
assets.push({
id: `asset-${index}`,
kind: shape.kind,
mediaType: shape.mediaType,
localPath: `assets/probe-${index}.${shape.ext}`,
source: { kind: 'generated' },
category: shape.category,
});
}
}
return assets;
}
function realManifest() {
return {
schemaVersion: 'game-creation-app.v1',
projectId: 'agc-real-probe',
name: '真机取证项目',
tasks: [],
assets: buildRealAssets(),
};
}
function countBy<T>(items: readonly T[], key: (item: T) => string) {
const counts = new Map<string, number>();
for (const item of items) {
const value = key(item);
counts.set(value, (counts.get(value) ?? 0) + 1);
}
return Object.fromEntries(counts);
}
describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
test('61 条真机资产全部进入资源投影', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
expect(resources).toHaveLength(61);
});
test('存量 category 与新 kind 口径的差异必须显式记录(别名救不了存量)', () => {
const assets = realManifest().assets;
const drifted = assets
.map((asset) => ({
kind: asset.kind,
onDisk: asset.category,
// 只按 kind 重新派生(丢弃存量 category),这就是加别名之后新登记资产会拿到的值。
derivedFromKind: gameCreationAppAssetCategory({
kind: asset.kind,
category: undefined,
}),
}))
.filter((entry) => entry.onDisk !== entry.derivedFromKind);
const driftedByKind = new Map<
string,
{ onDisk: string; derivedFromKind: string; count: number }
>();
for (const entry of drifted) {
const key = `${entry.kind}|${entry.onDisk}|${entry.derivedFromKind}`;
const existing = driftedByKind.get(key);
if (existing) {
existing.count += 1;
} else {
driftedByKind.set(key, { ...entry, count: 1 });
}
}
// 真机现状:57 条 ui 的落盘 category 是 unclassified,而按新 kind 口径应是
// ui-interaction。这条差异是「别名只救新登记、不救存量」的直接证据:读取侧
// `gameCreationAppAssetCategory` 优先信任落盘 category,所以别名加完,
// 这 57 条仍然留在「待归类」。
expect(drifted).toHaveLength(57);
expect(Object.fromEntries(driftedByKind)).toEqual({
'ui|unclassified|ui-interaction': {
kind: 'ui',
onDisk: 'unclassified',
derivedFromKind: 'ui-interaction',
count: 57,
},
});
});
test('别名落地后存量仍落待归类:58 不变(根治需要读时重派生或一次性回填)', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const byCategory = countBy(resources, (resource) =>
projectResourceCanvasCategory(resource),
);
// 期望分布是加别名**之后**的实际结果,不是目标态。
expect(byCategory).toEqual({
unclassified: 58,
'ui-interaction': 2,
scene: 1,
});
// 若哪天真做了存量回填,这两个数字应当变成 unclassified:1 / ui-interaction:59
// 届时这条断言会失败并提醒更新口径。
const uiResources = resources.filter(
(resource) => resource.subtype === 'ui',
);
expect(uiResources).toHaveLength(57);
expect(
uiResources.every(
(resource) =>
projectResourceCanvasCategory(resource) === 'unclassified',
),
).toBe(true);
});
test('按预览分支计数:只有 JSON 规格会落占位,52 张 PNG 走图片分支', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const previewKindByResource = resources.map((resource) => ({
resource,
previewKind: projectResourceCardPreviewKind(resource),
typeLabel: projectResourceTypeLabel(resource),
canvasCategory: projectResourceCanvasCategory(resource),
}));
const byPreviewKind = countBy(previewKindByResource, (e) => e.previewKind);
expect(byPreviewKind).toEqual({
'raster-image': 52,
placeholder: 8,
code: 1,
});
// 占位卡片的构成:8 条 ui/application/json。它们不是"图片读不出来",
// 而是被归成文档以外的未知分支后又没有可预览内容(game-entry 走 code 分支渲染文本)。
const placeholders = previewKindByResource.filter(
(e) => e.previewKind === 'placeholder',
);
expect(countBy(placeholders, (e) => e.resource.mediaType)).toEqual({
'application/json': 8,
});
expect(countBy(placeholders, (e) => e.resource.subtype)).toEqual({ ui: 8 });
});
test('按画布栏目计数:58 条落待归类,与真机截图一致', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const byCategory = countBy(resources, (resource) =>
projectResourceCanvasCategory(resource),
);
expect(byCategory).toEqual({
unclassified: 58,
'ui-interaction': 2,
scene: 1,
});
});
test('预览分支与画布栏目是两个独立轴:落 UI 交互的 PNG 仍走图片分支', () => {
const resources = projectResourcesFromReadModels(realManifest(), [], []);
const iconSpec = resources.find(
(resource) => resource.subtype === 'icon-spec',
);
expect(iconSpec).toBeDefined();
// 真机 icon-spec 落在 UI 交互栏目。
expect(projectResourceCanvasCategory(iconSpec!)).toBe('ui-interaction');
// 但它仍必须走 PNG 的图片预览分支,而不是媒体分支。
expect(projectResourceCardPreviewKind(iconSpec!)).toBe('raster-image');
});
});
@@ -500,6 +500,20 @@ export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [
export type GameCreationAppCanonicalAssetKind =
(typeof GAME_CREATION_APP_CANONICAL_ASSET_KINDS)[number];
/**
* 线上出现的非 canonical kind → canonical kind 的别名表。
*
* 职责是「线上值 → canonical」,与该值是否属于历史遗留无关:画板导出等现役写入侧
* 仍会写出 `ui` / `animation` / `asset` 这类非 canonical 值,同样必须在这里收口,
* 否则会落到 `canonicalGameCreationAppAssetKind` 的 `image` 兜底,再经
* `image → unclassified` 被误分到「待归类」。
*
* 本表与 Rust 侧 `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的
* `canonical_game_creation_app_asset_kind` 必须成对维护,由
* `tests/assetKindCanonicalMapping.test.ts` 交叉钉住。
*
* 注意不要动 `art-spritesheet-slice → icon`:它落到 ui-interaction 是正确口径。
*/
const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record<
string,
GameCreationAppCanonicalAssetKind
@@ -507,10 +521,16 @@ const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record<
'game-background': 'scene',
'character-art': 'character',
'ui-prototype': 'ui-design',
ui: 'ui-design',
'art-spritesheet': 'icon-spritesheet',
'art-spritesheet-slice': 'icon',
illustration: 'image',
'game-art': 'image',
'game-entry': 'code',
'game-script': 'code',
'game-style': 'code',
animation: 'character-animation',
asset: 'image',
};
export function canonicalGameCreationAppAssetKind(
@@ -570,9 +570,15 @@ pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str {
"game-background" => "scene",
"character-art" => "character",
"ui-prototype" => "ui-design",
// 画板导出等现役写入侧仍会写出这些非 canonical 值,必须在这里收口,
// 否则会落到 image 兜底并被 image -> unclassified 误分到「待归类」。
"ui" => "ui-design",
"art-spritesheet" => "icon-spritesheet",
"art-spritesheet-slice" => "icon",
"illustration" | "game-art" => "image",
"game-entry" | "game-script" | "game-style" => "code",
"animation" => "character-animation",
"asset" => "image",
canonical => match GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.iter()
.find(|candidate| **candidate == canonical)