Optimized routing
This commit is contained in:
+284
-32
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Coordinate, FairwayGraph } from "@watermaps/shared";
|
||||
import { buildServer } from "../src/app.js";
|
||||
import { loadEnv } from "../src/env.js";
|
||||
import { createCache } from "../src/services/cache.js";
|
||||
import type {
|
||||
LockOperationInfo,
|
||||
@@ -12,6 +14,79 @@ const jsonResponse = (body: unknown) =>
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
const demoEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "true" });
|
||||
const productionEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "false" });
|
||||
|
||||
const testFairwayGraph = (
|
||||
start: Coordinate,
|
||||
destination: Coordinate,
|
||||
id = "test-fairway"
|
||||
): FairwayGraph => ({
|
||||
id,
|
||||
name: "Test fairway",
|
||||
maxSnapDistanceNm: 0.25,
|
||||
nodes: [
|
||||
{ id: `${id}-start`, coordinate: start },
|
||||
{ id: `${id}-destination`, coordinate: destination }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: `${id}-edge`,
|
||||
name: "Test fairway edge",
|
||||
from: `${id}-start`,
|
||||
to: `${id}-destination`,
|
||||
coordinates: [start, destination],
|
||||
minDepthM: null,
|
||||
source: "test-fairway-source"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const boundedSnapSearchFixture = () => {
|
||||
const start = { lat: 52.00035, lon: 7.0005 };
|
||||
const destination = { lat: 52, lon: 7.04 };
|
||||
const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({
|
||||
lat: start.lat + (index - 4) * 0.000001,
|
||||
lon: start.lon
|
||||
}));
|
||||
const graph: FairwayGraph = {
|
||||
id: "api-bounded-snap-search",
|
||||
name: "API bounded snap search",
|
||||
maxSnapDistanceNm: 1,
|
||||
nodes: [
|
||||
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
||||
...trapCoordinates.map((coordinate, index) => ({
|
||||
id: `trap-${index + 1}`,
|
||||
coordinate
|
||||
})),
|
||||
{ id: "destination", coordinate: destination }
|
||||
],
|
||||
edges: [
|
||||
...trapCoordinates.map((coordinate, index) => ({
|
||||
id: `trap-${index + 1}`,
|
||||
name: `Trap ${index + 1}`,
|
||||
from: "junction",
|
||||
to: `trap-${index + 1}`,
|
||||
coordinates: [{ lat: 52, lon: 7 }, coordinate],
|
||||
minDepthM: 4,
|
||||
oneway: true as const,
|
||||
source: `api-bounded-trap-${index + 1}`
|
||||
})),
|
||||
{
|
||||
id: "ninth-main-route",
|
||||
name: "Ninth main route",
|
||||
from: "junction",
|
||||
to: "destination",
|
||||
coordinates: [{ lat: 52, lon: 7 }, destination],
|
||||
minDepthM: 4,
|
||||
oneway: true as const,
|
||||
source: "api-ninth-main-route"
|
||||
}
|
||||
]
|
||||
};
|
||||
return { start, destination, graph };
|
||||
};
|
||||
|
||||
describe("Watermaps API", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -146,7 +221,7 @@ describe("Watermaps API", () => {
|
||||
});
|
||||
|
||||
it("returns critical route warnings for shallow samples", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const app = await buildServer({ cache: createCache(), env: demoEnv });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
@@ -166,7 +241,16 @@ describe("Watermaps API", () => {
|
||||
});
|
||||
|
||||
it("rejects routes without a known fairway instead of returning a straight line", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return { graphs: [], failures: [] };
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
@@ -187,6 +271,7 @@ describe("Watermaps API", () => {
|
||||
it("reports unavailable fairway sources instead of claiming that no route exists", async () => {
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]);
|
||||
@@ -198,8 +283,8 @@ describe("Watermaps API", () => {
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 54.1749, lon: 12.0731 },
|
||||
destination: { lat: 54.1833, lon: 12.0928 },
|
||||
start: { lat: 53.4498, lon: 7.4509 },
|
||||
destination: { lat: 53.4646, lon: 7.4742 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
@@ -209,8 +294,140 @@ describe("Watermaps API", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reports a bounded dense-graph search instead of claiming that no route exists", async () => {
|
||||
const { start, destination, graph } = boundedSnapSearchFixture();
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return { graphs: [graph], failures: [] };
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start,
|
||||
destination,
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(422);
|
||||
expect(response.json().error).toBe("fairway_search_limited");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("tries another available graph after one graph exhausts its search budget", async () => {
|
||||
const { start, destination, graph } = boundedSnapSearchFixture();
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return {
|
||||
graphs: [graph, testFairwayGraph(start, destination, "bounded-search-fallback")],
|
||||
failures: []
|
||||
};
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start,
|
||||
destination,
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().dataSources).toContain(
|
||||
"fairway-graph:bounded-search-fallback"
|
||||
);
|
||||
expect(
|
||||
response.json().warnings.some(
|
||||
(warning: { code: string }) => warning.code === "ROUTE_SEARCH_LIMITED"
|
||||
)
|
||||
).toBe(true);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 503 when a remaining graph cannot route and another source failed", async () => {
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return {
|
||||
graphs: [
|
||||
testFairwayGraph(
|
||||
{ lat: 52, lon: 7 },
|
||||
{ lat: 52.01, lon: 7.01 },
|
||||
"unrelated-live-graph"
|
||||
)
|
||||
],
|
||||
failures: [{ source: "local", error: new Error("full local index unavailable") }]
|
||||
};
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 53.4498, lon: 7.4509 },
|
||||
destination: { lat: 53.4646, lon: 7.4742 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error).toBe("fairway_sources_unavailable");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses a routable graph even when another fairway source failed", async () => {
|
||||
const start = { lat: 54, lon: 10 };
|
||||
const destination = { lat: 54.04, lon: 10.1 };
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return {
|
||||
graphs: [testFairwayGraph(start, destination, "available-live-graph")],
|
||||
failures: [{ source: "local", error: new Error("full local index unavailable") }]
|
||||
};
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start,
|
||||
destination,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().dataSources).toContain(
|
||||
"fairway-graph:available-live-graph"
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const app = await buildServer({ cache: createCache(), env: demoEnv });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
@@ -235,8 +452,9 @@ describe("Watermaps API", () => {
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
return {
|
||||
graphs: [
|
||||
{
|
||||
id: "norddeich-norderney-test",
|
||||
name: "Norddeich–Norderney",
|
||||
maxSnapDistanceNm: 0.5,
|
||||
@@ -271,8 +489,10 @@ describe("Watermaps API", () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
],
|
||||
failures: []
|
||||
};
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
@@ -300,6 +520,7 @@ describe("Watermaps API", () => {
|
||||
it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => {
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: demoEnv,
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]);
|
||||
@@ -321,14 +542,21 @@ describe("Watermaps API", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(body.routingMode).toBe("fairway");
|
||||
expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed");
|
||||
expect(body.geometry.coordinates[0]).toEqual([7.1871, 53.3422]);
|
||||
expect(body.geometry.coordinates.at(-1)?.[0]).toBeCloseTo(7.4734, 3);
|
||||
expect(body.geometry.coordinates.at(-1)?.[1]).toBeCloseTo(53.465, 3);
|
||||
expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3422, lon: 7.1871 });
|
||||
expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.465, lon: 7.4734 });
|
||||
expect(body.geometry.coordinates[0]).toEqual([
|
||||
body.routeSnaps.start.snapped.lon,
|
||||
body.routeSnaps.start.snapped.lat
|
||||
]);
|
||||
expect(body.geometry.coordinates.at(-1)).toEqual([
|
||||
body.routeSnaps.destination.snapped.lon,
|
||||
body.routeSnaps.destination.snapped.lat
|
||||
]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns the inland fallback route from Emden to Hamm", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const app = await buildServer({ cache: createCache(), env: demoEnv });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
@@ -344,7 +572,14 @@ describe("Watermaps API", () => {
|
||||
expect(body.distanceNm).toBeGreaterThan(145);
|
||||
expect(body.distanceNm).toBeLessThan(165);
|
||||
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
||||
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]);
|
||||
expect(body.routeSnaps.destination.requested).toEqual({
|
||||
lat: 51.6814536,
|
||||
lon: 7.8042615
|
||||
});
|
||||
expect(body.geometry.coordinates.at(-1)).toEqual([
|
||||
body.routeSnaps.destination.snapped.lon,
|
||||
body.routeSnaps.destination.snapped.lat
|
||||
]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -353,8 +588,9 @@ describe("Watermaps API", () => {
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
return {
|
||||
graphs: [
|
||||
{
|
||||
id: "test-extracted",
|
||||
name: "Test Extracted Fairways",
|
||||
maxSnapDistanceNm: 1,
|
||||
@@ -389,8 +625,10 @@ describe("Watermaps API", () => {
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
],
|
||||
failures: []
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -418,8 +656,9 @@ describe("Watermaps API", () => {
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
return {
|
||||
graphs: [
|
||||
{
|
||||
id: "local-geofabrik-component-snap",
|
||||
name: "Lokaler Geofabrik-Komponententest",
|
||||
maxSnapDistanceNm: 0.3,
|
||||
@@ -428,10 +667,10 @@ describe("Watermaps API", () => {
|
||||
{ id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) },
|
||||
{ id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) },
|
||||
{ id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) },
|
||||
{ id: "shared-start", coordinate: coordinate(53.3395697, 7.1848883) },
|
||||
{ id: "shared-start", coordinate: coordinate(53.34145, 7.18585) },
|
||||
{ id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) },
|
||||
{ id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) },
|
||||
{ id: "shared-destination", coordinate: coordinate(53.3303531, 6.9334715) }
|
||||
{ id: "shared-destination", coordinate: coordinate(53.32805, 6.9302) }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
@@ -457,7 +696,7 @@ describe("Watermaps API", () => {
|
||||
name: "Gemeinsamer lokaler Korridor Ost",
|
||||
from: "shared-start",
|
||||
to: "shared-east",
|
||||
coordinates: [coordinate(53.3395697, 7.1848883), coordinate(53.3321722, 7.1329034)],
|
||||
coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)],
|
||||
minDepthM: null,
|
||||
source: "local-geofabrik-germany+netherlands"
|
||||
},
|
||||
@@ -475,13 +714,15 @@ describe("Watermaps API", () => {
|
||||
name: "Gemeinsamer lokaler Korridor West",
|
||||
from: "shared-south",
|
||||
to: "shared-destination",
|
||||
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.3303531, 6.9334715)],
|
||||
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.32805, 6.9302)],
|
||||
minDepthM: null,
|
||||
source: "local-geofabrik-germany+netherlands"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
],
|
||||
failures: []
|
||||
};
|
||||
},
|
||||
async close() {}
|
||||
}
|
||||
@@ -499,8 +740,16 @@ describe("Watermaps API", () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(body.routingMode).toBe("fairway");
|
||||
expect(body.geometry.coordinates[0]).toEqual([7.186, 53.3416]);
|
||||
expect(body.geometry.coordinates.at(-1)).toEqual([6.9304, 53.3282]);
|
||||
expect(body.geometry.coordinates[0]).toEqual([
|
||||
body.routeSnaps.start.snapped.lon,
|
||||
body.routeSnaps.start.snapped.lat
|
||||
]);
|
||||
expect(body.geometry.coordinates.at(-1)).toEqual([
|
||||
body.routeSnaps.destination.snapped.lon,
|
||||
body.routeSnaps.destination.snapped.lat
|
||||
]);
|
||||
expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3416, lon: 7.186 });
|
||||
expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.3282, lon: 6.9304 });
|
||||
expect(body.dataSources).toContain("local-geofabrik-germany+netherlands");
|
||||
expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed");
|
||||
expect(body.dataSources).not.toContain("closer-but-disconnected-start");
|
||||
@@ -523,8 +772,9 @@ describe("Watermaps API", () => {
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
return {
|
||||
graphs: [
|
||||
{
|
||||
id: "api-alternatives",
|
||||
name: "API Alternativen",
|
||||
maxSnapDistanceNm: 0.2,
|
||||
@@ -545,8 +795,10 @@ describe("Watermaps API", () => {
|
||||
edge("lower-out", "lower", "branch-out", [coordinate(51.988, 7.03), coordinate(52, 7.05)]),
|
||||
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
],
|
||||
failures: []
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadEnv } from "../src/env.js";
|
||||
|
||||
describe("API environment", () => {
|
||||
it("keeps demo data and seed routes disabled unless explicitly enabled", () => {
|
||||
expect(loadEnv({ NODE_ENV: "production" }).demoData).toBe(false);
|
||||
expect(loadEnv({ NODE_ENV: "test" }).demoData).toBe(false);
|
||||
expect(
|
||||
loadEnv({ NODE_ENV: "production", WATERMAPS_DEMO_DATA: "false" }).demoData
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the current and legacy explicit demo switches", () => {
|
||||
expect(
|
||||
loadEnv({ NODE_ENV: "production", WATERMAPS_DEMO_DATA: "true" }).demoData
|
||||
).toBe(true);
|
||||
expect(
|
||||
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildRoute } from "@watermaps/shared";
|
||||
import { buildRoute, haversineDistanceNm } from "@watermaps/shared";
|
||||
import { createCache } from "../src/services/cache.js";
|
||||
import {
|
||||
FairwayService,
|
||||
@@ -12,7 +12,180 @@ import {
|
||||
overpassToGraph
|
||||
} from "../src/services/fairways.js";
|
||||
|
||||
const EMDEN_CANAL_REGRESSION_WAYS: Parameters<typeof localFairwaysToGraph>[0] = [
|
||||
{
|
||||
id: "28021006-tail",
|
||||
bbox: [7.4498965, 53.4492034, 7.4512561, 53.4500483],
|
||||
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
|
||||
coordinates: [
|
||||
[53.4492034, 7.4498965],
|
||||
[53.4495624, 7.4505731],
|
||||
[53.4499672, 7.4511607],
|
||||
[53.4499875, 7.4511887],
|
||||
[53.4500483, 7.4512561]
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "278807710",
|
||||
bbox: [7.4512561, 53.4500483, 7.4518959, 53.4504899],
|
||||
tags: {
|
||||
lock: "yes",
|
||||
name: "Ems-Jade-Kanal",
|
||||
width: "8",
|
||||
waterway: "canal"
|
||||
},
|
||||
coordinates: [
|
||||
[53.4500483, 7.4512561],
|
||||
[53.4502057, 7.4514971],
|
||||
[53.4504899, 7.4518959]
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "278807709",
|
||||
bbox: [7.4518959, 53.4449969, 7.5736929, 53.4650304],
|
||||
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
|
||||
coordinates: [
|
||||
[53.4504899, 7.4518959],
|
||||
[53.4505236, 7.4519531],
|
||||
[53.4509222, 7.4525426],
|
||||
[53.4516529, 7.453623],
|
||||
[53.4584592, 7.463384],
|
||||
[53.4595046, 7.4648841],
|
||||
[53.4599438, 7.465618],
|
||||
[53.460753, 7.4673133],
|
||||
[53.4616984, 7.46945],
|
||||
[53.4620622, 7.4701175],
|
||||
[53.4625348, 7.4707952],
|
||||
[53.4627636, 7.4711325],
|
||||
[53.4629466, 7.4713664],
|
||||
[53.4630916, 7.4715145],
|
||||
[53.4632388, 7.4716743],
|
||||
[53.4633943, 7.4717923],
|
||||
[53.4636131, 7.4718959],
|
||||
[53.4639548, 7.4720761],
|
||||
[53.4642607, 7.4722805],
|
||||
[53.4646149, 7.4724784],
|
||||
[53.4647858, 7.472612],
|
||||
[53.4650071, 7.4729838],
|
||||
[53.4650304, 7.4733641],
|
||||
[53.4649736, 7.4737867],
|
||||
[53.4645791, 7.4743077],
|
||||
[53.4642518, 7.4745346],
|
||||
[53.4640847, 7.4746604],
|
||||
[53.4624017, 7.4759267],
|
||||
[53.4618827, 7.4763916],
|
||||
[53.4602632, 7.479245],
|
||||
[53.4587935, 7.4819111],
|
||||
[53.4585086, 7.482532],
|
||||
[53.4583429, 7.482893],
|
||||
[53.4570519, 7.4861185],
|
||||
[53.455688, 7.4897057],
|
||||
[53.4554283, 7.4910088],
|
||||
[53.4543892, 7.4963485],
|
||||
[53.4532243, 7.5021539],
|
||||
[53.4531102, 7.5027225],
|
||||
[53.4520897, 7.5077114],
|
||||
[53.4519492, 7.5083984],
|
||||
[53.4508683, 7.5126327],
|
||||
[53.4490515, 7.5208022],
|
||||
[53.4489869, 7.5211006],
|
||||
[53.4485124, 7.5232942],
|
||||
[53.4466401, 7.5319367],
|
||||
[53.4451989, 7.5385911],
|
||||
[53.4450014, 7.5399242],
|
||||
[53.4449969, 7.5406774],
|
||||
[53.4456267, 7.5463127],
|
||||
[53.4457299, 7.5470025],
|
||||
[53.446674, 7.5509229],
|
||||
[53.4477404, 7.55515],
|
||||
[53.4478348, 7.5554891],
|
||||
[53.4491825, 7.5603283],
|
||||
[53.4525244, 7.5722942],
|
||||
[53.4529207, 7.5736929]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
describe("fairway graph extraction", () => {
|
||||
it("reports that no routing source is configured", async () => {
|
||||
const service = new FairwayService({
|
||||
cache: createCache(),
|
||||
fetcher: fetch,
|
||||
liveEnabled: false
|
||||
});
|
||||
|
||||
const lookup = await service.getGraphsForRoute({
|
||||
start: { lat: 53.4498, lon: 7.4509 },
|
||||
destination: { lat: 53.4646, lon: 7.4742 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
|
||||
});
|
||||
|
||||
expect(lookup.graphs).toEqual([]);
|
||||
expect(lookup.failures).toMatchObject([
|
||||
{
|
||||
source: "configuration",
|
||||
error: expect.objectContaining({ message: "No fairway source is configured" })
|
||||
}
|
||||
]);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("retries a configured local routing index after a missing file is repaired", async () => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-missing-index-"));
|
||||
const localDataPath = join(temporaryDirectory, "missing-fairways.json");
|
||||
const service = new FairwayService({
|
||||
cache: createCache(),
|
||||
fetcher: fetch,
|
||||
liveEnabled: false,
|
||||
localDataPath
|
||||
});
|
||||
const request = {
|
||||
start: { lat: 53.4498, lon: 7.4509 },
|
||||
destination: { lat: 53.4646, lon: 7.4742 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
|
||||
};
|
||||
|
||||
try {
|
||||
const missingLookup = await service.getGraphsForRoute(request);
|
||||
expect(missingLookup.graphs).toEqual([]);
|
||||
expect(missingLookup.failures).toMatchObject([
|
||||
{
|
||||
source: "local",
|
||||
error: expect.objectContaining({ code: "ENOENT" })
|
||||
}
|
||||
]);
|
||||
|
||||
await writeFile(
|
||||
localDataPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
source: "repaired-test",
|
||||
ways: [
|
||||
{
|
||||
id: "repaired-way",
|
||||
bbox: [7.4509, 53.4498, 7.4742, 53.4646],
|
||||
tags: { waterway: "canal", name: "Repaired test fairway" },
|
||||
coordinates: [
|
||||
[53.4498, 7.4509],
|
||||
[53.4646, 7.4742]
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const repairedLookup = await service.getGraphsForRoute(request);
|
||||
expect(repairedLookup.failures).toEqual([]);
|
||||
expect(repairedLookup.graphs).toHaveLength(1);
|
||||
expect(repairedLookup.graphs[0]?.edges[0]?.source).toBe(
|
||||
"local-geofabrik-repaired-test"
|
||||
);
|
||||
} finally {
|
||||
await service.close();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("builds a routable graph from the persistent local Geofabrik format", () => {
|
||||
const graph = localFairwaysToGraph(
|
||||
[
|
||||
@@ -43,6 +216,110 @@ describe("fairway graph extraction", () => {
|
||||
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
|
||||
});
|
||||
|
||||
it("does not invent a traversable line between nearby disconnected waterway endpoints", () => {
|
||||
const graph = localFairwaysToGraph(
|
||||
[
|
||||
{
|
||||
id: "west",
|
||||
bbox: [7, 52, 7.01, 52],
|
||||
tags: { waterway: "canal" },
|
||||
coordinates: [
|
||||
[52, 7],
|
||||
[52, 7.01]
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "east",
|
||||
bbox: [7.011, 52, 7.02, 52],
|
||||
tags: { waterway: "canal" },
|
||||
coordinates: [
|
||||
[52, 7.011],
|
||||
[52, 7.02]
|
||||
]
|
||||
}
|
||||
],
|
||||
[6.9, 51.9, 7.1, 52.1],
|
||||
"disconnected-test"
|
||||
);
|
||||
expect(graph).not.toBeNull();
|
||||
|
||||
const route = buildRoute(
|
||||
{
|
||||
start: { lat: 52, lon: 7 },
|
||||
destination: { lat: 52, lon: 7.02 },
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
},
|
||||
graph ?? undefined
|
||||
);
|
||||
|
||||
expect(route).toBeNull();
|
||||
expect(graph?.edges.some((edge) => edge.source === "fairway-graph-connectors")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the reported Ems-Jade-Kanal route on the full source geometry", () => {
|
||||
const start = { lat: 53.4498, lon: 7.4509 };
|
||||
const destination = { lat: 53.4646, lon: 7.4742 };
|
||||
const graph = localFairwaysToGraph(
|
||||
EMDEN_CANAL_REGRESSION_WAYS,
|
||||
[7.3, 53.3, 7.7, 53.6],
|
||||
"emden-canal-regression"
|
||||
);
|
||||
expect(graph).not.toBeNull();
|
||||
if (!graph) {
|
||||
throw new Error("Expected Ems-Jade-Kanal regression graph");
|
||||
}
|
||||
|
||||
const route = buildRoute(
|
||||
{
|
||||
start,
|
||||
destination,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
},
|
||||
graph
|
||||
);
|
||||
expect(route).not.toBeNull();
|
||||
if (!route) {
|
||||
throw new Error("Expected Ems-Jade-Kanal regression route");
|
||||
}
|
||||
|
||||
const coordinates = route.geometry.coordinates;
|
||||
const largestSegmentNm = coordinates.slice(1).reduce(
|
||||
(largest, coordinate, index) =>
|
||||
Math.max(
|
||||
largest,
|
||||
haversineDistanceNm(
|
||||
{ lon: coordinates[index]![0], lat: coordinates[index]![1] },
|
||||
{ lon: coordinate[0], lat: coordinate[1] }
|
||||
)
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
expect(route.distanceNm).toBeGreaterThanOrEqual(1.2);
|
||||
expect(route.distanceNm).toBeLessThanOrEqual(1.35);
|
||||
expect(coordinates.length).toBeGreaterThanOrEqual(25);
|
||||
expect(largestSegmentNm).toBeLessThan(0.55);
|
||||
expect(coordinates).toContainEqual([7.4648841, 53.4595046]);
|
||||
expect(coordinates).toContainEqual([7.46945, 53.4616984]);
|
||||
expect(coordinates).toContainEqual([7.4724784, 53.4646149]);
|
||||
expect(route.dataSources).toContain(
|
||||
"local-geofabrik-emden-canal-regression"
|
||||
);
|
||||
expect(route.dataSources).not.toContain(
|
||||
"fairway-graph:emden-east-ems-seed"
|
||||
);
|
||||
expect(route.routeSnaps?.start.distanceM).toBeLessThan(5);
|
||||
expect(route.routeSnaps?.destination.distanceM).toBeLessThan(10);
|
||||
expect(coordinates[0]).toEqual([
|
||||
route.routeSnaps?.start.snapped.lon,
|
||||
route.routeSnaps?.start.snapped.lat
|
||||
]);
|
||||
expect(coordinates.at(-1)).toEqual([
|
||||
route.routeSnaps?.destination.snapped.lon,
|
||||
route.routeSnaps?.destination.snapped.lat
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows country-wide requests against the persistent local index", async () => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-"));
|
||||
const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json");
|
||||
@@ -78,10 +355,11 @@ describe("fairway graph extraction", () => {
|
||||
liveEnabled: false,
|
||||
localDataPath
|
||||
});
|
||||
const graphs = await service.getGraphsForRoute(request);
|
||||
const route = buildRoute(request, graphs[0]);
|
||||
const lookup = await service.getGraphsForRoute(request);
|
||||
const route = buildRoute(request, lookup.graphs[0]);
|
||||
|
||||
expect(graphs).toHaveLength(1);
|
||||
expect(lookup.failures).toEqual([]);
|
||||
expect(lookup.graphs).toHaveLength(1);
|
||||
expect(route?.routingMode).toBe("fairway");
|
||||
expect(route?.dataSources).toContain(
|
||||
"local-geofabrik-germany+netherlands"
|
||||
@@ -156,6 +434,7 @@ describe("fairway graph extraction", () => {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7.1752, 53.3306],
|
||||
[7.1751368, 53.3331995],
|
||||
[7.16, 53.42],
|
||||
[7.1474, 53.55],
|
||||
@@ -173,7 +452,8 @@ describe("fairway graph extraction", () => {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7.1474, 53.55],
|
||||
[7.1414995, 53.6668156]
|
||||
[7.1414995, 53.6668156],
|
||||
[7.1474, 53.6741]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -194,8 +474,16 @@ describe("fairway graph extraction", () => {
|
||||
expect(route).not.toBeNull();
|
||||
expect(route?.routingMode).toBe("fairway");
|
||||
expect(route?.dataSources).toContain("postgis-osm");
|
||||
expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
|
||||
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
|
||||
expect(route?.routeSnaps?.start.requested).toEqual(start);
|
||||
expect(route?.routeSnaps?.destination.requested).toEqual(destination);
|
||||
expect(route?.geometry.coordinates[0]).toEqual([
|
||||
route?.routeSnaps?.start.snapped.lon,
|
||||
route?.routeSnaps?.start.snapped.lat
|
||||
]);
|
||||
expect(route?.geometry.coordinates.at(-1)).toEqual([
|
||||
route?.routeSnaps?.destination.snapped.lon,
|
||||
route?.routeSnaps?.destination.snapped.lat
|
||||
]);
|
||||
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user