gendesign/frontend/src/lib/__tests__/wkt.test.ts
Light1YT df9d54b532
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m40s
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m4s
feat(site-finder): add opportunity-ЗУ + red-line map layers (§12.1-13, #958)
Render the §12.1-13 geometry already exposed on /analyze but previously
dropped by the MiniMap adapter:
- Перспективные ЗУ (nspd_opportunity_parcels.geom_wkt) — viz-3 polygons
- Красные линии застройки (nspd_red_lines.geom_wkt) — warn dashed lines

Wired as toggles in the existing CpLayerControlPanel (Рынок group),
reusing wkt.ts (extended with LINESTRING/MULTILINESTRING for red lines).
Empty/invalid geometry renders nothing gracefully; popups RU plain-text.

§22 forecast (future_market/special_indices), ППТ-ПМТ planning polygons
and future-ЖК points carry no map-able geometry on the frontend yet — left
as backend follow-ups, not faked.

Refs #958
2026-06-09 07:19:53 +00:00

187 lines
6.4 KiB
TypeScript

/**
* 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)");
});
});