补齐 AGC 标签库聚合派生纯模块

- 新增 gameCreationAppAssetTagLibrary:从 manifest assets[].tags 派生去重、稳定排序、含使用计数的标签库

- 标签归一化沿用写入路径 normalizeGameCreationAppAssetTags,同素材重复标签只计一次

- 新增 assetTagsMatchSelection / gameCreationAppAssetMatchesTags 标签筛选判据(AND、空选择不过滤)

- 新增 5 条纯模块测试覆盖去重计数、顺序稳定、空标签与筛选判据
This commit is contained in:
agent
2026-09-10 14:54:06 +08:00
parent 30e93c6c43
commit 4d0cd425e1
2 changed files with 143 additions and 0 deletions
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
assetTagsMatchSelection,
buildGameCreationAppAssetTagLibrary,
gameCreationAppAssetMatchesTags,
} from './gameCreationAppAssetTagLibrary';
describe('AI 游戏创作 App 标签库派生', () => {
it('dedupes tags and counts how many assets use each tag', () => {
const library = buildGameCreationAppAssetTagLibrary([
{ id: 'asset-a', tags: ['像素风', '主角'] },
{ id: 'asset-b', tags: ['像素风'] },
{ id: 'asset-c', tags: [] },
{ id: 'asset-d', tags: undefined },
]);
expect(library).toEqual([
{ tag: '像素风', assetCount: 2, assetIds: ['asset-a', 'asset-b'] },
{ tag: '主角', assetCount: 1, assetIds: ['asset-a'] },
]);
});
it('keeps the order stable across input permutations', () => {
const first = buildGameCreationAppAssetTagLibrary([
{ id: 'asset-a', tags: ['b', 'a'] },
{ id: 'asset-b', tags: ['b', 'c'] },
]);
const second = buildGameCreationAppAssetTagLibrary([
{ id: 'asset-b', tags: ['c', 'b'] },
{ id: 'asset-a', tags: ['a', 'b'] },
]);
expect(second).toEqual(first);
expect(first.map((entry) => entry.tag)).toEqual(['b', 'a', 'c']);
});
it('counts a repeated tag on one asset once and ignores blank tags', () => {
const library = buildGameCreationAppAssetTagLibrary([
{ id: 'asset-a', tags: ['像素风', '像素风', ' ', ''] },
]);
expect(library).toEqual([
{ tag: '像素风', assetCount: 1, assetIds: ['asset-a'] },
]);
});
it('returns an empty library without registered assets', () => {
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
});
it('requires every selected tag to match and treats an empty selection as no filter', () => {
const tags = ['像素风', '主角'];
expect(assetTagsMatchSelection(tags, [])).toBe(true);
expect(assetTagsMatchSelection(tags, ['像素风'])).toBe(true);
expect(assetTagsMatchSelection(tags, ['像素风', '主角'])).toBe(true);
expect(assetTagsMatchSelection(tags, ['像素风', '场景'])).toBe(false);
expect(assetTagsMatchSelection(undefined, ['像素风'])).toBe(false);
expect(gameCreationAppAssetMatchesTags({ tags }, ['主角'])).toBe(true);
expect(gameCreationAppAssetMatchesTags({ tags: undefined }, ['主角'])).toBe(
false,
);
});
});
@@ -0,0 +1,78 @@
import {
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} from './gameCreationApp';
/**
* 标签库里的一项。标签库是从 manifest `assets[].tags` 派生出来的数据,
* 不新增任何持久化字段:manifest 一改,标签库重算即可。
*/
export interface GameCreationAppAssetTagLibraryEntry {
tag: string;
/** 使用该标签的已登记素材数量。 */
assetCount: number;
/** 使用该标签的素材 id,按 id 稳定升序,与 manifest 中的写入顺序无关。 */
assetIds: string[];
}
type AssetTagSource = Pick<GameCreationAppAssetManifestEntry, 'id' | 'tags'>;
/**
* 派生标签库:按标签去重、统计使用次数,并给出稳定总序。
*
* 排序口径:使用次数降序 → 标签 `zh-CN` 本地化升序。
* 两条规则都能在任意输入顺序下复现同一结果,因此标签筛选 UI 不会因为
* manifest 里素材顺序变化而抖动。
*/
export function buildGameCreationAppAssetTagLibrary(
assets: readonly AssetTagSource[],
): GameCreationAppAssetTagLibraryEntry[] {
const byTag = new Map<string, string[]>();
for (const asset of assets) {
// 归一化口径沿用写入路径:去空白、丢空串、同素材内去重。
for (const tag of normalizeGameCreationAppAssetTags(
gameCreationAppAssetTags(asset),
)) {
const assetIds = byTag.get(tag);
if (!assetIds) {
byTag.set(tag, [asset.id]);
continue;
}
// 同一条素材重复写同一个标签只算一次。
if (!assetIds.includes(asset.id)) {
assetIds.push(asset.id);
}
}
}
return Array.from(byTag, ([tag, assetIds]) => ({
tag,
assetCount: assetIds.length,
assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
})).sort(
(left, right) =>
right.assetCount - left.assetCount ||
left.tag.localeCompare(right.tag, 'zh-CN'),
);
}
/**
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
*/
export function assetTagsMatchSelection(
tags: readonly string[] | undefined,
selectedTags: readonly string[],
): boolean {
if (selectedTags.length === 0) return true;
const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
return selectedTags.every((tag) => normalized.has(tag));
}
/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */
export function gameCreationAppAssetMatchesTags(
asset: Pick<GameCreationAppAssetManifestEntry, 'tags'>,
selectedTags: readonly string[],
): boolean {
return assetTagsMatchSelection(gameCreationAppAssetTags(asset), selectedTags);
}