Files
feuerwerk/tests/editor/graph-framing.test.ts
T
adminandClaude Opus 5 b9f1a9d319
game-maker / suite (push) Successful in 6m13s
A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
The graph could hold any number of nodes and said nothing about which of them
belonged together. Groups and comments are two separate things, as decided in
the issue: a group is a titled box that carries the nodes it encloses, a
comment is free text that carries nothing.

Both live as optional arrays on Graph, not as registry nodes. A comment that
were a real node would run through the interpreter, show up in the drop
palettes and be compiled into a shader — the wrong place for a piece of text.
As arrays on Graph they exist in all four graph kinds at once, and a document
without them means exactly "no groups", so no format bump.

Membership is computed when the box is grabbed, never stored: the center of a
node inside the box travels with it, and a node dragged out is simply out. A
box inside a box travels with the outer one, its own nodes included. A group
is grabbed by its title bar only — its body stays transparent to the pointer,
or a rubber band could never be drawn across the nodes it holds. Deleting a
box deletes the box; the nodes stay where they are, because a box that
silently took ten nodes with it would be the most expensive surprise in the
feature.

The color is a free picker, and the title color follows from it: relative
luminance per WCAG, threshold at 0.179 where the two contrast ratios meet —
not 0.5, which would pick white on mid grey where black reads better.

Resizing snaps the far edge to the grid, not the width, so the corner that is
not being dragged stays put. The minimum size rises in whole steps for that
reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat
off-grid in exactly the mode meant to keep it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:24:34 +02:00

183 lines
6.8 KiB
TypeScript
Raw 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.
import { describe, expect, it } from 'vitest';
import {
boundsOf,
type Box,
boxesInBand,
dragOffset,
frameBoxes,
resizeSize,
} 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('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);
});
});