Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+352
View File
@@ -0,0 +1,352 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildServer } from "../src/app.js";
import { createCache } from "../src/services/cache.js";
import type {
LockOperationInfo,
NavigationDataAdapter
} from "../src/services/navigation-data.js";
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" }
});
describe("Watermaps API", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns app config with map layers", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({ method: "GET", url: "/api/config" });
expect(response.statusCode).toBe(200);
expect(response.json().layers).toHaveLength(3);
await app.close();
});
it("normalizes marine weather responses", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
current: {
wave_height: 0.8,
wave_direction: 280,
wave_period: 4.2
}
})
)
.mockResolvedValueOnce(
jsonResponse({
current: {
wind_speed_10m: 11,
wind_direction_10m: 245,
weather_code: 3,
temperature_2m: 19
}
})
);
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
const response = await app.inject({
method: "GET",
url: "/api/weather/marine?lat=54.18&lon=12.08"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
waveHeightM: 0.8,
windSpeed: 11
});
await app.close();
});
it("returns partial marine weather when one provider request fails", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
current: {
wave_height: 1.1,
wave_direction: 290,
wave_period: 5.3
}
})
)
.mockRejectedValueOnce(new Error("timeout"));
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
const response = await app.inject({
method: "GET",
url: "/api/weather/marine?lat=53.5&lon=7.1"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
waveHeightM: 1.1,
windSpeed: null,
source: "Open-Meteo Marine"
});
await app.close();
});
it("serves injected live navigation adapters and forwards bounded route filters", async () => {
const lock: LockOperationInfo = {
id: "lock-1",
name: "Schleuse Hamm",
waterway: "DHK",
regularHours: "06:00-22:00",
operatingState: "restricted",
validFrom: "2026-07-20T04:00:00.000Z",
validTo: "2026-07-20T20:00:00.000Z",
phone: "+49 2381 1234",
vhf: "Kanal 20",
note: "Anmeldung erforderlich",
updatedAt: "2026-07-19T12:00:00.000Z",
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/"
};
const load = vi.fn(async () => [lock]);
const lockAdapter: NavigationDataAdapter<LockOperationInfo> = {
kind: "lock-operations",
id: "test-elwis-locks",
label: "Test ELWIS locks",
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/",
load
};
const app = await buildServer({
cache: createCache(),
navigationAdapters: {
waterLevels: null,
lockOperations: lockAdapter,
notices: null
}
});
const response = await app.inject({
method: "GET",
url: "/api/navigation/live?waterways=EMS,DHK,EMS&lockIds=lock-1,lock-2"
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(load).toHaveBeenCalledWith(
{ waterways: ["EMS", "DHK"], lockIds: ["lock-1", "lock-2"] },
expect.objectContaining({ signal: expect.any(AbortSignal), fetcher: expect.any(Function) })
);
expect(body.lockOperations).toEqual([lock]);
expect(body.sources).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "lock-operations", id: "test-elwis-locks", state: "live" }),
expect.objectContaining({ kind: "water-levels", state: "not-configured" }),
expect.objectContaining({ kind: "notices", state: "not-configured" })
])
);
expect(body.generatedAt).toEqual(expect.any(String));
await app.close();
});
it("returns critical route warnings for shallow samples", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 53.563776, lon: 6.750562 },
vesselProfile: { draughtM: 1.5, safetyReserveM: 0.4 },
depthSamples: [{ coordinate: { lat: 53.442996, lon: 6.833146 }, depthM: 1.6 }]
}
});
expect(response.statusCode).toBe(200);
expect(response.json().warnings.some((warning: { severity: string }) => warning.severity === "critical")).toBe(
true
);
await app.close();
});
it("rejects routes without a known fairway instead of returning a straight line", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 54.1749, lon: 12.0731 },
destination: { lat: 54.1833, lon: 12.0928 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(422);
expect(body.error).toBe("no_fairway_route");
expect(body.message).toContain("Keine Fahrwasserroute");
await app.close();
});
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 53.563776, lon: 6.750562 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed");
expect(body.geometry.coordinates.length).toBeGreaterThan(20);
expect(body.warnings.some((warning: { code: string }) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
await app.close();
});
it("returns the inland fallback route from Emden to Hamm", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
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]);
await app.close();
});
it("uses an extracted fairway graph before the seed graph", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "test-extracted",
name: "Test Extracted Fairways",
maxSnapDistanceNm: 1,
nodes: [
{ id: "a", coordinate: { lat: 54, lon: 10 } },
{ id: "b", coordinate: { lat: 54.02, lon: 10.05 } },
{ id: "c", coordinate: { lat: 54.04, lon: 10.1 } }
],
edges: [
{
id: "ab",
name: "AB",
from: "a",
to: "b",
minDepthM: 4,
source: "test-overpass",
coordinates: [
{ lat: 54, lon: 10 },
{ lat: 54.02, lon: 10.05 }
]
},
{
id: "bc",
name: "BC",
from: "b",
to: "c",
minDepthM: 4,
source: "test-overpass",
coordinates: [
{ lat: 54.02, lon: 10.05 },
{ lat: 54.04, lon: 10.1 }
]
}
]
}
];
}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 54, lon: 10 },
destination: { lat: 54.04, lon: 10.1 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:test-extracted");
expect(body.dataSources).toContain("test-overpass");
await app.close();
});
it("returns distinct route alternatives when the waterway graph contains them", async () => {
const coordinate = (lat: number, lon: number) => ({ lat, lon });
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
id,
name: id,
from,
to,
coordinates,
minDepthM: 4,
source: "test-alternatives"
});
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "api-alternatives",
name: "API Alternativen",
maxSnapDistanceNm: 0.2,
nodes: [
{ id: "start", coordinate: coordinate(52, 7) },
{ id: "branch-in", coordinate: coordinate(52, 7.01) },
{ id: "upper", coordinate: coordinate(52.012, 7.03) },
{ id: "lower", coordinate: coordinate(51.988, 7.03) },
{ id: "branch-out", coordinate: coordinate(52, 7.05) },
{ id: "destination", coordinate: coordinate(52, 7.06) }
],
edges: [
edge("start-access", "start", "branch-in", [coordinate(52, 7), coordinate(52, 7.01)]),
edge("main", "branch-in", "branch-out", [coordinate(52, 7.01), coordinate(52, 7.05)]),
edge("upper-in", "branch-in", "upper", [coordinate(52, 7.01), coordinate(52.012, 7.03)]),
edge("upper-out", "upper", "branch-out", [coordinate(52.012, 7.03), coordinate(52, 7.05)]),
edge("lower-in", "branch-in", "lower", [coordinate(52, 7.01), coordinate(51.988, 7.03)]),
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)])
]
}
];
}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: coordinate(52, 7),
destination: coordinate(52, 7.06),
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.name).toBe("Hauptroute");
expect(body.alternatives).toHaveLength(2);
expect(body.alternatives.map((route: { name: string }) => route.name)).toEqual(["Alternative 1", "Alternative 2"]);
await app.close();
});
});