补齐 AGC 标签库聚合派生纯模块
- 新增 gameCreationAppAssetTagLibrary:从 manifest assets[].tags 派生去重、稳定排序、含使用计数的标签库 - 标签归一化沿用写入路径 normalizeGameCreationAppAssetTags,同素材重复标签只计一次 - 新增 assetTagsMatchSelection / gameCreationAppAssetMatchesTags 标签筛选判据(AND、空选择不过滤) - 新增 5 条纯模块测试覆盖去重计数、顺序稳定、空标签与筛选判据
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user