import { describe, expect, it } from 'vitest'; import type { FrameRect } from '../../src/core/model/texture'; import { borderAt, borderLine, dragBorder, dragFrame, fitView, type FrameHandle, handleAt, handleCursor, handlePoint, imageToLocal, localToImage, MAX_SHEET_ZOOM, MIN_SHEET_ZOOM, rectBetween, zoomAbout, } from '../../src/editor/texture/sheetView'; const RECT: FrameRect = { x: 10, y: 20, w: 40, h: 30 }; describe('sheet view mapping', () => { it('maps image pixels to the widget and back', () => { const view = { panX: 100, panY: 50, zoom: 2 }; expect(imageToLocal(view, 0, 0)).toEqual({ x: 100, y: 50 }); expect(imageToLocal(view, 8, 4)).toEqual({ x: 116, y: 58 }); expect(localToImage(view, 116, 58)).toEqual({ x: 8, y: 4 }); }); it('fits a picture centred, with the same margin on both axes', () => { // A 100x50 picture in a 400x400 widget is limited by its width. const view = fitView(100, 50, 400, 400); expect(view.zoom).toBeCloseTo(3.6); expect(view.panX).toBeCloseTo((400 - 100 * 3.6) / 2); expect(view.panY).toBeCloseTo((400 - 50 * 3.6) / 2); // Fitting only has to guarantee that the whole picture is inside the widget. const end = imageToLocal(view, 100, 50); expect(view.panX).toBeGreaterThanOrEqual(0); expect(end.x).toBeLessThanOrEqual(400); expect(end.y).toBeLessThanOrEqual(400); }); it('has a defined answer for a picture that has not been measured', () => { expect(fitView(0, 0, 400, 400)).toEqual({ panX: 0, panY: 0, zoom: 1 }); expect(fitView(100, 50, 0, 0)).toEqual({ panX: 0, panY: 0, zoom: 1 }); }); it('keeps the pixel under the cursor under the cursor while zooming', () => { const view = { panX: 100, panY: 50, zoom: 2 }; const before = localToImage(view, 300, 260); const zoomed = zoomAbout(view, 1.5, 300, 260); expect(zoomed.zoom).toBeCloseTo(3); expect(localToImage(zoomed, 300, 260).x).toBeCloseTo(before.x); expect(localToImage(zoomed, 300, 260).y).toBeCloseTo(before.y); }); it('clamps the zoom at both ends', () => { const view = { panX: 0, panY: 0, zoom: 1 }; expect(zoomAbout(view, 1e6, 0, 0).zoom).toBe(MAX_SHEET_ZOOM); expect(zoomAbout(view, 1e-6, 0, 0).zoom).toBe(MIN_SHEET_ZOOM); }); }); describe('grabbing a frame', () => { it('finds each of the eight handles at its own corner or edge', () => { expect(handlePoint(RECT, { hx: -1, hy: -1 })).toEqual({ x: 10, y: 20 }); expect(handlePoint(RECT, { hx: 1, hy: 1 })).toEqual({ x: 50, y: 50 }); expect(handlePoint(RECT, { hx: 0, hy: -1 })).toEqual({ x: 30, y: 20 }); expect(handleAt(RECT, { x: 10, y: 20 }, 2)).toEqual({ hx: -1, hy: -1 }); expect(handleAt(RECT, { x: 50, y: 20 }, 2)).toEqual({ hx: 1, hy: -1 }); expect(handleAt(RECT, { x: 30, y: 50 }, 2)).toEqual({ hx: 0, hy: 1 }); }); it('grabs the inside as a move and misses outside entirely', () => { expect(handleAt(RECT, { x: 30, y: 35 }, 2)).toBe('move'); expect(handleAt(RECT, { x: 5, y: 35 }, 2)).toBeNull(); expect(handleAt(RECT, { x: 30, y: 60 }, 2)).toBeNull(); }); it('prefers a corner where handles overlap, so a tiny frame stays resizable', () => { // A 4x4 frame with a 6 px tolerance: every handle matches its centre, and a grab there // must resize, or the frame could never be adjusted. const tiny: FrameRect = { x: 0, y: 0, w: 4, h: 4 }; expect(handleAt(tiny, { x: 2, y: 2 }, 6)).toEqual({ hx: -1, hy: -1 }); }); it('names a cursor for every handle', () => { expect(handleCursor('move')).toBe('move'); expect(handleCursor({ hx: -1, hy: -1 })).toBe('nwse-resize'); expect(handleCursor({ hx: 1, hy: -1 })).toBe('nesw-resize'); expect(handleCursor({ hx: 0, hy: 1 })).toBe('ns-resize'); expect(handleCursor({ hx: -1, hy: 0 })).toBe('ew-resize'); }); }); describe('dragging a frame', () => { it('moves without changing the size', () => { expect(dragFrame(RECT, 'move', 5, -7)).toEqual({ x: 15, y: 13, w: 40, h: 30 }); }); it('pins the opposite edge when a corner is dragged', () => { const dragged = dragFrame(RECT, { hx: -1, hy: -1 }, 4, 6); expect(dragged).toEqual({ x: 14, y: 26, w: 36, h: 24 }); expect(dragged.x + dragged.w).toBe(RECT.x + RECT.w); expect(dragged.y + dragged.h).toBe(RECT.y + RECT.h); }); it('moves only its own axis when an edge is dragged', () => { expect(dragFrame(RECT, { hx: 0, hy: 1 }, 9, 5)).toEqual({ x: 10, y: 20, w: 40, h: 35 }); expect(dragFrame(RECT, { hx: 1, hy: 0 }, 9, 5)).toEqual({ x: 10, y: 20, w: 49, h: 30 }); }); it('is allowed to invert – squaring that up belongs to one place only', () => { // The negative width is on purpose: `normalizeFrameRect` corrects a drag and a typed // value identically. const dragged = dragFrame(RECT, { hx: -1, hy: 0 }, 60, 0); expect(dragged.w).toBeLessThan(0); }); it('spans a rectangle between two points in either order', () => { const forward = rectBetween({ x: 10, y: 20 }, { x: 30, y: 50 }); expect(forward).toEqual({ x: 10, y: 20, w: 20, h: 30 }); expect(rectBetween({ x: 30, y: 50 }, { x: 10, y: 20 })).toEqual(forward); }); it('walks a frame through the eight handles back to where it started', () => { // A sign error in any of the eight branches breaks the drag-and-back identity. const handles: FrameHandle[] = [ 'move', { hx: -1, hy: -1 }, { hx: 1, hy: -1 }, { hx: -1, hy: 1 }, { hx: 1, hy: 1 }, { hx: 0, hy: -1 }, { hx: 0, hy: 1 }, { hx: -1, hy: 0 }, { hx: 1, hy: 0 }, ]; for (const handle of handles) expect(dragFrame(dragFrame(RECT, handle, 3, 4), handle, -3, -4)).toEqual(RECT); }); }); describe('nine-slice border lines', () => { const BORDER = { left: 4, top: 6, right: 8, bottom: 10 }; it('runs each line beside its own edge, the far ones measured from the far side', () => { expect(borderLine(RECT, BORDER, 'left')).toEqual({ x1: 14, y1: 20, x2: 14, y2: 50 }); expect(borderLine(RECT, BORDER, 'right')).toEqual({ x1: 42, y1: 20, x2: 42, y2: 50 }); expect(borderLine(RECT, BORDER, 'top')).toEqual({ x1: 10, y1: 26, x2: 50, y2: 26 }); expect(borderLine(RECT, BORDER, 'bottom')).toEqual({ x1: 10, y1: 40, x2: 50, y2: 40 }); }); it('grabs a line only near it and only within the frame', () => { expect(borderAt(RECT, BORDER, { x: 14, y: 35 }, 2)).toBe('left'); expect(borderAt(RECT, BORDER, { x: 30, y: 40 }, 2)).toBe('bottom'); expect(borderAt(RECT, BORDER, { x: 30, y: 35 }, 2)).toBeNull(); // Far outside the frame the lines are not offered, however close the axis would be. expect(borderAt(RECT, BORDER, { x: 14, y: 200 }, 2)).toBeNull(); }); it('moves the near border with the pointer and the far one against it', () => { // Both grow when the pointer goes towards the middle, which is what the eye expects of a line // that is dragged inwards. expect(dragBorder(BORDER, 'left', 3, 0).left).toBe(7); expect(dragBorder(BORDER, 'right', -3, 0).right).toBe(11); expect(dragBorder(BORDER, 'top', 0, 3).top).toBe(9); expect(dragBorder(BORDER, 'bottom', 0, -3).bottom).toBe(13); // And each line moves only its own border. expect(dragBorder(BORDER, 'left', 3, 3)).toEqual({ ...BORDER, left: 7 }); }); });