/** * Groups and comments going along when nodes are arranged (#429), new nodes joining the group of * what they link to, room made for nodes put between, and comments kept clear of other nodes. */ import { describe, expect, it } from 'vitest'; import { freeNoteBox, groupBoxAround, noteBoxAbove } from '../../src/editor/graph/annotationDrag'; import { adoptingGroups, followedBoxes } from '../../src/editor/graph/annotationFollow'; import { arrange, COLUMN_GAP, type LayoutBox, type LayoutEdge, type LayoutGraph, type LayoutNode, lintLayout, } from '../../src/editor/graph/arrange'; function node(id: string, inputs: string[], outputs: string[], x = 0, y = 0): LayoutNode { const rows = (keys: string[]) => Object.fromEntries(keys.map((key, i) => [key, 30 + i * 20 + 10])); return { id, x, y, w: 176, h: 36 + Math.max(inputs.length, outputs.length) * 20, inputs: rows(inputs), outputs: rows(outputs), }; } let edgeCount = 0; function edge(from: string, fromPort: string, to: string, toPort: string): LayoutEdge { edgeCount += 1; return { id: `e${edgeCount}`, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, exec: fromPort === 'exec', }; } /** Arranges as the editor does: the nodes, then the groups and comments after them. */ function placed(graph: LayoutGraph, movable?: ReadonlySet, fresh = false): LayoutGraph { const joining = fresh && movable ? adoptingGroups(graph, movable) : []; const spots = arrange(graph, movable, { open: new Set(joining), fresh }); const boxes = followedBoxes(graph, spots, { groups: joining, nodes: [...(movable ?? [])] }); return { ...graph, nodes: graph.nodes.map((n) => ({ ...n, ...(spots.get(n.id) ?? {}) })), boxes: graph.boxes.map((b) => ({ ...b, ...boxes.get(b.id)! })), }; } const at = (graph: LayoutGraph, id: string) => graph.nodes.find((n) => n.id === id)!; const box = (graph: LayoutGraph, id: string) => graph.boxes.find((b) => b.id === id)!; const centredIn = (b: LayoutBox, n: LayoutNode) => n.x + n.w / 2 >= b.x && n.x + n.w / 2 <= b.x + b.w && n.y + n.h / 2 >= b.y && n.y + n.h / 2 <= b.y + b.h; /** The walking graph of #429 in its group, with the comment above it, as the user has it. */ function walking(): LayoutGraph { const nodes = [ node('update', [], ['exec', 'dt'], 0, 0), node('owner', [], ['object'], 0, 100), node('axis', [], ['x', 'y', 'active'], 0, 180), node('move', ['exec', 'object', 'dx', 'dy', 'scale', 'dt'], ['exec'], 248, 0), ]; const group = { id: 'group', kind: 'group' as const, ...groupBoxAround(nodes)! }; const note = { id: 'note', kind: 'note' as const, ...noteBoxAbove(nodes)! }; return { nodes, edges: [ edge('update', 'exec', 'move', 'exec'), edge('update', 'dt', 'move', 'dt'), edge('owner', 'object', 'move', 'object'), edge('axis', 'x', 'move', 'dx'), edge('axis', 'y', 'move', 'dy'), ], boxes: [group, note], }; } describe('new nodes and groups', () => { it('puts a new node feeding one in a group into that group, which grows round it', () => { const graph = walking(); graph.nodes.push(node('vars', [], ['speed'], 508, 0)); graph.edges.push(edge('vars', 'speed', 'move', 'scale')); expect(adoptingGroups(graph, new Set(['vars']))).toEqual(['group']); const result = placed(graph, new Set(['vars']), true); const group = box(result, 'group'); for (const n of result.nodes) expect(centredIn(group, n)).toBe(true); // It grew down for the new node only; the nodes there stay, and the comment stays above it. expect(group.y).toBe(box(graph, 'group').y); expect(group.h).toBeGreaterThan(box(graph, 'group').h); expect(box(result, 'note')).toEqual(box(graph, 'note')); expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0, coveredNotes: 0, crossings: 4 }); }); it('leaves a group as it is when growing round the new node would take in a node not its own', () => { const graph = walking(); graph.nodes.push(node('vars', [], ['speed'], 508, 0)); graph.edges.push(edge('vars', 'speed', 'move', 'scale')); // A stranger right under the group, where the new node would go. const group = box(graph, 'group'); graph.nodes.push(node('stranger', [], [], 0, group.y + group.h + 8)); const result = placed(graph, new Set(['vars']), true); expect(box(result, 'group')).toEqual(group); }); it('puts a new node taking from one in a group next to it, not where it waited far off', () => { const graph = walking(); graph.nodes.push(node('far', [], [], 2000, 400)); // Where the agent's new node waits: right of everything. graph.nodes.push(node('after', ['exec'], [], 2260, 0)); graph.edges.push(edge('move', 'exec', 'after', 'exec')); const result = placed(graph, new Set(['after']), true); const move = at(result, 'move'); expect(at(result, 'after').x).toBe(move.x + move.w + COLUMN_GAP); expect(centredIn(box(result, 'group'), at(result, 'after'))).toBe(true); expect(centredIn(box(result, 'group'), at(result, 'far'))).toBe(false); }); it('takes nodes in only while none of the new ones stands in a group already', () => { const graph = walking(); graph.nodes.push(node('vars', [], ['speed'], 40, 60)); graph.edges.push(edge('vars', 'speed', 'move', 'scale')); expect(adoptingGroups(graph, new Set(['vars']))).toEqual([]); }); }); describe('arranging with groups and comments', () => { it('fits a group round its nodes again after Arrange, with its comment going along', () => { const graph = walking(); // In one row, as the agent leaves new nodes, and grouped like that. graph.nodes.forEach((n, i) => Object.assign(n, { x: i * 260, y: 0 })); graph.boxes[0] = { id: 'group', kind: 'group', ...groupBoxAround(graph.nodes)! }; graph.boxes[1] = { id: 'note', kind: 'note', ...noteBoxAbove(graph.nodes)! }; const result = placed(graph); const group = box(result, 'group'); expect(group).toMatchObject(groupBoxAround(result.nodes)!); expect(group.w).toBeLessThan(box(graph, 'group').w / 2); const note = box(result, 'note'); // Just above the group, as far from it as before. expect(group.y - (note.y + note.h)).toBe( box(graph, 'group').y - (box(graph, 'note').y + box(graph, 'note').h), ); expect(lintLayout(result).coveredNotes).toBe(0); }); it('moves a group whose nodes all moved alike as it is, keeping its size', () => { const graph: LayoutGraph = { nodes: [node('a', [], ['exec'], 0, 0), node('b', ['exec'], [], 248, 0)], edges: [edge('a', 'exec', 'b', 'exec')], boxes: [{ id: 'roomy', kind: 'group', x: -100, y: -100, w: 700, h: 400 }], }; const spots = new Map([ ['a', { x: 50, y: 30 }], ['b', { x: 298, y: 30 }], ]); expect(followedBoxes(graph, spots).get('roomy')).toEqual({ x: -50, y: -70, w: 700, h: 400 }); }); }); describe('room for nodes put between', () => { /** A fresh instrument: Notes and Output close together, and the chain the agent puts between. */ function instrument(): LayoutGraph { return { nodes: [ node('notes', [], ['note'], 0, 0), node('output', ['audio'], [], 248, 0), node('beside', [], [], 400, 200), node('chip', ['note'], ['audio'], 600, 0), node('env', ['audio'], ['audio'], 860, 0), node('delay', ['audio'], ['audio'], 1120, 0), ], edges: [ edge('notes', 'note', 'chip', 'note'), edge('chip', 'audio', 'env', 'audio'), edge('env', 'audio', 'delay', 'audio'), edge('delay', 'audio', 'output', 'audio'), ], boxes: [{ id: 'about', kind: 'note', x: 248, y: -120, w: 220, h: 96 }], }; } it('pushes what they feed, and everything right of it, far enough right', () => { const graph = instrument(); const result = placed(graph, new Set(['chip', 'env', 'delay']), true); expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0, crossings: 0 }); const notes = at(result, 'notes'); const delay = at(result, 'delay'); expect(at(result, 'chip').x).toBe(notes.x + notes.w + COLUMN_GAP); expect(at(result, 'output').x).toBe(delay.x + delay.w + COLUMN_GAP); expect([notes.x, notes.y]).toEqual([0, 0]); // What stood right of the output moved by as much, and so did the comment on it. const by = at(result, 'output').x - 248; expect(at(result, 'beside')).toMatchObject({ x: 400 + by, y: 200 }); expect(box(result, 'about')).toMatchObject({ x: 248 + by, y: -120 }); }); it('pushes nothing where there is room between already', () => { const graph = instrument(); at(graph, 'output').x = 1500; const result = placed(graph, new Set(['chip', 'env', 'delay']), true); expect(at(result, 'output').x).toBe(1500); expect(at(result, 'beside').x).toBe(400); expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0 }); }); }); describe('freeNoteBox', () => { const targets = [{ id: 'a', x: 0, y: 200, w: 176, h: 76 }]; const above = noteBoxAbove(targets)!; const empty = { nodes: [], groups: [], notes: [] }; it('puts a comment above its nodes while that is free', () => { expect(freeNoteBox(targets, empty)).toEqual(above); }); it('puts it below them where a node stands above, and above again when below is taken too', () => { const over = { x: 0, y: above.y + 10, w: 176, h: 40 }; const below = freeNoteBox(targets, { ...empty, nodes: [over] })!; expect(below.y).toBe(200 + 76 + 40); const under = { x: 0, y: below.y + 10, w: 176, h: 40 }; expect(freeNoteBox(targets, { ...empty, nodes: [over, under] })).toEqual(above); }); it('may lie inside a group, but not over its title bar or across its edge', () => { const roomy = { x: -50, y: above.y - 50, w: 400, h: 500 }; expect(freeNoteBox(targets, { ...empty, groups: [roomy] })).toEqual(above); const titled = { ...roomy, y: above.y - 10 }; expect(freeNoteBox(targets, { ...empty, groups: [titled] })!.y).toBe(200 + 76 + 40); }); }); describe('lintLayout', () => { it('counts a comment a node lies on', () => { const graph: LayoutGraph = { nodes: [node('a', [], [], 0, 0), node('b', [], [], 0, 300)], edges: [], boxes: [ { id: 'hidden', kind: 'note', x: 50, y: 20, w: 220, h: 96 }, { id: 'clear', kind: 'note', x: 0, y: 150, w: 220, h: 96 }, { id: 'group', kind: 'group', x: -20, y: -44, w: 216, h: 120 }, ], }; const lint = lintLayout(graph); expect(lint.coveredNotes).toBe(1); expect(lint.examples).toContainEqual({ kind: 'covered', ids: ['hidden', 'a'] }); }); });