The user asked to see what the agent is working on through MCP. Until now the agent had to call open for that, and mostly did not. - After run_commands the editor shows what the call worked on last. A graph opens its tab with the touched nodes selected, deleted ones left out. A scene or container shows with its objects selected. Any other asset shows in the inspector. - Objects in another scene switch the scene inside the same undo step. A switch of its own would be its own step, and Ctrl+Z would take back the switch before the agent's edit. - A graph tab that is open already moves its camera after the nodes. It stays put while they are in view, moves only as far as needed with the zoom kept, and frames them like Fit only when they do not fit. This is revealBoxes in framing.ts and the state's reveal event. - A tab that has just opened frames itself first and moves after that. - The account menu has "Follow agent" below "Agent", on by default and kept in the browser. While the game plays the editor never follows. - The run_commands description and the relay's instructions tell the agent about it. Tests: - agent-edit.test.ts: 3 new tests, each red with its bent-back fix (scene switch outside the undo step, deleted nodes selected, switch ignored) - graph-framing.test.ts 29/29, 6 of them new - e2e 71 on melektaus-atx: ALL PASSED, 20 checks. 71g4 adds a node 3000 px to the right of an open tab: it ends 24 px inside the edge, zoom still 1 - Tried live on a test project through a real MCP session: the tab opened, the nodes and the object were selected. With the old build a far node stayed out of view, after the reload the camera went there - Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4931 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
8.4 KiB
TypeScript
224 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
boundsOf,
|
||
type Box,
|
||
boxesInBand,
|
||
dragOffset,
|
||
frameBoxes,
|
||
resizeSize,
|
||
revealBoxes,
|
||
} from '../../src/editor/framing';
|
||
|
||
const LIMITS = { min: 0.25, max: 2.5 };
|
||
|
||
/** Two nodes side by side, the right one twice as tall. */
|
||
const boxes: Box[] = [
|
||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||
{ id: 'b', x: 200, y: 0, w: 100, h: 120 },
|
||
];
|
||
|
||
describe('rubber band', () => {
|
||
it('catches a node the band merely touches, not only one it encloses', () => {
|
||
// The band clips a's right edge and never reaches b.
|
||
expect(boxesInBand(boxes, { x1: 90, y1: 10, x2: 150, y2: 20 })).toEqual(['a']);
|
||
});
|
||
|
||
it('catches both when the band spans them', () => {
|
||
expect(boxesInBand(boxes, { x1: -10, y1: -10, x2: 310, y2: 130 })).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('reads a band dragged up and to the left the same way', () => {
|
||
expect(boxesInBand(boxes, { x1: 310, y1: 130, x2: -10, y2: -10 })).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('catches nothing in empty space', () => {
|
||
expect(boxesInBand(boxes, { x1: 120, y1: 200, x2: 180, y2: 260 })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('drag offset', () => {
|
||
const STEP = 24;
|
||
|
||
it('rounds to whole pixels without snapping', () => {
|
||
expect(dragOffset({ x: 10, y: 10 }, 12.4, -7.6, 0)).toEqual({ x: 12, y: -8 });
|
||
});
|
||
|
||
it('puts a dragged node on the grid, wherever it started', () => {
|
||
// Started off-grid at 10: the drop is a multiple of 24, not 10 plus a multiple.
|
||
const off = dragOffset({ x: 10, y: 10 }, 50, 50, STEP);
|
||
expect({ x: 10 + off.x, y: 10 + off.y }).toEqual({ x: 72, y: 72 });
|
||
});
|
||
|
||
it('gives two differently placed nodes different offsets, so both land on the grid', () => {
|
||
// The same gesture over a selection: 10 and 17 are 7 apart and both end up on a grid line.
|
||
const a = dragOffset({ x: 10, y: 0 }, 50, 0, STEP);
|
||
const b = dragOffset({ x: 17, y: 0 }, 50, 0, STEP);
|
||
expect(a.x).not.toBe(b.x);
|
||
expect(10 + a.x).toBe(72);
|
||
expect(17 + b.x).toBe(72);
|
||
});
|
||
|
||
it('takes the nearest grid line, in both directions', () => {
|
||
expect(dragOffset({ x: 0, y: 0 }, 11, 13, STEP)).toEqual({ x: 0, y: 24 });
|
||
expect(dragOffset({ x: 0, y: 0 }, -11, -13, STEP)).toEqual({ x: 0, y: -24 });
|
||
});
|
||
|
||
it('still moves a node that starts on the grid', () => {
|
||
// A snapped drag shorter than half a step is the one case that could deadlock into "no move".
|
||
expect(dragOffset({ x: 48, y: 48 }, 20, 4, STEP)).toEqual({ x: 24, y: 0 });
|
||
});
|
||
});
|
||
|
||
describe('resize size', () => {
|
||
const STEP = 24;
|
||
const MIN = { w: 80, h: 48 };
|
||
|
||
it('follows the pointer exactly without snapping', () => {
|
||
expect(resizeSize({ x: 10, y: 10, w: 100, h: 60 }, 12.4, -7.6, 0, MIN)).toEqual({
|
||
w: 112.4,
|
||
h: 52.4,
|
||
});
|
||
});
|
||
|
||
it('puts the dragged corner on the grid, wherever the box starts', () => {
|
||
// Off-grid at 10: the far edge is a multiple of 24, so the width is not.
|
||
const size = resizeSize({ x: 10, y: 10, w: 100, h: 60 }, 5, 5, STEP, MIN);
|
||
expect({ right: 10 + size.w, bottom: 10 + size.h }).toEqual({ right: 120, bottom: 72 });
|
||
});
|
||
|
||
it('leaves the near corner alone – only the size changes', () => {
|
||
const start = { x: 24, y: 24, w: 120, h: 72 };
|
||
const size = resizeSize(start, -30, -30, STEP, MIN);
|
||
expect(size).toEqual({ w: 96, h: 48 });
|
||
});
|
||
|
||
it('honours the minimum in whole steps, so a shrunk box stays on the grid', () => {
|
||
// Dragged far past the minimum: 80 wide would leave the right edge between two grid lines.
|
||
const size = resizeSize({ x: 0, y: 0, w: 320, h: 200 }, -400, -400, STEP, MIN);
|
||
expect(size).toEqual({ w: 96, h: 48 });
|
||
expect(size.w).toBeGreaterThanOrEqual(MIN.w);
|
||
});
|
||
});
|
||
|
||
describe('fit', () => {
|
||
/** Where a graph point lands on screen under a framing. */
|
||
const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({
|
||
x: x * f.zoom + f.panX,
|
||
y: y * f.zoom + f.panY,
|
||
});
|
||
|
||
it('leaves no node outside the view – the reported bug', () => {
|
||
// Wider than the view at zoom 1, so framing has to zoom out rather than just centre.
|
||
const view = { w: 200, h: 200 };
|
||
const f = frameBoxes(boxes, view, 24, LIMITS);
|
||
expect(f.zoom).toBeLessThan(1);
|
||
for (const b of boxes) {
|
||
const topLeft = toScreen(f, b.x, b.y);
|
||
const bottomRight = toScreen(f, b.x + b.w, b.y + b.h);
|
||
expect(topLeft.x).toBeGreaterThanOrEqual(0);
|
||
expect(topLeft.y).toBeGreaterThanOrEqual(0);
|
||
expect(bottomRight.x).toBeLessThanOrEqual(view.w);
|
||
expect(bottomRight.y).toBeLessThanOrEqual(view.h);
|
||
}
|
||
});
|
||
|
||
it('measures each node by its own height', () => {
|
||
// b is twice as tall as a: pretending both were 60 high would frame too little.
|
||
const tall = frameBoxes([{ id: 'b', x: 0, y: 0, w: 100, h: 400 }], { w: 200, h: 200 }, 24, LIMITS);
|
||
const short = frameBoxes([{ id: 'a', x: 0, y: 0, w: 100, h: 60 }], { w: 200, h: 200 }, 24, LIMITS);
|
||
expect(tall.zoom).toBeLessThan(short.zoom);
|
||
});
|
||
|
||
it('centres what it frames', () => {
|
||
const view = { w: 200, h: 200 };
|
||
const f = frameBoxes(boxes, view, 24, LIMITS);
|
||
const centre = toScreen(f, 150, 60);
|
||
expect(centre.x).toBeCloseTo(100);
|
||
expect(centre.y).toBeCloseTo(100);
|
||
});
|
||
|
||
it('does not blow a small graph up past its natural size', () => {
|
||
const f = frameBoxes([{ id: 'a', x: 0, y: 0, w: 10, h: 10 }], { w: 800, h: 600 }, 24, LIMITS);
|
||
expect(f.zoom).toBe(1);
|
||
});
|
||
|
||
it('never zooms out past the limit, however far the nodes are apart', () => {
|
||
const far: Box[] = [
|
||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||
{ id: 'b', x: 100000, y: 0, w: 100, h: 60 },
|
||
];
|
||
expect(frameBoxes(far, { w: 200, h: 200 }, 24, LIMITS).zoom).toBe(LIMITS.min);
|
||
});
|
||
|
||
it('centres the view on an empty graph instead of returning nonsense', () => {
|
||
const f = frameBoxes([], { w: 200, h: 100 }, 24, LIMITS);
|
||
expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 });
|
||
});
|
||
});
|
||
|
||
describe('reveal (#429)', () => {
|
||
const view = { w: 400, h: 300 };
|
||
const at = { zoom: 1, panX: 0, panY: 0 };
|
||
|
||
it('leaves the view alone when the nodes are in it already', () => {
|
||
expect(revealBoxes(boxes, at, view, 24, LIMITS)).toEqual(at);
|
||
});
|
||
|
||
it('moves just far enough to bring a node off to the right in, keeping the zoom', () => {
|
||
// Right edge at 700, the picture ends at 400 - 24: moved left by 324, not centred.
|
||
const f = revealBoxes([{ id: 'c', x: 600, y: 100, w: 100, h: 60 }], at, view, 24, LIMITS);
|
||
expect(f).toEqual({ zoom: 1, panX: -324, panY: 0 });
|
||
});
|
||
|
||
it('moves up and left alike', () => {
|
||
const f = revealBoxes([{ id: 'c', x: -200, y: -100, w: 100, h: 60 }], at, view, 24, LIMITS);
|
||
expect(f).toEqual({ zoom: 1, panX: 224, panY: 124 });
|
||
});
|
||
|
||
it('counts the zoom: what fits at 1 can stick out at 2', () => {
|
||
const f = revealBoxes(boxes, { zoom: 2, panX: 0, panY: 0 }, { w: 800, h: 600 }, 24, LIMITS);
|
||
// At zoom 2 the two nodes end at 600 on screen and are 240 tall: inside, untouched.
|
||
expect(f).toEqual({ zoom: 2, panX: 0, panY: 0 });
|
||
const g = revealBoxes(boxes, { zoom: 2, panX: 0, panY: 0 }, view, 24, LIMITS);
|
||
expect(g.zoom).toBeLessThan(2);
|
||
});
|
||
|
||
it('zooms out to frame them only when they are too large for the picture', () => {
|
||
const wide: Box[] = [
|
||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||
{ id: 'b', x: 900, y: 0, w: 100, h: 60 },
|
||
];
|
||
expect(revealBoxes(wide, at, view, 24, LIMITS)).toEqual(frameBoxes(wide, view, 24, LIMITS));
|
||
});
|
||
|
||
it('keeps the view for no nodes at all', () => {
|
||
expect(revealBoxes([], at, view, 24, LIMITS)).toEqual(at);
|
||
});
|
||
});
|
||
|
||
describe('boundsOf', () => {
|
||
it('encloses every box', () => {
|
||
const boxes: Box[] = [
|
||
{ id: 'a', x: 10, y: 20, w: 100, h: 60 },
|
||
{ id: 'b', x: -40, y: 5, w: 20, h: 10 },
|
||
];
|
||
expect(boundsOf(boxes)).toEqual({ minX: -40, minY: 5, maxX: 110, maxY: 80 });
|
||
});
|
||
|
||
it('reports nothing for no boxes at all', () => {
|
||
expect(boundsOf([])).toBeNull();
|
||
});
|
||
|
||
/** The scene view seeds the origin so a template drawn far to one side cannot hide it. */
|
||
it('keeps a seed inside the result even when every box is far away', () => {
|
||
const far: Box[] = [{ id: 'a', x: 500, y: 500, w: 10, h: 10 }];
|
||
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||
expect(boundsOf(far, origin)).toEqual({ minX: 0, minY: 0, maxX: 510, maxY: 510 });
|
||
});
|
||
|
||
it('still answers for no boxes when a seed is given', () => {
|
||
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||
expect(boundsOf([], origin)).toEqual(origin);
|
||
});
|
||
});
|