import { describe, expect, test } from 'bun:test'; import { type CropperGeometry, CropperEngine } from './engine.ts'; import { fractionToZoom, rotatePoint, rotatedSize, zoomToFraction } from './geometry.ts'; import type { CropValue, Point, Rotation } from './types.ts'; const SOURCE = { width: 1600, height: 900 }; const STAGE = { width: 800, height: 600 }; const setup = ( geometry: Partial = {}, config?: ConstructorParameters[0], ) => { const engine = new CropperEngine(config); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: STAGE, ...geometry }); return engine; }; const sourceUnder = (engine: CropperEngine, focal: Point): Point => { const { cropWindow, value } = engine.getState(); const scale = cropWindow.width / value.width; return { x: value.x + (focal.x - cropWindow.x) / scale, y: value.y + (focal.y - cropWindow.y) / scale }; }; const centerOf = (engine: CropperEngine): Point => { const { value } = engine.getState(); return { x: value.x + value.width / 2, y: value.y + value.height / 2 }; }; const expectInsideSource = (engine: CropperEngine) => { const { value } = engine.getState(); const r = rotatedSize(SOURCE, value.rotation); expect(value.width).toBeLessThanOrEqual(r.width + 1e-9); expect(value.height).toBeLessThanOrEqual(r.height + 1e-9); expect(value.x).toBeGreaterThanOrEqual(-1e-9); expect(value.y).toBeGreaterThanOrEqual(-1e-9); expect(value.x + value.width).toBeLessThanOrEqual(r.width + 1e-9); expect(value.y + value.height).toBeLessThanOrEqual(r.height + 1e-9); }; describe('initial crop', () => { test('is the largest crop the source allows, centered', () => { const { value, zoom } = setup().getState(); expect(value).toEqual({ rotation: 0, x: 350, y: 0, width: 900, height: 900 }); expect(zoom).toBe(1); }); test('follows the source ratio when none is given', () => { const { value } = setup({ aspectRatio: undefined }).getState(); expect(value).toEqual({ rotation: 0, x: 0, y: 0, width: 1600, height: 900 }); }); test('is reported as empty until the natural size is known', () => { const engine = new CropperEngine(); engine.setGeometry({ aspectRatio: 1, natural: null, viewport: STAGE }); expect(engine.getState().value.width).toBe(0); expect(engine.getState().image.width).toBe(0); }); test('honors a supplied crop once the source lands', () => { const engine = new CropperEngine({ value: { rotation: 0, x: 10, y: 20, width: 400, height: 400 } }); engine.setGeometry({ aspectRatio: 1, natural: null, viewport: STAGE }); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: STAGE }); expect(engine.getState().value).toEqual({ rotation: 0, x: 10, y: 20, width: 400, height: 400 }); }); }); describe('the crop window', () => { test('is the largest box of the target ratio fitted into the stage', () => { expect(setup().getState().cropWindow).toEqual({ x: 100, y: 0, width: 600, height: 600 }); }); test('is inset by the configured margins', () => { const engine = setup({}, { config: { insets: { top: 50, right: 50, bottom: 50, left: 50 } } }); expect(engine.getState().cropWindow).toEqual({ x: 150, y: 50, width: 500, height: 500 }); }); }); describe('stage resizing', () => { test('leaves the source-pixel value untouched', () => { const engine = setup(); engine.setZoom(2.5); const before = engine.getState().value; engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: { width: 400, height: 1000 } }); expect(engine.getState().value).toEqual(before); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: { width: 1920, height: 137 } }); expect(engine.getState().value).toEqual(before); }); test('still moves the window and the image layer', () => { const engine = setup(); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: { width: 300, height: 300 } }); expect(engine.getState().cropWindow).toEqual({ x: 0, y: 0, width: 300, height: 300 }); }); }); describe('zoom', () => { test('holds the crop center still when anchored at the window center', () => { const engine = setup(); const before = centerOf(engine); const { cropWindow: w } = engine.getState(); engine.wheel({ x: w.x + w.width / 2, y: w.y + w.height / 2 }, -300); expect(engine.getState().zoom).toBeGreaterThan(1); expect(centerOf(engine).x).toBeCloseTo(before.x, 9); expect(centerOf(engine).y).toBeCloseTo(before.y, 9); }); test('holds the point under the cursor still when anchored off center', () => { const engine = setup(); const focal = { x: 150, y: 100 }; const before = sourceUnder(engine, focal); engine.wheel(focal, -300); const after = sourceUnder(engine, focal); expect(after.x).toBeCloseTo(before.x, 9); expect(after.y).toBeCloseTo(before.y, 9); }); test('round-trips exactly when it does not hit a limit', () => { const engine = setup(); engine.setZoom(2); const before = engine.getState().value; engine.wheel({ x: 200, y: 150 }, -120); engine.wheel({ x: 200, y: 150 }, 120); const after = engine.getState().value; expect(after.x).toBeCloseTo(before.x, 6); expect(after.y).toBeCloseTo(before.y, 6); expect(after.width).toBeCloseTo(before.width, 6); }); test('clamps to [1, maxZoom]', () => { const engine = setup({}, { config: { maxZoom: 3 } }); engine.setZoom(99); expect(engine.getState().zoom).toBeCloseTo(3, 9); expect(engine.getState().value.width).toBeCloseTo(300, 9); engine.setZoom(0.1); expect(engine.getState().zoom).toBeCloseTo(1, 9); expect(engine.getState().value.width).toBeCloseTo(900, 9); expectInsideSource(engine); }); test('never lets the crop escape the source, wherever it is anchored', () => { const engine = setup(); for (const focal of [ { x: 0, y: 0 }, { x: 800, y: 600 }, { x: -500, y: -500 }, { x: 5000, y: 5000 }, ]) { engine.wheel(focal, -400); expectInsideSource(engine); engine.wheel(focal, 400); expectInsideSource(engine); } }); }); describe('the zoom slider mapping', () => { test('puts the cover fit at 0 and the max at 1', () => { expect(zoomToFraction(1, 6)).toBe(0); expect(zoomToFraction(6, 6)).toBe(1); expect(fractionToZoom(0, 6)).toBeCloseTo(1, 9); expect(fractionToZoom(1, 6)).toBeCloseTo(6, 9); }); test('splits the travel evenly in doublings', () => { expect(zoomToFraction(2, 4)).toBeCloseTo(0.5, 9); expect(fractionToZoom(0.5, 4)).toBeCloseTo(2, 9); }); test('round-trips', () => { for (const zoom of [1, 1.5, 2.7, 4, 6]) { expect(fractionToZoom(zoomToFraction(zoom, 6), 6)).toBeCloseTo(zoom, 9); } }); test('clamps out-of-range and non-finite input', () => { expect(zoomToFraction(99, 6)).toBe(1); expect(zoomToFraction(0, 6)).toBe(0); expect(zoomToFraction(Number.NaN, 6)).toBe(0); expect(fractionToZoom(-1, 6)).toBeCloseTo(1, 9); expect(fractionToZoom(9, 6)).toBeCloseTo(6, 9); expect(fractionToZoom(Number.NaN, 6)).toBe(1); }); test('pins to the cover fit when zoom is disabled', () => { expect(zoomToFraction(1, 1)).toBe(0); expect(fractionToZoom(0.5, 1)).toBe(1); }); }); describe('pan', () => { test('reports travel only on the axis that has any', () => { const { pan, panSlack } = setup().getState(); expect(panSlack).toEqual({ x: 700, y: 0 }); expect(pan).toEqual({ x: 0.5, y: 0 }); }); test('maps a 0-1 fraction onto the available travel', () => { const engine = setup(); engine.setPan({ x: 0 }); expect(engine.getState().value.x).toBe(0); engine.setPan({ x: 1 }); expect(engine.getState().value.x).toBe(700); engine.setPan({ x: 0.25 }); expect(engine.getState().value.x).toBe(175); }); test('ignores an axis with no travel', () => { const engine = setup(); engine.setPan({ y: 1 }); expect(engine.getState().value.y).toBe(0); }); test('clamps pan at the source edges', () => { const engine = setup(); engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: 4000, y: 300 }); engine.pointerUp(1); expect(engine.getState().value.x).toBe(0); expectInsideSource(engine); engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: -4000, y: 300 }); engine.pointerUp(1); expect(engine.getState().value.x).toBe(700); expectInsideSource(engine); }); test('moves the crop against the drag, so the image follows the finger', () => { const engine = setup(); const before = engine.getState().value.x; engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: 460, y: 300 }); expect(engine.getState().value.x).toBeCloseTo(before - 90, 9); }); }); describe('pinch', () => { test('zooms about the midpoint and pans with it', () => { const engine = setup(); engine.setZoom(2); const anchor = sourceUnder(engine, { x: 400, y: 300 }); engine.pointerDown(1, { x: 350, y: 300 }); engine.pointerDown(2, { x: 450, y: 300 }); engine.pointerMove(1, { x: 300, y: 300 }); engine.pointerMove(2, { x: 500, y: 300 }); const after = sourceUnder(engine, { x: 400, y: 300 }); expect(engine.getState().zoom).toBeGreaterThan(2); expect(after.x).toBeCloseTo(anchor.x, 6); expect(after.y).toBeCloseTo(anchor.y, 6); }); test('carries on as a pan when one finger lifts', () => { const engine = setup(); engine.pointerDown(1, { x: 350, y: 300 }); engine.pointerDown(2, { x: 450, y: 300 }); expect(engine.getState().isPinching).toBe(true); engine.pointerUp(2); expect(engine.getState().isPinching).toBe(false); expect(engine.getState().isDragging).toBe(true); const before = engine.getState().value.x; engine.pointerMove(1, { x: 380, y: 300 }); expect(engine.getState().value.x).toBeCloseTo(before - 45, 9); }); }); describe('rotation', () => { test('re-fits to cover, so a turn never letterboxes the window', () => { const engine = setup({ aspectRatio: 640 / 225 }); for (const turns of [1, 1, 1, 1]) { engine.rotate(turns); expectInsideSource(engine); } }); test('carries the crop center into the new space', () => { const engine = setup(); engine.rotate(1); expect(engine.getState().value).toEqual({ rotation: 1, x: 0, y: 350, width: 900, height: 900 }); }); test('returns to where it started after four turns', () => { const engine = setup(); engine.setZoom(2.5); engine.setPan({ x: 0.2 }); const before = engine.getState().value; engine.rotate(1); engine.rotate(1); engine.rotate(1); engine.rotate(1); const after = engine.getState().value; expect(after.rotation).toBe(0); expect(after.x).toBeCloseTo(before.x, 6); expect(after.y).toBeCloseTo(before.y, 6); expect(after.width).toBeCloseTo(before.width, 6); }); test('normalizes turns past a full circle and below zero', () => { const engine = setup(); engine.rotate(-1); expect(engine.getState().value.rotation).toBe(3); engine.rotate(5); expect(engine.getState().value.rotation).toBe(0); }); test('swaps the window ratio when following the source', () => { const engine = setup({ aspectRatio: undefined }); expect(engine.getState().cropWindow).toEqual({ x: 0, y: 75, width: 800, height: 450 }); engine.rotate(1); expect(engine.getState().cropWindow).toEqual({ x: 231.25, y: 0, width: 337.5, height: 600 }); }); }); describe('aspect ratio changes', () => { test('keep the crop center rather than re-clamping a screen-space pan', () => { const engine = setup(); engine.setPan({ x: 0 }); const before = centerOf(engine); engine.setGeometry({ aspectRatio: 16 / 9, natural: SOURCE, viewport: STAGE }); expect(engine.getState().value).toEqual({ rotation: 0, x: 0, y: 0, width: 1600, height: 900 }); expect(before.x).toBe(450); }); test('keep the zoom factor', () => { const engine = setup(); engine.setZoom(2); engine.setGeometry({ aspectRatio: 16 / 9, natural: SOURCE, viewport: STAGE }); expect(engine.getState().zoom).toBeCloseTo(2, 9); expectInsideSource(engine); }); }); describe('the image layer', () => { test('places the crop exactly over the window', () => { const engine = setup(); engine.setZoom(1.7); engine.setPan({ x: 0.3 }); const { cropWindow, image, value } = engine.getState(); const scale = cropWindow.width / value.width; expect(image.x + value.x * scale).toBeCloseTo(cropWindow.x, 6); expect(image.y + value.y * scale).toBeCloseTo(cropWindow.y, 6); expect(image.width).toBeCloseTo(SOURCE.width * scale, 6); expect(image.height).toBeCloseTo(SOURCE.height * scale, 6); }); test('keeps the turned layer centered on the region the crop describes', () => { const engine = setup(); engine.rotate(1); const { cropWindow, image, value } = engine.getState(); const scale = cropWindow.width / value.width; const rotated = rotatedSize(SOURCE, image.rotation); const turnedX = image.x + image.width / 2 - (rotated.width * scale) / 2; const turnedY = image.y + image.height / 2 - (rotated.height * scale) / 2; expect(turnedX + value.x * scale).toBeCloseTo(cropWindow.x, 6); expect(turnedY + value.y * scale).toBeCloseTo(cropWindow.y, 6); }); }); describe('setValue', () => { test('clamps an out-of-bounds crop to the nearest legal one', () => { const engine = setup(); engine.setValue({ rotation: 0, x: -500, y: -500, width: 5000, height: 5000 }); expect(engine.getState().value).toEqual({ rotation: 0, x: 0, y: 0, width: 900, height: 900 }); }); test('snaps a mismatched crop to the target ratio', () => { const engine = setup(); engine.setValue({ rotation: 0, x: 0, y: 0, width: 400, height: 999 }); expect(engine.getState().value.height).toBe(400); }); test('survives garbage', () => { const engine = setup(); engine.setValue({ rotation: 0, x: Number.NaN, y: Number.NaN, width: Number.NaN, height: 0 }); expectInsideSource(engine); expect(Number.isFinite(engine.getState().value.width)).toBe(true); }); test('adopts a rotation named by the incoming value', () => { const engine = setup(); engine.setValue({ rotation: 3, x: 0, y: 0, width: 900, height: 900 }); expect(engine.getState().value.rotation).toBe(3); expectInsideSource(engine); }); }); describe('reset', () => { test('starts the crop over once the next source lands', () => { const engine = setup(); engine.setZoom(4); engine.setPan({ x: 0 }); engine.reset(); expect(engine.getState().value.width).toBe(0); const next = { width: 400, height: 400 }; engine.setGeometry({ aspectRatio: 1, natural: next, viewport: STAGE }); expect(engine.getState().value).toEqual({ rotation: 0, x: 0, y: 0, width: 400, height: 400 }); }); }); describe('subscription', () => { test('emits on change and stops after unsubscribing', () => { const engine = setup(); let calls = 0; const unsubscribe = engine.subscribe(() => { calls++; }); engine.setZoom(2); expect(calls).toBe(1); unsubscribe(); engine.setZoom(3); expect(calls).toBe(1); }); test('stays quiet when a drag is already pressed against a clamp', () => { const engine = setup(); engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: 4000, y: 300 }); let calls = 0; engine.subscribe(() => { calls++; }); engine.pointerMove(1, { x: 5000, y: 300 }); expect(calls).toBe(0); }); test('hands back a stable snapshot between changes', () => { const engine = setup(); expect(engine.getState()).toBe(engine.getState()); expect(engine.getState().cropWindow).toBe(engine.getState().cropWindow); }); }); describe('config', () => { test('falls back on invalid values instead of propagating them', () => { const engine = setup({}, { config: { maxZoom: -5, wheelSensitivity: Number.NaN } }); expect(engine.config.maxZoom).toBe(6); expect(engine.config.wheelSensitivity).toBe(0.0015); }); test('re-legalizes the crop when maxZoom tightens', () => { const engine = setup(); engine.setZoom(6); expect(engine.getState().zoom).toBeCloseTo(6, 9); engine.setConfig({ maxZoom: 2 }); expect(engine.getState().zoom).toBeCloseTo(2, 9); expectInsideSource(engine); }); }); describe('the canvas recipe in the README', () => { const paint = ( value: CropValue, natural: { width: number; height: number }, u: number, v: number, ): Point => { const turned = rotatedSize(natural, value.rotation); const tx = turned.width / 2 - value.x; const ty = turned.height / 2 - value.y; const angle = (value.rotation * Math.PI) / 2; const [cos, sin] = [Math.cos(angle), Math.sin(angle)]; const [px, py] = [u - natural.width / 2, v - natural.height / 2]; return { x: tx + cos * px - sin * py, y: ty + sin * px + cos * py }; }; test('lands the crop region exactly on the canvas', () => { const corners = [ { u: 0, v: 0 }, { u: SOURCE.width, v: 0 }, { u: 0, v: SOURCE.height }, { u: SOURCE.width, v: SOURCE.height }, ]; for (const rotation of [0, 1, 2, 3] as Rotation[]) { const engine = setup({ aspectRatio: 16 / 9 }); engine.setRotation(rotation); engine.setZoom(2.2); engine.setPan({ x: 0.7, y: 0.3 }); const { value } = engine.getState(); for (const { u, v } of corners) { const painted = paint(value, SOURCE, u, v); const inSpace = rotatePoint({ point: { x: u, y: v }, size: SOURCE, from: 0, to: rotation }); expect(painted.x).toBeCloseTo(inSpace.x - value.x, 6); expect(painted.y).toBeCloseTo(inSpace.y - value.y, 6); } } }); test('fills the canvas edge to edge, with no gap or overhang', () => { for (const rotation of [0, 1, 2, 3] as Rotation[]) { const engine = setup({ aspectRatio: undefined }); engine.setRotation(rotation); const { value } = engine.getState(); const painted = [ paint(value, SOURCE, 0, 0), paint(value, SOURCE, SOURCE.width, 0), paint(value, SOURCE, 0, SOURCE.height), paint(value, SOURCE, SOURCE.width, SOURCE.height), ]; expect(Math.min(...painted.map((p) => p.x))).toBeCloseTo(0, 6); expect(Math.min(...painted.map((p) => p.y))).toBeCloseTo(0, 6); expect(Math.max(...painted.map((p) => p.x))).toBeCloseTo(value.width, 6); expect(Math.max(...painted.map((p) => p.y))).toBeCloseTo(value.height, 6); } }); }); describe('every rotation', () => { test('keeps the crop legal at every zoom and pan extreme', () => { for (const rotation of [0, 1, 2, 3] as Rotation[]) { for (const aspectRatio of [undefined, 1, 16 / 9, 640 / 225, 0.5]) { const engine = setup({ aspectRatio }); engine.setRotation(rotation); for (const zoom of [1, 1.5, 6, 100]) { engine.setZoom(zoom); for (const pan of [0, 0.5, 1]) { engine.setPan({ x: pan, y: 1 - pan }); expectInsideSource(engine); } } } } }); });