Initial Watermaps import
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user