optimized events
This commit is contained in:
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -528,6 +528,7 @@ describe("RoutePlanner", () => {
|
||||
weatherReport={weatherReportFixture}
|
||||
weatherLoading={false}
|
||||
weatherError={null}
|
||||
bridgeReport={weatherReportFixture.bridgeReport}
|
||||
loading={false}
|
||||
error={null}
|
||||
pickMode={null}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user