Files
watermaps/apps/web/tests/route-planner.test.tsx
T
BuTzZ 6801fa9089
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Successful in 1m16s
Keep route drawings in sync
2026-07-31 07:45:06 +02:00

735 lines
22 KiB
TypeScript

import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner";
import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile";
import type { RouteWeatherReport } from "../src/routeWeatherReport";
afterEach(() => cleanup());
describe("RoutePlanner", () => {
it("uses the current boat while submitting route coordinates", () => {
const onSubmit = vi.fn();
render(
<RoutePlanner
startPoint={{ lat: 54.18, lon: 12.08 }}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={{ lat: 54.2, lon: 12.1 }}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
boatProfile={{
...DEFAULT_BOAT_PROFILE,
name: "Seestern",
category: "sailboat",
lengthM: 9.4,
draughtM: 1.8,
airDraftM: 13.2,
beamM: 3.3,
cruiseSpeedKn: 6.5
}}
loading={false}
error={null}
pickMode={null}
onSubmit={onSubmit}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
expect(onSubmit).toHaveBeenCalledWith({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.2, lon: 12.1 },
waypoints: [],
departureTime: expect.any(String)
});
expect(screen.getByRole("region", { name: "Aktuelles Boot: Seestern" })).toBeVisible();
expect(screen.queryByLabelText("Tiefgang (m)")).not.toBeInTheDocument();
});
it("submits ordered waypoints together with the selected departure time", () => {
const onSubmit = vi.fn();
const waypoints = [
{ lat: 52.4, lon: 7.1 },
{ lat: 51.9, lon: 7.45 }
];
render(
<RoutePlanner
startPoint={{ lat: 53.344167, lon: 7.186111 }}
gpsPosition={null}
destination={{ lat: 51.6814536, lon: 7.8042615 }}
waypoints={waypoints}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={onSubmit}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onPickWaypoint={vi.fn()}
onRemoveWaypoint={vi.fn()}
onMoveWaypoint={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
const localDeparture = "2026-07-20T08:30";
fireEvent.change(screen.getByLabelText("Abfahrt"), { target: { value: localDeparture } });
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
expect(onSubmit).toHaveBeenCalledWith({
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 },
waypoints,
departureTime: new Date(localDeparture).toISOString()
});
});
it("offers an explicit start picking action", () => {
const onPickStart = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={onPickStart}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Start auf Karte setzen" }));
expect(onPickStart).toHaveBeenCalledTimes(1);
});
it("offers an explicit destination picking action", () => {
const onPickDestination = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={onPickDestination}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Ziel auf Karte setzen" }));
expect(onPickDestination).toHaveBeenCalledTimes(1);
});
it("does not offer Borkum or Hamm as direct destination shortcuts", () => {
render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: /Borkum/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Hamm/i })).not.toBeInTheDocument();
});
it("lets the skipper switch between returned route alternatives", () => {
const onSelectRoute = vi.fn();
const primary = routeOption("primary", "Hauptroute", 12);
const alternative = routeOption("alternative", "Alternative 1", 14.5);
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={primary}
routeOptions={[primary, alternative]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={onSelectRoute}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Alternative 1/ }));
expect(onSelectRoute).toHaveBeenCalledWith("alternative");
});
it("can collapse the route planner", () => {
const onCollapse = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={onCollapse}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Routenfenster ausblenden" }));
expect(onCollapse).toHaveBeenCalledTimes(1);
});
it("controls mobile sheet heights and leaves compact mode when the desktop layout starts", () => {
let desktopLayout = false;
let changeListener: (() => void) | null = null;
const originalMatchMedia = window.matchMedia;
const mediaQuery = {
get matches() {
return desktopLayout;
},
media: "(min-width: 720px)",
onchange: null,
addEventListener: vi.fn((_type: string, listener: () => void) => {
changeListener = listener;
}),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
} as unknown as MediaQueryList;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => mediaQuery)
});
const { container, unmount } = render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
const panel = container.querySelector("aside.route-panel");
const body = container.querySelector(".route-panel-body");
expect(panel).toHaveAttribute("data-sheet-state", "half");
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" }));
expect(panel).toHaveAttribute("data-sheet-state", "full");
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" }));
expect(panel).toHaveAttribute("data-sheet-state", "compact");
expect(body).toHaveAttribute("hidden");
desktopLayout = true;
act(() => changeListener?.());
expect(panel).toHaveAttribute("data-sheet-state", "half");
expect(body).not.toHaveAttribute("hidden");
unmount();
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: originalMatchMedia
});
});
it("offers the course assistant only after a route was planned", () => {
const onStartGuidance = vi.fn();
const { rerender } = render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onStartGuidance={onStartGuidance}
onCollapse={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: "Navigation starten" })).not.toBeInTheDocument();
rerender(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={routeOption("primary", "Hauptroute", 4)}
routeOptions={[routeOption("primary", "Hauptroute", 4)]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onStartGuidance={onStartGuidance}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Navigation starten" }));
expect(onStartGuidance).toHaveBeenCalledTimes(1);
});
it("opens the compact result view and keeps critical warnings above secondary route details", () => {
const onEditRoute = vi.fn();
const result = {
...routeOption("primary", "Hauptroute", 4),
warnings: [
{ code: "weather", severity: "caution" as const, message: "Wind aufmerksam beobachten." },
{ code: "bridge", severity: "critical" as const, message: "Brücke ist zu niedrig." }
]
};
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={result}
routeOptions={[result]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onEditRoute={onEditRoute}
onCollapse={vi.fn()}
/>
);
expect(screen.getByText("Routenergebnis")).toBeVisible();
expect(screen.getByText("Hinweise (2)")).toBeVisible();
expect(screen.getByText("Brücke ist zu niedrig.")).toBeVisible();
expect(screen.getByText("Wind aufmerksam beobachten.")).toBeVisible();
expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Plan ändern" }));
expect(onEditRoute).toHaveBeenCalledTimes(1);
expect(screen.getByText("Route planen")).toBeVisible();
expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
it("explicitly discards a planned route through the parent state", () => {
const onCancelRoute = vi.fn();
const result = routeOption("primary", "Hauptroute", 4);
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={result}
routeOptions={[result]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCancelRoute={onCancelRoute}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route verwerfen" }));
expect(onCancelRoute).toHaveBeenCalledTimes(1);
expect(screen.getByText("Route planen")).toBeVisible();
});
it("filters retired fairway and depth warnings from route results", () => {
const result = {
...routeOption("primary", "Hauptroute", 24.48),
warnings: [
{
code: "FAIRWAY_ROUTE",
severity: "info" as const,
message: "Entfernter Graph-Hinweis."
},
{
code: "FAIRWAY_DATA_NOT_OFFICIAL",
severity: "caution" as const,
message: "Entfernter Daten-Hinweis."
},
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution" as const,
message: "Die Snap-Suche wurde begrenzt."
},
{
code: "DEPTH_PARTIAL",
severity: "caution" as const,
message: "Entfernter Teil-Tiefenhinweis."
},
{
code: "NO_KNOWN_DEPTH",
severity: "caution" as const,
message: "Entfernter Unbekannt-Hinweis."
},
{
code: "DEPTH_UNKNOWN",
severity: "caution" as const,
message: "Entfernter Tiefenpunkt-Hinweis."
},
{
code: "DEPTH_TOO_SHALLOW",
severity: "critical" as const,
message: "Entfernter Flachwasser-Hinweis."
}
]
};
render(
<RoutePlanner
startPoint={{ lat: 53.4647, lon: 7.474 }}
gpsPosition={null}
destination={{ lat: 53.5147, lon: 8.1203 }}
result={result}
routeOptions={[result]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
embedded
/>
);
const warnings = screen.getByRole("region", { name: "Routing-Hinweise" });
expect(within(warnings).getByText("Hinweise (1)")).toBeVisible();
expect(within(warnings).getByText("Die Snap-Suche wurde begrenzt.")).toBeVisible();
expect(screen.queryByText("Entfernter Graph-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Daten-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Teil-Tiefenhinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Unbekannt-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Tiefenpunkt-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Flachwasser-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText(/Routingdetails/)).not.toBeInTheDocument();
expect(screen.getByText("Hauptroute").closest(".route-result")).toHaveAttribute(
"data-severity",
"caution"
);
});
it("shows the route weather report after a route was calculated", () => {
render(
<RoutePlanner
startPoint={{ lat: 54.18, lon: 12.08 }}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={{ lat: 54.2, lon: 12.1 }}
result={{
geometry: { type: "LineString", coordinates: [[12.08, 54.18], [12.1, 54.2]] },
distanceNm: 4.2,
eta: null,
warnings: [],
dataSources: [],
routingMode: "fairway"
}}
routeOptions={[]}
weatherReport={weatherReportFixture}
weatherLoading={false}
weatherError={null}
bridgeReport={weatherReportFixture.bridgeReport}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.getByRole("region", { name: "Fahrtbericht" })).toBeVisible();
expect(screen.getByText(/Aufmerksam fahren/)).toBeVisible();
expect(screen.getByText("18 kn 270°")).toBeVisible();
expect(screen.getByText("1.2 m 290°")).toBeVisible();
expect(screen.getByText(/Nicht passierbar/)).toBeVisible();
expect(screen.getByText("Niedrige Brücke")).toBeVisible();
});
it("keeps lock-delay planning and the current-adjusted ETA in the embedded route tool", () => {
const adjustedReport: RouteWeatherReport = {
...weatherReportFixture,
adjustedEta: "2026-07-13T14:45:00.000Z",
currentAdjustmentMinutes: 45,
averageAlongRouteCurrentKn: -0.6
};
const result = routeOption("primary", "Hauptroute", 18);
render(
<RoutePlanner
startPoint={{ lat: 53.3, lon: 7.2 }}
gpsPosition={null}
destination={{ lat: 52.9, lon: 7.4 }}
result={result}
routeOptions={[result]}
weatherReport={adjustedReport}
weatherLoading={false}
weatherError={null}
routeLocks={[
{
id: "lock-1",
name: "Testschleuse",
coordinate: { lat: 53.1, lon: 7.3 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
openingHours: "06:00-22:00",
phone: "+49 123 456",
vhf: "20",
website: null
}
]}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
operationalPanelsVisible={false}
embedded
/>
);
expect(screen.getByText(/Strömungs-ETA/)).toBeVisible();
fireEvent.click(screen.getByText("Schleusenplanung · 1 auf der Route"));
expect(screen.getByRole("spinbutton", { name: /Pauschale/ })).toHaveValue(20);
expect(screen.getByText(/Plan-ETA inkl. Schleusenpuffer/)).toBeVisible();
expect(screen.queryByRole("region", { name: "Fahrtbericht" })).not.toBeInTheDocument();
});
});
const weatherReportFixture: RouteWeatherReport = {
samples: [
{
label: "Start",
coordinate: { lat: 54.18, lon: 12.08 },
forecast: {
waveHeightM: 0.8,
waveDirectionDeg: 280,
wavePeriodS: 4,
windSpeed: 12,
windDirectionDeg: 260,
weatherCode: 2,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z"
}
},
{
label: "Ziel",
coordinate: { lat: 54.2, lon: 12.1 },
forecast: {
waveHeightM: 1.2,
waveDirectionDeg: 290,
wavePeriodS: 5,
windSpeed: 18,
windDirectionDeg: 270,
weatherCode: 3,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z"
}
}
],
maxWaveHeightM: 1.2,
maxWindSpeedKn: 18,
maxWavePeriodS: 5,
strongestWindDirectionDeg: 270,
highestWaveDirectionDeg: 290,
severity: "caution",
summary: "Aufmerksam fahren: bis 18 kn Wind, 1.2 m Welle.",
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z",
unavailableSamples: 0,
departureTime: "2026-07-13T12:00:00.000Z",
adjustedEta: null,
currentAdjustmentMinutes: null,
averageAlongRouteCurrentKn: null,
bridgeReport: {
bridges: [
{
id: "bridge-low",
name: "Niedrige Brücke",
label: "Niedrige Brücke H 2.2 m",
coordinate: { lat: 54.19, lon: 12.09 },
distanceNm: 0,
clearanceM: 2.2,
clearanceLabel: "H 2.2 m",
requiredAirDraftM: 2.5,
marginM: -0.3,
status: "too_low",
source: "OSM/Geofabrik"
}
],
requiredAirDraftM: 2.5,
checkedCount: 1,
unknownCount: 0,
tooLowCount: 1,
tightCount: 0,
minClearanceM: 2.2,
severity: "critical",
summary: "Nicht passierbar: 1 Brücke(n) niedriger als 2.5 m Bootshöhe.",
source: "OSM/Geofabrik",
updatedAt: "2026-07-13T12:00:00.000Z"
}
};
function routeOption(id: string, name: string, distanceNm: number) {
return {
id,
name,
geometry: { type: "LineString" as const, coordinates: [[7, 52], [7.1, 52]] as [number, number][] },
distanceNm,
eta: "2026-07-13T14:00:00.000Z",
warnings: [],
dataSources: [],
routingMode: "fairway" as const
};
}