Files
geo-tools/tests/geo/core.test.ts
T
zemion 3538e4e12b
Verify / verify (push) Canceled after 0s
Release Geo Tools 0.2.0
2026-09-02 12:02:09 +02:00

232 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("<!DOCTYPE gpx><gpx/>")).toThrow(/DOCTYPE/u));
it("rejects excessive XML nesting before DOM construction", () =>
expect(() => parseGpx(`${"<x>".repeat(257)}${"</x>".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(
"<MultiGeometry>",
);
expect(serializeGeo(result.collection, "gpx").text).toContain("<trkseg>");
});
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);
});
});