1be5218aeb
- `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 字段构成与顺序不变
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
// 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');
|
||
});
|
||
});
|