/** * Unit tests for the hand-rolled WKT → GeoJSON parser (#999 risk-zone parser). * * Highest-risk untested code: this parser drives the risk-zone polygons drawn on * the Site Finder map. The bug class it must guard against is coordinate-order * inversion (WKT `lon lat` → GeoJSON `[lon, lat]`) — getting that wrong puts * zones/markers in the ocean. */ import type { LineString, MultiLineString, MultiPolygon, Point, Polygon, Position, } from "geojson"; import { describe, expect, it } from "vitest"; import { parseRing, splitTopLevel, wktToGeometry } from "../wkt"; // A simple square ring (closed) in `lon lat` order, EKB-ish coordinates. const SQUARE = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))"; describe("wktToGeometry — POLYGON", () => { it("parses a valid single-ring POLYGON", () => { const geom = wktToGeometry(SQUARE) as Polygon | null; expect(geom).not.toBeNull(); expect(geom?.type).toBe("Polygon"); expect(geom?.coordinates).toHaveLength(1); // one outer ring expect(geom?.coordinates[0]).toHaveLength(5); // 5 closed points }); it("preserves WKT lon/lat order as GeoJSON [lon, lat]", () => { // The bug class: this must NOT swap to [lat, lon]. const geom = wktToGeometry(SQUARE) as Polygon; const first = geom.coordinates[0][0]; expect(first).toEqual([60.5, 56.8]); // lon (60) is the X / first element; lat (56) is the Y / second. expect(first[0]).toBe(60.5); expect(first[1]).toBe(56.8); }); it("parses a POLYGON with a hole (two rings)", () => { const wkt = "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," + "(2 2, 4 2, 4 4, 2 4, 2 2))"; const geom = wktToGeometry(wkt) as Polygon | null; expect(geom?.type).toBe("Polygon"); expect(geom?.coordinates).toHaveLength(2); // outer + hole expect(geom?.coordinates[1][0]).toEqual([2, 2]); }); it("skips a ring with fewer than 3 points → null when none survive", () => { // Two points cannot form a ring; the single ring is filtered out → null. const geom = wktToGeometry("POLYGON((0 0, 1 1))"); expect(geom).toBeNull(); }); }); describe("wktToGeometry — MULTIPOLYGON", () => { it("parses a MULTIPOLYGON with two polygons", () => { const wkt = "MULTIPOLYGON(" + "((0 0, 1 0, 1 1, 0 1, 0 0))," + "((5 5, 6 5, 6 6, 5 6, 5 5))" + ")"; const geom = wktToGeometry(wkt) as MultiPolygon | null; expect(geom?.type).toBe("MultiPolygon"); expect(geom?.coordinates).toHaveLength(2); // two polygons expect(geom?.coordinates[0][0]).toHaveLength(5); expect(geom?.coordinates[1][0][0]).toEqual([5, 5]); }); it("does not let intra-coordinate commas split polygons", () => { // The top-level splitter must respect bracket depth — 8 coords, 1 polygon. const wkt = "MULTIPOLYGON(((0 0, 1 0, 1 1, 0 1, 0 0)))"; const geom = wktToGeometry(wkt) as MultiPolygon; expect(geom.coordinates).toHaveLength(1); expect(geom.coordinates[0]).toHaveLength(1); }); }); describe("wktToGeometry — POINT", () => { it("parses a POINT preserving lon/lat order", () => { const geom = wktToGeometry("POINT(60.6 56.8)") as Point | null; expect(geom?.type).toBe("Point"); expect(geom?.coordinates).toEqual([60.6, 56.8]); }); it("returns null for a POINT with a single coordinate", () => { expect(wktToGeometry("POINT(60.6)")).toBeNull(); }); }); describe("wktToGeometry — EWKT tolerance", () => { it("strips a leading SRID=4326; prefix and parses the same geometry", () => { const plain = wktToGeometry(SQUARE) as Polygon; const ewkt = wktToGeometry("SRID=4326;" + SQUARE) as Polygon | null; expect(ewkt?.type).toBe("Polygon"); expect(ewkt?.coordinates).toEqual(plain.coordinates); }); it("strips the SRID prefix case-insensitively", () => { const geom = wktToGeometry("srid=3857;POINT(1 2)") as Point | null; expect(geom?.type).toBe("Point"); expect(geom?.coordinates).toEqual([1, 2]); }); }); describe("wktToGeometry — invalid / unsupported input", () => { it("returns null for a garbage string", () => { expect(wktToGeometry("not wkt at all")).toBeNull(); }); it("returns null for an empty string", () => { expect(wktToGeometry("")).toBeNull(); }); it("returns null for an unsupported geometry type (GEOMETRYCOLLECTION)", () => { expect( wktToGeometry("GEOMETRYCOLLECTION(POINT(0 0),LINESTRING(0 0,1 1))"), ).toBeNull(); }); }); describe("wktToGeometry — LINESTRING (red lines §13)", () => { it("parses a LINESTRING preserving lon/lat order", () => { const geom = wktToGeometry( "LINESTRING(60.5 56.8, 60.6 56.9, 60.7 56.8)", ) as LineString | null; expect(geom?.type).toBe("LineString"); expect(geom?.coordinates).toHaveLength(3); // The bug class: WKT lon/lat must stay [lon, lat], not swap to [lat, lon]. expect(geom?.coordinates[0]).toEqual([60.5, 56.8]); }); it("returns null for a LINESTRING with a single point", () => { expect(wktToGeometry("LINESTRING(60.6 56.8)")).toBeNull(); }); }); describe("wktToGeometry — MULTILINESTRING (red lines §13)", () => { it("parses a MULTILINESTRING with two lines", () => { const geom = wktToGeometry( "MULTILINESTRING((0 0, 1 1, 2 2),(5 5, 6 6))", ) as MultiLineString | null; expect(geom?.type).toBe("MultiLineString"); expect(geom?.coordinates).toHaveLength(2); expect(geom?.coordinates[0]).toHaveLength(3); expect(geom?.coordinates[1][0]).toEqual([5, 5]); }); it("does not let intra-coordinate commas split lines", () => { const geom = wktToGeometry( "MULTILINESTRING((0 0, 1 1, 2 2))", ) as MultiLineString; expect(geom.coordinates).toHaveLength(1); expect(geom.coordinates[0]).toHaveLength(3); }); }); describe("parseRing", () => { it("parses a coordinate list into [lon, lat] positions", () => { const ring: Position[] = parseRing("60.5 56.8, 60.6 56.9"); expect(ring).toEqual([ [60.5, 56.8], [60.6, 56.9], ]); }); it("drops pairs that are not finite numbers", () => { const ring = parseRing("60.5 56.8, foo bar, 1 2"); expect(ring).toEqual([ [60.5, 56.8], [1, 2], ]); }); }); describe("splitTopLevel", () => { it("splits only on top-level commas, respecting bracket depth", () => { const parts = splitTopLevel("(0 0, 1 1),(2 2, 3 3)"); expect(parts).toHaveLength(2); expect(parts[0]).toBe("(0 0, 1 1)"); expect(parts[1].trim()).toBe("(2 2, 3 3)"); }); });