Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 1 addition & 213 deletions ui/src/lib/__tests__/dagLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,132 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
GAP_X,
NODE_W,
compareIds,
edgePath,
edgeWidth,
fitTransform,
graphSetHash,
layoutGraph,
neighborsOf,
walkFrom,
zoomAt,
type GraphEdgeRef,
} from '../dagLayout'

const E = (source: string, target: string): GraphEdgeRef => ({ source, target })

describe('layoutGraph — layering', () => {
it('lays a chain into successive layers', () => {
const layout = layoutGraph(['a', 'b', 'c'], [E('a', 'b'), E('b', 'c')])
expect(layout.layers).toEqual([['a'], ['b'], ['c']])
})

it('uses longest-path layering (skip edge does not pull the node left)', () => {
const layout = layoutGraph(
['a', 'b', 'c'],
[E('a', 'b'), E('b', 'c'), E('a', 'c')],
)
expect(layout.nodes.get('c')?.layer).toBe(2)
})

it('places diamond join below both branches', () => {
const layout = layoutGraph(
['a', 'b', 'c', 'd'],
[E('a', 'b'), E('a', 'c'), E('b', 'd'), E('c', 'd')],
)
expect(layout.nodes.get('d')?.layer).toBe(2)
expect(layout.layers[1]).toHaveLength(2)
})

it('terminates on cycles and keeps every node', () => {
const layout = layoutGraph(['a', 'b'], [E('a', 'b'), E('b', 'a')])
expect(layout.nodes.size).toBe(2)
expect(layout.nodes.get('a')?.layer).toBe(0)
expect(layout.nodes.get('b')?.layer).toBe(1)
})

it('handles a fully cyclic graph (no natural roots)', () => {
const layout = layoutGraph(
['x', 'y', 'z'],
[E('x', 'y'), E('y', 'z'), E('z', 'x')],
)
expect(layout.nodes.size).toBe(3)
expect(layout.layers.flat().sort((a, b) => a.localeCompare(b))).toEqual(['x', 'y', 'z'])
})

it('drops self-loops and edges to unknown nodes', () => {
const layout = layoutGraph(['a', 'b'], [E('a', 'a'), E('a', 'ghost'), E('a', 'b')])
expect(layout.nodes.get('b')?.layer).toBe(1)
})

it('places disconnected nodes in layer 0', () => {
const layout = layoutGraph(['a', 'b', 'lonely'], [E('a', 'b')])
expect(layout.nodes.get('lonely')?.layer).toBe(0)
})

it('handles the empty graph', () => {
const layout = layoutGraph([], [])
expect(layout.layers).toEqual([])
expect(layout.width).toBe(0)
expect(layout.height).toBe(0)
})
})

describe('layoutGraph — barycenter ordering', () => {
it('orders a layer by mean predecessor position (one pass)', () => {
// layer0 = [a, b] (alphabetical); y's caller is a (row 0), x's is b (row 1)
// → barycenter pass must flip the alphabetical [x, y] into [y, x].
const layout = layoutGraph(
['a', 'b', 'x', 'y'],
[E('a', 'y'), E('b', 'x')],
)
expect(layout.layers[1]).toEqual(['y', 'x'])
})

it('is deterministic regardless of input order', () => {
const nodes = ['gw', 'auth', 'pay', 'db', 'cache']
const edges = [E('gw', 'auth'), E('gw', 'pay'), E('pay', 'db'), E('auth', 'cache'), E('pay', 'cache')]
const a = layoutGraph(nodes, edges)
const b = layoutGraph([...nodes].reverse(), [...edges].reverse())
expect(a.layers).toEqual(b.layers)
expect([...a.nodes.entries()]).toEqual([...b.nodes.entries()])
})
})

describe('layoutGraph — positions', () => {
it('spaces layers along x and centers short layers vertically', () => {
const layout = layoutGraph(
['a', 'b', 'c', 'd'],
[E('a', 'b'), E('a', 'c'), E('b', 'd'), E('c', 'd')],
)
const a = layout.nodes.get('a')!
const b = layout.nodes.get('b')!
const d = layout.nodes.get('d')!
expect(b.x - a.x).toBe(NODE_W + GAP_X)
expect(d.x - a.x).toBe(2 * (NODE_W + GAP_X))
// single-node layers (a, d) sit at the vertical center of the 2-row layer
expect(a.y).toBeGreaterThan(0)
expect(a.y).toBe(d.y)
expect(layout.width).toBeGreaterThan(0)
expect(layout.height).toBeGreaterThan(0)
})
})

describe('graphSetHash', () => {
it('is order-insensitive over nodes and edges', () => {
const h1 = graphSetHash(['a', 'b'], [E('a', 'b')])
const h2 = graphSetHash(['b', 'a'], [E('a', 'b')])
expect(h1).toBe(h2)
})

it('changes when the node or edge set changes', () => {
const base = graphSetHash(['a', 'b'], [E('a', 'b')])
expect(graphSetHash(['a', 'b', 'c'], [E('a', 'b')])).not.toBe(base)
expect(graphSetHash(['a', 'b'], [])).not.toBe(base)
})
})

describe('edgeWidth', () => {
it('grows with log of call count and clamps to [1, 5]', () => {
expect(edgeWidth(0)).toBe(1)
Expand All @@ -135,100 +16,7 @@ describe('edgeWidth', () => {
})
})

describe('fitTransform / zoomAt', () => {
it('fits content into the viewport with padding', () => {
const t = fitTransform({ width: 1000, height: 500 }, { width: 520, height: 270 }, 10)
expect(t.k).toBeCloseTo(0.5, 5)
// content centered: x offset = (520 - 1000*0.5)/2 = 10
expect(t.x).toBeCloseTo(10, 5)
expect(t.y).toBeCloseTo(10, 5)
})

it('never scales small graphs above 1', () => {
const t = fitTransform({ width: 100, height: 50 }, { width: 1000, height: 800 })
expect(t.k).toBe(1)
})

it('handles empty content without dividing by zero', () => {
const t = fitTransform({ width: 0, height: 0 }, { width: 800, height: 600 })
expect(t.k).toBe(1)
expect(Number.isFinite(t.x)).toBe(true)
})

it('floors the scale at minScale (small field on a narrow viewport)', () => {
// Contain-fit would shrink to ~0.18 on a phone-width viewport; the floor
// keeps it legible and the content then overflows, staying centered.
const t = fitTransform({ width: 1100, height: 1100 }, { width: 360, height: 640 }, 24, 0.5)
expect(t.k).toBe(0.5)
// Centered overflow: the field spills symmetrically (negative offset).
expect(t.x).toBeCloseTo((360 - 1100 * 0.5) / 2, 5)
})

it('minScale never upscales past the contain fit when content already fits', () => {
const t = fitTransform({ width: 100, height: 100 }, { width: 1000, height: 800 }, 24, 0.5)
expect(t.k).toBe(1)
})

it('zoomAt keeps the anchor point stationary', () => {
const t = { x: 20, y: 30, k: 1 }
const t2 = zoomAt(t, 100, 100, 1.5)
// world point under (100,100) before: (80, 70); after must map back to (100,100)
expect(80 * t2.k + t2.x).toBeCloseTo(100, 5)
expect(70 * t2.k + t2.y).toBeCloseTo(100, 5)
})

it('zoomAt clamps the scale range', () => {
const t = { x: 0, y: 0, k: 2.4 }
expect(zoomAt(t, 0, 0, 10).k).toBeLessThanOrEqual(2.5)
expect(zoomAt({ ...t, k: 0.25 }, 0, 0, 0.01).k).toBeGreaterThanOrEqual(0.2)
})
})

describe('walkFrom', () => {
const nodes = ['gw', 'auth', 'pay', 'db']
const edges = [E('gw', 'auth'), E('gw', 'pay'), E('pay', 'db')]
const layout = layoutGraph(nodes, edges)

it('starts at the first node of the first layer when nothing is focused', () => {
expect(walkFrom(layout, edges, null, 'right')).toBe('gw')
})

it('walks right to a callee and left back to the caller', () => {
expect(walkFrom(layout, edges, 'pay', 'right')).toBe('db')
expect(walkFrom(layout, edges, 'pay', 'left')).toBe('gw')
})

it('walks up/down between layer siblings', () => {
const first = layout.layers[1][0]
const second = layout.layers[1][1]
expect(walkFrom(layout, edges, first, 'down')).toBe(second)
expect(walkFrom(layout, edges, second, 'up')).toBe(first)
})

it('returns null at boundaries', () => {
expect(walkFrom(layout, edges, 'gw', 'left')).toBeNull()
expect(walkFrom(layout, edges, 'db', 'right')).toBeNull()
expect(walkFrom(layout, edges, 'gw', 'up')).toBeNull()
})

it('returns null for an unknown focus id', () => {
expect(walkFrom(layout, edges, 'ghost', 'right')).toBeNull()
})

it('prefers the callee nearest in y when several exist', () => {
// gw has two callees: walkFrom picks the one closest to gw's own y.
const target = walkFrom(layout, edges, 'gw', 'right')
expect(['auth', 'pay']).toContain(target)
})
})

describe('edgePath / neighborsOf', () => {
it('emits a cubic bezier from source right edge to target left edge', () => {
const layout = layoutGraph(['a', 'b'], [E('a', 'b')])
const path = edgePath(layout.nodes.get('a')!, layout.nodes.get('b')!)
expect(path).toMatch(/^M [\d.-]+ [\d.-]+ C /)
})

describe('neighborsOf', () => {
it('collects 1-hop neighbors in both directions', () => {
const edges = [E('a', 'b'), E('c', 'a'), E('b', 'd')]
expect(neighborsOf(edges, 'a')).toEqual(new Set(['b', 'c']))
Expand Down
101 changes: 1 addition & 100 deletions ui/src/lib/__tests__/radialLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { layoutRadial, walkRadial } from '../radialLayout'
import { layoutRadial } from '../radialLayout'
import { NODE_H, NODE_W, type GraphEdgeRef, type Layout } from '../dagLayout'

const E = (source: string, target: string): GraphEdgeRef => ({ source, target })
Expand Down Expand Up @@ -192,102 +192,3 @@ describe('layoutRadial — determinism', () => {
expect([...a.nodes.entries()]).toEqual([...b.nodes.entries()])
})
})

describe('walkRadial — null focus', () => {
const layout = layoutRadial(
['core', 'a', 'b', 'c', 'd'],
[E('a', 'core'), E('b', 'core'), E('c', 'core')],
)

it("null focus + 'right' returns the most-critical node (ring 0, first)", () => {
const target = walkRadial(layout, null, 'right')
expect(target).toBe(layout.layers[0][0])
expect(layout.nodes.get(target!)!.layer).toBe(0)
})

// Cold-start bootstrap: a null focus seeds the most-critical entry node for
// ANY arrow key (not just 'right'), so Tabbing into the field and pressing
// the intuitive ArrowDown is never keyboard-inert. The seed is deterministic.
it('null focus seeds the SAME ring-0 entry node in every direction', () => {
const seed = layout.layers[0][0]
for (const dir of ['left', 'right', 'up', 'down'] as const) {
const target = walkRadial(layout, null, dir)
expect(target).toBe(seed)
expect(layout.nodes.get(target!)!.layer).toBe(0)
}
})
})

describe('walkRadial — unknown focus', () => {
const layout = layoutRadial(['a', 'b'], [E('a', 'b')])
it('returns null for an unknown focus id in every direction', () => {
for (const dir of ['left', 'right', 'up', 'down'] as const) {
expect(walkRadial(layout, 'ghost', dir)).toBeNull()
}
})
})

describe('walkRadial — angular movement (left/right)', () => {
// Several singletons land on the same outermost ring, spread by angle.
const ids = ['a', 'b', 'c', 'd', 'e', 'f']
const layout = layoutRadial(ids, [])

it('right then left returns to the start (inverse on the same ring)', () => {
// pick a node that is not at a ring boundary
const ring = layout.layers.find((r) => r.length >= 3)!
const start = ring[1]
const right = walkRadial(layout, start, 'right')
expect(right).toBe(ring[2])
const back = walkRadial(layout, right, 'left')
expect(back).toBe(start)
})

it('returns null at the angular ring boundaries (no wraparound)', () => {
const ring = layout.layers.find((r) => r.length >= 2)!
expect(walkRadial(layout, ring[0], 'left')).toBeNull()
expect(walkRadial(layout, ring[ring.length - 1], 'right')).toBeNull()
})

it('returns null moving angularly on a single-node ring', () => {
const callers = Array.from({ length: 10 }, (_, i) => `c${i}`)
const l = layoutRadial(['solo', ...callers], callers.map((c) => E(c, 'solo')))
// solo is alone on ring 0
expect(l.layers[0]).toEqual(['solo'])
expect(walkRadial(l, 'solo', 'left')).toBeNull()
expect(walkRadial(l, 'solo', 'right')).toBeNull()
})
})

describe('walkRadial — ring movement (up/down)', () => {
// hub on inner ring, callers on outer ring.
const callers = Array.from({ length: 8 }, (_, i) => `c${i}`)
const layout = layoutRadial(['hub', ...callers], callers.map((c) => E(c, 'hub')))

it('down moves outward to a higher ring index', () => {
const target = walkRadial(layout, 'hub', 'down')
expect(target).not.toBeNull()
expect(layout.nodes.get(target!)!.layer).toBeGreaterThan(layout.nodes.get('hub')!.layer)
})

it('up from the outermost-ring node reaches a lower ring index', () => {
const outer = callers.find((c) => layout.nodes.get(c)!.layer === layout.layers.length - 1)!
const target = walkRadial(layout, outer, 'up')
expect(target).not.toBeNull()
expect(layout.nodes.get(target!)!.layer).toBeLessThan(layout.nodes.get(outer)!.layer)
})

it('up from ring 0 returns null (innermost boundary)', () => {
expect(walkRadial(layout, 'hub', 'up')).toBeNull()
})

it('down from the outermost ring returns null', () => {
const outer = callers.find((c) => layout.nodes.get(c)!.layer === layout.layers.length - 1)!
expect(walkRadial(layout, outer, 'down')).toBeNull()
})

it('ring movement is deterministic (picks nearest by angle, compareIds tiebreak)', () => {
const a = walkRadial(layout, 'hub', 'down')
const b = walkRadial(layout, 'hub', 'down')
expect(a).toBe(b)
})
})
Loading