An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s

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.
This commit is contained in:
root committed 2026-09-14 11:50:36 +02:00
1 parent d5e67cd80c
commit a041322a0e
8 files changed
+372 -16

No files matched your search

+189
View File
@@ -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();
});
});