From a041322a0eb2dd66eb36b5da847ade871a7fc369 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 14 Sep 2026 11:50:36 +0200 Subject: [PATCH] An edge to a vanished port needs a handle, so the port stays as a ghost (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ways erase a derived port and take no edge with them: deleting a state, deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting the asset. The common cause is `RemoveNodeCommand` clearing only its own graph, so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site. A right-click on an edge goes through `view.portAt` only — there is no curve hit-test. Without a port row such an edge is not merely invisible, it is unreachable, so the ghost row is the handle Disconnect hangs on. `resolveDef` stays untouched: it is the truth for interpreter, compiler, ConnectCommand and dropTarget, so nothing runs or connects through a ghost without a second guard. --- README.md | 2 +- src/core/graph/NodeRegistry.ts | 2 + src/core/graph/nested.ts | 52 ++++++++- src/editor/graph/EdgeLayer.ts | 48 +++++++- src/editor/graph/GraphCanvas.ts | 19 +++- src/editor/graph/NodeView.ts | 16 ++- tests/core/ghost-ports.test.ts | 189 ++++++++++++++++++++++++++++++++ tools/e2e/26-state-machine.mjs | 60 ++++++++++ 8 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 tests/core/ghost-ports.test.ts diff --git a/README.md b/README.md index fc45b2b8..1fb245cc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 837 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 844 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 79d7f303..7309aa26 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -16,6 +16,8 @@ export interface PortDef { field?: PropertyDef; /** `false`: nothing to type in even for a number – unconnected, the node's own rule applies. */ inline?: boolean; + /** Not a port of the definition: a key only an edge still names, drawn so that edge can be seen. */ + ghost?: true; } /** diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index ed890a62..a617686c 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -1,7 +1,7 @@ import { textOf } from '../model/Properties'; import { isTaggedKind } from '../properties/kinds'; import { flatDef, isValidFieldName } from '../properties/PropertySchema'; -import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph'; +import { type DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph'; import type { GraphLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; @@ -109,6 +109,56 @@ function execFirst(ports: PortDef[]): PortDef[] { return [...ports.filter((p) => p.type === 'exec'), ...ports.filter((p) => p.type !== 'exec')]; } +/** One end of an edge naming a key its node no longer has. */ +interface GhostEnd { + edge: GraphEdge; + key: string; + isOutput: boolean; +} + +/** Read from the edge list, not from the ports: what is looked for is exactly what has no port. */ +function ghostEnds(node: GraphNode, graph: Graph, def: NodeDef): GhostEnd[] { + const inputs = new Set(def.inputs.map((p) => p.key)); + const outputs = new Set(def.outputs.map((p) => p.key)); + const ends: GhostEnd[] = []; + for (const edge of graph.edges) { + if (edge.to.node === node.id && !inputs.has(edge.to.port)) + ends.push({ edge, key: edge.to.port, isOutput: false }); + if (edge.from.node === node.id && !outputs.has(edge.from.port)) + ends.push({ edge, key: edge.from.port, isOutput: true }); + } + return ends; +} + +/** `any` because the type the key once had is exactly what is no longer known. */ +function ghostPort(key: string): PortDef { + return { key, label: key, type: 'any', inline: false, ghost: true }; +} + +/** + * The definition as the canvas draws it: the real ports, then the keys only edges still name. The + * ghosts go last and skip `execFirst`, so no real row moves because of one – and they stay out of + * `resolveDef`, which the interpreter, the compilers and `ConnectCommand` read as the truth. + */ +export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef): NodeDef { + const ends = ghostEnds(node, graph, def); + if (ends.length === 0) return def; + const side = (isOutput: boolean) => + [...new Set(ends.filter((e) => e.isOutput === isOutput).map((e) => e.key))].map(ghostPort); + return { + ...def, + inputs: [...def.inputs, ...side(false)], + outputs: [...def.outputs, ...side(true)], + }; +} + +/** Why a node warns although its own settings are sound: edges left hanging on keys that are gone. */ +export function ghostProblem(node: GraphNode, graph: Graph, def: NodeDef): string | null { + const count = ghostEnds(node, graph, def).length; + if (count === 0) return null; + return count === 1 ? '1 edge on a port that is gone' : `${count} edges on ports that are gone`; +} + /** * Why this call cannot do anything, or null if it can. A call is the one node that depends on a * *different* graph, so it is the one node that can look perfectly healthy and still be dead. diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index 7c9e0c65..2e637c9a 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -18,6 +18,10 @@ const FOLD_STUB = 16; const FOLD_MARK_RADIUS = 5; const FOLD_LABEL_WIDTH = 96; const BEAD_RADIUS = 4.5; +/** Dash pattern and sampling of an edge hanging on a port that is gone. */ +const GHOST_DASH = 7; +const GHOST_GAP = 5; +const DASH_STEPS = 80; /** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */ export interface FoldMark { @@ -99,9 +103,13 @@ export class EdgeLayer extends Container { const a = from?.portPosition(edge.from.port, true); const b = to?.portPosition(edge.to.port, false); if (!from || !to || !a || !b) return; - const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any'; - const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any'; - const valid = portsCompatible(type, inType); + const out = from.def.outputs.find((p) => p.key === edge.from.port); + const inp = to.def.inputs.find((p) => p.key === edge.to.port); + const type = out?.type ?? 'any'; + const inType = inp?.type ?? 'any'; + // One end on a port that is gone: the edge is drawn, but never as a working link. + const ghost = out?.ghost === true || inp?.ghost === true; + const valid = !ghost && portsCompatible(type, inType); const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger; const start = { x: from.x + a.x, y: from.y + a.y }; const end = { x: to.x + b.x, y: to.y + b.y }; @@ -110,7 +118,8 @@ export class EdgeLayer extends Container { this.addFoldEnd(edge, views, end, false, from.def.label, color, hot); if (!foldedEdgeShown(edge, selected, hot)) return; } - bezier(this.curves, start.x, start.y, end.x, end.y); + if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP); + else bezier(this.curves, start.x, start.y, end.x, end.y); this.curves.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color, @@ -191,3 +200,34 @@ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: numb const dx = curveReach(x1, x2); g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); } + +/** + * The same curve as dashes. Pixi has no dash style, and unlike a straight line a bezier cannot be + * stepped by length, so it is sampled and the walked distance decides which steps are drawn. + */ +export function dashedBezier( + g: Graphics, + x1: number, + y1: number, + x2: number, + y2: number, + dash: number, + gap: number, +): void { + const dx = curveReach(x1, x2); + const at = (t: number) => { + const u = 1 - t; + return { + x: u * u * u * x1 + 3 * u * u * t * (x1 + dx) + 3 * u * t * t * (x2 - dx) + t * t * t * x2, + y: u * u * u * y1 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y2, + }; + }; + let prev = at(0); + let walked = 0; + for (let i = 1; i <= DASH_STEPS; i++) { + const point = at(i / DASH_STEPS); + if (walked % (dash + gap) < dash) g.moveTo(prev.x, prev.y).lineTo(point.x, point.y); + walked += Math.hypot(point.x - prev.x, point.y - prev.y); + prev = point; + } +} diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index bb6bab59..e5afeab5 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -13,10 +13,12 @@ import { callProblem, canConnect, defAcceptsFrom, + drawnDef, dropTargetPort, edgesInto, edgesOutOf, firstPortFor, + ghostProblem, type Graph, type GraphNode, type GraphLookup, @@ -229,8 +231,9 @@ export class GraphCanvas extends Widget { ? particleLibrary.buildFor(asset).problems : null; for (const node of asset.graph.nodes) { - const def = resolveDef(node, asset.graph, lookup); - if (!def) continue; + const resolved = resolveDef(node, asset.graph, lookup); + if (!resolved) continue; + const def = drawnDef(node, asset.graph, resolved); seen.add(node.id); let view = this.views.get(node.id); if (!view || view.node !== node) { @@ -240,9 +243,9 @@ export class GraphCanvas extends Widget { this.views.set(node.id, view); this.nodeLayer.addChild(view); } - const problem = compilerProblems - ? (compilerProblems[node.id] ?? null) - : callProblem(node, lookup, asset.id); + const problem = + (compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ?? + ghostProblem(node, asset.graph, resolved); view.sync(node, asset.graph, this.state.project, def, problem); } for (const [id, view] of this.views) { @@ -358,6 +361,9 @@ export class GraphCanvas extends Widget { return; } if (e.button !== 0) return; + // A ghost pin is a handle for the right-click menu only: nothing to drag from, and dragging the + // node instead would make a press on the very edge of it move the whole node. + if (hit?.port.ghost) return; if (hit) { const p = this.toLocal(e.global); this.connecting = { @@ -595,7 +601,8 @@ export class GraphCanvas extends Widget { for (const view of this.topDownViews()) { const local = view.toLocal({ x: globalX, y: globalY }); const hit = view.portAt(local.x, local.y); - if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; + // A ghost is no target, and it must not shadow the node body underneath it either. + if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; } return null; } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 98631d2a..56982e10 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -82,6 +82,11 @@ export function portColor(type: PortType): number { } } +function rowStyle(port: PortDef): TextStyleToken { + if (port.ghost) return 'warning'; + return port.type === 'exec' ? 'body' : 'muted'; +} + export interface PortHit { port: PortDef; isOutput: boolean; @@ -336,7 +341,7 @@ export class NodeView extends Widget { if (input) { const text = add( this.inputText(input), - input.type === 'exec' ? 'body' : 'muted', + rowStyle(input), PAD + PORT_RADIUS + 4, rowTop, 'left', @@ -347,13 +352,13 @@ export class NodeView extends Widget { if (output) { const text = add( output.label, - output.type === 'exec' ? 'body' : 'muted', + rowStyle(output), this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10, ); - if (output.type !== 'exec') + if (output.type !== 'exec' && !output.ghost) this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 }); } } @@ -394,7 +399,10 @@ export class NodeView extends Widget { this.ports.circle(x, y, r + 2).stroke({ width: 2, color: c.node }); this.ports.circle(x, y, r + 4).stroke({ width: 2, color: c.portTarget }); } - if (port.type === 'exec') { + if (port.ghost) { + // Hollow: a pin is drawn where a port used to be, and nothing stands behind it any more. + this.ports.circle(x, y, r).stroke({ width: 2, color: c.danger }); + } else if (port.type === 'exec') { // A pentagon pointing right: the Blueprint-style exec pin. this.ports .moveTo(x - r, y - r) diff --git a/tests/core/ghost-ports.test.ts b/tests/core/ghost-ports.test.ts new file mode 100644 index 00000000..227dcc65 --- /dev/null +++ b/tests/core/ghost-ports.test.ts @@ -0,0 +1,189 @@ +/** + * A derived port list can lose a key without anything touching the edges: a state deleted in a + * machine, the `machine` edge cut, a terminal node or a whole asset removed. Such an edge draws as + * nothing yet survives every save. `drawnDef` brings the key back as a ghost port so the canvas can + * draw the edge and offer its right-click menu; `ghostProblem` marks the node. `resolveDef` stays + * the truth – the interpreter, the compilers and ConnectCommand read it, and it never sees a ghost. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; +import { + AddNodeCommand, + ConnectCommand, + RemoveNodeCommand, + SetNodeParamCommand, +} from '../../src/core/commands/graphCommands'; +import { AddPortCommand } from '../../src/core/commands/portCommands'; +import { CommandError } from '../../src/core/commands/Command'; +import { + BEHAVIOR_END, + BEHAVIOR_PARAM, + BEHAVIOR_START, + drawnDef, + ghostProblem, + MACHINE_PORT, + resolveDef, + RUN_BEHAVIOR, + STATE, + STATE_NAME_PARAM, +} from '../../src/core/graph'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; + +/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */ +function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) { + const create = new CreateAssetCommand('statemachine', 'Doors'); + fixture.stack.execute(create); + const ids = names.map((name) => { + const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name }); + fixture.stack.execute(add); + return add.node.id; + }); + return { id: create.asset.id, ids }; +} + +function setup() { + const f = makeProject(); + const behavior = makeBehavior(f, 'B'); + const edit = graphEditor(f, behavior); + const seen = (asset: BehaviorAsset, id: string) => { + const node = asset.graph.nodes.find((n) => n.id === id)!; + const real = resolveDef(node, asset.graph, graphLookup(f.project))!; + return { + real, + drawn: drawnDef(node, asset.graph, real), + problem: ghostProblem(node, asset.graph, real), + }; + }; + const ghosts = (asset: BehaviorAsset, id: string, side: 'inputs' | 'outputs') => + seen(asset, id) + .drawn[side].filter((p) => p.ghost) + .map((p) => p.key); + return { f, behavior, edit, seen, ghosts }; +} + +describe('an edge on a port that is gone', () => { + it('is no ghost while every edge names a port that exists', () => { + const { f, behavior, edit, seen, ghosts } = setup(); + const machine = makeMachine(f); + const start = edit.addNode('OnStart'); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + edit.connect(start, 'exec', set, machine.ids[0]!); + expect(ghosts(behavior, set.id, 'inputs')).toEqual([]); + expect(seen(behavior, set.id).problem).toBeNull(); + }); + + it('comes back as a ghost when the state it names is deleted in the machine', () => { + const { f, behavior, edit, seen, ghosts } = setup(); + const machine = makeMachine(f); + const start = edit.addNode('OnStart'); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + edit.connect(start, 'exec', set, machine.ids[0]!); + // Deleting the state reaches only the machine's own graph – the behavior's edge stays behind. + f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!)); + expect(behavior.graph.edges).toHaveLength(1); + expect(ghosts(behavior, set.id, 'inputs')).toEqual([machine.ids[0]!]); + expect(seen(behavior, set.id).problem).toBe('1 edge on a port that is gone'); + f.stack.undo(); + expect(ghosts(behavior, set.id, 'inputs')).toEqual([]); + }); + + it('appears once per state when the State machine literal feeding the input is deleted', () => { + const { f, behavior, edit, seen, ghosts } = setup(); + const machine = makeMachine(f); + const literal = edit.addNode('StateMachine', { value: machine.id }); + const branch = edit.addNode('StateBranch'); + edit.connect(literal, 'value', branch, MACHINE_PORT); + const log = edit.addNode('Log', { message: 'x' }); + const other = edit.addNode('Log', { message: 'y' }); + edit.connect(branch, machine.ids[0]!, log, 'exec'); + edit.connect(branch, machine.ids[1]!, other, 'exec'); + // The literal takes its own edge with it; the two exec edges out of the branch it cannot see. + f.stack.execute(new RemoveNodeCommand(behavior.id, literal.id)); + expect(ghosts(behavior, branch.id, 'outputs')).toEqual(machine.ids); + expect(seen(behavior, branch.id).problem).toBe('2 edges on ports that are gone'); + }); + + it('appears on every caller when a behavior loses its start node', () => { + const f = makeProject(); + const callee = makeBehavior(f, 'Double'); + const calleeEdit = graphEditor(f, callee); + const start = calleeEdit.addNode(BEHAVIOR_START); + const end = calleeEdit.addNode(BEHAVIOR_END); + f.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'in1', kind: 'number' })); + f.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); + const caller = makeBehavior(f, 'Caller'); + const edit = graphEditor(f, caller); + const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); + const number = edit.addNode('Number', { value: 3 }); + edit.connect(number, 'value', call, 'in1'); + const node = () => caller.graph.nodes.find((n) => n.id === call.id)!; + const real = () => resolveDef(node(), caller.graph, graphLookup(f.project))!; + expect(drawnDef(node(), caller.graph, real()).inputs.filter((p) => p.ghost)).toHaveLength(0); + f.stack.execute(new RemoveNodeCommand(callee.id, start.id)); + expect( + drawnDef(node(), caller.graph, real()) + .inputs.filter((p) => p.ghost) + .map((p) => p.key), + ).toEqual(['in1']); + }); + + it('appears when the machine asset itself is deleted', () => { + const { f, behavior, edit, ghosts } = setup(); + const machine = makeMachine(f); + const start = edit.addNode('OnStart'); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + edit.connect(start, 'exec', set, machine.ids[0]!); + f.stack.execute(new DeleteAssetCommand(machine.id)); + expect(ghosts(behavior, set.id, 'inputs')).toEqual([machine.ids[0]!]); + }); + + it('goes last, so no real port row moves because of it', () => { + const { f, behavior, edit, seen } = setup(); + const machine = makeMachine(f); + const start = edit.addNode('OnStart'); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + edit.connect(start, 'exec', set, machine.ids[0]!); + f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!)); + const { real, drawn } = seen(behavior, set.id); + const keys = drawn.inputs.map((p) => p.key); + expect(keys.slice(0, real.inputs.length)).toEqual(real.inputs.map((p) => p.key)); + expect(keys.at(-1)).toBe(machine.ids[0]!); + }); + + it('is a handle only: resolveDef has no ghost, so nothing can be connected to one', () => { + const { f, behavior, edit, seen } = setup(); + const machine = makeMachine(f); + const start = edit.addNode('OnStart'); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + edit.connect(start, 'exec', set, machine.ids[0]!); + f.stack.execute(new RemoveNodeCommand(machine.id, machine.ids[0]!)); + const { real } = seen(behavior, set.id); + expect(real.inputs.some((p) => p.key === machine.ids[0]!)).toBe(false); + const second = edit.addNode('OnUpdate'); + expect(() => + f.stack.execute( + new ConnectCommand( + behavior.id, + { node: second.id, port: 'exec' }, + { node: set.id, port: machine.ids[0]! }, + ), + ), + ).toThrow(CommandError); + }); + + it('survives a param change that orphans the edge, until that command takes it', () => { + const { f, behavior, edit, seen, ghosts } = setup(); + const machine = makeMachine(f); + const start = edit.addNode('OnStart'); + const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); + edit.connect(start, 'exec', set, machine.ids[0]!); + // SetNodeParamCommand is the one path that already cleans up: no edge left, so no ghost. + f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, '')); + expect(behavior.graph.edges).toHaveLength(0); + expect(ghosts(behavior, set.id, 'inputs')).toEqual([]); + f.stack.undo(); + expect(seen(behavior, set.id).problem).toBeNull(); + }); +}); diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index a9b4d971..af7eb229 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -22,6 +22,7 @@ export async function stateMachine(t) { has, doubleClick, drag, + mouse, } = t; const { typeText, undo, redo, world, play, stop } = t; const machines = () => @@ -273,6 +274,65 @@ export async function stateMachine(t) { ); await shot('03-machine-picked'); + // 26i. A deleted state leaves its edge hanging; the port comes back as a ghost so it can be seen. + const named = await states(); + const nameOf = (id) => named.find((s) => s.id === id)?.name; + check( + 'before the deletion the edge to the second state is drawn', + (await json("__gm.graph.edgeDrawn('sm-e4')")) === true, + ); + await mutate(` + const m = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + m.graph.nodes = m.graph.nodes.filter((n) => n.id !== ${JSON.stringify(second)}); + `); + await sleep(400); + const ghosted = await outputsOf('sm-branch'); + check( + 'the deleted state leaves a row named after its key, and takes its name away', + names(ghosted).includes(second) && !names(ghosted).includes(nameOf(second)), + JSON.stringify({ ghosted, was: nameOf(second) }), + ); + check( + 'the edge on it is drawn again instead of vanishing', + (await json("__gm.graph.edgeDrawn('sm-e4')")) === true, + ); + check( + 'the node warns about it', + (await json("__gm.graph.nodeProblem('sm-branch')")) === '1 edge on a port that is gone', + JSON.stringify(await json("__gm.graph.nodeProblem('sm-branch')")), + ); + check( + 'the ghost pin can be reached, so the edge has a right-click handle', + (await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`)) !== null, + ); + const pin = await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`); + const before = await json("__gm.graph.nodeRect('sm-branch')"); + const links = () => json('__gm.state.project.assets.find((a) => a.id === "sm-logic").graph.edges.length'); + const linksBefore = await links(); + // Measured mid-drag: after the release nothing would show either way, because a ghost has no type + // and `openPaletteForDrop` gives up on that. The rubber band is the only visible difference. + const empty = await json('__gm.graph.emptySpot()'); + await mouse('mouseMoved', pin.x, pin.y); + await mouse('mousePressed', pin.x, pin.y, 'left', {}); + await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 }); + await sleep(150); + const dangling = await json('__gm.graph.danglingEdge()'); + await mouse('mouseReleased', empty.x, empty.y, 'left', {}); + await sleep(250); + const after = await json("__gm.graph.nodeRect('sm-branch')"); + check( + 'pressing a ghost pin starts no rubber band, adds no link, and leaves the node where it was', + dangling === false && (await links()) === linksBefore && after.x === before.x && after.y === before.y, + JSON.stringify({ dangling, linksBefore, links: await links(), moved: after.x - before.x }), + ); + check( + 'the state that is left keeps its edge and its name', + (await json("__gm.graph.edgeDrawn('sm-e3')")) === true && + names(await outputsOf('sm-branch')).includes(nameOf(first)), + JSON.stringify({ lines: await outputsOf('sm-branch'), kept: nameOf(first) }), + ); + await shot('04-ghost-port'); + // 26h. Everything the step made goes away again. const left = await rig.remove(); await mutate(`