/** * A rubber band spans a piece of the graph, and what lies in that piece is what it caught – groups * and comments included. They are not nodes, though: a group wraps the nodes it holds and therefore * touches every band drawn over any of them, so touching cannot be the rule for a box. The band has * to hold all of it. From then on both sorts travel and die together, in one undo step. */ import { beforeEach, describe, expect, it } from 'vitest'; import { AddAnnotationCommand } from '../../src/core/commands/annotationCommands'; import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { annotationsOf, type Graph } from '../../src/core/graph'; import { createEmptyProject, findGraphAsset } from '../../src/core/model/Project'; import { removeGraphSelection } from '../../src/editor/actions'; import { type Box, boxesInBand, boxesWithinBand } from '../../src/editor/framing'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { annotationSelection, EditorState, graphSelection, sameSelection } from '../../src/editor/state'; function fixture() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const create = new CreateAssetCommand('behavior', 'B'); // A rejected command only returns false; an unasserted setup would let assertions pass emptily. expect(state.execute(create)).toBe(true); const behaviorId = create.asset.id; const graphOf = (): Graph => findGraphAsset(state.project, behaviorId)!.graph; const addNode = (): string => { const command = new AddNodeCommand(behaviorId, 'Number', 0, 0); expect(state.execute(command)).toBe(true); return command.node.id; }; const addBox = (kind: 'group' | 'note'): string => { const command = new AddAnnotationCommand(behaviorId, kind, { x: 0, y: 0, w: 200, h: 120 }, 'T'); expect(state.execute(command)).toBe(true); return command.annotation.id; }; return { state, behaviorId, graphOf, addNode, addBox }; } const boxes: Box[] = [ { id: 'group', x: 0, y: 0, w: 300, h: 200 }, { id: 'note', x: 400, y: 0, w: 100, h: 60 }, ]; describe('what a rubber band catches', () => { it('holds a box only when the band encloses all of it', () => { // A band across the middle of the group – the gesture one makes to catch the nodes inside it. const across = { x1: 50, y1: 50, x2: 250, y2: 150 }; expect(boxesInBand(boxes, across)).toEqual(['group']); expect(boxesWithinBand(boxes, across)).toEqual([]); }); it('catches the box once the band is drawn around it, whichever way it is drawn', () => { expect(boxesWithinBand(boxes, { x1: -10, y1: -10, x2: 320, y2: 220 })).toEqual(['group']); // Right to left and bottom to top mean the same rectangle. expect(boxesWithinBand(boxes, { x1: 320, y1: 220, x2: -10, y2: -10 })).toEqual(['group']); }); it('leaves out the one that only pokes in, and takes it when the band grows past it', () => { expect(boxesWithinBand(boxes, { x1: -10, y1: -10, x2: 460, y2: 220 })).toEqual(['group']); expect(boxesWithinBand(boxes, { x1: -10, y1: -10, x2: 520, y2: 220 })).toEqual(['group', 'note']); }); }); describe('a selection holding nodes and boxes', () => { let f: ReturnType; beforeEach(() => { f = fixture(); }); it('stays a node selection when the band caught both, so the inspector still shows a node', () => { const node = f.addNode(); const group = f.addBox('group'); f.state.select(graphSelection(f.behaviorId, [node], [group])); expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: node }); expect(f.state.selectedNodeIds).toEqual([node]); expect(f.state.selectedAnnotationIds).toEqual([group]); }); it('becomes a box selection when the band caught boxes alone', () => { const group = f.addBox('group'); const note = f.addBox('note'); f.state.select(graphSelection(f.behaviorId, [], [group, note])); expect(f.state.selection).toMatchObject({ kind: 'annotation', annotationId: note }); expect(f.state.selectedAnnotationIds).toEqual([group, note]); }); it('is a different selection once the boxes differ, even with the same nodes', () => { const node = f.addNode(); const group = f.addBox('group'); const without = graphSelection(f.behaviorId, [node], []); const with_ = graphSelection(f.behaviorId, [node], [group]); expect(sameSelection(without, graphSelection(f.behaviorId, [node], []))).toBe(true); expect(sameSelection(without, with_)).toBe(false); }); it('keeps the boxes when a ctrl-click adds a node', () => { const first = f.addNode(); const second = f.addNode(); const group = f.addBox('group'); f.state.select(graphSelection(f.behaviorId, [first], [group])); f.state.toggleNode(f.behaviorId, second); expect(f.state.selectedNodeIds).toEqual([first, second]); expect(f.state.selectedAnnotationIds).toEqual([group]); }); it('falls back to the boxes when the last node is toggled off, not to the asset', () => { const node = f.addNode(); const group = f.addBox('group'); f.state.select(graphSelection(f.behaviorId, [node], [group])); f.state.toggleNode(f.behaviorId, node); expect(f.state.selection).toMatchObject({ kind: 'annotation', annotationId: group }); }); it('accepts the short form a test hook passes for a box, filling in the list', () => { const group = f.addBox('group'); f.state.select({ kind: 'annotation', assetId: f.behaviorId, annotationId: group } as never); expect(f.state.selectedAnnotationIds).toEqual([group]); }); }); describe('deleting what the band caught', () => { let f: ReturnType; beforeEach(() => { f = fixture(); }); it('takes nodes and boxes in one undo step, and gives both back', () => { const node = f.addNode(); const kept = f.addNode(); const group = f.addBox('group'); f.state.select(graphSelection(f.behaviorId, [node], [group])); expect(removeGraphSelection(f.state, f.behaviorId, [node], [group])).toBe(true); expect(f.graphOf().nodes.map((n) => n.id)).toEqual([kept]); expect(annotationsOf(f.graphOf(), 'group')).toHaveLength(0); f.state.undo(); expect(f.graphOf().nodes).toHaveLength(2); expect(annotationsOf(f.graphOf(), 'group')).toHaveLength(1); }); it('still leaves the nodes a group holds alone – deleting a box is not deleting its content', () => { const node = f.addNode(); const group = f.addBox('group'); removeGraphSelection(f.state, f.behaviorId, [], [group]); expect(f.graphOf().nodes.map((n) => n.id)).toEqual([node]); }); it('drops a deleted box from a mixed selection but keeps the nodes', () => { const node = f.addNode(); const group = f.addBox('group'); f.state.select(graphSelection(f.behaviorId, [node], [group])); removeGraphSelection(f.state, f.behaviorId, [], [group]); expect(f.state.selectedNodeIds).toEqual([node]); expect(f.state.selectedAnnotationIds).toEqual([]); }); it('keeps the surviving box when one of several goes', () => { const group = f.addBox('group'); const note = f.addBox('note'); f.state.select(annotationSelection(f.behaviorId, [group, note])); removeGraphSelection(f.state, f.behaviorId, [], [note]); expect(f.state.selectedAnnotationIds).toEqual([group]); }); it('refuses an empty ask, so no empty step lands on the undo stack', () => { const node = f.addNode(); const group = f.addBox('group'); removeGraphSelection(f.state, f.behaviorId, [node], [group]); expect(removeGraphSelection(f.state, f.behaviorId, [], [])).toBe(false); // One undo, not two: the refused ask left nothing behind that a first undo would spend itself on. f.state.undo(); expect(f.graphOf().nodes).toHaveLength(1); expect(annotationsOf(f.graphOf(), 'group')).toHaveLength(1); }); });