Files
feuerwerk/tests/editor/graph-framing.test.ts
T
adminandClaude Opus 5.5 4027afc228
game-maker / gate (push) Successful in 3m1s
game-maker / suite (push) Failing after 8m34s
Let Fit fill the tile tab instead of stopping at 100 percent
Fit in the tile tab left the board small in the middle of the tab.
It used frameBoxes, which never zooms past 1 so that two graph nodes
do not blow up to fill the screen. The tile board is measured in image
pixels, though, and with 16 px tiles it is only about 345 x 186 px.

- frameBoxes takes a new cap parameter, default 1. The graph and the
  node board keep their behaviour.
- The tile tab passes its zoom limit of 6, so Fit fills the tab up
  to 600 percent.
- TileCanvas remembers whether the view is still the one Fit set.
  While it is, a change in the size of the tab or of the board fits
  again. After a pan or zoom by hand the view stays until Fit.

Found on the way: switching the image to another tile size (48 to
16 px) kept the old zoom, and the board shrank to a third. The refit
above covers that too.

Tests:
- ./check.sh: 161 files, 2370 tests, CHECK EXIT 0 (was 2369).
- New unit test in graph-framing: with a raised cap Fit fills the
  view up to the limit. The graph test for "not past 100 %" holds.
- e2e 39: ALL PASSED, 31 checks (was 29). Fit fills the tab at 48 px
  (zoom 0.41) and fits again after the switch to 16 px (zoom 1.24).
- Counter probes: a cap of 1 and no refit on a new board size each
  fail exactly the 16 px check (zoom 1 and zoom 0.41).
- e2e sum in the README is 1384, computed, not yet seen in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:35:50 +02:00

190 lines
7.2 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('fills the view up to a raised cap – the Tile tab, whose board is a few tiles of 16 px', () => {
const board: Box[] = [{ id: 'board', x: 0, y: 0, w: 345, h: 186 }];
const f = frameBoxes(board, { w: 1000, h: 600 }, 16, { min: 0.1, max: 6 }, 6);
expect(f.zoom).toBeCloseTo((1000 - 32) / 345);
expect(frameBoxes(board, { w: 10000, h: 6000 }, 16, { min: 0.1, max: 6 }, 6).zoom).toBe(6);
});
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);
});
});