import { describe, expect, it } from "vitest"; import { CLICK_TOLERANCE_PX, chunkNameAtMap, clampView, fitView, headingFromDirection, hoverLabel, isClick, isMapToggleKey, isTextEntry, landingFor, mapFilesProblem, mapToWorld, maskMatches, nearestWalkable, screenToMap, sha256Hex, wheelZoomFactor, worldToMap, zoomAt, } from "../src/ui/mapMath.js"; const INFO = { scale: 1, walkable_scale: 0.25, slice: { x0: -1000, x1: 2000, y0: -500, y1: 2500 } }; describe("Welt und Karte", () => { it("setzt den Spawn an die richtige Kartenstelle", () => { expect(worldToMap(INFO, 1699, -743)).toEqual({ px: 2699, py: 1757 }); }); it("legt die vier Ecken des Ausschnitts auf die vier Bildecken — Norden oben", () => { expect(worldToMap(INFO, -1000, -2500)).toEqual({ px: 0, py: 0 }); // Nordwest expect(worldToMap(INFO, 2000, -2500)).toEqual({ px: 3000, py: 0 }); // Nordost expect(worldToMap(INFO, -1000, 500)).toEqual({ px: 0, py: 3000 }); // Südwest expect(worldToMap(INFO, 2000, 500)).toEqual({ px: 3000, py: 3000 }); // Südost }); it("kommt hin und zurück", () => { for (const [x, z] of [[1699, -743], [-511, -254], [0, 0], [123.5, -2001.25]]) { const { px, py } = worldToMap(INFO, x, z); const back = mapToWorld(INFO, px, py); expect(back.x).toBeCloseTo(x, 9); expect(back.z).toBeCloseTo(z, 9); } }); it("liegt ein nördlicherer Punkt weiter oben — fängt eine gespiegelte Achse", () => { const south = worldToMap(INFO, 0, -100); // Welt y = 100 const north = worldToMap(INFO, 0, -900); // Welt y = 900 expect(north.py).toBeLessThan(south.py); }); }); describe("Ansicht", () => { it("passt die ganze Karte zentriert ins Fenster", () => { expect(fitView(3000, 1200, 900)).toEqual({ scale: 0.3, offsetX: 150, offsetY: 0, minScale: 0.3 }); }); it("hält beim Zoomen den Punkt unter dem Mauszeiger fest", () => { const view = fitView(3000, 1200, 900); const before = screenToMap(view, 700, 400); const after = screenToMap(zoomAt(view, 2, 700, 400), 700, 400); expect(after.px).toBeCloseTo(before.px, 9); expect(after.py).toBeCloseTo(before.py, 9); }); it("zoomt nie weiter heraus als der ganze Ausschnitt und nie weiter hinein als vierfach", () => { const view = fitView(3000, 1200, 900); expect(zoomAt(view, 0.01, 600, 450).scale).toBeCloseTo(0.3, 9); expect(zoomAt(view, 100, 600, 450).scale).toBeCloseTo(1.2, 9); }); it("zentriert eine Karte, die kleiner als das Fenster ist", () => { const view = { scale: 0.3, offsetX: -500, offsetY: 999, minScale: 0.3 }; expect(clampView(view, 3000, 1200, 900)).toMatchObject({ offsetX: 150, offsetY: 0 }); }); it("lässt eine vergrößerte Karte nicht über ihren Rand hinaus verschieben", () => { const view = { scale: 1.2, offsetX: 50, offsetY: -99999, minScale: 0.3 }; // Inhalt 3600 px const clamped = clampView(view, 3000, 1200, 900); expect(clamped.offsetX).toBe(0); expect(clamped.offsetY).toBe(900 - 3600); }); }); describe("Klick gegen Ziehen", () => { it("zählt unter 4 px Bewegung als Klick und ab 4 px als Ziehen", () => { expect(CLICK_TOLERANCE_PX).toBe(4); expect(isClick({ x: 10, y: 10 }, { x: 13.9, y: 10 })).toBe(true); expect(isClick({ x: 10, y: 10 }, { x: 14, y: 10 })).toBe(false); expect(isClick({ x: 10, y: 10 }, { x: 10, y: 10 }, 4, 30)).toBe(false); // weggezogen und zurück }); }); describe("Landeplatz", () => { const blank = (w, h) => new Uint8Array(w * h); it("landet bei einem begehbaren Treffer in der Mitte dieses Pixels", () => { const mask = blank(10, 10); mask[2 * 10 + 5] = 255; expect(nearestWalkable(mask, 10, 10, 5.4, 2.2, 5)).toEqual({ x: 5.5, y: 2.5 }); }); it("findet von einem Gebäude aus die Straße daneben", () => { const mask = blank(10, 10); for (let y = 0; y < 10; y++) mask[y * 10 + 5] = 255; // Straße in Spalte 5 expect(nearestWalkable(mask, 10, 10, 3.2, 3.7, 5)).toEqual({ x: 5.5, y: 3.5 }); }); it("findet nichts, wenn im Umkreis nur Wasser ist", () => { expect(nearestWalkable(blank(10, 10), 10, 10, 4, 4, 5)).toBeNull(); }); it("zählt einen Treffer genau auf dem Radius, aber nicht knapp dahinter", () => { const onRadius = blank(20, 20); onRadius[3 * 20 + 8] = 255; // Abstand 5 expect(nearestWalkable(onRadius, 20, 20, 3, 3, 5)).toEqual({ x: 8.5, y: 3.5 }); const beyond = blank(20, 20); beyond[4 * 20 + 8] = 255; // Abstand √26 ≈ 5,1 expect(nearestWalkable(beyond, 20, 20, 3, 3, 5)).toBeNull(); }); it("rechnet einen Kartenklick über die Maske in eine Weltposition in der Pixelmitte um", () => { const walk = { mask: blank(750, 750), width: 750, height: 750 }; walk.mask[200 * 750 + 100] = 255; // Maskenpixel (100, 200) = Karte 400..404 / 800..804 expect(landingFor(INFO, walk, 401, 801)).toEqual({ x: -598, z: -1698 }); // Treffer: Pixelmitte (402, 802) const nearby = landingFor(INFO, walk, 412, 800); // 3 Maskenpixel daneben expect(nearby.x).toBeCloseTo(-598, 9); // Mitte von (100, 200) = Karte (402, 802) expect(nearby.z).toBeCloseTo(-1698, 9); expect(landingFor(INFO, walk, 2000, 2000)).toBeNull(); }); }); describe("Chunk-Namen und Anzeige", () => { it("nennt am Downtown-Bezugspunkt B9 und am Southeast Financial Center C12", () => { expect(chunkNameAtMap(INFO, 489, 2246)).toBe("B9"); expect(chunkNameAtMap(INFO, 538.69, 2960.1)).toBe("C12"); }); it("liefert am östlichen Kartenrand keinen Namen mehr", () => { expect(chunkNameAtMap(INFO, 3000, 10)).toBeNull(); }); it("zeigt Chunk und Weltkoordinate im Format der Spec", () => { expect(hoverLabel(INFO, 489, 2246)).toBe("B9 · x -511 / y 254"); }); }); describe("Richtung und Tasten", () => { it("misst die Richtung ab Norden im Uhrzeigersinn", () => { expect(headingFromDirection(0, -1)).toBeCloseTo(0, 9); expect(headingFromDirection(1, 0)).toBeCloseTo(Math.PI / 2, 9); expect(Math.abs(headingFromDirection(0, 1))).toBeCloseTo(Math.PI, 9); expect(headingFromDirection(-1, 0)).toBeCloseTo(-Math.PI / 2, 9); }); it("schaltet mit M, aber nicht beim Tippen, nicht bei Wiederholung und nicht mit Strg", () => { const key = (extra) => ({ code: "KeyM", repeat: false, ctrlKey: false, metaKey: false, altKey: false, ...extra }); expect(isMapToggleKey(key({}), false)).toBe(true); expect(isMapToggleKey(key({}), true)).toBe(false); expect(isMapToggleKey(key({ repeat: true }), false)).toBe(false); expect(isMapToggleKey(key({ ctrlKey: true }), false)).toBe(false); expect(isMapToggleKey(key({ code: "KeyN" }), false)).toBe(false); }); it("zählt Text- und Zahlenfelder als Tippen, Auswahllisten und Kontrollkästchen nicht", () => { expect(isTextEntry({ tagName: "INPUT", type: "text" })).toBe(true); expect(isTextEntry({ tagName: "INPUT", type: "number" })).toBe(true); expect(isTextEntry({ tagName: "INPUT" })).toBe(true); expect(isTextEntry({ tagName: "TEXTAREA" })).toBe(true); expect(isTextEntry({ tagName: "DIV", isContentEditable: true })).toBe(true); expect(isTextEntry({ tagName: "SELECT" })).toBe(false); expect(isTextEntry({ tagName: "INPUT", type: "checkbox" })).toBe(false); expect(isTextEntry({ tagName: "INPUT", type: "color" })).toBe(false); expect(isTextEntry({ tagName: "BODY" })).toBe(false); expect(isTextEntry(null)).toBe(false); }); }); describe("Fingerabdruck", () => { it("liefert SHA-256 als Hex", async () => { expect(await sha256Hex(new TextEncoder().encode("abc"))).toBe( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); }); it("liefert null ohne sicheren Kontext, statt zu werfen", async () => { expect(await sha256Hex(new TextEncoder().encode("abc"), null)).toBeNull(); }); }); describe("Mausrad und Trackpad", () => { it("zoomt proportional zur Scrollstrecke statt je Ereignis um einen festen Schritt", () => { expect(wheelZoomFactor(-100)).toBeGreaterThan(1.1); expect(wheelZoomFactor(-100)).toBeLessThan(1.25); expect(wheelZoomFactor(100)).toBeCloseTo(1 / wheelZoomFactor(-100), 9); expect(wheelZoomFactor(-4) ** 25).toBeCloseTo(wheelZoomFactor(-100), 9); // Trackpad: viele kleine Schritte }); it("rechnet Zeilen, Seiten und die Pinch-Geste in Pixel um und zoomt bei reiner Querbewegung nicht", () => { expect(wheelZoomFactor(-3, 1)).toBeCloseTo(wheelZoomFactor(-48), 9); expect(wheelZoomFactor(-1, 2)).toBeCloseTo(wheelZoomFactor(-100), 9); expect(wheelZoomFactor(-2, 0, true)).toBeCloseTo(wheelZoomFactor(-20), 9); expect(wheelZoomFactor(0)).toBe(1); }); }); describe("Kartendateien prüfen", () => { it("nimmt eine passende map.json mit passenden Bildern an", () => { expect(mapFilesProblem(INFO, 3000, 3000)).toBeNull(); expect(maskMatches(INFO, 750, 750)).toBe(true); }); it("benennt eine unvollständige map.json oder ein unpassendes Bild, statt später zu werfen", () => { expect(mapFilesProblem({ ...INFO, scale: undefined }, 3000, 3000)).toMatch(/scale/); expect(mapFilesProblem({ ...INFO, slice: undefined }, 3000, 3000)).toMatch(/slice/); expect(mapFilesProblem(null, 3000, 3000)).toMatch(/scale/); expect(mapFilesProblem(INFO, 1500, 1500)).toMatch(/map\.png/); expect(maskMatches(INFO, 375, 375)).toBe(false); }); });