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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user