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( ); 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( ); 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( ); 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 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( ); 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( ); 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( ); 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( ); 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: [], dataSources: [], routingMode: "fairway" as const }; }