Files
feuerwerk/tests/editor/graph-band-selection.test.ts
adminandClaude Opus 5 36ccc7626f
game-maker / suite (push) Successful in 6m22s
A box drawn around a part of the graph is part of it, so a fit frames it and a band catches it (#95)
Fit measured the nodes alone: a group drawn around them was cut off, and a graph holding nothing
but a comment framed the empty origin. It now frames both sorts together.

The rubber band ignored boxes entirely. It catches them now, into the *same* selection as the
nodes — `nodeIds` and `annotationIds` side by side — so one drag moves all of it, one Delete
removes all of it, and one undo takes it back. A box counts only when the band holds all of it,
where a node counts on touching: a group wraps the nodes it holds and therefore overlaps every
band drawn over any of them, and since it drags those nodes along, catching it by accident would
be felt at once.

A node inside a selected group is moved once, not twice — `travellers()` collects everything one
gesture carries and lets each thing appear in it a single time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:23:56 +02:00

176 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<typeof fixture>;
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<typeof fixture>;
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);
});
});