From 70256c75856db626cfb95774546322daea1999c6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 19:50:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=AD=9B=E9=80=89=E7=BA=AF=E5=87=BD=E6=95=B0=E4=B8=8E?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E5=8F=98=E5=BC=82=E9=AA=8C=E8=AF=81=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../resourceCanvasFilterModel.ts | 145 ++++++++++ .../tests/resourceCanvasFilterModel.test.ts | 271 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts new file mode 100644 index 000000000..528a9f4e3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasFilterModel.ts @@ -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(); + 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'), + ); +} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts new file mode 100644 index 000000000..a058916af --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts @@ -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 & { 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([]); + }); +});