Review feedback from the live 3384 install, fixed at the root: - COLORS: the new stylesheet used var(--dsw-alias-accent) in 17 places — a token the DSH theme does NOT define, so every declaration was dropped (older files only survived because they carry fallbacks); container edges used border-l1 (4% black, invisible). Accent now rides --dsw-alias-state-business-primary, container edges --dsw-alias-border-l4. - CLICKS: the canvas called setPointerCapture on pointerdown, which retargets the derived click to the container and killed every node (and ⓘ) interaction; jsdom cannot see this, a real browser can. Capture is gone and panning only starts from the background. - LAYOUT: fit gave up silently at zero container size and never retried, centered only horizontally, and capped at 100% while a five-child level scaled to 35%. Now: ResizeObserver + first non-zero fit, both axes centered, a 0.78 readability floor, and layoutTasksGraphForWidth picks the flattest sibling arrangement that still meets that floor. - CARDS: title (one line) + mono meta + live line only; display title, team role, full model id and latest text moved into the ⓘ popover. - PANEL: metrics follow the approved 360x660 mockup (132x46 cards, 112 row stride), explicit line-heights, user-select:none, horizontal control cluster with a labelled mode button, always-visible team board strip. - Tokens for two new popover headings across all 20 dictionaries. Tests: 3 new page-interaction specs (click after background pointerdown, node gestures never pan, team board visible without a click) and a rewritten 9-case layout suite (band wrap, row reservation, width solver, reserved live row). Full suite 1400 passed / 133 files.
125 lines
5.3 KiB
TypeScript
125 lines
5.3 KiB
TypeScript
/**
|
|
* Unit tests for the layered graph layout: parent centering, depth rows, the
|
|
* sibling-band WRAP that keeps a narrow sidebar readable (and the row
|
|
* reservation that stops a wrapped band from landing on a sibling's
|
|
* children), the live-line height, and the width-aware solver.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
GRAPH_GAP_Y,
|
|
GRAPH_LIVE_H,
|
|
GRAPH_NODE_H,
|
|
GRAPH_NODE_W,
|
|
GRAPH_PAD,
|
|
GRAPH_ROW_STRIDE,
|
|
bandColsFor,
|
|
layoutTasksGraph,
|
|
layoutTasksGraphForWidth,
|
|
} from '../src/client/tasks-graph-layout.ts'
|
|
import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts'
|
|
|
|
/** An agent node. */
|
|
function agent(id: string, parentId?: string, live = false): TasksAgentNode {
|
|
return {
|
|
kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }),
|
|
label: id, state: 'running', activity: 'running', current: false,
|
|
...(live ? { live: { text: 'x' } } : {}),
|
|
}
|
|
}
|
|
|
|
/** The row index of one laid-out node. */
|
|
function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): number {
|
|
const box = layout.boxes.get(id)
|
|
if (box === undefined) throw new Error(`no box for ${id}`)
|
|
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
|
|
}
|
|
|
|
describe('layoutTasksGraph', () => {
|
|
it('places the root on row 0 and children one row below', () => {
|
|
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
|
|
const layout = layoutTasksGraph(nodes)
|
|
// A running agent reserves the live-line row (its "thinking" fallback).
|
|
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H + GRAPH_LIVE_H })
|
|
expect(layout.boxes.get('a')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
|
|
expect(layout.boxes.get('b')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
|
|
})
|
|
|
|
it('centers a parent over its children band', () => {
|
|
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
|
|
const layout = layoutTasksGraph(nodes)
|
|
const a = layout.boxes.get('a')
|
|
const b = layout.boxes.get('b')
|
|
const root = layout.boxes.get('root')
|
|
expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined()
|
|
const bandCenter = (a!.x + b!.x + GRAPH_NODE_W) / 2
|
|
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
|
|
})
|
|
|
|
it('grows live rows and widens the canvas for deep/wide forests', () => {
|
|
const nodes: TasksNode[] = [
|
|
agent('root'), agent('a', 'root', true),
|
|
agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'),
|
|
]
|
|
const layout = layoutTasksGraph(nodes)
|
|
expect(layout.boxes.get('a')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H)
|
|
expect(layout.width).toBeGreaterThan(3 * GRAPH_NODE_W)
|
|
expect(layout.height).toBeGreaterThan(2 * (GRAPH_NODE_H + GRAPH_GAP_Y))
|
|
})
|
|
|
|
it('gives every node a box even with a defensive orphan', () => {
|
|
const nodes: TasksNode[] = [agent('root'), agent('ghost', 'missing')]
|
|
const layout = layoutTasksGraph(nodes)
|
|
expect(layout.boxes.get('root')).toBeDefined()
|
|
expect(layout.boxes.get('ghost')).toBeDefined()
|
|
})
|
|
|
|
it('wraps a wide sibling band into rows of maxBandCols', () => {
|
|
const nodes: TasksNode[] = [agent('root')]
|
|
for (let index = 0; index < 5; index += 1) nodes.push(agent(`c${index}`, 'root'))
|
|
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
|
|
// Three wrapped rows of 2/2/1, never five on one line.
|
|
expect([layoutRow(layout, 'c0'), layoutRow(layout, 'c1')]).toEqual([1, 1])
|
|
expect([layoutRow(layout, 'c2'), layoutRow(layout, 'c3')]).toEqual([2, 2])
|
|
expect(layoutRow(layout, 'c4')).toBe(3)
|
|
// The canvas stays as narrow as one wrapped band.
|
|
expect(layout.width).toBeLessThanOrEqual(2 * GRAPH_NODE_W + 28 + 2 * GRAPH_PAD)
|
|
})
|
|
|
|
it('never lets a wrapped band land on a sibling subtree row', () => {
|
|
// root → [a (with a child), b, c, d] wrapped at 2 columns: the second
|
|
// band must start BELOW a's child, not on the same row as it.
|
|
const nodes: TasksNode[] = [
|
|
agent('root'), agent('a', 'root'), agent('b', 'root'),
|
|
agent('c', 'root'), agent('d', 'root'), agent('a1', 'a'),
|
|
]
|
|
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
|
|
expect(layoutRow(layout, 'c')).toBeGreaterThan(layoutRow(layout, 'a1'))
|
|
expect(layoutRow(layout, 'd')).toBe(layoutRow(layout, 'c'))
|
|
})
|
|
})
|
|
|
|
describe('bandColsFor / layoutTasksGraphForWidth', () => {
|
|
it('derives the column count from the container width', () => {
|
|
expect(bandColsFor(360)).toBe(2)
|
|
expect(bandColsFor(720)).toBe(4)
|
|
expect(bandColsFor(120)).toBe(1)
|
|
})
|
|
|
|
it('prefers a flat band when the container is wide', () => {
|
|
const nodes: TasksNode[] = [agent('root')]
|
|
for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
|
|
const wide = layoutTasksGraphForWidth(nodes, 720)
|
|
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
|
|
})
|
|
|
|
it('wraps instead of overflowing wildly when the container is narrow', () => {
|
|
const nodes: TasksNode[] = [agent('root')]
|
|
for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
|
|
const narrow = layoutTasksGraphForWidth(nodes, 360)
|
|
// Never wider than the tolerant budget (a small pan is allowed, a 3x
|
|
// overflow is not), and never a one-child-per-row ladder.
|
|
expect(narrow.width).toBeLessThanOrEqual(((360 - 24) / 0.78) * 1.2)
|
|
expect(layoutRow(narrow, 'c1')).toBe(layoutRow(narrow, 'c0'))
|
|
})
|
|
})
|