import { describe, expect, it } from 'vitest'; import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, isSafeProjectResourceCanvasCoordinate, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, createResourceCanvasCardSizeByResourceId, DEFAULT_RESOURCE_CANVAS_CARD_SIZE, fitResourceCanvasViewportToContent, moveResourceCanvasPosition, normalizeInfiniteResourceCanvasViewport, reconcileResourceCanvasLayout, RESOURCE_CANVAS_CARD_HEIGHT, RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS, RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, RESOURCE_CANVAS_SECTION_ORDER, type ResourceCanvasCardSize, resourceCanvasContentBounds, resourceCanvasImageCardSize, type ResourceCanvasItem, type ResourceCanvasLayoutTopology, resourceCanvasSectionExtent, } from '../src/view/project-development/resourceCanvasLayoutModel'; function resource( id: string, category: ResourceCanvasItem['category'], dependencyDepth = 0, ): ResourceCanvasItem { return { id, category, subtype: 'default', dependencyDepth, label: id, mediaType: category === 'scene' ? 'image/png' : 'text/markdown', }; } function dependencyTopology( referenceEdges: ResourceCanvasLayoutTopology['referenceEdges'] = [], taskFlows: ResourceCanvasLayoutTopology['taskFlows'] = [], ): ResourceCanvasLayoutTopology { return { referenceEdges, taskFlows }; } function positionById( layout: ReturnType['layout'], resourceId: string, ) { const position = layout.positions.find( (candidate) => candidate.resourceId === resourceId, ); expect(position).toBeDefined(); return position!; } describe('resource canvas layout model', () => { it('allows infinite background panning while keeping zoom within shared limits', () => { expect( normalizeInfiniteResourceCanvasViewport({ x: 2_000, y: 2_000, scale: 1, }), ).toEqual({ x: 2_000, y: 2_000, scale: 1 }); expect( normalizeInfiniteResourceCanvasViewport({ x: -2_000, y: -2_000, scale: 0.001, }), ).toEqual({ x: -2_000, y: -2_000, scale: 0.025 }); }); it('does not pull the viewport back when resource extents change', () => { const before = normalizeInfiniteResourceCanvasViewport({ x: 12_000, y: -9_000, scale: 0.75, }); const after = normalizeInfiniteResourceCanvasViewport(before); expect(after).toEqual(before); }); it('repairs invalid viewport coordinates without adding a canvas boundary', () => { expect( normalizeInfiniteResourceCanvasViewport({ x: Number.NaN, y: Number.POSITIVE_INFINITY, scale: Number.NaN, }), ).toEqual({ x: 0, y: 0, scale: 1 }); }); it('creates non-overlapping defaults and keeps the two modes independent', () => { const resources = [ resource('a', 'scene', 0), resource('b', 'scene', 0), resource('c', 'scene', 1), ]; const dependency = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-1', 'dependency'), resources, ).layout; const type = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-1', 'type'), resources, ).layout; expect(dependency.positions).toMatchObject([ { resourceId: 'a', x: 0, y: 0, manuallyPlaced: false }, { resourceId: 'c', x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, y: 0, manuallyPlaced: false, }, { resourceId: 'b', x: 0, y: RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, manuallyPlaced: false, }, ]); expect(type.positions).toMatchObject([ { resourceId: 'a', x: 0, y: 0 }, { resourceId: 'b', x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, y: 0, }, { resourceId: 'c', x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP), y: 0, }, ]); }); it('preserves existing positions, appends new resources, and removes stale ids', () => { const base = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-1', 'type'), [resource('a', 'document'), resource('stale', 'document')], ).layout; const moved = moveResourceCanvasPosition( base, 'a', 'document', 333.4, 41.6, ); const reconciled = reconcileResourceCanvasLayout(moved, [ resource('a', 'document'), resource('new', 'document'), ]); expect(reconciled.changed).toBe(true); expect(reconciled.layout.positions).toContainEqual({ resourceId: 'a', section: 'document', x: 333, y: 42, manuallyPlaced: true, }); expect( reconciled.layout.positions.some( (position) => position.resourceId === 'stale', ), ).toBe(false); expect( reconciled.layout.positions.find( (position) => position.resourceId === 'new', )?.manuallyPlaced, ).toBe(false); }); it('preserves negative manual positions within the symmetric canvas range', () => { const base = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-negative', 'type'), [resource('a', 'document')], ).layout; const moved = moveResourceCanvasPosition( base, 'a', 'document', -123.4, -45.6, ); expect(moved.positions[0]).toMatchObject({ x: -123, y: -46 }); expect( isSafeProjectResourceCanvasCoordinate( GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, ), ).toBe(true); expect( isSafeProjectResourceCanvasCoordinate( GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1, ), ).toBe(false); }); it('rewrites a persisted position whose section no longer matches, keeping the coordinates', () => { // 旧行为是丢弃这条坐标、让资源按现行分区自动重排 —— 那等于把用户手摆的位置抹掉。 // 资源分类本来就会变(`kind:"UI"` 的 8 条资产从「待归类」被修到「UI 交互」就是真机一例), // 所以现在改成改写 section、原样保留 x / y / manuallyPlaced。 const layout = createEmptyResourceCanvasLayout('project-1', 'dependency'); layout.positions = [ { resourceId: 'asset-a', section: 'audio', x: 10, y: 10, manuallyPlaced: true, }, ]; const reconciled = reconcileResourceCanvasLayout(layout, [ resource('asset-a', 'scene'), ]); expect(reconciled.changed).toBe(true); expect(reconciled.layout.positions).toEqual([ { resourceId: 'asset-a', section: 'scene', x: 10, y: 10, manuallyPlaced: true, }, ]); }); it('栏目顺序固定为 6 类资产分类加末尾的项目版本栏目', () => { expect(RESOURCE_CANVAS_SECTION_ORDER).toEqual([ 'ui-interaction', 'character', 'scene', 'audio', 'document', 'unclassified', 'version', ]); }); it('读时归并旧栏目 sidecar:坐标与手动标记原样保留、section 改写并判定为变化', () => { const source = createEmptyResourceCanvasLayout('project-legacy', 'type'); source.positions = [ { resourceId: 'legacy-art', section: 'art', x: -32, y: 64, manuallyPlaced: true, }, { resourceId: 'legacy-code', section: 'code', x: 180, y: 0, manuallyPlaced: false, }, { resourceId: 'legacy-version', section: 'version', x: 12, y: 34, manuallyPlaced: true, }, ]; const reconciled = reconcileResourceCanvasLayout(source, [ { ...resource('legacy-art', 'ui-interaction'), subtype: 'icon' }, { ...resource('legacy-code', 'unclassified'), subtype: 'code' }, { ...resource('legacy-version', 'version'), subtype: 'project-version' }, ]); // 改写后的布局与 sidecar 逐项不同:既有协调路径据此写回一次新分区。 expect(reconciled.changed).toBe(true); expect(reconciled.layout.positions).toEqual([ { resourceId: 'legacy-art', section: 'ui-interaction', x: -32, y: 64, manuallyPlaced: true, }, { resourceId: 'legacy-code', section: 'unclassified', x: 180, y: 0, manuallyPlaced: false, }, { resourceId: 'legacy-version', section: 'version', x: 12, y: 34, manuallyPlaced: true, }, ]); }); it('只登记游戏代码的项目落在待归类栏目而不是被分区轴排除', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-code-only', 'type'), [ resource('asset:game-entry', 'unclassified'), resource('task:code-prototype:game/main.js', 'unclassified'), ], ).layout; expect(RESOURCE_CANVAS_SECTION_ORDER).toContain('unclassified'); expect(layout.positions).toHaveLength(2); expect( new Set(layout.positions.map((position) => position.section)), ).toEqual(new Set(['unclassified'])); }); it('sorts type defaults by subtype before media type and label with an id fallback', () => { const typeLayout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-type-order', 'type'), [ { ...resource('ui-prototype', 'scene'), subtype: 'ui-prototype', mediaType: 'image/png', label: '甲界面', }, { ...resource('art-spritesheet-b', 'scene'), subtype: 'art-spritesheet', mediaType: 'image/png', label: '乙图集', }, { ...resource('art-spritesheet-a', 'scene'), subtype: 'art-spritesheet', mediaType: 'image/png', label: '乙图集', }, ], ).layout; expect( typeLayout.positions.map(({ resourceId, x, y }) => ({ resourceId, x, y, })), ).toEqual([ { resourceId: 'art-spritesheet-a', x: 0, y: 0 }, { resourceId: 'art-spritesheet-b', x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, y: 0, }, { resourceId: 'ui-prototype', x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP), y: 0, }, ]); }); it('keeps connected art cards close while placing separate components before isolates', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-clusters', 'dependency'), [ resource('art:canvas', 'scene', 0), resource('art:wireframe', 'scene', 1), resource('art:character', 'scene', 0), resource('art:screen', 'scene', 1), resource('art:unrelated', 'scene', 0), ], dependencyTopology([ { sourceResourceId: 'art:canvas', targetResourceId: 'art:wireframe', }, { sourceResourceId: 'art:character', targetResourceId: 'art:screen', }, ]), ).layout; const canvas = positionById(layout, 'art:canvas'); const wireframe = positionById(layout, 'art:wireframe'); const character = positionById(layout, 'art:character'); const screen = positionById(layout, 'art:screen'); const unrelated = positionById(layout, 'art:unrelated'); expect(wireframe.y).toBe(canvas.y); expect(screen.y).toBe(character.y); expect(character.y - canvas.y).toBeGreaterThanOrEqual( RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, ); expect(unrelated.y).toBeGreaterThan(screen.y); }); it('ignores cross-category references and task flows when forming layout clusters', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout( 'project-no-cross-category', 'dependency', ), [ resource('art:a', 'scene', 0), resource('art:b', 'scene', 0), resource('document:a', 'document', 0), ], dependencyTopology( [ { sourceResourceId: 'document:a', targetResourceId: 'art:a', }, ], [ { sourceResourceIds: ['document:a'], targetResourceIds: ['art:a'], }, ], ), ).layout; expect(positionById(layout, 'art:a').y).toBe(0); expect(positionById(layout, 'art:b').y).toBe( RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, ); expect(positionById(layout, 'document:a')).toMatchObject({ section: 'document', y: 0, }); }); it('uses bounded layer scans to align multi-edge targets with their upstream neighbors', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-crossings', 'dependency'), [ resource('source:bottom', 'scene', 0), resource('source:top', 'scene', 0), resource('target:first', 'scene', 1), resource('target:second', 'scene', 1), ], dependencyTopology( [ { sourceResourceId: 'source:bottom', targetResourceId: 'target:second', }, { sourceResourceId: 'source:top', targetResourceId: 'target:first', }, ], [ { sourceResourceIds: ['source:bottom', 'source:top'], targetResourceIds: ['target:first', 'target:second'], }, ], ), ).layout; expect(positionById(layout, 'source:bottom').y).toBeLessThan( positionById(layout, 'source:top').y, ); expect(positionById(layout, 'target:second').y).toBeLessThan( positionById(layout, 'target:first').y, ); }); it('centers narrow dependency layers so diamond edges have balanced lengths', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-balanced-diamond', 'dependency'), [ resource('source', 'scene', 0), resource('middle:upper', 'scene', 1), resource('middle:lower', 'scene', 1), resource('target', 'scene', 2), ], dependencyTopology([ { sourceResourceId: 'source', targetResourceId: 'middle:upper' }, { sourceResourceId: 'source', targetResourceId: 'middle:lower' }, { sourceResourceId: 'middle:upper', targetResourceId: 'target' }, { sourceResourceId: 'middle:lower', targetResourceId: 'target' }, ]), ).layout; const dependencySlotHeight = RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; const dependencySlotWidth = RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP; expect(positionById(layout, 'middle:lower')).toMatchObject({ x: dependencySlotWidth, y: 0, }); expect(positionById(layout, 'middle:upper')).toMatchObject({ x: dependencySlotWidth, y: dependencySlotHeight, }); expect(positionById(layout, 'source')).toMatchObject({ x: 0, y: dependencySlotHeight / 2, }); expect(positionById(layout, 'target')).toMatchObject({ x: dependencySlotWidth * 2, y: dependencySlotHeight / 2, }); }); it('keeps cycles contiguous, preserves their Rust depth, and leaves cross-section cards in place', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-cycles', 'dependency'), [ resource('art:cycle-a', 'scene', 2), resource('art:cycle-b', 'scene', 2), resource('art:after-cycle', 'scene', 3), resource('art:self', 'scene', 0), resource('document:brief', 'document', 0), ], dependencyTopology([ { sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' }, { sourceResourceId: 'art:cycle-b', targetResourceId: 'art:cycle-a' }, { sourceResourceId: 'art:cycle-b', targetResourceId: 'art:after-cycle', }, { sourceResourceId: 'art:self', targetResourceId: 'art:self' }, { sourceResourceId: 'document:brief', targetResourceId: 'art:cycle-a', }, ]), ).layout; const cycleA = positionById(layout, 'art:cycle-a'); const cycleB = positionById(layout, 'art:cycle-b'); const afterCycle = positionById(layout, 'art:after-cycle'); expect(cycleA.x).toBe( 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP), ); expect(cycleB.x).toBe(cycleA.x); expect(Math.abs(cycleA.y - cycleB.y)).toBe( RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, ); expect(afterCycle.x).toBe( 3 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP), ); expect(positionById(layout, 'document:brief').section).toBe('document'); }); it('keeps same-depth cycle members consecutive when another related card shares the layer', () => { const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-cycle-contiguity', 'dependency'), [ resource('art:cycle-a', 'scene', 2), resource('art:cycle-b', 'scene', 2), resource('art:sibling', 'scene', 2), resource('art:after', 'scene', 3), ], dependencyTopology([ { sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' }, { sourceResourceId: 'art:cycle-b', targetResourceId: 'art:cycle-a' }, { sourceResourceId: 'art:cycle-b', targetResourceId: 'art:after' }, { sourceResourceId: 'art:sibling', targetResourceId: 'art:after' }, ]), ).layout; const cycleA = positionById(layout, 'art:cycle-a'); const cycleB = positionById(layout, 'art:cycle-b'); const sibling = positionById(layout, 'art:sibling'); const dependencySlotWidth = RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP; const dependencySlotHeight = RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; // 同层 3 张卡按层内网格展开成 2 列,环成员仍占相邻两格(同排相邻列), // 另一张共享该层的相关卡落在它们之后的下一行。 expect(cycleB.y).toBe(cycleA.y); expect(cycleB.x - cycleA.x).toBe(dependencySlotWidth); expect(sibling.x).toBe(cycleA.x); expect(sibling.y).toBe(cycleA.y + dependencySlotHeight); }); it('is deterministic, does not modify type placement, and avoids historical manual positions', () => { const resources = [ resource('art:source', 'scene', 0), resource('art:target', 'scene', 1), resource('art:other', 'scene', 1), ]; const topology = dependencyTopology([ { sourceResourceId: 'art:source', targetResourceId: 'art:target' }, ]); const source = createEmptyResourceCanvasLayout( 'project-stable', 'dependency', ); source.positions = [ { resourceId: 'art:other', section: 'scene', x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, y: 0, manuallyPlaced: true, }, ]; const first = reconcileResourceCanvasLayout( source, resources, topology, ).layout; const second = reconcileResourceCanvasLayout( source, resources, topology, ).layout; const type = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-stable', 'type'), resources, topology, ).layout; expect(second.positions).toEqual(first.positions); expect(positionById(first, 'art:other')).toMatchObject({ x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, y: 0, manuallyPlaced: true, }); expect(positionById(first, 'art:target').y).toBe( RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, ); expect(positionById(type, 'art:other')).toMatchObject({ x: 0, y: 0 }); }); it('saturates super-deep dependency columns and avoids collisions within the coordinate contract', () => { const resources = [ resource('art:depth-4385', 'scene', 4_385), resource('art:depth-4386-a', 'scene', 4_386), resource('art:depth-4386-b', 'scene', 4_386), resource('art:depth-9000', 'scene', 9_000), ]; const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'), resources, ).layout; const repeatedLayout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'), resources, ).layout; const lastLegalColumn = Math.floor( GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE / (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP), ) * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP); const deepPositions = [ positionById(layout, 'art:depth-4386-a'), positionById(layout, 'art:depth-4386-b'), positionById(layout, 'art:depth-9000'), ]; expect(lastLegalColumn).toBe(999_780); expect( isSafeProjectResourceCanvasCoordinate( GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, ), ).toBe(true); expect( isSafeProjectResourceCanvasCoordinate( GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1, ), ).toBe(false); expect( isSafeProjectResourceCanvasCoordinate( GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, ), ).toBe(true); expect( isSafeProjectResourceCanvasCoordinate( GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1, ), ).toBe(false); expect(deepPositions.map((position) => position.x)).toEqual([ lastLegalColumn, lastLegalColumn, lastLegalColumn, ]); expect(new Set(deepPositions.map((position) => position.y)).size).toBe(3); expect(repeatedLayout.positions).toEqual(layout.positions); expect( layout.positions.every( (position) => isSafeProjectResourceCanvasCoordinate(position.x) && isSafeProjectResourceCanvasCoordinate(position.y), ), ).toBe(true); expect(resources.map((resource) => resource.dependencyDepth)).toEqual([ 4_385, 4_386, 4_386, 9_000, ]); }); it.each(['dependency', 'type'] as const)( 'reconciles 4096 resources in %s mode within the bounded layout budget', (mode) => { const resources = Array.from({ length: 4096 }, (_, index) => ({ ...resource(`resource-${index.toString().padStart(4, '0')}`, 'scene'), dependencyDepth: index % 64, mediaType: `image/type-${index % 16}`, })); const startedAt = performance.now(); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-performance', mode), resources, ).layout; const elapsedMs = performance.now() - startedAt; expect(layout.positions).toHaveLength(4096); expect( new Set( layout.positions.map((position) => `${position.x}:${position.y}`), ).size, ).toBe(4096); expect(elapsedMs).toBeLessThan(2000); }, ); it('keeps dependency clustering within the 4096-resource layout budget', () => { const resources = Array.from({ length: 4096 }, (_, index) => resource( `resource-${index.toString().padStart(4, '0')}`, 'scene', index % 64, ), ); const topology = dependencyTopology( resources.slice(1).map((target, index) => ({ sourceResourceId: resources[index]!.id, targetResourceId: target.id, })), ); const startedAt = performance.now(); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout( 'project-cluster-performance', 'dependency', ), resources, topology, ).layout; expect(layout.positions).toHaveLength(4096); expect(performance.now() - startedAt).toBeLessThan(2000); }); }); describe('resource canvas variable card geometry', () => { it.each([ [1, 1, { width: 180, height: 180 }], [16, 9, { width: 220, height: 124 }], [9, 16, { width: 101, height: 180 }], [2, 3, { width: 120, height: 180 }], [3, 2, { width: 220, height: 147 }], [100, 1, { width: 220, height: 110 }], [1, 100, { width: 96, height: 180 }], ])( 'bounds a %d:%d image inside the shared card limits', (pixelWidth, pixelHeight, expected) => { expect(resourceCanvasImageCardSize({ pixelWidth, pixelHeight })).toEqual( expected, ); }, ); it('keeps non-images and images without metadata at the fixed fallback size', () => { const sizes = createResourceCanvasCardSizeByResourceId( [resource('image', 'scene'), resource('document', 'document')], new Map([ ['image', { pixelWidth: 1920, pixelHeight: 1080 }], ['document', { pixelWidth: 1920, pixelHeight: 1080 }], ]), ); expect(sizes.get('image')).toEqual({ width: 220, height: 124 }); expect(sizes.get('document')).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE); expect( createResourceCanvasCardSizeByResourceId( [resource('pending-image', 'scene')], new Map(), ).get('pending-image'), ).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE); }); it('uses actual widths and heights for type placement collision checks', () => { const resources = [ resource('wide-a', 'scene'), resource('wide-b', 'scene'), ]; const cardSizes = new Map([ ['wide-a', { width: 220, height: 180 }], ['wide-b', { width: 220, height: 180 }], ]); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('variable-type', 'type'), resources, undefined, cardSizes, ).layout; expect(positionById(layout, 'wide-a')).toMatchObject({ x: 0, y: 0 }); expect(positionById(layout, 'wide-b')).toMatchObject({ x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP), y: 0, }); }); it('uses actual dimensions for dependency columns, rows, and section extent', () => { const resources = [ resource('source-a', 'scene', 0), resource('source-b', 'scene', 0), resource('target', 'scene', 1), ]; const cardSizes = new Map([ ['source-a', { width: 220, height: 180 }], ['source-b', { width: 96, height: 180 }], ['target', { width: 180, height: 128 }], ]); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('variable-dependency', 'dependency'), resources, undefined, cardSizes, ).layout; expect(positionById(layout, 'source-a')).toMatchObject({ x: 0, y: 0 }); expect(positionById(layout, 'source-b')).toMatchObject({ x: 0, y: 180 + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, }); expect(positionById(layout, 'target')).toMatchObject({ x: 220 + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, y: 0, }); expect(resourceCanvasSectionExtent(layout.positions, cardSizes)).toEqual({ x: 0, y: 0, width: 620, height: 416, }); }); it('includes negative manual coordinates in the fit bounds', () => { const positions: ProjectResourceCanvasPosition[] = [ { resourceId: 'negative-card', section: 'scene', x: -240, y: -160, manuallyPlaced: true, }, { resourceId: 'positive-card', section: 'scene', x: 480, y: 320, manuallyPlaced: true, }, ]; const extent = resourceCanvasSectionExtent( positions, new Map([ ['negative-card', { width: 180, height: 128 }], ['positive-card', { width: 220, height: 180 }], ]), ); expect(extent).toEqual({ x: -256, y: -176, width: 972, height: 692, }); }); it('fits the exact resource card bounds to the reset viewport', () => { const positions: ProjectResourceCanvasPosition[] = [ { resourceId: 'negative-card', section: 'scene', x: -240, y: -160, manuallyPlaced: true, }, { resourceId: 'positive-card', section: 'scene', x: 480, y: 320, manuallyPlaced: true, }, ]; const bounds = resourceCanvasContentBounds( positions, new Map([ ['negative-card', { width: 180, height: 128 }], ['positive-card', { width: 220, height: 180 }], ]), ); expect(bounds).toEqual({ x: -240, y: -160, width: 940, height: 660, }); const viewport = fitResourceCanvasViewportToContent({ bounds, canvasSize: { width: 800, height: 600 }, }); expect(viewport.scale).toBeCloseTo(768 / 940, 8); expect(bounds.width * viewport.scale).toBeCloseTo(768, 8); expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8); }); it.each([ 'ui-interaction', 'character', 'scene', 'audio', 'document', 'unclassified', 'version', ])( 'caps the initial fit for every resource category (%s) while keeping explicit zoom overrides available', () => { const viewport = fitResourceCanvasViewportToContent({ bounds: { x: 0, y: 0, width: 180, height: 128 }, canvasSize: { width: 800, height: 600 }, }); const explicitViewport = fitResourceCanvasViewportToContent({ bounds: { x: 0, y: 0, width: 180, height: 128 }, canvasSize: { width: 800, height: 600 }, maxScale: 2.5, }); expect(viewport.scale).toBe(RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE); expect(explicitViewport.scale).toBe(2.5); }, ); it('accepts card sizes directly on resource items for hook integration', () => { const wide = { ...resource('wide', 'scene', 0), cardSize: { width: 220, height: 180 }, }; const target = { ...resource('target', 'scene', 1), cardSize: { width: 180, height: 128 }, }; const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('item-size', 'dependency'), [wide, target], ).layout; expect(positionById(layout, 'target').x).toBe( wide.cardSize.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, ); }); }); describe('resource canvas dependency layer grid', () => { const dependencySlotWidth = RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP; const dependencySlotHeight = RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; function gridResources(count: number, dependencyDepth = 0) { return Array.from({ length: count }, (_, index) => resource( `art:grid-${index.toString().padStart(2, '0')}`, 'scene', dependencyDepth, ), ); } function expectNoOverlappingCards( layout: ReturnType['layout'], ) { const rects = layout.positions.map((position) => ({ position, size: DEFAULT_RESOURCE_CANVAS_CARD_SIZE, })); rects.forEach((left, index) => { rects.slice(index + 1).forEach((right) => { expect( left.position.x < right.position.x + right.size.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP && left.position.x + left.size.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP > right.position.x && left.position.y < right.position.y + right.size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP && left.position.y + left.size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP > right.position.y, ).toBe(false); }); }); } it('lays a single-depth section out as a grid plane instead of one column', () => { const resources = gridResources(12); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-layer-grid', 'dependency'), resources, ).layout; // 12 张同深度卡:4 列 × 3 行,横向展开成平面,而不是 12 张摞成一列。 expect( Array.from(new Set(layout.positions.map((position) => position.x))), ).toEqual([ 0, dependencySlotWidth, 2 * dependencySlotWidth, 3 * dependencySlotWidth, ]); expect( Array.from(new Set(layout.positions.map((position) => position.y))), ).toEqual([0, dependencySlotHeight, 2 * dependencySlotHeight]); expect(layout.positions).toHaveLength(12); expectNoOverlappingCards(layout); }); it('is stable for repeated runs and independent of the resource input order', () => { const resources = gridResources(9); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout( 'project-layer-grid-stable', 'dependency', ), resources, ).layout; const repeated = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout( 'project-layer-grid-stable', 'dependency', ), resources, ).layout; const reversed = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout( 'project-layer-grid-stable', 'dependency', ), [...resources].reverse(), ).layout; expect(repeated.positions).toEqual(layout.positions); expect(reversed.positions).toEqual(layout.positions); }); it('keeps depth bands monotone and clear of the wrapped layer', () => { const shallow = gridResources(9); const deep = resource('art:deep', 'scene', 1); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-layer-band', 'dependency'), [...shallow, deep], ).layout; const shallowPositions = shallow.map((item) => positionById(layout, item.id), ); const deepPosition = positionById(layout, deep.id); // 9 张同深度铺成 3 列,层带按 3 列预留;深度 1 的层带整体让开,仍随深度向右推进。 expect( Array.from(new Set(shallowPositions.map((position) => position.x))).sort( (left, right) => left - right, ), ).toEqual([0, dependencySlotWidth, 2 * dependencySlotWidth]); expect(deepPosition.x).toBe(3 * dependencySlotWidth); expect( Math.max(...shallowPositions.map((position) => position.x)) + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, ).toBeLessThanOrEqual(deepPosition.x); }); it('caps the wrapped columns so a large layer stays readable', () => { const resources = gridResources(100); const layout = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout('project-layer-cap', 'dependency'), resources, ).layout; const xValues = Array.from( new Set(layout.positions.map((position) => position.x)), ); // 100 张同深度卡铺成封顶的 6 列,而不是一条 100 张的超高竖带或超长横带。 expect(xValues).toHaveLength(RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS); expect(xValues).toHaveLength(6); expect(Math.max(...xValues)).toBe( (RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS - 1) * dependencySlotWidth, ); expectNoOverlappingCards(layout); }); it('keeps manual coordinates identical while the automatic grid re-derives', () => { const resources = gridResources(9); const source = createEmptyResourceCanvasLayout( 'project-layer-grid-manual', 'dependency', ); source.positions = [ { resourceId: 'art:grid-00', section: 'scene', x: 900, y: 640, manuallyPlaced: true, }, ]; const layout = reconcileResourceCanvasLayout(source, resources).layout; // 重派生路径只保留手动坐标后重算自动坐标,手动坐标必须逐项不变。 const redriven = reconcileResourceCanvasLayout( { ...layout, positions: layout.positions.filter( (position) => position.manuallyPlaced, ), }, resources, ).layout; expect(positionById(layout, 'art:grid-00')).toMatchObject({ x: 900, y: 640, manuallyPlaced: true, }); expect(positionById(redriven, 'art:grid-00')).toMatchObject({ x: 900, y: 640, manuallyPlaced: true, }); expectNoOverlappingCards(layout); expectNoOverlappingCards(redriven); expect( redriven.positions.filter((position) => !position.manuallyPlaced), ).toEqual(layout.positions.filter((position) => !position.manuallyPlaced)); }); });