import { describe, expect, it } from 'vitest'; import { buildResourceBookAllResourcesSections, buildResourceBookScenePlan, groupResourceBookStacks, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, resourceBookOverviewCardLayout, type ResourceBookOverviewRect, resourceBookSceneCardKey, selectResourceBookOverviewCards, } from '../src/view/project-development/resourceBookLayout'; import { RESOURCE_BOOK_ALL_TARGET, type ResourceBookTarget, } from '../src/view/project-development/resourceBookModel'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; function resource( id: string, category: ProjectResource['category'] = 'unclassified', ): ProjectResource { return { id, category, subtype: 'image', label: id, path: `assets/${id}.png`, mediaType: 'image/png', sourceLabel: '测试', taskTitle: null, manifestAssetId: id, producerTaskId: null, externalResourceId: null, referenceResourceIds: [], dependencies: [], dependencyDepth: 0, }; } const rect = { left: 100, top: 200, width: 280, height: 210 }; describe('resourceBookOverviewCardLayout', () => { it('keeps the stack tilt bounded however many resources share one type', () => { const rotations = [0, 1, 2, 3, 12, 59].map( (stackIndex) => resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 }) .rotation, ); for (const rotation of rotations) { expect(Math.abs(rotation)).toBeLessThanOrEqual(3); } expect(rotations[0]).toBe(0.5); expect(rotations.at(-1)).toBe(rotations[2]); }); it('stops offsetting deeper cards once the visible stack depth is reached', () => { const positions = [0, 1, 2, 3, 59].map( (stackIndex) => resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 }).y, ); expect(positions[0]).toBe(rect.top + 58); expect(positions[1]).toBe(rect.top + 63); expect(positions[2]).toBe(rect.top + 68); expect(positions.at(-1)).toBe(positions[2]); }); it('limits how many cards the overview renders per type stack', () => { expect(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT).toBe(3); }); describe('selectResourceBookOverviewCards', () => { const cards = ['a', 'b', 'c', 'd'].map(resource); const positions = new Map([ ['a', { x: 0, y: 0 }], ['b', { x: 1_000, y: 0 }], ['c', { x: 2_000, y: 0 }], ['d', { x: 3_000, y: 0 }], ]); const cardSizes = new Map(); it('keeps the first visible cards when the view center is unknown', () => { expect( selectResourceBookOverviewCards({ resources: cards, visibleResourceIds: new Set(['a', 'b', 'c', 'd']), positions, cardSizes, viewCenter: undefined, }).map((item) => item.id), ).toEqual(['a', 'b', 'c']); }); it('prefers the cards nearest the current view center', () => { expect( selectResourceBookOverviewCards({ resources: cards, visibleResourceIds: new Set(['a', 'b', 'c', 'd']), positions, cardSizes, viewCenter: { x: 3_000, y: 64 }, }).map((item) => item.id), ).toEqual(['d', 'c', 'b']); }); it('never surfaces cards hidden by the current filter', () => { expect( selectResourceBookOverviewCards({ resources: cards, visibleResourceIds: new Set(['a', 'c', 'd']), positions, cardSizes, viewCenter: { x: 1_000, y: 64 }, }).map((item) => item.id), ).toEqual(['a', 'c', 'd']); }); }); }); describe('groupResourceBookStacks', () => { it('keeps resources of one type in a single stack', () => { const stacks = groupResourceBookStacks([ resource('overview-art-0'), resource('overview-art-1'), resource('overview-art-2'), resource('overview-art-3'), ]); expect(stacks).toHaveLength(1); expect(stacks[0]?.[1]).toHaveLength(4); }); }); describe('buildResourceBookScenePlan', () => { const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource); const positions = new Map( resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]), ); const cardSizes = new Map( resources.map((item) => [item.id, { width: 180, height: 128 }]), ); const base = { visibleCategoryOrder: ['unclassified'] as const, resourcesByCategory: new Map([['unclassified', resources]]), overviewRects: new Map([['unclassified', rect]]), positions, cardSizes, visibleResourceIds: new Set(resources.map((item) => item.id)), categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]), cardsReady: true, }; it('renders the bounded overview stack on the steady main canvas', () => { const plan = buildResourceBookScenePlan({ ...base, state: { view: 'main', category: null, phase: 'idle', token: 0, }, }); expect(plan).toHaveLength(1); expect(plan[0]!.cards).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT); expect(plan[0]!.cards[0]!.presentation).toBe('overview'); expect(plan[0]!.cards[0]!.stackCount).toBe( RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, ); expect(plan[0]!.cards[0]!.layout).toMatchObject({ x: rect.left + 18, y: rect.top + 58, width: 92, height: 64, }); expect(plan[0]!.titlebarActive).toBe(false); expect(plan[0]!.titlebarOpacity).toBe(1); expect(plan[0]!.resourceCount).toBe(4); }); it('keeps other categories mounted for a fade-out while entering', () => { const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: ['unclassified', 'scene'], resourcesByCategory: new Map([ ['unclassified', resources], ['scene', [resource('code-0')]], ]), overviewRects: new Map([ ['unclassified', rect], ['scene', { ...rect, left: 500 }], ]), state: { view: 'child', category: 'unclassified', phase: 'entering', token: 1, }, }); const artGroup = plan.find((group) => group.category === 'unclassified')!; const codeGroup = plan.find((group) => group.category === 'scene')!; expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe( true, ); expect(artGroup.cards).toHaveLength(4); expect(artGroup.titlebarActive).toBe(true); // 其他栏目留在 DOM 里淡出,而不是直接卸载。 expect( codeGroup.cards.every((card) => card.presentation === 'overview'), ).toBe(true); expect(codeGroup.titlebarActive).toBe(false); expect(codeGroup.titlebarOpacity).toBe(0); }); it('marks cards that do not return to the stack as exiting', () => { const plan = buildResourceBookScenePlan({ ...base, state: { view: 'main', category: null, phase: 'returning-main', token: 2, }, exitingCategory: 'unclassified', }); const cards = plan[0]!.cards; expect( cards.filter((card) => card.presentation === 'overview'), ).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT); const exiting = cards.filter((card) => card.presentation === 'exiting'); expect(exiting).toHaveLength(1); // 淡出节点按子画布布局渲染,位置由 FLIP 钉在 First。 expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x); }); it('keeps search-driven removals mounted without duplicating keys', () => { const plan = buildResourceBookScenePlan({ ...base, state: { view: 'main', category: null, phase: 'idle', token: 0 }, exitingCards: [ { key: resourceBookSceneCardKey('art-3'), category: 'unclassified', resource: resources[3]!, presentation: 'exiting', stackIndex: 0, stackColumn: 0, stackCount: 1, layout: { x: 0, y: 0, width: 180, height: 128, dragX: 0, dragY: 0, rotation: 0, }, }, { key: resourceBookSceneCardKey('art-0'), category: 'unclassified', resource: resources[0]!, presentation: 'exiting', stackIndex: 0, stackColumn: 0, stackCount: 1, layout: { x: 0, y: 0, width: 180, height: 128, dragX: 0, dragY: 0, rotation: 0, }, }, ], }); const keys = plan[0]!.cards.map((card) => card.key); expect(keys).toContain(resourceBookSceneCardKey('art-3')); expect( keys.filter((key) => key === resourceBookSceneCardKey('art-0')), ).toHaveLength(1); }); /** * 资源总览第 0 张「所有资源」卡走同一条渲染管线:标题栏的计数是全量资源数, * 预览摞则是全量投影按稳定顺序的前 N 张,且只铺一列(跨类型的一摞)。 * * 它是同一张资源卡的第二份宿主(各栏目摞里仍各有一份),所以这里既钉预览的来源与 * 上限,也钉各栏目摞没有被改动。 */ it('puts the first overview-stack slice of the full projection on the all-resources tile', () => { const documentResource = resource('design-0', 'document'); const allResources = [...resources, documentResource]; const allPositions = new Map([ ...positions, [documentResource.id, { x: 900, y: 0 }], ]); const allCardSizes = new Map([ ...cardSizes, [documentResource.id, { width: 180, height: 128 }], ]); const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: [ RESOURCE_BOOK_ALL_TARGET, 'unclassified', 'document', ], resourcesByCategory: new Map([ [RESOURCE_BOOK_ALL_TARGET, allResources], ['unclassified', resources], ['document', [documentResource]], ]), overviewRects: new Map([ [RESOURCE_BOOK_ALL_TARGET, { ...rect, left: 0, top: 0 }], ['unclassified', rect], ['document', { ...rect, left: 500 }], ]), positions: allPositions, cardSizes: allCardSizes, visibleResourceIds: new Set(allResources.map((item) => item.id)), categoryViewCenters: new Map([ ['unclassified', { x: 0, y: 64 }], ['document', { x: 900, y: 64 }], ]), state: { view: 'main', category: null, phase: 'idle', token: 0 }, }); const allGroup = plan.find( (group) => group.category === RESOURCE_BOOK_ALL_TARGET, )!; expect(allGroup.resourceCount).toBe(allResources.length); expect(allGroup.titlebarActive).toBe(false); expect(allGroup.titlebarOpacity).toBe(1); expect(allGroup.cards.map((card) => card.resource.id)).toEqual( allResources .slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT) .map((item) => item.id), ); // 跨类型也只铺一列:不然 66 项会一口气铺出十几列。 expect( Array.from(new Set(allGroup.cards.map((card) => card.stackColumn))), ).toEqual([0]); expect( allGroup.cards.every((card) => card.presentation === 'overview'), ).toBe(true); // 各栏目自己的摞照旧:预览是叠加的一份,不改其它栏目卡。 expect( plan .find((group) => group.category === 'document')! .cards.map((card) => card.resource.id), ).toEqual(['design-0']); expect( plan.find((group) => group.category === 'unclassified')!.cards.length, ).toBeGreaterThan(0); }); it('keeps the all-resources tile preview empty for an empty projection', () => { const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET], resourcesByCategory: new Map([ [RESOURCE_BOOK_ALL_TARGET, []], ]), overviewRects: new Map([ [RESOURCE_BOOK_ALL_TARGET, rect], ]), visibleResourceIds: new Set(), categoryViewCenters: new Map(), state: { view: 'main', category: null, phase: 'idle', token: 0 }, }); expect(plan).toHaveLength(1); expect(plan[0]!.resourceCount).toBe(0); expect(plan[0]!.cards).toHaveLength(0); }); }); describe('buildResourceBookAllResourcesSections', () => { it('keeps the overview section order and drops empty sections', () => { const sections = buildResourceBookAllResourcesSections({ categoryOrder: ['ui-interaction', 'character', 'audio'], resourcesByCategory: new Map([ ['ui-interaction', [resource('ui-0', 'ui-interaction')]], ['character', []], ['audio', [resource('bgm-0', 'audio'), resource('bgm-1', 'audio')]], ]), }); expect( sections.map((section) => [section.category, section.resources.length]), ).toEqual([ ['ui-interaction', 1], ['audio', 2], ]); }); });