src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat under graph/ although the scene view wants the same bounds sum. The graph, the texture and the container tab repeated the same header, subscription and disposal code with two silent divergences: the graph tab said "nothing shown" with null instead of '', and the texture tab refell back to the first asset on a selection change but not on a project event. These arrive together because GraphCanvas ties them: it reads framing from its new home, hands its fold marks to folding.ts, and its '' change is what GraphEditor must agree with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.2 KiB
TypeScript
112 lines
4.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { boundsOf, type Box, boxesInBand, frameBoxes } 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('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('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);
|
||
});
|
||
});
|