新增资源画布筛选纯函数与三条变异验证用例

- 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:
2026-09-11 19:50:19 +08:00
parent 1be5218aeb
commit 70256c7585
2 changed files with 416 additions and 0 deletions
@@ -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([]);
});
});