Files
Genarrative/apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts
suzmii 1be5218aeb kind 别名表查表加 Object.hasOwn 守卫:原型键不再被误当成别名
- `canonicalGameCreationAppAssetKind`(TS)此前用对象字面量直接下标查别名表:`kind` 是外部输入,`constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 会命中 `Object.prototype` 上的成员,被当成 canonical kind 返回(`__proto__` 返回原型对象本身);Rust 侧是 `match` 字面量,只会落 `image` 兜底,同一输入两侧分叉
- 改用 `Object.hasOwn(GAME_CREATION_APP_LEGACY_ASSET_KINDS, normalized)` 先确认命中的是表自己的键,再取值;`noUncheckedIndexedAccess` 下仍保留一次取值后的 truthy 判定
- 补断言(无断言的边界修法下次会被改回去):`gameCreationApp.test.ts` 对 `constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 逐条断言 `canonicalGameCreationAppAssetKind` 落 `image`、`gameCreationAppAssetCategoryForKind` 落 `unclassified`
- 跨语言对照:`assetKindCanonicalMapping.test.ts` 的 `decided` 表新增 `constructor` 与 `__proto__` 两行(`image` / `unclassified`),与 TS 同一份口径对照
- Rust 侧同步断言:`game_creation_app.rs` 的 `asset_category_mapping_covers_every_canonical_kind` 补 `constructor` / `__proto__` / `toString` / `valueOf` 必须落 `image` 与 `unclassified`,把「两侧一致」这件事写进两侧各自的用例
- 边界范围:只收口「原型成员被当成别名」这一种极端输入;`kind` 的正常词表、大小写口径与读时自愈规则不变,manifest 字段构成与顺序不变
2026-09-11 19:44:04 +08:00

313 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
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]!);
}
/** Rust 的 `GameCreationAppAssetCategory` 变体名 → kebab-case 线上值。 */
function rustCategorySocketName(variant: string) {
return variant.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
/** 从 Rust 侧 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 里解析出 kind → 栏目表。 */
function rustAssetCategoryByKind(): Map<string, string> {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const start = source.indexOf(
'pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND',
);
expect(start).toBeGreaterThan(-1);
// 该表有多种排版(单行、以及 kind 与变体各占一行的多行条目),先去空白再逐条取。
const body = source
.slice(start, source.indexOf('];', start))
.replace(/\s+/g, '');
const table = new Map<string, string>();
for (const entry of body.matchAll(
/\("([^"]+)",GameCreationAppAssetCategory::(\w+),?\)/g,
)) {
table.set(entry[1]!, rustCategorySocketName(entry[2]!));
}
return table;
}
/**
* 从 Rust 侧解析「有效分类」的决策矩阵。
*
* 读时自愈(落盘 `unclassified` 而 kind 能派生出明确分类时采用派生值)在两侧必须只有
* 一个口径:TS 侧 `gameCreationAppAssetCategory`、Rust 侧
* `game_creation_app_asset_effective_category`Agent 投影走这条)。矩阵是唯一真源,
* 写在 Rust 源码里、由 Rust 单测逐条断言,本文件解析同一份矩阵再喂给 TS 实现对照,
* 两侧各写一份的话会重新分叉。
*/
function rustEffectiveCategoryContract(): Array<[string, string, string]> {
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
const start = source.indexOf('const EFFECTIVE_CATEGORY_CONTRACT');
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf('];', start));
const rows: Array<[string, string, string]> = [];
for (const row of body.matchAll(/\("([^"]*)",\s*"([^"]*)",\s*"([^"]*)"\)/g)) {
rows.push([row[1]!, row[2]!, row[3]!]);
}
return rows;
}
/** 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 目录一致', () => {
'font',
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));
}
});
test('两侧 canonical kind → 栏目表逐条一致', () => {
const rustTable = rustAssetCategoryByKind();
expect([...rustTable.keys()].sort()).toEqual(
[...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort(),
);
const mismatches: Array<{ kind: string; ts: string; rust: string }> = [];
for (const [kind, rustCategory] of rustTable) {
const tsCategory =
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
kind as GameCreationAppCanonicalAssetKind
];
if (tsCategory !== rustCategory) {
mismatches.push({ kind, ts: tsCategory, rust: rustCategory });
}
}
expect(mismatches).toEqual([]);
});
test('读时自愈决策矩阵两侧一致(单一真源在 Rust 侧)', () => {
const rows = rustEffectiveCategoryContract();
// 矩阵必须覆盖:落盘明确值、落盘 unclassified + kind 可明确分类、
// 落盘 unclassified + 派生结果本来就是 unclassified、大写写侧字面量。
expect(rows.length).toBeGreaterThanOrEqual(8);
const mismatches: Array<{
kind: string;
persisted: string;
expected: string;
ts: string;
}> = [];
for (const [kind, persisted, expected] of rows) {
const ts = gameCreationAppAssetCategory({
kind,
category: persisted as GameCreationAppAssetCategory,
});
if (ts !== expected) {
mismatches.push({ kind, persisted, expected, ts });
}
}
expect(mismatches).toEqual([]);
});
});
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',
},
// 现役写入侧的大写字面量与字体 kind,两者都必须落进明确栏目。
{ kind: 'UI', canonical: 'ui-design', category: 'ui-interaction' },
{ kind: 'font', canonical: 'document', category: 'document' },
// `Object.prototype` 上的键不是别名:TS 侧必须用 `Object.hasOwn` 挡住对象字面量的
// 原型命中,Rust 侧 match 字面量本来就落 `image`;这类极端输入两侧也必须一致。
{ kind: 'constructor', canonical: 'image', category: 'unclassified' },
{ kind: '__proto__', canonical: 'image', category: 'unclassified' },
];
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());
});
});
describe('写侧 kind 字面量 → 分类的端到端口径', () => {
const UI_EDITOR_WRITE_SITES = [
'apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs',
'apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs',
'apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs',
];
/**
* 取每个 `register_local_asset_(at|entry)(` 调用的第 3 个实参——写侧固定把 kind 放在
* 第 3 位、单独占一行(`root` / 路径 / `"UI"`)。只认独占一行的字符串字面量,
* 传变量的写点直接跳过,避免把第 4 位的 mediaType 误当成 kind。
*/
function registeredKindLiterals(file: string): string[] {
const source = readFileSync(file, 'utf8');
const kinds: string[] = [];
for (const call of source.matchAll(
/register_local_asset_(?:at|entry)\(\r?\n(?:[^\n]*\r?\n){2}([^\n]*)/g,
)) {
const literal = /^\s*"([^"]*)",\s*$/.exec(call[1]!);
if (literal) kinds.push(literal[1]!);
}
return kinds;
}
test('UI 设计写点仍直接写 `"UI"`,且它落 UI 交互而不是待归类', () => {
// 8 条真机 UI 资产是 `kind:"UI"` + `mediaType:"application/json"`
// 该 kind 不在别名表里时派生结果也是 unclassified,读时自愈同样救不回来。
for (const file of UI_EDITOR_WRITE_SITES) {
expect(registeredKindLiterals(file)).toContain('UI');
}
expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction');
});
test('UI 编辑器写点写出的每个 kind 都落明确栏目', () => {
// 画板导出这条链上的写点只写 UI 设计资产,不允许再写出落「待归类」的 kind。
for (const file of UI_EDITOR_WRITE_SITES) {
for (const kind of registeredKindLiterals(file)) {
expect(
gameCreationAppAssetCategoryForKind(kind),
`${file} 写出的 kind ${kind} 落到了待归类`,
).not.toBe('unclassified');
}
}
});
test('字体写点写 `"font"`,落文档栏目', () => {
const commandsSource = readFileSync(
'apps/ai-game-creator-shell/src-tauri/src/commands.rs',
'utf8',
);
// 字体上传的写入侧:`register_local_asset_entry(root, path, "font", …)`。
expect(commandsSource).toMatch(
/register_local_asset_entry\(\s*\n\s*root,\s*\n\s*&relative_path,\s*\n\s*"font",/,
);
expect(gameCreationAppAssetCategoryForKind('font')).toBe('document');
});
});