新增资源画布筛选纯函数与三条变异验证用例
- resourceCanvasFilterModel.ts:把画布筛选收成一处纯函数——区域判据做恒等比较表达「只筛当前区域」,「全部区域」放行全量;标签复用共享 assetTagsMatchSelection(AND 语义);关键词沿用既有搜索的名称 / 路径 / 媒体类型 / 任务名四字段与大小写不敏感口径,使搜索浮层与筛选面板对同一关键词给出同一结果 - resourceCanvasFilterModel.ts:区域选项由 PROJECT_RESOURCE_CANVAS_SECTIONS 派生,中文显示名复用 @ 面板那份唯一权威口径 resourceReferenceCategoryLabel,不为「项目版本」以外的栏目另写译名表 - resourceCanvasFilterModel.ts:标签库只统计当前区域内的资源,切换区域后不残留只属于别的区域的标签 - tests/resourceCanvasFilterModel.test.ts:覆盖区域选项顺序与显示名、空筛选不重排、区域恒等比较、关键词四字段与空白归一、标签 AND 语义、无标签事实源资源的口径、三维度叠加、标签库计数与排序 - 三条变异验证实测均变红后还原:把区域判据改成恒 true → 4 failed;去掉关键词判据 → 3 failed;去掉标签判据 → 4 failed;还原后 13 passed
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||||
type ProjectResourceCanvasCategory,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { assetTagsMatchSelection } from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
|
||||
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
/**
|
||||
* 画布筛选的一个区域取值:`全部区域`,或某个现行分区栏目。
|
||||
*
|
||||
* 区域不是单独一份状态——它就是画布的「当前栏目」本身。这里只把该状态表达成
|
||||
* 筛选面板能用的取值,不额外保存一份区域。
|
||||
*/
|
||||
export const RESOURCE_FILTER_ALL_REGION = 'all' as const;
|
||||
|
||||
export type ResourceFilterRegion =
|
||||
| typeof RESOURCE_FILTER_ALL_REGION
|
||||
| ProjectResourceCanvasCategory;
|
||||
|
||||
export type ResourceFilterRegionOption = {
|
||||
id: ResourceFilterRegion;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 栏目显示名沿用 `@` 面板资源筛选那份唯一中文口径(`RESOURCE_REFERENCE_FILTERS`),
|
||||
* 不另写一张译名表:下拉说「角色与对象」而栏目说别的,用户会以为是两个区域。
|
||||
*
|
||||
* 「项目版本」不是资源资产、不在该 6 类筛选表里,单独给出它的固定显示名。
|
||||
*/
|
||||
export function resourceFilterRegionLabel(
|
||||
category: ProjectResourceCanvasCategory,
|
||||
) {
|
||||
if (category === 'version') return '项目版本';
|
||||
return resourceReferenceCategoryLabel(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域选项 = 「全部区域」+ 现行分区栏目,顺序与 `PROJECT_RESOURCE_CANVAS_SECTIONS`
|
||||
* 一致,因此下拉顺序与画布栏目顺序同源,不各写一套。
|
||||
*/
|
||||
export const RESOURCE_FILTER_REGION_OPTIONS: readonly ResourceFilterRegionOption[] =
|
||||
[
|
||||
{ id: RESOURCE_FILTER_ALL_REGION, label: '全部区域' },
|
||||
...PROJECT_RESOURCE_CANVAS_SECTIONS.map((category) => ({
|
||||
id: category as ResourceFilterRegion,
|
||||
label: resourceFilterRegionLabel(category),
|
||||
})),
|
||||
];
|
||||
|
||||
export type ResourceFilterTagOption = {
|
||||
tag: string;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export type ResourceCanvasFilter = {
|
||||
/** 所在区域;`全部区域` 时不过滤区域。 */
|
||||
region: ResourceFilterRegion;
|
||||
/** 已选标签,AND 语义:全部命中才算匹配。 */
|
||||
tags: readonly string[];
|
||||
/** 关键词,大小写不敏感、去首尾空白。 */
|
||||
keyword: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 关键词判据与既有画布搜索逐字一致:名称、路径、媒体类型、任务名任一命中。
|
||||
*
|
||||
* 既有搜索浮层与本面板读写同一份关键词,所以判据必须只有这一处实现,
|
||||
* 否则两处入口会对同一关键词给出不同结果。
|
||||
*/
|
||||
export function resourceMatchesKeyword(
|
||||
resource: ProjectResource,
|
||||
keyword: string,
|
||||
) {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return [
|
||||
resource.label,
|
||||
resource.path,
|
||||
resource.mediaType,
|
||||
resource.taskTitle ?? '',
|
||||
].some((value) => value.toLowerCase().includes(normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域判据。
|
||||
*
|
||||
* `全部区域` 放行全部;具体区域只放行该栏目资源——「只筛当前区域」这件事因此由
|
||||
* 一次比较表达,而不是靠调用方先裁集合再筛。
|
||||
*/
|
||||
export function resourceMatchesRegion(
|
||||
resource: ProjectResource,
|
||||
region: ResourceFilterRegion,
|
||||
) {
|
||||
if (region === RESOURCE_FILTER_ALL_REGION) return true;
|
||||
return resource.category === region;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个资源是否命中全部筛选维度(区域 AND 标签 AND 关键词)。
|
||||
*
|
||||
* 区域比较与标签判据都放在这里:任一条去掉,对应维度的用例就会变红。
|
||||
*/
|
||||
export function resourceMatchesCanvasFilter(
|
||||
resource: ProjectResource,
|
||||
filter: ResourceCanvasFilter,
|
||||
) {
|
||||
return (
|
||||
resourceMatchesRegion(resource, filter.region) &&
|
||||
assetTagsMatchSelection(resource.assetTags, filter.tags) &&
|
||||
resourceMatchesKeyword(resource, filter.keyword)
|
||||
);
|
||||
}
|
||||
|
||||
/** 按全部维度收窄资源集合;顺序保持输入顺序,筛选不重排。 */
|
||||
export function filterCanvasResources(
|
||||
resources: readonly ProjectResource[],
|
||||
filter: ResourceCanvasFilter,
|
||||
) {
|
||||
return resources.filter((resource) =>
|
||||
resourceMatchesCanvasFilter(resource, filter),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前资源集合派生可用标签库(去重、按使用次数降序)。
|
||||
*
|
||||
* 只统计当前区域内的资源:切换区域后下拉里不应残留只属于别的区域的标签。
|
||||
*/
|
||||
export function buildResourceFilterTagOptions(
|
||||
regionResources: readonly ProjectResource[],
|
||||
): ResourceFilterTagOption[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const resource of regionResources) {
|
||||
for (const tag of resource.assetTags ?? []) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(counts, ([tag, assetCount]) => ({ tag, assetCount })).sort(
|
||||
(left, right) =>
|
||||
right.assetCount - left.assetCount ||
|
||||
left.tag.localeCompare(right.tag, 'zh-CN'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildResourceFilterTagOptions,
|
||||
filterCanvasResources,
|
||||
RESOURCE_FILTER_ALL_REGION,
|
||||
RESOURCE_FILTER_REGION_OPTIONS,
|
||||
resourceFilterRegionLabel,
|
||||
resourceMatchesCanvasFilter,
|
||||
resourceMatchesKeyword,
|
||||
resourceMatchesRegion,
|
||||
} from '../src/view/project-development/resourceCanvasFilterModel';
|
||||
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
|
||||
|
||||
/**
|
||||
* 造一条最小资源。只填筛选用得到的字段,其余按投影的真实形状给默认值——
|
||||
* 判据只允许读这些字段,测试因此能钉住「筛选读了什么」。
|
||||
*/
|
||||
function resource(overrides: Partial<ProjectResource> & { id: string }) {
|
||||
return {
|
||||
category: 'unclassified',
|
||||
subtype: 'image',
|
||||
label: overrides.id,
|
||||
path: `assets/${overrides.id}.png`,
|
||||
mediaType: 'image/png',
|
||||
sourceLabel: '生成',
|
||||
taskTitle: null,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
...overrides,
|
||||
} as ProjectResource;
|
||||
}
|
||||
|
||||
/** 三个区域各一条:标签与关键词都刻意错开,便于逐维隔离。 */
|
||||
const UI_RESOURCE = resource({
|
||||
id: 'ui-btn',
|
||||
category: 'ui-interaction',
|
||||
label: '按钮底图',
|
||||
path: 'assets/ui/btn.png',
|
||||
mediaType: 'image/png',
|
||||
assetTags: ['主页', '节日'],
|
||||
});
|
||||
const CHARACTER_RESOURCE = resource({
|
||||
id: 'char-hero',
|
||||
category: 'character',
|
||||
label: '主角立绘',
|
||||
path: 'assets/char/hero.png',
|
||||
mediaType: 'image/png',
|
||||
taskTitle: '生成主角',
|
||||
assetTags: ['主页'],
|
||||
});
|
||||
const AUDIO_RESOURCE = resource({
|
||||
id: 'bgm-main',
|
||||
category: 'audio',
|
||||
label: '主界面音乐',
|
||||
path: 'assets/audio/main.mp3',
|
||||
mediaType: 'audio/mpeg',
|
||||
assetTags: ['节日'],
|
||||
});
|
||||
const ALL_RESOURCES = [UI_RESOURCE, CHARACTER_RESOURCE, AUDIO_RESOURCE];
|
||||
|
||||
const NO_FILTER = {
|
||||
region: RESOURCE_FILTER_ALL_REGION,
|
||||
tags: [],
|
||||
keyword: '',
|
||||
} as const;
|
||||
|
||||
describe('资源画布筛选模型', () => {
|
||||
it('区域选项为「全部区域」加现行分区栏目,顺序与画布栏目同源', () => {
|
||||
expect(RESOURCE_FILTER_REGION_OPTIONS.map((option) => option.id)).toEqual([
|
||||
'all',
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
'audio',
|
||||
'document',
|
||||
'unclassified',
|
||||
'version',
|
||||
]);
|
||||
expect(RESOURCE_FILTER_REGION_OPTIONS[0].label).toBe('全部区域');
|
||||
});
|
||||
|
||||
it('区域显示名沿用 @ 面板那份唯一中文口径,并为项目版本单列', () => {
|
||||
expect(resourceFilterRegionLabel('ui-interaction')).toBe('UI 交互');
|
||||
expect(resourceFilterRegionLabel('character')).toBe('角色与对象');
|
||||
expect(resourceFilterRegionLabel('scene')).toBe('场景与环境');
|
||||
expect(resourceFilterRegionLabel('audio')).toBe('音频');
|
||||
expect(resourceFilterRegionLabel('document')).toBe('文档');
|
||||
expect(resourceFilterRegionLabel('unclassified')).toBe('待归类');
|
||||
expect(resourceFilterRegionLabel('version')).toBe('项目版本');
|
||||
});
|
||||
|
||||
it('空筛选原样放行全部资源,且不重排顺序', () => {
|
||||
expect(filterCanvasResources(ALL_RESOURCES, NO_FILTER)).toEqual(
|
||||
ALL_RESOURCES,
|
||||
);
|
||||
});
|
||||
|
||||
it('具体区域只放行该栏目资源,「全部区域」放行全量', () => {
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
region: 'ui-interaction',
|
||||
}),
|
||||
).toEqual([UI_RESOURCE]);
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
region: 'audio',
|
||||
}),
|
||||
).toEqual([AUDIO_RESOURCE]);
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
region: RESOURCE_FILTER_ALL_REGION,
|
||||
}),
|
||||
).toEqual(ALL_RESOURCES);
|
||||
});
|
||||
|
||||
/**
|
||||
* 变异验证①:去掉 `resourceMatchesRegion` 里那次区域比较(改成恒 true),
|
||||
* 这条用例必须变红——否则「只筛当前区域」没有被真正钉住。
|
||||
*
|
||||
* 构造方式贴合真实场景:一条属于「角色与对象」但命中标签与关键词的资源,
|
||||
* 在区域=UI 交互时绝不能出现。
|
||||
*/
|
||||
it('区域与其它维度叠加时只在该区域内取结果(改全局筛即失败)', () => {
|
||||
const result = filterCanvasResources(ALL_RESOURCES, {
|
||||
region: 'ui-interaction',
|
||||
tags: ['主页'],
|
||||
keyword: '主',
|
||||
});
|
||||
|
||||
// 若把「只筛当前区域」改成全局筛,CHARACTER_RESOURCE 会因命中标签与关键词冒出来。
|
||||
expect(result).toEqual([]);
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
region: 'character',
|
||||
tags: ['主页'],
|
||||
keyword: '主',
|
||||
}),
|
||||
).toEqual([CHARACTER_RESOURCE]);
|
||||
});
|
||||
|
||||
it('关键词匹配名称 / 路径 / 媒体类型 / 任务名,大小写不敏感', () => {
|
||||
expect(resourceMatchesKeyword(UI_RESOURCE, '按钮')).toBe(true);
|
||||
expect(resourceMatchesKeyword(UI_RESOURCE, 'assets/ui')).toBe(true);
|
||||
expect(resourceMatchesKeyword(UI_RESOURCE, 'IMAGE/PNG')).toBe(true);
|
||||
expect(resourceMatchesKeyword(CHARACTER_RESOURCE, '生成主角')).toBe(true);
|
||||
expect(resourceMatchesKeyword(UI_RESOURCE, '不存在')).toBe(false);
|
||||
// 首尾空白不参与比较;纯空白等同不过滤。
|
||||
expect(resourceMatchesKeyword(UI_RESOURCE, ' 按钮 ')).toBe(true);
|
||||
expect(resourceMatchesKeyword(UI_RESOURCE, ' ')).toBe(true);
|
||||
});
|
||||
|
||||
/** 变异验证②:去掉关键词判据,这条用例必须变红。 */
|
||||
it('关键词真的收窄结果(去掉关键词判据即失败)', () => {
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
keyword: '音乐',
|
||||
}),
|
||||
).toEqual([AUDIO_RESOURCE]);
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
keyword: '不存在的词',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('标签为 AND 语义:全部命中才匹配,空选择不过滤', () => {
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, { ...NO_FILTER, tags: ['主页'] }),
|
||||
).toEqual([UI_RESOURCE, CHARACTER_RESOURCE]);
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
tags: ['主页', '节日'],
|
||||
}),
|
||||
).toEqual([UI_RESOURCE]);
|
||||
expect(
|
||||
filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
tags: ['主页', '不存在'],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
/** 变异验证③:去掉标签判据,这条用例必须变红。 */
|
||||
it('标签真的收窄结果(去掉标签判据即失败)', () => {
|
||||
const everyone = filterCanvasResources(ALL_RESOURCES, NO_FILTER);
|
||||
const tagged = filterCanvasResources(ALL_RESOURCES, {
|
||||
...NO_FILTER,
|
||||
tags: ['节日'],
|
||||
});
|
||||
|
||||
expect(everyone).toHaveLength(3);
|
||||
expect(tagged).toEqual([UI_RESOURCE, AUDIO_RESOURCE]);
|
||||
expect(tagged.length).toBeLessThan(everyone.length);
|
||||
});
|
||||
|
||||
it('三个维度同时生效:任一条不满足都不放行', () => {
|
||||
expect(
|
||||
resourceMatchesCanvasFilter(UI_RESOURCE, {
|
||||
region: 'ui-interaction',
|
||||
tags: ['主页'],
|
||||
keyword: '按钮',
|
||||
}),
|
||||
).toBe(true);
|
||||
// 区域不满足
|
||||
expect(
|
||||
resourceMatchesCanvasFilter(UI_RESOURCE, {
|
||||
region: 'audio',
|
||||
tags: ['主页'],
|
||||
keyword: '按钮',
|
||||
}),
|
||||
).toBe(false);
|
||||
// 标签不满足
|
||||
expect(
|
||||
resourceMatchesCanvasFilter(UI_RESOURCE, {
|
||||
region: 'ui-interaction',
|
||||
tags: ['不存在'],
|
||||
keyword: '按钮',
|
||||
}),
|
||||
).toBe(false);
|
||||
// 关键词不满足
|
||||
expect(
|
||||
resourceMatchesCanvasFilter(UI_RESOURCE, {
|
||||
region: 'ui-interaction',
|
||||
tags: ['主页'],
|
||||
keyword: '不存在',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('没有标签事实源的资源在空选标签时放行、在选了标签时被排除', () => {
|
||||
const bare = resource({ id: 'bare', category: 'version' });
|
||||
|
||||
expect(resourceMatchesCanvasFilter(bare, NO_FILTER)).toBe(true);
|
||||
expect(
|
||||
resourceMatchesCanvasFilter(bare, { ...NO_FILTER, tags: ['主页'] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('区域判据单独可用,且对具体栏目做恒等比较', () => {
|
||||
expect(resourceMatchesRegion(UI_RESOURCE, RESOURCE_FILTER_ALL_REGION)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resourceMatchesRegion(UI_RESOURCE, 'ui-interaction')).toBe(true);
|
||||
expect(resourceMatchesRegion(UI_RESOURCE, 'character')).toBe(false);
|
||||
});
|
||||
|
||||
it('标签库只统计传入区域内的资源,按使用次数降序、同次数按标签升序', () => {
|
||||
// 两个标签各被两条资源使用,次数相同 → 回退到 `zh-CN` 升序:节日 < 主页。
|
||||
expect(buildResourceFilterTagOptions(ALL_RESOURCES)).toEqual([
|
||||
{ tag: '节日', assetCount: 2 },
|
||||
{ tag: '主页', assetCount: 2 },
|
||||
]);
|
||||
// 只统计当前区域:区域切到 UI 交互时不残留只属于别的区域的标签。
|
||||
expect(buildResourceFilterTagOptions([UI_RESOURCE])).toEqual([
|
||||
{ tag: '节日', assetCount: 1 },
|
||||
{ tag: '主页', assetCount: 1 },
|
||||
]);
|
||||
expect(buildResourceFilterTagOptions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user