@tanstack/devtools
Version:
TanStack Devtools is a set of tools for building advanced devtools for your application.
891 lines (787 loc) • 30.3 kB
text/typescript
import { describe, expect, it } from 'vitest'
import {
activateTab,
allGroups,
appendPane,
canSplit,
closeGroup,
closeTab,
flattenTabs,
insertionIndex,
layoutRects,
moveTab,
nextGroupId,
paneRects,
repairLayout,
resize,
singleGroup,
splitAt,
splitterHandles,
stackInto,
zoneAt,
} from './layout-tree'
import type { GroupNode, LayoutNode, SplitNode } from './layout-tree'
const group = (id: string, tabs: Array<string>, active = 0): GroupNode => ({
kind: 'group',
id,
tabs,
active,
})
const split = (
dir: SplitNode['dir'],
children: Array<LayoutNode>,
sizes?: Array<number>,
): SplitNode => ({
kind: 'split',
dir,
sizes: sizes ?? children.map(() => 1 / children.length),
children,
})
const BOX = { w: 1000, h: 400 }
const isGroupNode = (node: LayoutNode) => node.kind === 'group'
/** Every invariant the module promises, asserted on a returned tree. */
const expectWellFormed = (tree: LayoutNode | null) => {
if (tree === null) return
const ids = flattenTabs(tree)
expect(
new Set(ids).size,
`duplicate plugin id in ${JSON.stringify(tree)}`,
).toBe(ids.length)
const groupIds = allGroups(tree).map((entry) => entry.id)
expect(new Set(groupIds).size, 'duplicate group id').toBe(groupIds.length)
const visit = (node: LayoutNode) => {
if (node.kind === 'group') {
expect(node.tabs.length).toBeGreaterThan(0)
expect(node.active).toBeGreaterThanOrEqual(0)
expect(node.active).toBeLessThan(node.tabs.length)
return
}
expect(node.children.length).toBeGreaterThanOrEqual(2)
expect(node.sizes).toHaveLength(node.children.length)
expect(node.sizes.every((size) => size > 0)).toBe(true)
expect(node.sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1, 10)
// A same-direction split nested directly inside another is the same layout
// written twice, and prune is supposed to have flattened it.
for (const child of node.children) {
if (child.kind === 'split') expect(child.dir).not.toBe(node.dir)
visit(child)
}
}
visit(tree)
}
describe('flattenTabs / lookups', () => {
it('reads tabs left to right, top to bottom', () => {
const tree = split('row', [
group('g0', ['query', 'form']),
split('col', [group('g1', ['router']), group('g2', ['a11y'])]),
])
expect(flattenTabs(tree)).toEqual(['query', 'form', 'router', 'a11y'])
expect(flattenTabs(null)).toEqual([])
})
it('derives the next group id from the ids in use, not a counter', () => {
expect(nextGroupId(null)).toBe('g0')
expect(nextGroupId(group('g0', ['a']))).toBe('g1')
expect(
nextGroupId(split('row', [group('g0', ['a']), group('g7', ['b'])])),
).toBe('g8')
// Non-matching ids must not break the scan.
expect(nextGroupId(group('custom', ['a']))).toBe('g0')
})
})
describe('closeTab', () => {
it('keeps the neighbour selected when an earlier tab goes', () => {
const next = closeTab(group('g0', ['a', 'b', 'c'], 2), 'a')
expect(next).toEqual(group('g0', ['b', 'c'], 1))
expectWellFormed(next)
})
it('clamps active when the last tab goes', () => {
const next = closeTab(group('g0', ['a', 'b'], 1), 'b')
expect(next).toEqual(group('g0', ['a'], 0))
expectWellFormed(next)
})
it('collapses the split when a group empties', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const next = closeTab(tree, 'a')
expect(next).toEqual(group('g1', ['b'], 0))
expectWellFormed(next)
})
it('collapses two levels when a nested group empties', () => {
const tree = split('row', [
group('g0', ['a']),
split('col', [group('g1', ['b']), group('g2', ['c'])]),
])
// Removing b leaves the col split with one child, which unwraps to g2, and
// the row split then holds g0 and g2.
const next = closeTab(tree, 'b')
expect(next).toEqual(split('row', [group('g0', ['a']), group('g2', ['c'])]))
expectWellFormed(next)
})
it('returns null when the last tab anywhere goes', () => {
expect(closeTab(group('g0', ['a']), 'a')).toBeNull()
expect(closeTab(null, 'a')).toBeNull()
})
it('is a no-op for an unknown tab', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
expect(closeTab(tree, 'nope')).toEqual(tree)
})
it('preserves the surviving siblings share of the space', () => {
const tree = split('row', [
group('g0', ['a']),
group('g1', ['b']),
group('g2', ['c']),
])
const next = closeTab(tree, 'b') as SplitNode
expect(next.children).toHaveLength(2)
expect(next.sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1, 10)
expectWellFormed(next)
})
})
describe('closeGroup', () => {
it('removes every tab in the group and prunes', () => {
const tree = split('row', [group('g0', ['a', 'b']), group('g1', ['c'])])
const next = closeGroup(tree, 'g0')
expect(next).toEqual(group('g1', ['c'], 0))
expectWellFormed(next)
})
it('is a no-op for an unknown group', () => {
const tree = group('g0', ['a'])
expect(closeGroup(tree, 'gX')).toEqual(tree)
})
})
describe('activateTab', () => {
it('selects without moving', () => {
const next = activateTab(group('g0', ['a', 'b', 'c']), 'c')
expect(next).toEqual(group('g0', ['a', 'b', 'c'], 2))
})
it('ignores an unknown tab', () => {
const tree = group('g0', ['a'])
expect(activateTab(tree, 'zzz')).toEqual(tree)
})
})
describe('moveTab', () => {
it('reorders within one group', () => {
const next = moveTab(group('g0', ['a', 'b', 'c']), 'a', 'g0', 2)
expect(next).toEqual(group('g0', ['b', 'c', 'a'], 2))
expectWellFormed(next)
})
it('moves between groups and selects it at the destination', () => {
const tree = split('row', [group('g0', ['a', 'b']), group('g1', ['c'])])
const next = moveTab(tree, 'a', 'g1', 0)
expect(next).toEqual(
split('row', [group('g0', ['b']), group('g1', ['a', 'c'], 0)]),
)
expectWellFormed(next)
})
it('collapses the source group when it empties', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const next = moveTab(tree, 'a', 'g1', 1)
expect(next).toEqual(group('g1', ['b', 'a'], 1))
expectWellFormed(next)
})
it('clamps an out-of-range index instead of creating a hole', () => {
expect(moveTab(group('g0', ['a', 'b']), 'a', 'g0', 99)).toEqual(
group('g0', ['b', 'a'], 1),
)
expect(moveTab(group('g0', ['a', 'b']), 'b', 'g0', -5)).toEqual(
group('g0', ['b', 'a'], 0),
)
})
it('never duplicates the tab it moved', () => {
const tree = split('row', [group('g0', ['a', 'b']), group('g1', ['c'])])
const next = moveTab(tree, 'b', 'g1', 0)
expect(flattenTabs(next).filter((id) => id === 'b')).toHaveLength(1)
expectWellFormed(next)
})
it('is a no-op for an unknown group or tab', () => {
const tree = group('g0', ['a'])
expect(moveTab(tree, 'a', 'gX', 0)).toEqual(tree)
expect(moveTab(tree, 'zz', 'g0', 0)).toEqual(group('g0', ['zz', 'a'], 0))
})
})
describe('stackInto', () => {
it('appends and selects', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const next = stackInto(tree, 'g0', 'b')
expect(next).toEqual(group('g0', ['a', 'b'], 1))
expectWellFormed(next)
})
})
describe('splitAt', () => {
it('puts the new group on the dropped side', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const right = splitAt(tree, 'g0', 'right', 'b') as SplitNode
expect(flattenTabs(right)).toEqual(['a', 'b'])
expect((right.children[0] as GroupNode).tabs).toEqual(['a'])
expectWellFormed(right)
const left = splitAt(tree, 'g1', 'left', 'a') as SplitNode
expect(flattenTabs(left)).toEqual(['a', 'b'])
expectWellFormed(left)
})
it('splits on the correct axis', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
// g0 is inside a row split, so a vertical split nests a col beneath it.
const below = splitAt(tree, 'g0', 'bottom', 'b') as SplitNode
expect(below.dir).toBe('col')
expectWellFormed(below)
})
it('treats center as a stack', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
expect(splitAt(tree, 'g0', 'center', 'b')).toEqual(
group('g0', ['a', 'b'], 1),
)
})
it('refuses to split a group by its own only tab', () => {
// Dragging a lone tab onto its own pane edge would otherwise put it beside
// itself, or delete it outright.
const tree = group('g0', ['a'])
expect(splitAt(tree, 'g0', 'right', 'a')).toEqual(tree)
expect(flattenTabs(splitAt(tree, 'g0', 'right', 'a'))).toEqual(['a'])
})
it('splits a group out of one of its own tabs when it has several', () => {
const next = splitAt(group('g0', ['a', 'b']), 'g0', 'right', 'b')
expect(flattenTabs(next)).toEqual(['a', 'b'])
expect(allGroups(next)).toHaveLength(2)
expectWellFormed(next)
})
it('seeds a tree from nothing', () => {
expect(splitAt(null, 'g0', 'right', 'a')).toEqual(group('g0', ['a'], 0))
})
it('gives the new group an unused id', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const next = splitAt(tree, 'g0', 'right', 'c')
const ids = allGroups(next).map((entry) => entry.id)
expect(new Set(ids).size).toBe(ids.length)
expectWellFormed(next)
})
it('is a no-op for an unknown group', () => {
const tree = group('g0', ['a'])
expect(splitAt(tree, 'gX', 'right', 'b')).toEqual(tree)
})
})
describe('resize', () => {
it('moves budget across one gutter and leaves the rest alone', () => {
const tree = split(
'row',
[group('g0', ['a']), group('g1', ['b']), group('g2', ['c'])],
[0.4, 0.4, 0.2],
)
const next = resize(tree, [], 0, 0.1) as SplitNode
expect(next.sizes[0]).toBeCloseTo(0.5, 10)
expect(next.sizes[1]).toBeCloseTo(0.3, 10)
expect(next.sizes[2]).toBeCloseTo(0.2, 10)
expectWellFormed(next)
})
it('conserves the total', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
for (const delta of [0.3, -0.45, 5, -5]) {
const next = resize(tree, [], 0, delta) as SplitNode
expect(next.sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1, 10)
}
})
it('clamps at the minimum instead of collapsing a pane', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const next = resize(tree, [], 0, 0.9, 0.2) as SplitNode
expect(next.sizes[0]).toBeCloseTo(0.8, 10)
expect(next.sizes[1]).toBeCloseTo(0.2, 10)
})
it('falls back to an even share when the minimum cannot fit twice', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const next = resize(tree, [], 0, 1, 0.9) as SplitNode
expect(next.sizes[0]).toBeCloseTo(0.5, 10)
expect(next.sizes[1]).toBeCloseTo(0.5, 10)
})
it('resizes a nested split by path', () => {
const tree = split('row', [
group('g0', ['a']),
split('col', [group('g1', ['b']), group('g2', ['c'])]),
])
const next = resize(tree, [1], 0, 0.2) as SplitNode
const inner = next.children[1] as SplitNode
expect(inner.sizes[0]).toBeCloseTo(0.7, 10)
expect(inner.sizes[1]).toBeCloseTo(0.3, 10)
// The outer split is untouched.
expect(next.sizes[0]).toBeCloseTo(0.5, 10)
expectWellFormed(next)
})
it('is a no-op for a bad path or gutter', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
expect(resize(tree, [9], 0, 0.1)).toEqual(tree)
expect(resize(tree, [], 5, 0.1)).toEqual(tree)
expect(resize(tree, [], 0, 0)).toEqual(tree)
expect(resize(group('g0', ['a']), [], 0, 0.1)).toEqual(group('g0', ['a']))
})
})
describe('layoutRects', () => {
it('fills the box with one group', () => {
expect(layoutRects(group('g0', ['a']), BOX)).toEqual({
g0: { left: 0, top: 0, width: 1000, height: 400 },
})
})
it('divides a row by weight', () => {
const tree = split(
'row',
[group('g0', ['a']), group('g1', ['b'])],
[0.6, 0.4],
)
expect(layoutRects(tree, BOX)).toEqual({
g0: { left: 0, top: 0, width: 600, height: 400 },
g1: { left: 600, top: 0, width: 400, height: 400 },
})
})
it('divides a column by weight', () => {
const tree = split('col', [group('g0', ['a']), group('g1', ['b'])])
expect(layoutRects(tree, BOX)).toEqual({
g0: { left: 0, top: 0, width: 1000, height: 200 },
g1: { left: 0, top: 200, width: 1000, height: 200 },
})
})
it('subtracts gutters from the available extent', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const rects = layoutRects(tree, BOX, 8)
expect(rects.g0).toEqual({ left: 0, top: 0, width: 496, height: 400 })
expect(rects.g1).toEqual({ left: 504, top: 0, width: 496, height: 400 })
// The two panes plus the gutter account for the whole box.
expect(rects.g0!.width + 8 + rects.g1!.width).toBe(BOX.w)
})
it('handles nesting', () => {
const tree = split('row', [
group('g0', ['a']),
split('col', [group('g1', ['b']), group('g2', ['c'])]),
])
expect(layoutRects(tree, BOX)).toEqual({
g0: { left: 0, top: 0, width: 500, height: 400 },
g1: { left: 500, top: 0, width: 500, height: 200 },
g2: { left: 500, top: 200, width: 500, height: 200 },
})
})
it('gives every tab in a group the group rect', () => {
const tree = split('row', [group('g0', ['a', 'b']), group('g1', ['c'])])
const rects = paneRects(tree, BOX)
expect(rects.a).toEqual(rects.b)
expect(rects.a).toEqual({ left: 0, top: 0, width: 500, height: 400 })
expect(rects.c!.left).toBe(500)
})
it('returns nothing for an empty tree', () => {
expect(layoutRects(null, BOX)).toEqual({})
expect(paneRects(null, BOX)).toEqual({})
})
})
describe('closing a pane gives its space back', () => {
// The reported shape: one pane top-left, one bottom-left, one down the right.
const topBottomLeftPlusRight = () =>
split('row', [
split('col', [group('g0', ['top']), group('g1', ['bottom'])]),
group('g2', ['right']),
])
it('expands the top-left pane to full height when the bottom-left closes', () => {
const before = layoutRects(topBottomLeftPlusRight(), BOX)
expect(before.g0!.height).toBeCloseTo(BOX.h / 2, 6)
const after = closeTab(topBottomLeftPlusRight(), 'bottom')
const rects = layoutRects(after, BOX)
// The column is gone, so the survivor takes the whole left half.
expect(rects.g0!.height).toBe(BOX.h)
expect(rects.g0!.top).toBe(0)
expect(rects.g0!.width).toBeCloseTo(BOX.w / 2, 6)
// The right pane is untouched.
expect(rects.g2!.height).toBe(BOX.h)
expect(rects.g2!.width).toBeCloseTo(BOX.w / 2, 6)
expectWellFormed(after)
})
it('expands the bottom-left pane when the top-left closes', () => {
const after = closeTab(topBottomLeftPlusRight(), 'top')
const rects = layoutRects(after, BOX)
expect(rects.g1!.height).toBe(BOX.h)
expect(rects.g1!.top).toBe(0)
expectWellFormed(after)
})
it('leaves no empty split behind, whichever left pane goes', () => {
for (const closing of ['top', 'bottom']) {
const after = closeTab(topBottomLeftPlusRight(), closing)!
expect(after.kind).toBe('split')
expect((after as SplitNode).dir).toBe('row')
expect((after as SplitNode).children).toHaveLength(2)
// Every child is a plain group now: the column collapsed rather than
// lingering with a single child.
expect((after as SplitNode).children.every(isGroupNode)).toBe(true)
}
})
it('shares the space out when one of three siblings closes', () => {
const tree = split('row', [
group('g0', ['a']),
group('g1', ['b']),
group('g2', ['c']),
])
const after = closeTab(tree, 'b')
const rects = layoutRects(after, BOX)
expect((after as SplitNode).children).toHaveLength(2)
expect(rects.g0!.width).toBeCloseTo(BOX.w / 2, 6)
expect(rects.g2!.width).toBeCloseTo(BOX.w / 2, 6)
// No gap where the middle pane used to be.
expect(rects.g0!.left + rects.g0!.width).toBeCloseTo(rects.g2!.left, 6)
expectWellFormed(after)
})
it('keeps a resized split proportional after a sibling closes', () => {
// A user who dragged the gutter should not have the survivor land somewhere
// arbitrary.
const tree = split(
'row',
[
split(
'col',
[group('g0', ['top']), group('g1', ['bottom'])],
[0.8, 0.2],
),
group('g2', ['right']),
],
[0.3, 0.7],
)
const after = closeTab(tree, 'bottom')
const rects = layoutRects(after, BOX)
expect(rects.g0!.height).toBe(BOX.h)
expect(rects.g0!.width).toBeCloseTo(BOX.w * 0.3, 6)
expect(rects.g2!.width).toBeCloseTo(BOX.w * 0.7, 6)
expectWellFormed(after)
})
it('does not collapse a group that still has another tab', () => {
// Closing one of two stacked tabs leaves the group in place, at its own size.
const tree = split('row', [
split('col', [group('g0', ['top']), group('g1', ['b1', 'b2'])]),
group('g2', ['right']),
])
const after = closeTab(tree, 'b2')
const rects = layoutRects(after, BOX)
expect(rects.g0!.height).toBeCloseTo(BOX.h / 2, 6)
expect(rects.g1!.height).toBeCloseTo(BOX.h / 2, 6)
expectWellFormed(after)
})
})
describe('appendPane', () => {
it('seeds from nothing', () => {
expect(appendPane(null, 'a')).toEqual(group('g0', ['a'], 0))
})
it('gives every pane an equal share as they are added', () => {
let tree = appendPane(null, 'a')
tree = appendPane(tree, 'b')
expect((tree as SplitNode).sizes).toEqual([0.5, 0.5])
tree = appendPane(tree, 'c')
// The reason appendPane exists: splitting the last pane instead would give
// 1/2, 1/4, 1/4, so panes opened side by side would not match.
const sizes = (tree as SplitNode).sizes
expect(sizes).toHaveLength(3)
for (const size of sizes) expect(size).toBeCloseTo(1 / 3, 10)
expect(flattenTabs(tree)).toEqual(['a', 'b', 'c'])
expectWellFormed(tree)
})
it('extends the existing row rather than nesting', () => {
const tree = appendPane(appendPane(appendPane(null, 'a'), 'b'), 'c')
expect((tree as SplitNode).children.every(isGroupNode)).toBe(true)
})
it('never duplicates a pane that is already open', () => {
const tree = appendPane(appendPane(null, 'a'), 'b')
const again = appendPane(tree, 'a')
expect(flattenTabs(again).filter((id) => id === 'a')).toHaveLength(1)
expectWellFormed(again)
})
it('can stack a column instead', () => {
const tree = appendPane(appendPane(null, 'a'), 'b', 'col')
expect((tree as SplitNode).dir).toBe('col')
})
})
describe('splitterHandles', () => {
it('finds none in a lone group', () => {
expect(splitterHandles(group('g0', ['a']), BOX, 6)).toEqual([])
expect(splitterHandles(null, BOX, 6)).toEqual([])
})
it('places one gutter between two panes, filling the gap', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
const [handle] = splitterHandles(tree, BOX, 6)
const rects = layoutRects(tree, BOX, 6)
expect(handle!.dir).toBe('row')
expect(handle!.gutterIndex).toBe(0)
expect(handle!.path).toEqual([])
// It sits exactly in the space layoutRects left between the two panes.
expect(handle!.rect.left).toBe(rects.g0!.left + rects.g0!.width)
expect(handle!.rect.width).toBe(6)
expect(handle!.rect.left + handle!.rect.width).toBe(rects.g1!.left)
expect(handle!.extent).toBe(BOX.w - 6)
})
it('spans the cross axis of a column split', () => {
const tree = split('col', [group('g0', ['a']), group('g1', ['b'])])
const [handle] = splitterHandles(tree, BOX, 6)
expect(handle!.dir).toBe('col')
expect(handle!.rect.width).toBe(BOX.w)
expect(handle!.rect.height).toBe(6)
expect(handle!.extent).toBe(BOX.h - 6)
})
it('gives n-1 gutters and paths into nested splits', () => {
const tree = split('row', [
group('g0', ['a']),
group('g1', ['b']),
split('col', [group('g2', ['c']), group('g3', ['d'])]),
])
const handles = splitterHandles(tree, BOX, 6)
// Two in the outer row, one in the nested column.
expect(handles).toHaveLength(3)
expect(handles.filter((h) => h.dir === 'row')).toHaveLength(2)
const nested = handles.find((h) => h.dir === 'col')!
expect(nested.path).toEqual([2])
expect(nested.gutterIndex).toBe(0)
})
it('keeps gutters inside the box', () => {
const tree = split('row', [
group('g0', ['a']),
group('g1', ['b']),
group('g2', ['c']),
])
for (const handle of splitterHandles(tree, BOX, 6)) {
expect(handle.rect.left).toBeGreaterThanOrEqual(0)
expect(handle.rect.left + handle.rect.width).toBeLessThanOrEqual(BOX.w)
}
})
})
describe('canSplit', () => {
const min = { w: 280, h: 160 }
it('allows a horizontal split with room', () => {
expect(canSplit(group('g0', ['a']), 'g0', 'right', min, BOX)).toBe(true)
})
it('refuses a vertical split in a short workspace', () => {
// 400 tall halves to 200, which clears 160; nesting again would not.
const tree = split('col', [group('g0', ['a']), group('g1', ['b'])])
expect(canSplit(tree, 'g0', 'bottom', min, BOX)).toBe(false)
})
it('refuses a horizontal split once panes are narrow', () => {
const tree = split('row', [
group('g0', ['a']),
group('g1', ['b']),
group('g2', ['c']),
])
// Each pane is ~333 wide, so halving gives ~166, under the 280 minimum.
expect(canSplit(tree, 'g0', 'right', min, BOX)).toBe(false)
})
it('always allows center, and refuses an unknown group', () => {
expect(canSplit(group('g0', ['a']), 'g0', 'center', min, BOX)).toBe(true)
expect(canSplit(group('g0', ['a']), 'gX', 'right', min, BOX)).toBe(false)
})
})
describe('selecting a stacked tab', () => {
it('only changes which tab is active, never the structure', () => {
// Two panes merged into one group, then each tab selected in turn. The tree
// must stay a single group: selecting is not a rearrangement.
const merged = stackInto(
split('row', [group('g0', ['a']), group('g1', ['b'])]),
'g0',
'b',
)
expect(merged).toEqual(group('g0', ['a', 'b'], 1))
const backToA = activateTab(merged, 'a')
expect(backToA).toEqual(group('g0', ['a', 'b'], 0))
expect(allGroups(backToA)).toHaveLength(1)
const backToB = activateTab(backToA, 'b')
expect(backToB).toEqual(group('g0', ['a', 'b'], 1))
expect(allGroups(backToB)).toHaveLength(1)
expectWellFormed(backToB)
})
})
describe('zoneAt', () => {
const rect = { left: 0, top: 0, width: 400, height: 200 }
it('picks the nearest edge inside the band', () => {
expect(zoneAt({ x: 10, y: 100 }, rect)).toBe('left')
expect(zoneAt({ x: 390, y: 100 }, rect)).toBe('right')
expect(zoneAt({ x: 200, y: 5 }, rect)).toBe('top')
expect(zoneAt({ x: 200, y: 195 }, rect)).toBe('bottom')
})
it('picks center away from the edges', () => {
expect(zoneAt({ x: 200, y: 100 }, rect)).toBe('center')
})
it('respects the rect offset', () => {
const offset = { left: 500, top: 200, width: 400, height: 200 }
expect(zoneAt({ x: 510, y: 300 }, offset)).toBe('left')
expect(zoneAt({ x: 700, y: 300 }, offset)).toBe('center')
})
it('does not divide by zero on a collapsed rect', () => {
expect(
zoneAt({ x: 0, y: 0 }, { left: 0, top: 0, width: 0, height: 0 }),
).toBe('center')
})
})
describe('insertionIndex', () => {
const tabs = [
{ left: 0, top: 0, width: 100, height: 30 },
{ left: 100, top: 0, width: 100, height: 30 },
{ left: 200, top: 0, width: 100, height: 30 },
]
it('inserts before a tab in its first half', () => {
expect(insertionIndex(10, tabs)).toBe(0)
expect(insertionIndex(120, tabs)).toBe(1)
})
it('inserts after a tab in its second half', () => {
expect(insertionIndex(80, tabs)).toBe(1)
expect(insertionIndex(180, tabs)).toBe(2)
})
it('appends past the end and handles an empty bar', () => {
expect(insertionIndex(999, tabs)).toBe(3)
expect(insertionIndex(0, [])).toBe(0)
})
})
describe('repairLayout', () => {
const known = new Set(['a', 'b', 'c'])
it('passes a valid tree through', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['b'])])
expect(repairLayout(tree, known)).toEqual(tree)
})
it('prunes unknown plugin ids and collapses what empties', () => {
const tree = split('row', [group('g0', ['gone']), group('g1', ['b'])])
const repaired = repairLayout(tree, known)
expect(repaired).toEqual(group('g1', ['b'], 0))
expectWellFormed(repaired)
})
it('drops a duplicated plugin id, keeping the first', () => {
const tree = split('row', [group('g0', ['a']), group('g1', ['a', 'b'])])
const repaired = repairLayout(tree, known)
expect(flattenTabs(repaired)).toEqual(['a', 'b'])
expectWellFormed(repaired)
})
it('renormalises sizes that do not sum to 1', () => {
const tree = split(
'row',
[group('g0', ['a']), group('g1', ['b'])],
[10, 30],
)
const repaired = repairLayout(tree, known) as SplitNode
expect(repaired.sizes[0]).toBeCloseTo(0.25, 10)
expect(repaired.sizes[1]).toBeCloseTo(0.75, 10)
expectWellFormed(repaired)
})
it('replaces missing or non-numeric sizes with equal shares', () => {
for (const sizes of [undefined, 'nope', [1], [0, 0]]) {
const repaired = repairLayout(
{
kind: 'split',
dir: 'row',
sizes,
children: [group('g0', ['a']), group('g1', ['b'])],
},
known,
) as SplitNode
expect(repaired.sizes).toHaveLength(2)
expect(repaired.sizes[0]).toBeCloseTo(0.5, 10)
expectWellFormed(repaired)
}
})
it('clamps an out-of-range active index', () => {
expect(repairLayout(group('g0', ['a', 'b'], 99), known)).toEqual(
group('g0', ['a', 'b'], 1),
)
expect(repairLayout(group('g0', ['a', 'b'], -3), known)).toEqual(
group('g0', ['a', 'b'], 0),
)
})
it('gives duplicated group ids a fresh one', () => {
const tree = split('row', [group('dup', ['a']), group('dup', ['b'])])
const repaired = repairLayout(tree, known)
const ids = allGroups(repaired).map((entry) => entry.id)
expect(new Set(ids).size).toBe(2)
expectWellFormed(repaired)
})
it('salvages plugin ids from an unrecognisable shape', () => {
// The arrangement is lost, but the plugins that were open stay open.
const repaired = repairLayout(
{ totally: 'wrong', but: ['a', { nested: 'c' }, 'unknown'] },
known,
)
expect(flattenTabs(repaired)).toEqual(['a', 'c'])
expectWellFormed(repaired)
})
it('never throws on hostile input', () => {
const cycle: Record<string, unknown> = { kind: 'split', dir: 'row' }
cycle.children = [cycle]
for (const raw of [
null,
undefined,
0,
'string',
[],
{},
{ kind: 'group' },
{ kind: 'group', id: 'g0', tabs: 'not-an-array' },
{ kind: 'split', dir: 'sideways', children: [] },
{ kind: 'split', dir: 'row', children: [null, 3, 'x'] },
cycle,
]) {
expect(() => repairLayout(raw, known)).not.toThrow()
expectWellFormed(repairLayout(raw, known))
}
})
it('returns null when nothing is salvageable', () => {
expect(
repairLayout({ kind: 'group', id: 'g0', tabs: ['zzz'] }, known),
).toBeNull()
expect(repairLayout(null, known)).toBeNull()
})
})
describe('migration from activePlugins', () => {
it('becomes one group in the stored order', () => {
expect(singleGroup(['a', 'b', 'c'])).toEqual(
group('g0', ['a', 'b', 'c'], 0),
)
})
it('is null for no open plugins', () => {
expect(singleGroup([])).toBeNull()
})
it('round-trips through flattenTabs, which is what activePlugins becomes', () => {
const stored = ['query', 'router', 'a11y']
expect(flattenTabs(singleGroup(stored))).toEqual(stored)
})
})
describe('sequences of operations keep the tree well formed', () => {
it('survives building up to nine panes and tearing them down', () => {
const ids = Array.from({ length: 9 }, (_, index) => `p${index}`)
let tree = singleGroup([ids[0]!])
expectWellFormed(tree)
// Alternate splitting right and bottom, and stack a couple, so the tree
// ends up genuinely mixed rather than a single row.
const zones = [
'right',
'bottom',
'center',
'right',
'bottom',
'right',
'center',
'bottom',
] as const
ids.slice(1).forEach((id, index) => {
const target = allGroups(tree)[index % allGroups(tree).length]!
const zone = zones[index]!
tree =
zone === 'center'
? stackInto(tree, target.id, id)
: splitAt(tree, target.id, zone, id)
expectWellFormed(tree)
})
expect(flattenTabs(tree).sort()).toEqual([...ids].sort())
// Rects must tile the box without overlapping, at every stage.
const rects = Object.values(layoutRects(tree, BOX))
const area = rects.reduce((sum, r) => sum + r.width * r.height, 0)
expect(area).toBeCloseTo(BOX.w * BOX.h, 4)
for (const id of ids) {
tree = closeTab(tree, id)
expectWellFormed(tree)
}
expect(tree).toBeNull()
})
it('survives repeated moves between every pair of groups', () => {
let tree: LayoutNode | null = split('row', [
group('g0', ['a', 'b']),
split('col', [group('g1', ['c']), group('g2', ['d'])]),
])
for (const tabId of ['a', 'b', 'c', 'd']) {
for (const target of allGroups(tree)) {
const moved = moveTab(tree, tabId, target.id, 0)
expectWellFormed(moved)
expect(flattenTabs(moved).sort()).toEqual(['a', 'b', 'c', 'd'])
tree = moved
}
}
})
})