import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner";
import type { RouteWeatherReport } from "../src/routeWeatherReport";
afterEach(() => cleanup());
describe("RoutePlanner", () => {
it("submits vessel profile when start and destination exist", () => {
const onSubmit = vi.fn();
render(
);
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),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
});
});
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(
);
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(),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
});
});
it("offers an explicit start picking action", () => {
const onPickStart = vi.fn();
render(
);
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(
);
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(
);
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(
);
fireEvent.click(screen.getByRole("button", { name: /Alternative 1/ }));
expect(onSelectRoute).toHaveBeenCalledWith("alternative");
});
it("can collapse the route planner", () => {
const onCollapse = vi.fn();
render(
);
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(
);
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(
);
expect(screen.queryByRole("button", { name: "Navigation starten" })).not.toBeInTheDocument();
rerender(
);
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 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(
);
expect(screen.getByText("Routenergebnis")).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(screen.getByText("Route planen")).toBeVisible();
expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
expect(screen.getByRole("button", { name: "Zur Route" })).toBeVisible();
});
it("shows the route weather report after a route was calculated", () => {
render(
);
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(
);
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: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway" as const
};
}