import { describe, expect, it } from 'vitest'; import { clampRatioForAxis, createInitialTree, DEFAULT_SPLIT_RATIO, listTileIds, MIN_TILE_HEIGHT_PX, MIN_TILE_WIDTH_PX, planTileRemoval, removeLeaf, splitLeaf, updateSplitRatio, type SplitNode, type TileNode, type TilingNode, } from './model'; function tile(id: string): TileNode { return { kind: 'tile', id }; } function splitRow( id: string, ratio: number, first: TilingNode, second: TilingNode, ): SplitNode { return { kind: 'split', id, orientation: 'row', ratio, first, second }; } describe('createInitialTree', () => { it('returns a single tile node', () => { expect(createInitialTree('a')).toEqual(tile('a')); }); }); describe('splitLeaf', () => { it('wraps the active leaf in a split with the new tile as second child', () => { const tree = createInitialTree('left'); const out = splitLeaf(tree, 'left', 'column', { splitId: 'split-1', tileId: 'right', }); expect(out).not.toBeNull(); expect(out!.newTileId).toBe('right'); expect(out!.nextTree).toEqual({ kind: 'split', id: 'split-1', orientation: 'column', ratio: DEFAULT_SPLIT_RATIO, first: tile('left'), second: tile('right'), }); }); it('preserves sibling subtrees when splitting a nested leaf', () => { const tree: TilingNode = splitRow( 'root', 0.6, tile('keep'), tile('replace'), ); const out = splitLeaf(tree, 'replace', 'row', { splitId: 'nested', tileId: 'new', }); expect(out).not.toBeNull(); expect(out!.newTileId).toBe('new'); const next = out!.nextTree as SplitNode; expect(next.kind).toBe('split'); expect(next.first).toEqual(tile('keep')); expect(next.orientation).toBe('row'); expect(next.second).toMatchObject({ kind: 'split', id: 'nested', orientation: 'row', ratio: DEFAULT_SPLIT_RATIO, first: tile('replace'), second: tile('new'), }); }); it('places the new tile first when before=true', () => { const tree = createInitialTree('left'); const out = splitLeaf( tree, 'left', 'row', { splitId: 'split-1', tileId: 'right' }, true, ); expect(out!.nextTree).toEqual({ kind: 'split', id: 'split-1', orientation: 'row', ratio: DEFAULT_SPLIT_RATIO, first: tile('right'), second: tile('left'), }); }); it('searches depth-first along first branch before second', () => { const tree: TilingNode = splitRow('root', 0.5, tile('alpha'), tile('beta')); const out = splitLeaf(tree, 'beta', 'column', { splitId: 'inner', tileId: 'gamma', }); expect(out).not.toBeNull(); const root = out!.nextTree as SplitNode; expect(root.first).toEqual(tile('alpha')); const inner = root.second as SplitNode; expect(inner.id).toBe('inner'); expect(inner.second).toEqual(tile('gamma')); }); it('returns null when active tile does not exist', () => { const tree = createInitialTree('only'); expect( splitLeaf(tree, 'missing', 'row', { splitId: 's', tileId: 't' }), ).toBeNull(); }); it('does not mutate the original tree references', () => { const tree: TilingNode = splitRow('root', 0.25, tile('one'), tile('two')); const before = structuredClone(tree); splitLeaf(tree, 'two', 'column', { splitId: 's', tileId: 'three' }); expect(tree).toEqual(before); }); }); describe('updateSplitRatio', () => { it('updates the matching split and leaves unrelated nodes unchanged', () => { const tree: TilingNode = splitRow( 's1', 0.3, tile('a'), splitRow('s2', 0.4, tile('b'), tile('c')), ); const next = updateSplitRatio(tree, 's2', 0.75); const root = next as SplitNode; expect(root.ratio).toBe(0.3); const inner = root.second as SplitNode; expect(inner.ratio).toBe(0.75); }); it('is a no-op for tile nodes', () => { expect(updateSplitRatio(tile('x'), 'any', 0.99)).toEqual(tile('x')); }); }); describe('removeLeaf', () => { it('collapses a split into its sibling when removing the first child', () => { const tree = splitRow('root', 0.4, tile('a'), tile('b')); expect(removeLeaf(tree, 'a')).toEqual(tile('b')); }); it('collapses a split into its sibling when removing the second child', () => { const tree = splitRow('root', 0.4, tile('a'), tile('b')); expect(removeLeaf(tree, 'b')).toEqual(tile('a')); }); it('collapses a nested split, preserving the rest of the tree', () => { const tree: TilingNode = splitRow( 'root', 0.5, tile('keep'), splitRow('inner', 0.5, tile('gone'), tile('survivor')), ); expect(removeLeaf(tree, 'gone')).toEqual( splitRow('root', 0.5, tile('keep'), tile('survivor')), ); }); it('is a no-op when removing the only (root) tile', () => { const tree = createInitialTree('only'); expect(removeLeaf(tree, 'only')).toBe(tree); }); it('returns the same reference when the tile is not found', () => { const tree = splitRow('root', 0.5, tile('a'), tile('b')); expect(removeLeaf(tree, 'missing')).toBe(tree); }); it('does not mutate the original tree', () => { const tree: TilingNode = splitRow( 'root', 0.5, tile('keep'), splitRow('inner', 0.5, tile('gone'), tile('survivor')), ); const before = structuredClone(tree); removeLeaf(tree, 'gone'); expect(tree).toEqual(before); }); }); describe('listTileIds', () => { it('returns the single id for a lone tile', () => { expect(listTileIds(tile('solo'))).toEqual(['solo']); }); it('lists tile ids left-to-right, depth-first', () => { const tree: TilingNode = splitRow( 'root', 0.5, tile('a'), splitRow('inner', 0.5, tile('b'), tile('c')), ); expect(listTileIds(tree)).toEqual(['a', 'b', 'c']); }); }); describe('planTileRemoval', () => { it('reseeds when only one tile remains', () => { const tree = createInitialTree('only'); expect(planTileRemoval(tree, 'only', 'only')).toEqual({ kind: 'reseed' }); }); it('collapses the split and keeps focus when a non-active tile closes', () => { const tree = splitRow('root', 0.5, tile('a'), tile('b')); const plan = planTileRemoval(tree, 'b', 'a'); expect(plan).toEqual({ kind: 'collapse', nextTree: tile('a'), nextActiveTileId: 'a', }); }); it('moves focus to the first survivor when the active tile closes', () => { const tree: TilingNode = splitRow( 'root', 0.5, tile('a'), splitRow('inner', 0.5, tile('b'), tile('c')), ); const plan = planTileRemoval(tree, 'a', 'a'); expect(plan.kind).toBe('collapse'); if (plan.kind === 'collapse') { expect(listTileIds(plan.nextTree)).toEqual(['b', 'c']); expect(plan.nextActiveTileId).toBe('b'); } }); }); describe('clampRatioForAxis', () => { it('returns the input ratio when axis size is non-positive', () => { expect(clampRatioForAxis(0.1, 'row', 0)).toBe(0.1); expect(clampRatioForAxis(0.9, 'column', -10)).toBe(0.9); }); it('respects MIN_TILE_WIDTH_PX for row orientation', () => { const w = 1000; const lo = MIN_TILE_WIDTH_PX / w; const hi = 1 - lo; expect(clampRatioForAxis(0, 'row', w)).toBeCloseTo(lo); expect(clampRatioForAxis(1, 'row', w)).toBeCloseTo(hi); expect(clampRatioForAxis(0.5, 'row', w)).toBe(0.5); }); it('respects MIN_TILE_HEIGHT_PX for column orientation', () => { const h = 800; const lo = MIN_TILE_HEIGHT_PX / h; const hi = 1 - lo; expect(clampRatioForAxis(0, 'column', h)).toBeCloseTo(lo); expect(clampRatioForAxis(1, 'column', h)).toBeCloseTo(hi); }); it('returns DEFAULT_SPLIT_RATIO when the axis is too small for two min panes', () => { const narrow = MIN_TILE_WIDTH_PX * 2 - 1; expect(clampRatioForAxis(0.2, 'row', narrow)).toBe(DEFAULT_SPLIT_RATIO); }); });