// Unit tests for the no-wrap camera helpers in src/map/no-wrap.ts. // // The bounded-plane mode has three invariants that the helpers must // uphold together: // // 1. The visible viewport stays inside the world rectangle, except // when the visible viewport span exceeds the world span on an // axis — in that case the camera centres on that axis. // 2. minScaleNoWrap is the smallest scale at which the visible // viewport fits the world along both axes. // 3. pivotZoom keeps the world point under the cursor stable // across a scale change. // // Each invariant is tested in isolation here; the renderer composes // them in render.ts. import { describe, expect, test } from "vitest"; import { screenToWorld } from "../src/map/math"; import { clampCameraNoWrap, minScaleNoWrap, pivotZoom } from "../src/map/no-wrap"; import { World } from "../src/map/world"; const world = new World(1000, 800); const viewport = { widthPx: 400, heightPx: 300 }; describe("clampCameraNoWrap", () => { test("leaves the camera unchanged when the viewport sits inside the world", () => { const c = clampCameraNoWrap({ centerX: 500, centerY: 400, scale: 1 }, viewport, world); expect(c.centerX).toBe(500); expect(c.centerY).toBe(400); }); test("clamps the camera to the left edge", () => { const c = clampCameraNoWrap({ centerX: 0, centerY: 400, scale: 1 }, viewport, world); expect(c.centerX).toBe(viewport.widthPx / 2); }); test("clamps the camera to the right edge", () => { const c = clampCameraNoWrap({ centerX: 9999, centerY: 400, scale: 1 }, viewport, world); expect(c.centerX).toBe(world.width - viewport.widthPx / 2); }); test("clamps the camera to the top edge", () => { const c = clampCameraNoWrap({ centerX: 500, centerY: -50, scale: 1 }, viewport, world); expect(c.centerY).toBe(viewport.heightPx / 2); }); test("clamps the camera to the bottom edge", () => { const c = clampCameraNoWrap({ centerX: 500, centerY: 9999, scale: 1 }, viewport, world); expect(c.centerY).toBe(world.height - viewport.heightPx / 2); }); test("centres the camera on an axis when the viewport span exceeds world span", () => { // At scale=0.1, viewport.widthPx/scale = 4000 world units > world.width=1000. const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.1 }, viewport, world); expect(c.centerX).toBe(world.width / 2); expect(c.centerY).toBe(world.height / 2); }); test("does not modify scale", () => { const c = clampCameraNoWrap({ centerX: 999, centerY: 999, scale: 0.5 }, viewport, world); expect(c.scale).toBe(0.5); }); }); describe("minScaleNoWrap", () => { test("equals the larger axis ratio (width-bound)", () => { // world 1000×800, viewport 400×300: // width ratio = 0.4, height ratio = 0.375 — width wins. expect(minScaleNoWrap(viewport, world)).toBeCloseTo(0.4, 12); }); test("equals the larger axis ratio (height-bound)", () => { // world 100×100, viewport 200×400: height ratio = 4 wins over width = 2. expect(minScaleNoWrap({ widthPx: 200, heightPx: 400 }, new World(100, 100))).toBeCloseTo( 4, 12, ); }); }); describe("pivotZoom", () => { const camera = { centerX: 500, centerY: 400, scale: 1 }; test("keeps the world point under the cursor stable", () => { const cursor = { x: 100, y: 250 }; const before = screenToWorld(cursor, camera, viewport); const newCam = pivotZoom(camera, viewport, cursor, 2.5); const after = screenToWorld(cursor, newCam, viewport); expect(after.x).toBeCloseTo(before.x, 9); expect(after.y).toBeCloseTo(before.y, 9); expect(newCam.scale).toBe(2.5); }); test("invariant holds when the cursor sits at the viewport centre", () => { const cursor = { x: viewport.widthPx / 2, y: viewport.heightPx / 2 }; const before = screenToWorld(cursor, camera, viewport); const newCam = pivotZoom(camera, viewport, cursor, 0.4); const after = screenToWorld(cursor, newCam, viewport); expect(after.x).toBeCloseTo(before.x, 9); expect(after.y).toBeCloseTo(before.y, 9); }); test("invariant holds at the viewport corner", () => { const cursor = { x: 0, y: 0 }; const before = screenToWorld(cursor, camera, viewport); const newCam = pivotZoom(camera, viewport, cursor, 7.7); const after = screenToWorld(cursor, newCam, viewport); expect(after.x).toBeCloseTo(before.x, 9); expect(after.y).toBeCloseTo(before.y, 9); }); test("rejects non-positive scale", () => { expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, 0)).toThrow(); expect(() => pivotZoom(camera, viewport, { x: 0, y: 0 }, -1)).toThrow(); }); });