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); }); });