import { node } from '../lib/builders.mjs'; /** * Groups and comments in a graph (#38). Two separate things on purpose: a group is a titled box * that carries what it encloses, a comment is a card of text that frames nothing. * * What only a running editor can answer is in here: that a group takes the nodes whose *centre* * lies in it and leaves the one beside it, that deleting the box leaves every node where it was, * and that the text on a freely picked colour flips between black and white so it stays readable – * none of which a unit test can see, because all three are about what is drawn and dragged. */ const GRAPH = { nodes: [ node('gg-start', 'OnStart', 0, 0), node('gg-self', 'Self', 0, 200), node('gg-far', 'Self', 900, 600), ], // No links: this step is about the boxes behind the nodes, not about what runs. edges: [], }; export async function graphGroups(t) { const { check, json, evaluate, mutate, click, clickText, drag, locate, press, typeText, shot, showTab, settle, } = t; const boxes = () => json('__gm.graph.annotations()'); const one = async (kind) => (await boxes()).find((a) => a.kind === kind) ?? null; const nodeAt = (id) => json( `(() => { const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph;` + ` const n = g.nodes.find((n) => n.id === ${JSON.stringify(id)});` + ` return n ? { x: n.x, y: n.y } : null; })()`, ); await press('Escape'); await evaluate('__gm.state.select(null)'); // 36a. A graph of its own: two nodes close together and a third far away, which is what makes // "the group took the right ones" a question with a wrong answer available. await mutate(` const p = __gm.state.project; const logic = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(logic, { id: 'gg-logic', name: 'Grouped', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(GRAPH)}, }); p.assets.push(logic);`); await evaluate("__gm.state.select({ kind: 'asset', assetId: 'gg-logic' })"); await showTab('graph'); await evaluate('__gm.graph.current()'); // 36b. The palette is the one gesture the canvas has for putting something on itself, so both // boxes are offered there – above the nodes, and reachable by the same search field. const spot = await json('__gm.graph.emptySpot()'); await click(spot.x, spot.y, 'right'); const offered = await json('__gm.graph.paletteRows()'); check( 'the drop palette offers a group and a comment before the nodes', offered[0]?.type === 'add-group' && offered[1]?.type === 'add-comment', JSON.stringify(offered.slice(0, 4)), ); // The search reaches them by what they are, not by the "Add" they both start with: that word is // what somebody types to find the `Add` node, and two entries jumping the list for it would cost // more than they help. await typeText('add'); const searchedAdd = await json('__gm.graph.paletteRows()'); await press('a', { ctrl: true }); await typeText('comment'); const searchedComment = await json('__gm.graph.paletteRows()'); check( 'the search finds them by what they are, and leaves the query "add" to the nodes', searchedAdd.every((row) => !row.type.startsWith('add-')) && searchedComment[0]?.type === 'add-comment', JSON.stringify({ add: searchedAdd.slice(0, 2), comment: searchedComment.slice(0, 2) }), ); await clickText('Add comment'); const comment = await one('note'); check( 'a comment is drawn where the palette was opened, in white on the default colour', comment !== null && comment.text === 'Comment' && comment.textColor === '#ffffff', JSON.stringify(comment), ); // 36c. A group out of a selection: the two near nodes, right-clicked as a selection. await evaluate( "__gm.state.select({ kind: 'node', assetId: 'gg-logic', nodeIds: ['gg-start', 'gg-self'] })", ); const start = await json('__gm.graph.nodeAt("gg-start")'); await click(start.x, start.y, 'right'); const menu = await json('__gm.menuRows()'); await clickText('Group these 2 nodes'); const group = await one('group'); const farBefore = await nodeAt('gg-far'); check( 'the node menu groups the whole selection, and the box is drawn around it', menu.some((row) => (row ?? '') === 'Group these 2 nodes') && group !== null && group.text === 'Group' && (await json('__gm.state.selection')).kind === 'annotation', JSON.stringify({ menu: menu.filter(Boolean).slice(0, 3), group }), ); await shot('36-group'); // 36d. Dragging the title bar. The far node is the check that matters: it never moves, because // its centre is nowhere near the box – that is the whole membership rule. const startBefore = await nodeAt('gg-start'); const commentBefore = (await one('note')).x; const title = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)})`); await drag(title.x, title.y, title.x + 80, title.y + 60); await settle(); const startAfter = await nodeAt('gg-start'); const farAfter = await nodeAt('gg-far'); const movedBox = await one('group'); check( 'moving a group takes the nodes inside it and leaves the one beside it', startAfter.x > startBefore.x && startAfter.y > startBefore.y && movedBox.x > group.x && farAfter.x === farBefore.x && farAfter.y === farBefore.y && (await one('note')).x === commentBefore, JSON.stringify({ startBefore, startAfter, farBefore, farAfter }), ); // 36e. One undo for the whole gesture: the box and every node it carried, in one step. await t.undo(); check( 'one undo takes the move back whole', JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore) && (await one('group')).x === group.x, JSON.stringify({ node: await nodeAt('gg-start'), box: (await one('group')).x }), ); // 36f. The corner resizes and nothing else: membership is derived from where the box ends up, // so a node the corner sweeps over is simply inside it afterwards – it is not dragged. const corner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); await drag(corner.x, corner.y, corner.x + 60, corner.y + 40); await settle(); const resized = await one('group'); check( 'the corner resizes the box while every node stays put', resized.w > group.w && resized.h > group.h && JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore), JSON.stringify({ before: { w: group.w, h: group.h }, after: { w: resized.w, h: resized.h } }), ); // 36f2. With snapping on, the corner obeys the grid like a dragged node does – and it is the far // edge that lands on it, because the near one is exactly what a resize must not move. const magnet = await json('__gm.graph.snapButton()'); await click(magnet.x, magnet.y); const held = await one('group'); const snapCorner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); // 53 and 37 are deliberately ragged: a multiple of 24 afterwards cannot come from the gesture. await drag(snapCorner.x, snapCorner.y, snapCorner.x + 53, snapCorner.y + 37); await settle(); const snapped = await one('group'); check( 'with snapping on the resized box ends on the grid, without moving its near corner', (snapped.x + snapped.w) % 24 === 0 && (snapped.y + snapped.h) % 24 === 0 && snapped.x === held.x && snapped.y === held.y && snapped.w > held.w, JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), ); await click(magnet.x, magnet.y); // 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: // the text flips to black on a light fill, which is what makes a free picker safe. await evaluate( `__gm.state.select({ kind: 'annotation', assetId: 'gg-logic', annotationId: ${JSON.stringify(group.id)} })`, ); const field = await json("__gm.inspectorField('Color')"); await click(field.x, field.y); // The field already holds the current hex; typing without this appends to it and parses as nothing. await press('a', { ctrl: true }); await typeText('#ffe08a'); await press('Enter'); const colored = await one('group'); check( 'a group takes any colour, and its title turns black to stay readable on a light one', colored.color === '#ffe08a' && colored.textColor === '#000000', JSON.stringify({ color: colored.color, textColor: colored.textColor }), ); await shot('36-group-colored'); // 36h. Delete takes the box and nothing else. A group that silently deleted what it framed // would be the most expensive surprise in the editor. const nodesBefore = await json( "__gm.state.project.assets.find((a) => a.id === 'gg-logic').graph.nodes.length", ); await press('Delete'); check( 'deleting a group leaves every node it framed exactly where it was', (await one('group')) === null && (await json("__gm.state.project.assets.find((a) => a.id === 'gg-logic').graph.nodes.length")) === nodesBefore && JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore), JSON.stringify({ nodes: nodesBefore, boxes: (await boxes()).map((a) => a.kind) }), ); // 36i. And it all survives the round trip through the store, which is what writing a note is for. await t.saved(); const stored = await t.api(`projects/${t.projectId}`); const saved = stored.project.assets.find((a) => a.id === 'gg-logic'); check( 'the comment is part of the saved document, the deleted group is not', (saved.graph.notes ?? []).length === 1 && (saved.graph.notes ?? [])[0].text === 'Comment' && (saved.graph.groups ?? []).length === 0, JSON.stringify({ notes: saved.graph.notes, groups: saved.graph.groups }), ); // 36j. Fit frames the boxes too (#95). A comment far from every node is the case that tells the // two answers apart: framing the nodes alone leaves it off screen, and nothing in the // picture says why. The graph itself never moves – only the view does. const away = await one('note'); // Far enough that framing the nodes alone leaves it off screen, near enough that the fit can // still show everything: the view stops zooming out at 0.25, and past that no fit can succeed. await mutate(` const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph; const note = g.notes.find((n) => n.id === ${JSON.stringify(away.id)}); note.x = -700; note.y = -500;`); // The button, not a hook: "click Fit" is the gesture the issue is about. const fitButton = await locate('Fit'); await click(fitButton.x, fitButton.y); const canvas = await json('__gm.graph.canvasRect()'); const noteRect = await json(`__gm.graph.annotationRect(${JSON.stringify(away.id)})`); const startRect = await json('__gm.graph.nodeRect("gg-start")'); const inside = (r) => r !== null && r.x >= canvas.x - 1 && r.y >= canvas.y - 1 && r.x + r.w <= canvas.x + canvas.w + 1 && r.y + r.h <= canvas.y + canvas.h + 1; check( 'fit brings a far-off comment into view without losing the nodes', inside(noteRect) && inside(startRect), JSON.stringify({ canvas, note: noteRect, node: startRect }), ); await shot('36-fit-with-comment'); // 36k. The rubber band catches boxes as well (#95). Everything is on screen after the fit, so a // band across the whole canvas must come back holding both sorts at once – and it stays a // node selection, which is what keeps the inspector showing what it showed before. await evaluate('__gm.state.select(null)'); const band = await json(`__gm.graph.annotationRect(${JSON.stringify(away.id)})`); await drag(canvas.x + 3, canvas.y + 3, canvas.x + canvas.w - 3, canvas.y + canvas.h - 3); const caught = await json('__gm.state.selection'); check( 'a band over the whole graph catches the comment along with the nodes', caught?.kind === 'node' && (caught.annotationIds ?? []).includes(away.id) && caught.nodeIds.includes('gg-start'), JSON.stringify(caught), ); // 36l. But a band that merely crosses a box leaves it alone – the rule that keeps a group, which // wraps the nodes it holds, from joining every band drawn over any of them. It starts to the // left of the comment on empty canvas and stops halfway across it. await evaluate('__gm.state.select(null)'); await drag(band.x - 8, band.y + band.h / 2, band.x + band.w / 2, band.y + band.h / 2 + 4); const grazed = await json('__gm.state.selectedAnnotationIds ?? []'); const stillThere = await one('note'); check( 'a band that only crosses a box does not catch it, and does not move it either', !grazed.includes(away.id) && stillThere.x === -700 && stillThere.y === -500, JSON.stringify({ grazed, note: { x: stillThere.x, y: stillThere.y } }), ); // 36m. What one band caught, one drag moves – nodes and boxes by the same offset. The group here // is the trap: it carries the nodes inside it, and those are selected too, so a naive // implementation offsets them twice. Equal deltas are what says it did not. await mutate(` const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph; g.groups = [{ id: 'gg-box', x: -20, y: -44, w: 216, h: 320, text: 'Held' }];`); const fitAgain = await locate('Fit'); await click(fitAgain.x, fitAgain.y); const sheet = await json('__gm.graph.canvasRect()'); await evaluate('__gm.state.select(null)'); await drag(sheet.x + 3, sheet.y + 3, sheet.x + sheet.w - 3, sheet.y + sheet.h - 3); const before = { start: await nodeAt('gg-start'), far: await nodeAt('gg-far'), group: (await boxes()).find((a) => a.id === 'gg-box'), note: await one('note'), }; const grip = await json('__gm.graph.nodeAt("gg-far")'); await drag(grip.x, grip.y, grip.x + 70, grip.y + 50); const after = { start: await nodeAt('gg-start'), far: await nodeAt('gg-far'), group: (await boxes()).find((a) => a.id === 'gg-box'), note: await one('note'), }; const delta = (key) => ({ x: after[key].x - before[key].x, y: after[key].y - before[key].y }); const moved = delta('far'); check( 'dragging one node moves every caught box and node by the same step, none of them twice', moved.x > 0 && moved.y > 0 && ['start', 'group', 'note'].every((key) => delta(key).x === moved.x && delta(key).y === moved.y), JSON.stringify({ far: moved, start: delta('start'), group: delta('group'), note: delta('note') }), ); // And one undo takes the whole mixed gesture back, boxes included. await t.undo(); check( 'one undo returns the nodes and the boxes together', JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(before.start) && (await boxes()).find((a) => a.id === 'gg-box').x === before.group.x && (await one('note')).x === before.note.x, JSON.stringify({ start: await nodeAt('gg-start'), note: (await one('note')).x }), ); }