import { describe, expect, it } from 'vitest'; import { buildResourceBookAllLayout, buildResourceBookScenePlan, groupResourceBookStacks, resolveResourceBookAllLayout, RESOURCE_BOOK_ALL_BAND_GAP, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, type ResourceBookAllBand, resourceBookAllBandLocalPoint, type ResourceBookAllLayout, resourceBookAllLayoutKey, resourceBookOverviewCardLayout, type ResourceBookOverviewRect, resourceBookSceneCardKey, selectResourceBookOverviewCards, } from '../src/view/project-development/resourceBookLayout'; import { RESOURCE_BOOK_ALL_TARGET, type ResourceBookCategory, type ResourceBookTarget, } from '../src/view/project-development/resourceBookModel'; import { createEmptyResourceCanvasLayout, reconcileResourceCanvasLayout, RESOURCE_CANVAS_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasImageCardSize, type ResourceCanvasItem, } from '../src/view/project-development/resourceCanvasLayoutModel'; 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); }); /** * 展开态(子画布里的「所有资源」)与栏目页共用同一份计划: * 每个真实栏目铺在自己的分带上,`all` 只出钉住的标题栏与全量计数。 */ it('spreads every real category on its own band and keeps the all group as a titlebar', () => { const sceneResource = resource('code-0', 'scene'); const allResources = [...resources, sceneResource]; const allPositions = new Map([ ...positions, [sceneResource.id, { x: 40, y: 20 }], ]); const allCardSizes = new Map([ ...cardSizes, [sceneResource.id, { width: 180, height: 128 }], ]); const allLayout = buildResourceBookAllLayout({ categoryOrder: ['unclassified', 'scene'], resourcesByCategory: new Map([ ['unclassified', resources], ['scene', [sceneResource]], ]), positions: allPositions, cardSizes: allCardSizes, }); const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified', 'scene'], resourcesByCategory: new Map([ [RESOURCE_BOOK_ALL_TARGET, allResources], ['unclassified', resources], ['scene', [sceneResource]], ]), overviewRects: new Map(), positions: allPositions, cardSizes: allCardSizes, visibleResourceIds: new Set(allResources.map((item) => item.id)), state: { view: 'child', category: RESOURCE_BOOK_ALL_TARGET, phase: 'idle', token: 1, }, allLayout, }); const allGroup = plan.find( (group) => group.category === RESOURCE_BOOK_ALL_TARGET, )!; // `all` 一张卡都不挂:卡片由各真实栏目承载,否则同一张卡会挂两份、FLIP 键互相顶掉。 expect(allGroup.cards).toEqual([]); expect(allGroup.titlebar).toBe(true); expect(allGroup.titlebarActive).toBe(true); expect(allGroup.titlebarOpacity).toBe(1); expect(allGroup.resourceCount).toBe(allResources.length); const artGroup = plan.find((group) => group.category === 'unclassified')!; const band = allLayout.bandByCategory.get('unclassified')!; expect(artGroup.cards).toHaveLength(resources.length); expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe( true, ); // 展开态的真实栏目**不出标题栏**:那一页是一张平铺画布,标题栏只有 `all` 那一条。 // 曾经"每条带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会集体回落到 // 世界原点叠成一摞(用户报的正是这个)。 expect(artGroup.titlebar).toBe(false); expect(artGroup.titlebarActive).toBe(false); // 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。 const first = artGroup.cards[0]!; expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX); expect(first.layout.y).toBe(positions.get('art-0')!.y + band.originY); expect(first.layout.dragX).toBe(first.layout.x); expect(first.layout.dragY).toBe(first.layout.y); // 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。 const sceneCard = plan .find((group) => group.category === 'scene')! .cards.at(0)!; expect(sceneCard.layout.y).toBe( allPositions.get(sceneResource.id)!.y + allLayout.bandByCategory.get('scene')!.originY, ); expect(sceneCard.layout.y).not.toBe(first.layout.y); // 展开态里每张资源在整份计划里只有一个宿主(总览态不是这样:`all` 那一格另有预览摞)。 const keys = plan.flatMap((group) => group.cards.map((card) => card.key)); expect(keys).toHaveLength(new Set(keys).size); expect(keys).toHaveLength(allResources.length); }); /** * 展开态(进「所有资源」)也要把每张卡认回"它在总览里属于哪一摞"。 * * 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,展开态铺满全部卡片:没有 First * 帧的卡片由转场层按 (栏目, 列号) 认领那一摞的起飞点(见 `resourceBookController` 的堆锚点)。 * 列号一律写 0 的话,第 2 摞之后的卡片会全都从第 1 摞飞出来。 */ it('keeps every expanded card on its own overview pile column', () => { const svg = { ...resource('svg-0'), path: 'assets/svg-0.svg', mediaType: 'image/svg+xml', }; // 同一个栏目里两摞:'图片' 一摞(2 张)+ 'SVG' 一摞(1 张)。 const mixed = [resources[0]!, resources[1]!, svg]; const mixedPositions = new Map([ [resources[0]!.id, { x: 0, y: 0 }], [resources[1]!.id, { x: 200, y: 0 }], [svg.id, { x: 0, y: 300 }], ]); const mixedCardSizes = new Map( mixed.map((item) => [item.id, { width: 180, height: 128 }]), ); const allLayout = buildResourceBookAllLayout({ categoryOrder: ['unclassified'], resourcesByCategory: new Map([ ['unclassified', mixed], ]), positions: mixedPositions, cardSizes: mixedCardSizes, }); const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified'], resourcesByCategory: new Map([ [RESOURCE_BOOK_ALL_TARGET, mixed], ['unclassified', mixed], ]), overviewRects: new Map(), positions: mixedPositions, cardSizes: mixedCardSizes, visibleResourceIds: new Set(mixed.map((item) => item.id)), state: { view: 'child', category: RESOURCE_BOOK_ALL_TARGET, phase: 'idle', token: 1, }, allLayout, }); const cards = plan.find( (group) => group.category === 'unclassified', )!.cards; expect(cards.map((card) => card.resource.id)).toEqual( mixed.map((item) => item.id), ); expect(cards.map((card) => card.stackColumn)).toEqual([0, 0, 1]); }); it('fades the leaving all-resources cards from their band positions', () => { const allLayout = buildResourceBookAllLayout({ categoryOrder: ['unclassified'], resourcesByCategory: new Map([ ['unclassified', resources], ]), positions, cardSizes, }); const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified'], resourcesByCategory: new Map([ [RESOURCE_BOOK_ALL_TARGET, resources], ['unclassified', resources], ]), overviewRects: new Map(), state: { view: 'main', category: null, phase: 'returning-main', token: 2, }, // 返回总览时要按"刚离开的那段子画布"淡出:展开态是分带位置,栏目页是局部坐标。 exitingCategory: RESOURCE_BOOK_ALL_TARGET, allLayout, }); const band = allLayout.bandByCategory.get('unclassified')!; const artGroup = plan.find((group) => group.category === 'unclassified')!; const exiting = artGroup.cards.filter( (card) => card.presentation === 'exiting', ); expect(exiting).toHaveLength(1); expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x + band.originX); expect(exiting[0]!.layout.y).toBe(positions.get('art-3')!.y + band.originY); // 「所有资源」组这一态只出预览摞:它不接 child / exiting 卡,否则同一张卡会在两组里 // 各挂一份(DOM 查询与 FLIP 键都会撞车)。 expect( plan .find((group) => group.category === RESOURCE_BOOK_ALL_TARGET)! .cards.every((card) => card.presentation === 'overview'), ).toBe(true); }); }); describe('buildResourceBookAllLayout', () => { const layout = buildResourceBookAllLayout({ 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')]], ]), positions: new Map([ ['ui-0', { x: 0, y: 0 }], ['bgm-0', { x: 0, y: 0 }], ['bgm-1', { x: 200, y: 0 }], ]), cardSizes: new Map([ ['ui-0', { width: 180, height: 128 }], ['bgm-0', { width: 180, height: 128 }], ['bgm-1', { width: 180, height: 128 }], ]), }); it('keeps the overview category order and drops the categories with nothing to show', () => { expect(layout.bands.map((band) => band.category)).toEqual([ 'ui-interaction', 'audio', ]); }); it('stacks the bands downward so no two categories share one origin', () => { const [first, second] = layout.bands; // 带里只有卡片,所以带宽从原点直接长出 extent 高;两带之间隔一条固定的留白: // 否则拖上面一带的卡会压到下面一带的卡上。 expect(first!.originY).toBe(0); expect(first!.height).toBeGreaterThan(0); expect(second!.originY).toBe( first!.originY + first!.height + RESOURCE_BOOK_ALL_BAND_GAP, ); expect(second!.originY).toBeGreaterThan(first!.originY); }); it('covers every card box in its bounds', () => { const last = layout.bands.at(-1)!; expect(layout.bounds.x).toBeLessThanOrEqual(0); expect(layout.bounds.y).toBeLessThanOrEqual(0); expect(layout.bounds.width).toBeGreaterThanOrEqual(last.width); expect(layout.bounds.height).toBeGreaterThanOrEqual( last.originY + last.height, ); }); }); describe('resourceBookAllBandLocalPoint', () => { const band: ResourceBookAllBand = { category: 'document', originX: 40, originY: 100, width: 620, height: 200, }; const layout: ResourceBookAllLayout = { bands: [band], bandByCategory: new Map([['document', band]]), bounds: { x: 0, y: 0, width: 620, height: 300 }, }; /** * 这是本方案唯一会落到布局 sidecar 上的换算:展开态画的是"局部坐标 + 带原点", * 落盘只认栏目内局部坐标。换算方向写错 = 把卡写到别处,所以单独钉住。 */ it('subtracts the band origin so the world point lands back on the column-local point', () => { expect( resourceBookAllBandLocalPoint({ layout, category: 'document', x: 140, y: 260, }), ).toEqual({ x: 100, y: 160 }); }); it('passes the point through for the column page and for a category without a band', () => { // 栏目页没有分带(`layout` 传 null),减 0:与既有"局部坐标直接落盘"逐字等价。 expect( resourceBookAllBandLocalPoint({ layout: null, category: 'document', x: 140, y: 260, }), ).toEqual({ x: 140, y: 260 }); expect( resourceBookAllBandLocalPoint({ layout, category: 'audio', x: 7, y: 9, }), ).toEqual({ x: 7, y: 9 }); }); }); /** * 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。 * * 场景与反馈里的项目同形:12 份 Agent 文本回执落在「文档」栏(180×128 的宽卡), * 12 张 1:1 小图落在「待归类」栏(探测出像素尺寸后是 180×180 的高卡)。两者尺寸不同, * 分带几何必须按各自真实尺寸算,且必须在坐标到位后重算 —— 这两件事分别由本组用例钉住。 */ function documentResource(index: number): ProjectResource { return { id: `agent-result:design:run-${index}`, category: 'document', subtype: 'agent-result', label: `测试文档 0${index}`, path: `专业 Agent 文本回执 · ${index}`, mediaType: 'Agent 历史文本回执', sourceLabel: '历史成果', taskTitle: null, manifestAssetId: null, producerTaskId: null, externalResourceId: null, referenceResourceIds: [], dependencies: [], dependencyDepth: 0, content: `测试文档 ${index} 内容`, }; } function imageAssetResource(index: number): ProjectResource { return { ...resource(`asset:img-0${index}`, 'unclassified'), label: `img-0${index}.png`, path: `assets/test-kit/images/img-0${index}.png`, manifestAssetId: `img-0${index}`, assetCategory: 'unclassified', assetTags: [], }; } function canvasItemOf(resourceItem: ProjectResource): ResourceCanvasItem { return { id: resourceItem.id, category: resourceItem.category, subtype: resourceItem.subtype, label: resourceItem.label, mediaType: resourceItem.mediaType, dependencyDepth: resourceItem.dependencyDepth, }; } type ResourceCardRect = { resourceId: string; category: string; left: number; top: number; right: number; bottom: number; }; function resourceCardRectsInAllView({ resources, resourcesByCategory, positions, cardSizes, allLayout, }: { resources: readonly ProjectResource[]; resourcesByCategory: ReadonlyMap; positions: ReadonlyMap; cardSizes: ReadonlyMap; allLayout: ResourceBookAllLayout; }): ResourceCardRect[] { const sceneResourcesByCategory = new Map< ResourceBookTarget, ProjectResource[] >([[RESOURCE_BOOK_ALL_TARGET, [...resources]]]); for (const [category, items] of resourcesByCategory) { sceneResourcesByCategory.set(category, items); } const plan = buildResourceBookScenePlan({ state: { view: 'child', category: RESOURCE_BOOK_ALL_TARGET, phase: 'idle', token: 1, }, visibleCategoryOrder: [ RESOURCE_BOOK_ALL_TARGET, ...RESOURCE_CANVAS_SECTION_ORDER, ], resourcesByCategory: sceneResourcesByCategory, overviewRects: new Map(), positions, cardSizes, visibleResourceIds: new Set(resources.map((item) => item.id)), categoryViewCenters: new Map(), cardsReady: true, allLayout, }); return plan.flatMap((group) => group.cards.map((card) => ({ resourceId: card.resource.id, category: group.category, left: card.layout.x, top: card.layout.y, right: card.layout.x + card.layout.width, bottom: card.layout.y + card.layout.height, })), ); } /** 逐对矩形相交检查:返回的字符串自带两张卡的矩形数值,红了就能直接看是哪两张撞了。 */ function intersectingResourceCardRects( rects: readonly ResourceCardRect[], ): string[] { const intersections: string[] = []; for (let left = 0; left < rects.length; left += 1) { for (let right = left + 1; right < rects.length; right += 1) { const a = rects[left]!; const b = rects[right]!; if ( a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom ) { intersections.push( `${a.resourceId}[${a.left},${a.top},${a.right},${a.bottom}] ∩ ` + `${b.resourceId}[${b.left},${b.top},${b.right},${b.bottom}]`, ); } } } return intersections; } describe('「所有资源」展开态:混合类型卡片自动布局不重叠', () => { const documents = Array.from({ length: 12 }, (_, index) => documentResource(index + 1), ); const imageSize = resourceCanvasImageCardSize({ pixelWidth: 512, pixelHeight: 512, }); const imageAssets = Array.from({ length: 12 }, (_, index) => imageAssetResource(index + 1), ); const mixedResources = [...documents, ...imageAssets]; const cardSizes = new Map([ ...documents.map((item) => [item.id, { width: 180, height: 128 }] as const), ...imageAssets.map((item) => [item.id, imageSize] as const), ]); const resourcesByCategory = new Map( RESOURCE_CANVAS_SECTION_ORDER.map((category) => [ category, mixedResources.filter((item) => item.category === category), ]), ); const canvasItems = mixedResources.map(canvasItemOf); const noPositions = new Map(); /** * 依赖模式在关系图就绪前的布局是空的:一张坐标都没有。此时按"所有卡都在原点"算出的分带 * 只有单行高 —— 文档带 144(128 + 行间距)、图片带原点 192(144 + 带间距)。坐标到位后 * 文档卡会铺到第 2、3 行(y = 168 / 336),若带几何不跟着重算,图片那一带就压在文档卡上。 */ function dependencyPositions() { return new Map( reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('mixed', 'dependency'), canvasItems, undefined, cardSizes, ).layout.positions.map((position) => [ position.resourceId, { x: position.x, y: position.y }, ]), ); } it('重算第一批坐标到位后的分带,展开态里任意两张卡的矩形都不相交', () => { const pending = resolveResourceBookAllLayout(null, { projectId: 'mixed', mode: 'dependency', layoutReady: false, categoryOrder: RESOURCE_CANVAS_SECTION_ORDER, resourcesByCategory, positions: noPositions, cardSizes, }); // 坐标就绪前的那一份只描述"还没有坐标",卡片此时也不渲染(见 cardsReady)。 expect(pending.layout.bands.map((band) => band.height)).toEqual([144, 196]); const positions = dependencyPositions(); const settled = resolveResourceBookAllLayout(pending, { projectId: 'mixed', mode: 'dependency', layoutReady: true, categoryOrder: RESOURCE_CANVAS_SECTION_ORDER, resourcesByCategory, positions, cardSizes, }); // 硬判据:展开态里逐对矩形都不相交(红了会直接列出撞在一起的那两张卡的矩形)。 const rects = resourceCardRectsInAllView({ resources: mixedResources, resourcesByCategory, positions, cardSizes, allLayout: settled.layout, }); expect(intersectingResourceCardRects(rects)).toEqual([]); expect(rects).toHaveLength(mixedResources.length); // 几何形状:文档那一带按 3 行真实坐标撑开,图片带被推到它下面,而不是挤在 144 + 48。 const documentBand = settled.layout.bandByCategory.get('document')!; const imageBand = settled.layout.bandByCategory.get('unclassified')!; expect(documentBand.height).toBe(480); expect(imageBand.originY).toBe( documentBand.originY + documentBand.height + RESOURCE_BOOK_ALL_BAND_GAP, ); }); it('栏目页里同一栏混排不同尺寸的卡也不相交(类型 / 依赖两种模式)', () => { const mixedSizes = new Map([ ['variant-doc', { width: 180, height: 128 }], [ 'variant-square', resourceCanvasImageCardSize({ pixelWidth: 512, pixelHeight: 512 }), ], [ 'variant-tall', resourceCanvasImageCardSize({ pixelWidth: 256, pixelHeight: 512 }), ], [ 'variant-wide', resourceCanvasImageCardSize({ pixelWidth: 1024, pixelHeight: 512 }), ], ]); const variantResources = [ { ...resource('variant-doc'), label: 'variant-doc.md', path: 'assets/docs/variant-doc.md', mediaType: 'text/markdown', subtype: 'document', }, { ...resource('variant-square'), label: 'variant-square.png' }, { ...resource('variant-tall'), label: 'variant-tall.png' }, { ...resource('variant-wide'), label: 'variant-wide.png' }, ]; const variantItems = variantResources.map(canvasItemOf); for (const mode of ['type', 'dependency'] as const) { const positions = new Map( reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('variants', mode), variantItems, undefined, mixedSizes, ).layout.positions.map((position) => [ position.resourceId, { x: position.x, y: position.y }, ]), ); const allLayout = buildResourceBookAllLayout({ categoryOrder: ['unclassified'], resourcesByCategory: new Map([ ['unclassified', variantResources] as [ ResourceBookCategory, ProjectResource[], ], ]), positions, cardSizes: mixedSizes, }); const rects = resourceCardRectsInAllView({ resources: variantResources, resourcesByCategory: new Map([ ['unclassified', variantResources] as [ ResourceBookCategory, ProjectResource[], ], ]), positions, cardSizes: mixedSizes, allLayout, }); expect(rects).toHaveLength(variantResources.length); expect(intersectingResourceCardRects(rects)).toEqual([]); } }); it('已有 sidecar 坐标(含手放)原样保留,并按下一条带让开', () => { // 用户上一轮在「文档」栏把第一张卡拖到 y = 600(远超自动网格),sidecar 里存的就是它。 const sidecar = createEmptyResourceCanvasLayout('mixed', 'type'); const sidecarPositions = [ ...sidecar.positions, { resourceId: documents[0]!.id, section: 'document' as const, x: 40, y: 600, manuallyPlaced: true, }, ]; const positions = new Map( reconcileResourceCanvasLayout( { ...sidecar, positions: sidecarPositions }, canvasItems, undefined, cardSizes, ).layout.positions.map((position) => [ position.resourceId, { x: position.x, y: position.y }, ]), ); // 手放坐标没有被协调改写。 expect(positions.get(documents[0]!.id)).toEqual({ x: 40, y: 600 }); const resolved = resolveResourceBookAllLayout(null, { projectId: 'mixed', mode: 'type', layoutReady: true, categoryOrder: RESOURCE_CANVAS_SECTION_ORDER, resourcesByCategory, positions, cardSizes, }); const documentBand = resolved.layout.bandByCategory.get('document')!; const imageBand = resolved.layout.bandByCategory.get('unclassified')!; // 画出来的仍是"sidecar 局部坐标 + 带原点",即手放位置就是用户放下的那一处。 const rects = resourceCardRectsInAllView({ resources: mixedResources, resourcesByCategory, positions, cardSizes, allLayout: resolved.layout, }); const manualRect = rects.find( (rect) => rect.resourceId === documents[0]!.id, )!; expect(manualRect.left).toBe(40 + documentBand.originX); expect(manualRect.top).toBe(600 + documentBand.originY); // 带高按被拖到 600 的那张卡撑开,图片带整体让到它下面。 expect(manualRect.bottom).toBeLessThanOrEqual( documentBand.originY + documentBand.height, ); expect(imageBand.originY).toBeGreaterThanOrEqual( manualRect.bottom + RESOURCE_BOOK_ALL_BAND_GAP, ); expect(intersectingResourceCardRects(rects)).toEqual([]); }); }); describe('resourceBookAllLayoutKey', () => { const layoutInput = { projectId: 'key-project', mode: 'dependency' as const, categoryOrder: ['document', 'unclassified'] as const, resourcesByCategory: new Map([ ['document', [documentResource(1)]], ['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]], ]), cardSizes: new Map([ ['agent-result:design:run-1', { width: 180, height: 128 }], ['asset:img-01', { width: 180, height: 180 }], ]), }; const settledPositions = new Map([ ['agent-result:design:run-1', { x: 0, y: 0 }], ['asset:img-01', { x: 0, y: 0 }], ]); it('keeps the key when only the coordinates move, so dragging never re-stacks the bands', () => { const before = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: true, positions: settledPositions, }); const dragged = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: true, positions: new Map([ ['agent-result:design:run-1', { x: 900, y: 1_200 }], ['asset:img-01', { x: 0, y: 0 }], ]), }); expect(dragged).toBe(before); }); it('changes the key while the layout is not settled yet, so the first real bands are rebuilt', () => { const pending = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: false, positions: new Map(), }); const settled = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: true, positions: settledPositions, }); // 坐标缺一张也仍是 pending:缺坐标时算出来的带高只代表"还没布局",不能当最终几何用。 const partial = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: true, positions: new Map([['agent-result:design:run-1', { x: 0, y: 0 }]]), }); expect(pending).not.toBe(settled); expect(partial).toBe(pending); }); it('changes the key when real card sizes or the sort mode change', () => { const base = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: true, positions: settledPositions, }); const measuredLater = resourceBookAllLayoutKey({ ...layoutInput, layoutReady: true, positions: settledPositions, cardSizes: new Map([ ['agent-result:design:run-1', { width: 180, height: 128 }], ['asset:img-01', { width: 220, height: 180 }], ]), }); const typeMode = resourceBookAllLayoutKey({ ...layoutInput, mode: 'type', layoutReady: true, positions: settledPositions, }); expect(measuredLater).not.toBe(base); expect(typeMode).not.toBe(base); }); });