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.
190 lines
8.5 KiB
TypeScript
190 lines
8.5 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|