import { describe, expect, it } from 'vitest'; import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, isSafeProjectResourceCanvasCoordinate, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, moveResourceCanvasPosition, reconcileResourceCanvasLayout, RESOURCE_CANVAS_CARD_HEIGHT, RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, type ResourceCanvasItem, type ResourceCanvasLayoutTopology, } 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 === 'art' ? '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('creates non-overlapping defaults and keeps the two modes independent', () => { const resources = [ resource('a', 'art', 0), resource('b', 'art', 0), resource('c', 'art', 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('drops a persisted position whose section no longer matches the resource', () => { 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', 'art'), ]); expect(reconciled.layout.positions).toEqual([ { resourceId: 'asset-a', section: 'art', x: 0, y: 0, manuallyPlaced: false, }, ]); }); 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', 'art'), subtype: 'ui-prototype', mediaType: 'image/png', label: '甲界面', }, { ...resource('art-spritesheet-b', 'art'), subtype: 'art-spritesheet', mediaType: 'image/png', label: '乙图集', }, { ...resource('art-spritesheet-a', 'art'), 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', 'art', 0), resource('art:wireframe', 'art', 1), resource('art:character', 'art', 0), resource('art:screen', 'art', 1), resource('art:unrelated', 'art', 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', 'art', 0), resource('art:b', 'art', 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', 'art', 0), resource('source:top', 'art', 0), resource('target:first', 'art', 1), resource('target:second', 'art', 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', 'art', 0), resource('middle:upper', 'art', 1), resource('middle:lower', 'art', 1), resource('target', 'art', 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', 'art', 2), resource('art:cycle-b', 'art', 2), resource('art:after-cycle', 'art', 3), resource('art:self', 'art', 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', 'art', 2), resource('art:cycle-b', 'art', 2), resource('art:sibling', 'art', 2), resource('art:after', 'art', 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 cycleRows = ['art:cycle-a', 'art:cycle-b'] .map((resourceId) => positionById(layout, resourceId).y) .sort((left, right) => left - right); const siblingRow = positionById(layout, 'art:sibling').y; expect(cycleRows[1] - cycleRows[0]).toBe( RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, ); expect(siblingRow).toBeGreaterThan(cycleRows[1]); }); it('is deterministic, does not modify type placement, and avoids historical manual positions', () => { const resources = [ resource('art:source', 'art', 0), resource('art:target', 'art', 1), resource('art:other', 'art', 1), ]; const topology = dependencyTopology([ { sourceResourceId: 'art:source', targetResourceId: 'art:target' }, ]); const source = createEmptyResourceCanvasLayout( 'project-stable', 'dependency', ); source.positions = [ { resourceId: 'art:other', section: 'art', 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', 'art', 4_385), resource('art:depth-4386-a', 'art', 4_386), resource('art:depth-4386-b', 'art', 4_386), resource('art:depth-9000', 'art', 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(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')}`, 'art'), 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')}`, 'art', 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); }); });