An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s
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:
1 parent
d5e67cd80c
commit
a041322a0e
8 files changed
+372
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(`
|
||||
|
||||
Reference in new issue
Block a user