/** * 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; 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([]); }); });