optimized events
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s

This commit is contained in:
BuTzZ
2026-07-28 14:36:47 +02:00
parent b98ec8bc6f
commit 593dbd5f85
42 changed files with 2405 additions and 283 deletions
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FeatureDataUnavailableError,
getMapFeatures
} from "../src/api";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("getMapFeatures", () => {
it("rejects an unavailable feature source instead of treating it as an empty result", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
type: "FeatureCollection",
features: [],
metadata: {
source: "unavailable",
warning: "Keine lokale Feature-Datenbank konfiguriert."
}
}),
{
status: 200,
headers: { "content-type": "application/json" }
}
)
)
);
await expect(
getMapFeatures({
bbox: [7, 53, 8, 54],
layers: ["harbours"]
})
).rejects.toEqual(
expect.objectContaining({
name: "FeatureDataUnavailableError",
message: "Keine lokale Feature-Datenbank konfiguriert."
})
);
await expect(
getMapFeatures({
bbox: [7, 53, 8, 54],
layers: ["locks"]
})
).rejects.toBeInstanceOf(FeatureDataUnavailableError);
});
it("keeps usable postgis and legacy feature collections intact", async () => {
const responses = [
{
type: "FeatureCollection",
features: [],
metadata: { source: "postgis" }
},
{
type: "FeatureCollection",
features: []
}
];
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify(responses.shift()), {
status: 200,
headers: { "content-type": "application/json" }
})
)
);
await expect(
getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] })
).resolves.toMatchObject({ metadata: { source: "postgis" } });
await expect(
getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] })
).resolves.toMatchObject({ features: [] });
});
it("uses the API error message for an unavailable source", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
error: "feature_source_unavailable",
message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
}),
{
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "application/json" }
}
)
)
);
await expect(
getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["locks"] })
).rejects.toThrow(
"Keine lokale Karten- und Ereignisdatenbank konfiguriert."
);
});
});
@@ -0,0 +1,199 @@
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { useRouteEventFeatures } from "../src/hooks/useRouteEventFeatures";
const apiMocks = vi.hoisted(() => ({
getMapFeatures: vi.fn()
}));
vi.mock("../src/api", () => ({
getMapFeatures: apiMocks.getMapFeatures
}));
beforeEach(() => {
apiMocks.getMapFeatures.mockReset();
});
afterEach(cleanup);
describe("useRouteEventFeatures", () => {
it("loads harbours, locks and bridges once and independently", async () => {
apiMocks.getMapFeatures.mockImplementation(
async ({ layers }: { layers: string[] }) =>
featureCollection(layers[0]!)
);
const activeRoute = route("first", 0);
const { result } = renderHook(() =>
useRouteEventFeatures(activeRoute, { airDraftM: 3 })
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3);
expect(
apiMocks.getMapFeatures.mock.calls.map(([request]) => request.layers)
).toEqual(expect.arrayContaining([["harbours"], ["locks"], ["bridges"]]));
expect(result.current.harbours.map((harbour) => harbour.name)).toEqual([
"Testhafen"
]);
expect(result.current.locks.map((lock) => lock.name)).toEqual([
"Testschleuse"
]);
expect(result.current.bridgeReport?.bridges.map((bridge) => bridge.name)).toEqual([
"Testbrücke"
]);
expect(result.current.error).toBeNull();
});
it("keeps working categories when one feature request fails", async () => {
apiMocks.getMapFeatures.mockImplementation(
async ({ layers }: { layers: string[] }) => {
if (layers[0] === "harbours") {
throw new Error("Feature-Datenbank fehlt");
}
return featureCollection(layers[0]!);
}
);
const activeRoute = route("partial", 0);
const { result } = renderHook(() =>
useRouteEventFeatures(activeRoute, { airDraftM: 3 })
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.harbours).toEqual([]);
expect(result.current.locks).toHaveLength(1);
expect(result.current.bridgeReport?.bridges).toHaveLength(1);
expect(result.current.errors.harbours).toContain(
"Häfen nicht erreichbar: Feature-Datenbank fehlt"
);
expect(result.current.errors.locks).toBeNull();
expect(result.current.errors.bridges).toBeNull();
});
it("reassesses only bridges when the boat height changes", async () => {
apiMocks.getMapFeatures.mockImplementation(
async ({ layers }: { layers: string[] }) =>
featureCollection(layers[0]!)
);
const activeRoute = route("height", 0);
const { result, rerender } = renderHook(
({ airDraftM }) =>
useRouteEventFeatures(activeRoute, { airDraftM }),
{ initialProps: { airDraftM: 3 } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
apiMocks.getMapFeatures.mockClear();
rerender({ airDraftM: 3.5 });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
expect.objectContaining({ layers: ["bridges"] })
);
expect(result.current.harbours).toHaveLength(1);
expect(result.current.locks).toHaveLength(1);
expect(result.current.bridgeReport?.requiredAirDraftM).toBe(3.5);
});
it("aborts stale category requests on a route change and exposes only the new route", async () => {
const staleSignals: AbortSignal[] = [];
const currentSignals: AbortSignal[] = [];
let staleCalls = 0;
apiMocks.getMapFeatures.mockImplementation(
({ layers, signal }: { layers: string[]; signal: AbortSignal }) => {
if (staleCalls < 3) {
staleCalls += 1;
staleSignals.push(signal);
return new Promise(() => undefined);
}
currentSignals.push(signal);
return Promise.resolve(featureCollection(layers[0]!, 0.5));
}
);
const firstRoute = route("first", 0);
const secondRoute = route("second", 0.5);
const { result, rerender, unmount } = renderHook(
({ activeRoute }) =>
useRouteEventFeatures(activeRoute, { airDraftM: 3 }),
{ initialProps: { activeRoute: firstRoute } }
);
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
rerender({ activeRoute: secondRoute });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(6);
expect(staleSignals).toHaveLength(3);
expect(staleSignals.every((signal) => signal.aborted)).toBe(true);
expect(result.current.harbours[0]?.coordinate.lon).toBeCloseTo(0.7);
expect(result.current.locks[0]?.coordinate.lon).toBeCloseTo(0.8);
expect(result.current.bridgeReport?.bridges[0]?.coordinate.lon).toBeCloseTo(0.9);
unmount();
expect(currentSignals).toHaveLength(3);
expect(currentSignals.every((signal) => signal.aborted)).toBe(true);
});
});
function featureCollection(layer: string, offset = 0) {
const fixtures = {
harbours: {
type: "Feature" as const,
id: `harbour-${offset}`,
properties: { layer: "harbours", name: "Testhafen" },
geometry: {
type: "Point" as const,
coordinates: [0.2 + offset, 0]
}
},
locks: {
type: "Feature" as const,
id: `lock-${offset}`,
properties: { layer: "locks", name: "Testschleuse" },
geometry: {
type: "Point" as const,
coordinates: [0.3 + offset, 0]
}
},
bridges: {
type: "Feature" as const,
id: `bridge-${offset}`,
properties: {
layer: "bridges",
name: "Testbrücke",
clearance_m: 4
},
geometry: {
type: "Point" as const,
coordinates: [0.4 + offset, 0]
}
}
};
return {
type: "FeatureCollection" as const,
features: [fixtures[layer as keyof typeof fixtures]]
};
}
function route(id: string, offset: number): RouteResult {
return {
id,
geometry: {
type: "LineString",
coordinates: [
[offset, 0],
[offset + 1, 0]
]
},
distanceNm: 60,
eta: null,
warnings: [],
dataSources: ["test"],
routingMode: "fairway"
};
}
+61
View File
@@ -4,6 +4,7 @@ import type {
VoyageHarbour
} from "@watermaps/shared";
import {
DEFAULT_ROUTE_EVENT_CORRIDORS_NM,
nextRouteEventsByKind,
upcomingRouteEvents
} from "../src/routeEvents";
@@ -80,6 +81,66 @@ describe("upcomingRouteEvents", () => {
expect(events.map((event) => event.id)).toEqual(["detour"]);
});
it("uses a focused half-mile harbour corridor by default", () => {
expect(DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour).toBe(0.5);
const events = upcomingRouteEvents({
route,
harbours: [
harbour("near-harbour", 0.4, 0.008),
harbour("unrelated-harbour", 0.5, 0.01)
]
});
expect(events.map((event) => event.id)).toEqual(["near-harbour"]);
});
it("prefers a nearby same-named lock over a duplicate harbour classification", () => {
const duplicateHarbour = {
...harbour("harbour-lock", 0.4, 0.0005),
name: "Nesserländer Schleuse"
};
const duplicateLock = {
...lock("lock", 0.4, 0, 0, 0),
name: "Nesserlander Schleuse"
};
const distinctHarbour = {
...harbour("real-harbour", 0.6, 0),
name: "Stadthafen"
};
const events = upcomingRouteEvents({
route,
harbours: [duplicateHarbour, distinctHarbour],
locks: [duplicateLock]
});
expect(events.map((event) => `${event.kind}:${event.name}`)).toEqual([
"lock:Nesserlander Schleuse",
"harbour:Stadthafen"
]);
});
it("does not merge same-named facilities that are spatially distinct", () => {
const events = upcomingRouteEvents({
route,
harbours: [
{
...harbour("harbour", 0.4, 0.003),
name: "Kanalschleuse"
}
],
locks: [
{
...lock("lock", 0.4, 0, 0, 0),
name: "Kanalschleuse"
}
]
});
expect(events.map((event) => event.kind).sort()).toEqual(["harbour", "lock"]);
});
it("calculates ETA and retains both speed and reference-time provenance", () => {
const [event] = upcomingRouteEvents({
route,
+1
View File
@@ -528,6 +528,7 @@ describe("RoutePlanner", () => {
weatherReport={weatherReportFixture}
weatherLoading={false}
weatherError={null}
bridgeReport={weatherReportFixture.bridgeReport}
loading={false}
error={null}
pickMode={null}
+77 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { MarineForecast, RouteResult } from "@watermaps/shared";
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
import {
createRouteBridgeReport,
createRouteWeatherReport,
summarizeRouteWeather
} from "../src/routeWeatherReport";
describe("route weather report", () => {
it("samples start, middle and destination forecasts", async () => {
@@ -81,7 +85,9 @@ describe("route weather report", () => {
clearance_m: 2.7,
clearance_label: "H 2.7 m",
label: "Niedrige Brücke H 2.7 m",
source: "OSM/Geofabrik"
source: "OSM/Geofabrik",
phone: "+49 491 234",
website: "https://bridge.example"
},
geometry: {
type: "LineString",
@@ -120,6 +126,75 @@ describe("route weather report", () => {
expect(report.bridgeReport?.checkedCount).toBe(2);
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
expect(report.bridgeReport?.bridges[0]?.phone).toBe("+49 491 234");
expect(report.bridgeReport?.bridges[0]?.website).toBe("https://bridge.example");
});
it("deduplicates only conservatively matching bridge ways and keeps the richest safe assessment", async () => {
const route: RouteResult = {
...routeFixture,
geometry: {
type: "LineString",
coordinates: [[0, 0], [1, 0]]
},
distanceNm: 60
};
const point = (
id: string,
lon: number,
lat: number,
properties: Record<string, unknown> = {}
) => ({
type: "Feature" as const,
id,
properties: { layer: "bridges", ...properties },
geometry: { type: "Point" as const, coordinates: [lon, lat] }
});
const report = await createRouteBridgeReport(
route,
{ airDraftM: 3 },
async () => ({
type: "FeatureCollection",
features: [
point("named-1", 0.2, 0, {
name: "Am Tonnenhof",
clearance_m: 4,
phone: "+49 491 111"
}),
point("named-2", 0.2, 0.0003, {
name: "Am Tonnenhof",
clearance_m: 3.5,
website: "https://tonnenhof.example"
}),
point("distinct-east", 0.4, 0, { name: "Klappbrücke Ost" }),
point("distinct-west", 0.4, 0.0001, { name: "Klappbrücke West" }),
point("unnamed-tight-1", 0.6, 0),
point("unnamed-tight-2", 0.6, 0.00005),
point("unnamed-separate-1", 0.8, 0),
point("unnamed-separate-2", 0.8, 0.0001),
point("named-with-way", 0.9, 0, { name: "Auricher Straße" }),
point("unnamed-with-name", 0.9, 0.0001)
]
})
);
expect(report.bridges).toHaveLength(7);
const tonnenhof = report.bridges.find((bridge) => bridge.name === "Am Tonnenhof");
expect(tonnenhof).toMatchObject({
clearanceM: 3.5,
clearanceLabel: "H 3.5 m",
label: "Am Tonnenhof H 3.5 m",
marginM: 0.5,
phone: "+49 491 111",
website: "https://tonnenhof.example"
});
expect(
report.bridges.filter((bridge) => bridge.name?.startsWith("Klappbrücke"))
).toHaveLength(2);
expect(
report.bridges.filter((bridge) => bridge.name === null)
).toHaveLength(3);
});
it("marks critical weather when wind or wave thresholds are exceeded", () => {
+11 -1
View File
@@ -26,6 +26,7 @@ describe("UpcomingEventsPanel", () => {
expect.stringContaining("Brücke Mitte"),
expect.stringContaining("Hafen Weit")
]);
expect(within(list).getAllByText("Infos")).toHaveLength(3);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
@@ -81,6 +82,12 @@ describe("UpcomingEventsPanel", () => {
expect(within(detail).getByText("3.8 m")).toBeVisible();
const reserve = within(detail).getByText("Reserve").closest("div");
expect(reserve).toHaveTextContent("0.4 m Reserve");
expect(
within(detail).getByRole("link", { name: "Brücke Mitte anrufen" })
).toHaveAttribute("href", "tel:+4949123457");
expect(
within(detail).getByRole("link", { name: "Website von Brücke Mitte öffnen" })
).toHaveAttribute("href", "https://bridge.example/");
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
});
@@ -180,7 +187,10 @@ const events: UpcomingRouteEvent[] = [
requiredAirDraftM: 3.8,
marginM: 0.4,
status: "tight",
source: "Test"
source: "Test",
phone: "+49 49 123457",
website: "bridge.example",
operator: "Brückenamt"
}
}
];