补齐资源 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:
@@ -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
|
||||
// 每条都带显式 category:scene ×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)
|
||||
|
||||
Reference in New Issue
Block a user