import { describe, expect, it } from "vitest"; import { analyseCollection, haversine, simplifyLine, toDms, } from "../../src/geo/analysis"; import { appendFeature, listPositionReferences, measureSegment, replaceFeature, reverseFeatureTracks, summarizeTracks, updatePosition, } from "../../src/geo/editor"; import { parseCoordinateCsv, parseGeoJson, parseGpx, serializeGeo, } from "../../src/geo/formats"; describe("geospatial formats", () => { it("parses GeoJSON and preserves safe scalar properties", () => { const result = parseGeoJson( '{"type":"Feature","properties":{"name":"A","nested":{"x":1}},"geometry":{"type":"Point","coordinates":[13.4,52.5]}}', ); expect(result.collection.features[0]?.properties).toEqual({ name: "A" }); }); it("rejects XML document types", () => expect(() => parseGpx("")).toThrow(/DOCTYPE/u)); it("rejects excessive XML nesting before DOM construction", () => expect(() => parseGpx(`${"".repeat(257)}${"".repeat(257)}`)).toThrow( /256-level/u, )); it("round-trips coordinate CSV into valid GeoJSON", () => { const result = parseCoordinateCsv( "name,lat,lon,elevation\nBerlin,52.5,13.4,42", ); expect( parseGeoJson(serializeGeo(result.collection, "geojson").text).collection, ).toEqual(result.collection); }); it("preserves every GeoJSON multi geometry and bounded collections", () => { const result = parseGeoJson( JSON.stringify({ type: "Feature", properties: { name: "Mixed" }, geometry: { type: "GeometryCollection", geometries: [ { type: "MultiPoint", coordinates: [ [1, 2], [3, 4], ], }, { type: "MultiLineString", coordinates: [ [ [0, 0], [1, 0], ], [ [2, 0], [3, 0], ], ], }, { type: "MultiPolygon", coordinates: [ [ [ [0, 0], [1, 0], [0, 0], ], ], ], }, ], }, }), ); expect( parseGeoJson(serializeGeo(result.collection, "geojson").text).collection, ).toEqual(result.collection); expect(analyseCollection(result.collection)).toMatchObject({ points: 9, features: 1, }); expect(serializeGeo(result.collection, "kml").text).toContain( "", ); expect(serializeGeo(result.collection, "gpx").text).toContain(""); }); it("bounds nested GeometryCollections", () => { let geometry: unknown = { type: "Point", coordinates: [0, 0] }; for (let index = 0; index < 18; index += 1) geometry = { type: "GeometryCollection", geometries: [geometry] }; expect(() => parseGeoJson(JSON.stringify(geometry))).toThrow( /nesting limit/iu, ); }); }); describe("geospatial analysis", () => { it("uses great-circle distance", () => expect(haversine([0, 0], [1, 0])).toBeCloseTo(111_195, -1)); it("simplifies a line while preserving endpoints", () => expect( simplifyLine( [ [0, 0], [0.001, 0.00001], [0.002, 0], ], 10, ), ).toEqual([ [0, 0], [0.002, 0], ])); it("analyses distance and elevation", () => expect( analyseCollection({ type: "FeatureCollection", features: [ { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [ [0, 0, 10], [0.01, 0, 25], ], }, }, ], }), ).toMatchObject({ points: 2, features: 1, ascent: 15, descent: 0 })); it("formats directional DMS", () => expect(toDms(-13.5, "longitude")).toBe("13° 30′ 0.000″ W")); }); describe("bounded geometry editing and local measurement", () => { const mixed = parseGeoJson( JSON.stringify({ type: "FeatureCollection", features: [ { type: "Feature", properties: { name: "Track" }, geometry: { type: "GeometryCollection", geometries: [ { type: "Point", coordinates: [13, 52] }, { type: "MultiLineString", coordinates: [ [ [13, 52, 10], [13.01, 52, 20], ], ], }, ], }, }, ], }), ).collection; it("addresses and updates coordinates inside GeometryCollections", () => { const references = listPositionReferences(mixed); expect(references).toHaveLength(3); const updated = updatePosition(mixed, references[2]!, [14, 53, 25]); expect(listPositionReferences(updated)[2]?.position).toEqual([14, 53, 25]); expect(listPositionReferences(mixed)[2]?.position).toEqual([13.01, 52, 20]); }); it("replaces/appends full geometry features and reverses only tracks", () => { const reversed = reverseFeatureTracks(mixed, 0); expect( listPositionReferences(reversed).map((item) => item.position), ).toEqual([ [13, 52], [13.01, 52, 20], [13, 52, 10], ]); expect(summarizeTracks(reversed)[0]).toMatchObject({ pathCount: 1, positions: 2, hasElevation: true, }); const replacement = { type: "Feature", properties: { name: "Area" }, geometry: { type: "MultiPolygon", coordinates: [ [ [ [0, 0], [1, 0], [0, 0], ], ], ], }, }; expect( replaceFeature(mixed, 0, replacement).features[0]?.geometry.type, ).toBe("MultiPolygon"); expect(appendFeature(mixed, replacement).features).toHaveLength(2); }); it("measures a bounded great-circle segment and bearing", () => { const result = measureSegment([0, 0], [1, 0]); expect(result.distanceMetres).toBeCloseTo(111_195, -1); expect(result.initialBearingDegrees).toBeCloseTo(90); expect(result.midpoint).toEqual([0.5, 0]); expect(measureSegment([179, 0], [-179, 0]).midpoint[0]).toBeCloseTo(-180); expect(() => measureSegment([0, 0], [180, 0])).toThrow(/antipodal/u); }); });