Optimized routing
Test and publish container images / test (push) Successful in 2m21s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-27 10:18:31 +02:00
parent 55ce94066e
commit 9813a1fafb
16 changed files with 1602 additions and 364 deletions
+284 -32
View File
@@ -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: "NorddeichNorderney",
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: []
};
}
}
});