A link could only be removed through the menu of its port, and the line itself took no input. A transition of a state machine could only be deleted in the list of the inspector. Now the line is the handle, in a behavior's graph and between states alike. - A click on a line selects that link and draws a halo behind it. A press counts within 6 screen pixels of the line, and where two cross the one drawn last wins. - Ctrl-click or Shift-click adds a link or takes it away. The rubber band takes every line it crosses, into the same selection as the nodes and boxes it caught. - Delete or Backspace removes what is picked as one undo step. A right-click on a line offers Hide link and Delete link. - The inspector shows a picked link: the node and port at each end and the type it carries, with Hide link and Delete link below. For several it says how many. - The arrow of a transition is picked the same way, on its name too. Delete or a right-click with Delete transition removes it. - The menu of a port is unchanged, Disconnect included, as answered in the issue. - editor/graph/edgeHit.ts holds the hit test as pure functions, and editor/inspector/edgeSection.ts builds the section. The selection has a new kind edge, and node and annotation carry edgeIds. Decided here: - A hidden link has no line and is never picked this way, also while it is shown on loan. Its mark at the port stays its handle. - The menu of a line speaks about the whole selection, like the menu of a box. With nodes or boxes held alongside it reads Delete N items. - Hiding picked links lets go of them, since no line is left. - A link wins over the box it crosses, and a mark wins over a link. - A transition has no Hide entry. - With nodes or boxes in the selection the inspector keeps showing the node or box, not the link. Tests: - Gate: 201 files, 2982 tests (2967 + 15), exit 0 - e2e, full run: 1635 checks passed (1629 + 6), exit 0 - Smokes: 24 / 19 / 21, exit 0 - The full run was done for exactly this state. - 15 unit tests in tests/editor/edge-selection.test.ts - Six e2e checks with a real mouse, block 6z-xi in module 06 and 26e7 in module 26 - 12 unit counter-probes all fail tests, run on stein2 - Three browser counter-probes fail, the build went through each time: no hit on the line, no halo, a band that skips lines - Screenshots of a picked link and a picked transition looked at before the last build, none after it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
10 KiB
TypeScript
236 lines
10 KiB
TypeScript
/**
|
|
* A link is picked on its line (#289): a click near the curve means that link, Ctrl adds another,
|
|
* a rubber band takes every line it crosses, and Delete removes what is picked in one undo step.
|
|
* A hidden link has no line, so it is never among the paths these rules are asked about.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
|
import { AddNodeCommand, ConnectCommand, NameTransitionCommand } from '../../src/core/commands/graphCommands';
|
|
import { type Graph, STATE, STATE_IN, STATE_NAME_PARAM, STATE_OUT } from '../../src/core/graph';
|
|
import { createEmptyProject, findGraphAsset } from '../../src/core/model/Project';
|
|
import { hideLinks, removeGraphSelection, removeSelection } from '../../src/editor/actions';
|
|
import {
|
|
distanceToPath,
|
|
edgeAt,
|
|
type EdgePath,
|
|
edgesInBand,
|
|
pathMiddle,
|
|
} from '../../src/editor/graph/edgeHit';
|
|
import { selfLoop } from '../../src/editor/graph/transitions';
|
|
import { describeEdge, edgeSignature } from '../../src/editor/inspector/edgeSection';
|
|
import { edgeSelection, EditorState, graphSelection } from '../../src/editor/state';
|
|
|
|
const curve = (edgeId: string, y1: number, y2: number): EdgePath => ({
|
|
edgeId,
|
|
kind: 'curve',
|
|
start: { x: 0, y: y1 },
|
|
end: { x: 200, y: y2 },
|
|
});
|
|
|
|
describe('which link a point means', () => {
|
|
it('takes the link whose curve runs within the tolerance, and none further off', () => {
|
|
const paths = [curve('a', 0, 100)];
|
|
const on = pathMiddle(paths[0]!);
|
|
expect(on).toEqual({ x: 100, y: 50 });
|
|
expect(edgeAt(paths, { x: on.x + 4, y: on.y }, 6)).toBe('a');
|
|
expect(edgeAt(paths, { x: on.x + 40, y: on.y }, 6)).toBeNull();
|
|
// The straight line between the ports is not the curve: its quarter point is well off it.
|
|
expect(distanceToPath(paths[0]!, { x: 50, y: 25 })).toBeGreaterThan(6);
|
|
});
|
|
|
|
it('takes the one drawn last where two cross', () => {
|
|
const paths = [curve('under', 0, 100), curve('over', 100, 0)];
|
|
expect(edgeAt(paths, { x: 100, y: 50 }, 6)).toBe('over');
|
|
expect(edgeAt([...paths].reverse(), { x: 100, y: 50 }, 6)).toBe('under');
|
|
});
|
|
|
|
it('follows a straight transition and a loop back into its own state', () => {
|
|
const line: EdgePath = { edgeId: 'line', kind: 'line', start: { x: 0, y: 0 }, end: { x: 100, y: 100 } };
|
|
const loop: EdgePath = {
|
|
edgeId: 'loop',
|
|
kind: 'loop',
|
|
loop: selfLoop({ x: 300, y: 300, w: 100, h: 40 }),
|
|
};
|
|
expect(edgeAt([line, loop], { x: 52, y: 48 }, 6)).toBe('line');
|
|
expect(edgeAt([line, loop], { x: 80, y: 20 }, 6)).toBeNull();
|
|
expect(edgeAt([line, loop], pathMiddle(loop), 6)).toBe('loop');
|
|
// Inside the loop is not on it.
|
|
expect(edgeAt([line, loop], { x: 350, y: 285 }, 6)).toBeNull();
|
|
});
|
|
|
|
it('means the transition when its name plate is pressed, before any line under it', () => {
|
|
const paths = [curve('line', 0, 0)];
|
|
const plates = [{ edgeId: 'named', x: 80, y: -8, w: 40, h: 16 }];
|
|
expect(edgeAt(paths, { x: 100, y: 0 }, 6, plates)).toBe('named');
|
|
expect(edgeAt(paths, { x: 20, y: 0 }, 6, plates)).toBe('line');
|
|
});
|
|
|
|
it('gives a band every link it crosses, not only those with an end inside', () => {
|
|
const paths = [curve('a', 0, 100), curve('far', 400, 400)];
|
|
expect(edgesInBand(paths, { x1: 90, y1: 20, x2: 110, y2: 80 })).toEqual(['a']);
|
|
// Drawn the other way round it is the same rectangle.
|
|
expect(edgesInBand(paths, { x1: 110, y1: 80, x2: 90, y2: 20 })).toEqual(['a']);
|
|
expect(edgesInBand(paths, { x1: 90, y1: 150, x2: 110, y2: 200 })).toEqual([]);
|
|
expect(edgesInBand(paths, { x1: -10, y1: -10, x2: 300, y2: 500 })).toEqual(['a', 'far']);
|
|
});
|
|
});
|
|
|
|
function fixture() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const create = new CreateAssetCommand('behavior', 'B');
|
|
expect(state.execute(create)).toBe(true);
|
|
const behaviorId = create.asset.id;
|
|
const graphOf = (): Graph => findGraphAsset(state.project, behaviorId)!.graph;
|
|
const addNode = (type: string): string => {
|
|
const command = new AddNodeCommand(behaviorId, type, 0, 0);
|
|
expect(state.execute(command)).toBe(true);
|
|
return command.node.id;
|
|
};
|
|
const connect = (from: string, to: string, port: string): string => {
|
|
const command = new ConnectCommand(behaviorId, { node: from, port: 'value' }, { node: to, port });
|
|
expect(state.execute(command)).toBe(true);
|
|
return command.edge.id;
|
|
};
|
|
const [one, two, add] = [addNode('Number'), addNode('Number'), addNode('Add')];
|
|
const [first, second] = [connect(one, add, 'a'), connect(two, add, 'b')];
|
|
const edgeIds = () => graphOf().edges.map((e) => e.id);
|
|
return { state, behaviorId, graphOf, one, two, add, first, second, edgeIds };
|
|
}
|
|
|
|
describe('a selection of links', () => {
|
|
let f: ReturnType<typeof fixture>;
|
|
beforeEach(() => {
|
|
f = fixture();
|
|
});
|
|
|
|
it('is its own kind, and keeps the graph of its asset on screen', () => {
|
|
f.state.select(edgeSelection(f.behaviorId, [f.first]));
|
|
expect(f.state.selection).toEqual({
|
|
kind: 'edge',
|
|
assetId: f.behaviorId,
|
|
edgeId: f.first,
|
|
edgeIds: [f.first],
|
|
});
|
|
expect(f.state.selectedEdgeIds).toEqual([f.first]);
|
|
expect(f.state.selectedBehaviorId).toBe(f.behaviorId);
|
|
});
|
|
|
|
it('adds and takes a link with the toggle, and falls back to the asset when emptied', () => {
|
|
f.state.select(edgeSelection(f.behaviorId, [f.first]));
|
|
f.state.toggleEdge(f.behaviorId, f.second);
|
|
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeId: f.second, edgeIds: [f.first, f.second] });
|
|
f.state.toggleEdge(f.behaviorId, f.second);
|
|
f.state.toggleEdge(f.behaviorId, f.first);
|
|
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId });
|
|
});
|
|
|
|
it('rides along with nodes: the node stays what the inspector shows, either toggle keeps the rest', () => {
|
|
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first]));
|
|
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first] });
|
|
f.state.toggleEdge(f.behaviorId, f.second);
|
|
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first, f.second] });
|
|
f.state.toggleNode(f.behaviorId, f.two);
|
|
expect(f.state.selectedNodeIds).toEqual([f.one, f.two]);
|
|
expect(f.state.selectedEdgeIds).toEqual([f.first, f.second]);
|
|
// The last node toggled away leaves the links, as a selection of their own.
|
|
f.state.toggleNode(f.behaviorId, f.one);
|
|
f.state.toggleNode(f.behaviorId, f.two);
|
|
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first, f.second] });
|
|
});
|
|
|
|
it('deletes exactly the picked link on Delete, and one undo brings it back', () => {
|
|
f.state.select(edgeSelection(f.behaviorId, [f.first]));
|
|
expect(removeSelection(f.state)).toBe(true);
|
|
expect(f.edgeIds()).toEqual([f.second]);
|
|
// The link is gone, so the selection falls back to the graph it was in.
|
|
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId });
|
|
f.state.undo();
|
|
expect(f.edgeIds()).toEqual([f.first, f.second]);
|
|
});
|
|
|
|
it('deletes several links, and a node with them, as one undo step', () => {
|
|
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first, f.second]));
|
|
expect(removeSelection(f.state)).toBe(true);
|
|
expect(f.edgeIds()).toEqual([]);
|
|
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.two, f.add]);
|
|
f.state.undo();
|
|
expect(f.edgeIds()).toEqual([f.first, f.second]);
|
|
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.one, f.two, f.add]);
|
|
f.state.redo();
|
|
expect(f.edgeIds()).toEqual([]);
|
|
});
|
|
|
|
it('has nothing to delete when the link is already gone', () => {
|
|
expect(removeGraphSelection(f.state, f.behaviorId, [], [], ['gone'])).toBe(false);
|
|
expect(f.edgeIds()).toEqual([f.first, f.second]);
|
|
});
|
|
|
|
it('lets go of a link that is hidden, since it has no line left to be picked on', () => {
|
|
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first]));
|
|
expect(hideLinks(f.state, f.behaviorId, [f.first])).toBe(true);
|
|
expect(f.graphOf().edges[0]!.folded).toBe(true);
|
|
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [] });
|
|
});
|
|
|
|
it('drops a link from the selection when an undo takes it away', () => {
|
|
f.state.select(edgeSelection(f.behaviorId, [f.first, f.second]));
|
|
f.state.undo();
|
|
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first] });
|
|
});
|
|
|
|
it('describes a link by the names drawn at its two ends', () => {
|
|
expect(describeEdge(f.state.project, f.behaviorId, f.second)).toEqual({
|
|
transition: false,
|
|
from: 'Number',
|
|
output: 'value',
|
|
to: 'Add',
|
|
input: 'b',
|
|
type: 'number',
|
|
name: '',
|
|
});
|
|
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
|
|
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
|
|
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first])).not.toBe(
|
|
edgeSignature(f.state.project, f.behaviorId, f.second, [f.second]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('a picked transition', () => {
|
|
function machine() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const create = new CreateAssetCommand('statemachine', 'Guard');
|
|
expect(state.execute(create)).toBe(true);
|
|
const id = create.asset.id;
|
|
const [patrol, chase] = ['Patrol', 'Chase'].map((name) => {
|
|
const add = new AddNodeCommand(id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
|
|
expect(state.execute(add)).toBe(true);
|
|
return add.node.id;
|
|
}) as [string, string];
|
|
const connect = new ConnectCommand(
|
|
id,
|
|
{ node: patrol, port: STATE_OUT },
|
|
{ node: chase, port: STATE_IN },
|
|
);
|
|
expect(state.execute(connect)).toBe(true);
|
|
return { state, id, edge: connect.edge.id };
|
|
}
|
|
|
|
it('is described by its two states and its name, and goes as a transition', () => {
|
|
const m = machine();
|
|
expect(m.state.execute(new NameTransitionCommand(m.id, m.edge, 'alarm'))).toBe(true);
|
|
expect(describeEdge(m.state.project, m.id, m.edge)).toMatchObject({
|
|
transition: true,
|
|
from: 'Patrol',
|
|
to: 'Chase',
|
|
name: 'alarm',
|
|
});
|
|
m.state.select(edgeSelection(m.id, [m.edge]));
|
|
expect(m.state.selectedStateMachineId).toBe(m.id);
|
|
expect(removeSelection(m.state)).toBe(true);
|
|
expect(findGraphAsset(m.state.project, m.id)!.graph.edges).toEqual([]);
|
|
});
|
|
});
|