chore(frontend): vitest runner + WKT parser unit tests (#999) #1132

Merged
bot-backend merged 1 commit from chore/frontend-vitest-wkt-tests into main 2026-06-07 11:47:02 +00:00
11 changed files with 2335 additions and 1477 deletions

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
},
"dependencies": {
@ -26,16 +28,23 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/leaflet": "^1.9.0",
"@types/leaflet-draw": "^1.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"jsdom": "^25.0.1",
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^2.1.9"
}
}

View file

@ -23,8 +23,9 @@
*/
import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet";
import type { Feature, Geometry, Position } from "geojson";
import type { Feature } from "geojson";
import { wktToGeometry } from "@/lib/wkt";
import type {
ParcelAnalysisCompetitor,
PipelineObject,
@ -79,92 +80,6 @@ function hasCoords<T extends { lat?: number | null; lon?: number | null }>(
);
}
// ---------------------------------------------------------------------------
// Minimal WKT → GeoJSON parser (no new deps — see #999 conventions).
// Поддерживает типы, которые реально отдаёт NSPD risk-слой (ST_AsText, EPSG:4326,
// порядок «lon lat»): POINT, POLYGON, MULTIPOLYGON. Прочее / мусор → null
// (зона тогда просто не рисуется). НЕ general-purpose: только для risk-зон.
// ---------------------------------------------------------------------------
function parseRing(body: string): Position[] {
return body
.split(",")
.map((pair) => pair.trim().split(/\s+/).map(Number))
.filter(
(nums): nums is [number, number] =>
nums.length >= 2 &&
Number.isFinite(nums[0]) &&
Number.isFinite(nums[1]),
)
.map(([lon, lat]) => [lon, lat] as Position);
}
// Делит верхнеуровневые группы внутри MULTIPOLYGON по скобочной вложенности,
// чтобы запятые ВНУТРИ координат не рвали полигоны.
function splitTopLevel(body: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
else if (ch === "," && depth === 0) {
parts.push(body.slice(start, i));
start = i + 1;
}
}
parts.push(body.slice(start));
return parts;
}
function wktToGeometry(wkt: string): Geometry | null {
const trimmed = wkt.trim();
const upper = trimmed.toUpperCase();
try {
if (upper.startsWith("POINT")) {
const body = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")"));
const nums = body.trim().split(/\s+/).map(Number);
if (nums.length < 2 || !Number.isFinite(nums[0]) || !Number.isFinite(nums[1])) {
return null;
}
return { type: "Point", coordinates: [nums[0], nums[1]] };
}
if (upper.startsWith("MULTIPOLYGON")) {
// MULTIPOLYGON(((ring),(hole)),((ring)))
const inner = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")"));
const polygons = splitTopLevel(inner)
.map((polyChunk) => {
const polyBody = polyChunk.trim().replace(/^\(/, "").replace(/\)$/, "");
const rings = splitTopLevel(polyBody)
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
.filter((ring) => ring.length >= 3);
return rings;
})
.filter((rings) => rings.length > 0);
if (polygons.length === 0) return null;
return { type: "MultiPolygon", coordinates: polygons };
}
if (upper.startsWith("POLYGON")) {
// POLYGON((ring),(hole))
const inner = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")"));
const rings = splitTopLevel(inner)
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
.filter((ring) => ring.length >= 3);
if (rings.length === 0) return null;
return { type: "Polygon", coordinates: rings };
}
} catch {
// Любой неожиданный WKT — graceful skip (зона не рисуется).
return null;
}
return null;
}
// ---------------------------------------------------------------------------
// Layer styles
// ---------------------------------------------------------------------------

View file

@ -1,8 +1,5 @@
/**
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
*/
import { render, screen } from "@testing-library/react";
import { Badge } from "../Badge";
describe("Badge", () => {

View file

@ -1,8 +1,6 @@
/**
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
*/
import { render, screen, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import { Drawer } from "../Drawer";
describe("Drawer", () => {
@ -27,7 +25,7 @@ describe("Drawer", () => {
});
it("calls onClose when Escape is pressed", () => {
const onClose = jest.fn();
const onClose = vi.fn();
render(
<Drawer open={true} onClose={onClose} side="right">
<p>Контент</p>

View file

@ -1,8 +1,5 @@
/**
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
*/
import { render, screen } from "@testing-library/react";
import { HeadlineBar } from "../HeadlineBar";
describe("HeadlineBar", () => {
@ -25,9 +22,15 @@ describe("HeadlineBar", () => {
it("does not render subtitle section when omitted", () => {
const { container } = render(<HeadlineBar title="Только заголовок" />);
// subtitle div has marginTop style — not present when subtitle is undefined
const subtitleDivs = container.querySelectorAll("div > div > div");
expect(subtitleDivs).toHaveLength(0);
// The content wrapper (flex:1 column) holds the title and, when present, the
// subtitle as a second child. With no subtitle it must hold exactly one
// child (the title) — the subtitle separator block is not rendered.
const contentWrapper = container.querySelector('[role="banner"] > div');
expect(contentWrapper).not.toBeNull();
expect(contentWrapper?.children).toHaveLength(1);
expect(contentWrapper?.firstElementChild?.textContent).toBe(
"Только заголовок",
);
});
it("renders rightSlot content", () => {

View file

@ -0,0 +1,148 @@
/**
* 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 {
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 (LINESTRING)", () => {
expect(wktToGeometry("LINESTRING(0 0, 1 1, 2 2)")).toBeNull();
});
});
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)");
});
});

122
frontend/src/lib/wkt.ts Normal file
View file

@ -0,0 +1,122 @@
/**
* Minimal WKT GeoJSON parser (no new deps see #999 conventions).
*
* Extracted from MarketLayers.tsx so the hand-rolled parser is unit-testable in
* isolation (the #999 risk-zone parser had zero coverage and never ran on the QA
* parcel). Behavior is otherwise identical to the inlined version.
*
* Supports the types the NSPD risk layer actually emits (ST_AsText, EPSG:4326,
* `lon lat` order): POINT, POLYGON, MULTIPOLYGON. Anything else / garbage null
* (the zone then simply is not drawn). NOT general-purpose risk zones only.
*
* Coordinate order: WKT is `lon lat`; GeoJSON Position is `[lon, lat]` we keep
* that order verbatim (mismatching it is the bug class that drops markers in the
* ocean).
*
* EWKT tolerance: a leading `SRID=<digits>;` prefix (case-insensitive) is
* stripped before type detection, so `SRID=4326;POLYGON(...)` parses to the same
* geometry as the bare `POLYGON(...)`. Backend currently emits plain WKT via
* ST_AsText, but this is cheap defense-in-depth (per the #999 review + chip).
*/
import type { Geometry, Position } from "geojson";
// Leading `SRID=4326;` EWKT prefix (case-insensitive), if present.
const EWKT_SRID_PREFIX = /^SRID=\d+;/i;
export function parseRing(body: string): Position[] {
return body
.split(",")
.map((pair) => pair.trim().split(/\s+/).map(Number))
.filter(
(nums): nums is [number, number] =>
nums.length >= 2 &&
Number.isFinite(nums[0]) &&
Number.isFinite(nums[1]),
)
.map(([lon, lat]) => [lon, lat] as Position);
}
// Делит верхнеуровневые группы внутри MULTIPOLYGON по скобочной вложенности,
// чтобы запятые ВНУТРИ координат не рвали полигоны.
export function splitTopLevel(body: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
else if (ch === "," && depth === 0) {
parts.push(body.slice(start, i));
start = i + 1;
}
}
parts.push(body.slice(start));
return parts;
}
export function wktToGeometry(wkt: string): Geometry | null {
// Strip a leading EWKT `SRID=<n>;` prefix before type detection so EWKT input
// parses identically to plain WKT.
const trimmed = wkt.trim().replace(EWKT_SRID_PREFIX, "");
const upper = trimmed.toUpperCase();
try {
if (upper.startsWith("POINT")) {
const body = trimmed.slice(
trimmed.indexOf("(") + 1,
trimmed.lastIndexOf(")"),
);
const nums = body.trim().split(/\s+/).map(Number);
if (
nums.length < 2 ||
!Number.isFinite(nums[0]) ||
!Number.isFinite(nums[1])
) {
return null;
}
return { type: "Point", coordinates: [nums[0], nums[1]] };
}
if (upper.startsWith("MULTIPOLYGON")) {
// MULTIPOLYGON(((ring),(hole)),((ring)))
const inner = trimmed.slice(
trimmed.indexOf("(") + 1,
trimmed.lastIndexOf(")"),
);
const polygons = splitTopLevel(inner)
.map((polyChunk) => {
const polyBody = polyChunk
.trim()
.replace(/^\(/, "")
.replace(/\)$/, "");
const rings = splitTopLevel(polyBody)
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
.filter((ring) => ring.length >= 3);
return rings;
})
.filter((rings) => rings.length > 0);
if (polygons.length === 0) return null;
return { type: "MultiPolygon", coordinates: polygons };
}
if (upper.startsWith("POLYGON")) {
// POLYGON((ring),(hole))
const inner = trimmed.slice(
trimmed.indexOf("(") + 1,
trimmed.lastIndexOf(")"),
);
const rings = splitTopLevel(inner)
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
.filter((ring) => ring.length >= 3);
if (rings.length === 0) return null;
return { type: "Polygon", coordinates: rings };
}
} catch {
// Любой неожиданный WKT — graceful skip (зона не рисуется).
return null;
}
return null;
}

2
frontend/vitest-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vitest/globals" />
/// <reference types="@testing-library/jest-dom" />

21
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Mirror tsconfig "paths": { "@/*": ["./src/*"] } so test imports like
// `@/lib/wkt` resolve the same way they do in the Next.js build.
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

3
frontend/vitest.setup.ts Normal file
View file

@ -0,0 +1,3 @@
// Registers jest-dom matchers (toBeInTheDocument, toHaveStyle, …) on Vitest's
// `expect`. Imported via vitest.config.ts `setupFiles`.
import "@testing-library/jest-dom/vitest";