Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TideSummary } from "@watermaps/shared";
import type { GpsState } from "../src/hooks/useGeolocation";
const apiMocks = vi.hoisted(() => ({ getNearestTide: vi.fn() }));
vi.mock("../src/api", () => apiMocks);
import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
beforeEach(() => {
apiMocks.getNearestTide.mockReset();
apiMocks.getNearestTide.mockResolvedValue(tideFixture());
});
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useAnchorWatch", () => {
it("captures an immutable anchor point only after an explicit action", async () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: gps } }
);
expect(result.current.phase).toBe("idle");
expect(result.current.anchorPoint).toBeNull();
act(() => expect(result.current.captureAnchor()).toBe(true));
expect(result.current.phase).toBe("set");
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
rerender({ value: gpsFix({ lat: 53.21, lon: 7.11 }) });
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
await waitFor(() => expect(result.current.tide?.station).toBe("Testpegel"));
expect(apiMocks.getNearestTide).toHaveBeenCalledWith(
{ lat: 53.2, lon: 7.1 },
expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/)
);
});
it("rejects stale and inaccurate fixes instead of silently setting a point", () => {
const stale = gpsFix({ lat: 53.2, lon: 7.1 }, { timestampMs: Date.now() - 20_000 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: stale } }
);
act(() => expect(result.current.captureAnchor()).toBe(false));
expect(result.current.operationError).toMatch(/älter als 10 Sekunden/);
expect(result.current.anchorPoint).toBeNull();
rerender({ value: gpsFix({ lat: 53.2, lon: 7.1 }, { accuracyM: 55 }) });
act(() => expect(result.current.captureAnchor()).toBe(false));
expect(result.current.operationError).toMatch(/GPS noch zu ungenau/);
expect(result.current.anchorPoint).toBeNull();
});
it("warns and vibrates once a conservative GPS distance exceeds the radius", async () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const initial = gpsFix({ lat: 0, lon: 0 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: initial } }
);
act(() => {
result.current.captureAnchor();
result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 });
});
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
expect(result.current.positionAlarm).toBe(false);
rerender({
value: gpsFix(
{ lat: 150 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
await waitFor(() => expect(result.current.positionAlarm).toBe(true));
expect(result.current.watchResult?.alarmTriggered).toBe(true);
expect(result.current.watchResult?.conservativeDistanceFromAnchorM).toBeGreaterThan(100);
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({
value: gpsFix(
{ lat: 155 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
expect(vibrate).toHaveBeenCalledTimes(1);
});
it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => {
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 })));
act(() => result.current.captureAnchor());
await waitFor(() => expect(result.current.tideWindow?.coverage).toBe("partial"));
expect(result.current.rodePlan?.calculationComplete).toBe(false);
expect(result.current.rodePlan?.rodeReserveM).toBeNull();
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
expect(result.current.phase).toBe("armed");
});
});
function gpsFix(
position: { lat: number; lon: number },
overrides: Partial<Pick<GpsState, "accuracyM" | "timestampMs" | "status">> = {}
): Pick<GpsState, "status" | "position" | "accuracyM" | "timestampMs"> {
return {
status: overrides.status ?? "tracking",
position,
accuracyM: overrides.accuracyM ?? 5,
timestampMs: overrides.timestampMs ?? Date.now()
};
}
function tideFixture(hours = 30): TideSummary {
const now = Date.now();
return {
station: "Testpegel",
distanceKm: 3.2,
nextHigh: null,
nextLow: null,
waterLevelCurve: [
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
{ time: new Date(now + hours * 60 * 60_000).toISOString(), predictedM: 0.5 }
],
source: "Test",
updatedAt: new Date(now).toISOString()
};
}
+229
View File
@@ -0,0 +1,229 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { MarineForecast, TideSummary } from "@watermaps/shared";
import {
ConditionsPanel,
type ConditionsRouteWeatherReport,
type ConditionsRouteWeatherSample
} from "../src/components/ConditionsPanel";
afterEach(cleanup);
const NOW = Date.parse("2026-07-23T10:00:00.000Z");
describe("ConditionsPanel", () => {
it("labels GPS as the source and shows the current marine weather metrics", () => {
render(
<ConditionsPanel
forecast={forecast()}
tide={null}
positionSource={{ kind: "gps" }}
now={NOW}
/>
);
expect(screen.getByRole("complementary", { name: "Wetter & Tide" })).toHaveAttribute(
"data-position-source",
"gps"
);
expect(screen.getByText("Aktuelle GPS-Position")).toBeVisible();
const currentConditions = screen
.getByRole("heading", { name: "Aktuelle Bedingungen" })
.closest("section");
expect(currentConditions).not.toBeNull();
expect(within(currentConditions!).getByText("12 kn · 270° W")).toBeVisible();
expect(within(currentConditions!).getByText("0.8 m · 6 s · 225° SW")).toBeVisible();
expect(within(currentConditions!).getByText("0.7 kn · 090° O")).toBeVisible();
expect(within(currentConditions!).getByText("14.2 °C")).toBeVisible();
expect(within(currentConditions!).getByText("vor 15 Min.")).toBeVisible();
expect(within(currentConditions!).getByText("Quelle: Test-Meteo")).toBeVisible();
});
it("shows the nearest tide station and both high- and low-water events", () => {
render(
<ConditionsPanel
forecast={null}
tide={tideSummary("Pegel Emden", 4.2, 1.72, 0.31)}
positionSource={{ kind: "gps", label: "Außenhafen" }}
now={NOW}
/>
);
expect(screen.getByText("GPS · Außenhafen")).toBeVisible();
expect(screen.getByText("Pegel Emden")).toBeVisible();
expect(screen.getByText("4,2 km entfernt")).toBeVisible();
const tideEvents = document.querySelector(".conditions-tide-events");
expect(tideEvents).not.toBeNull();
expect(tideEvents).toHaveTextContent(/HW.*1\.72 m/);
expect(tideEvents).toHaveTextContent(/NW.*0\.31 m/);
expect(within(tideEvents!).getByTitle("Nächstes Hochwasser")).toHaveTextContent("HW");
expect(within(tideEvents!).getByTitle("Nächstes Niedrigwasser")).toHaveTextContent("NW");
});
it("renders Start, Mitte and Ziel with weather and the middle tide", () => {
const routeWeatherReport: ConditionsRouteWeatherReport = {
severity: "caution",
summary: "Wind nimmt zur Mitte der Strecke zu.",
source: "Streckenprognose",
updatedAt: "2026-07-23T09:40:00.000Z",
unavailableSamples: 0,
samples: [
routeSample("Start", { windSpeed: 8, waveHeightM: 0.3 }),
routeSample("Mitte", { windSpeed: 17, waveHeightM: 0.9 }, -0.2),
routeSample("Ziel", { windSpeed: 11, waveHeightM: 0.5 })
]
};
render(
<ConditionsPanel
forecast={null}
tide={null}
positionSource={{ kind: "fallback", label: "Routenstart" }}
routeWeatherReport={routeWeatherReport}
routeTides={{
start: tideSummary("Startpegel", 2.1, 1.1, 0.2),
middle: tideSummary("Mittelplate", 7.5, 1.48, 0.18),
destination: tideSummary("Zielpegel", 3.4, 1.25, 0.14)
}}
now={NOW}
/>
);
expect(screen.getByText("Fallback · Routenstart")).toBeVisible();
expect(screen.getByText("Wind nimmt zur Mitte der Strecke zu.")).toBeVisible();
const start = routeCard("Start");
const middle = routeCard("Mitte");
const destination = routeCard("Ziel");
expect(start).toHaveTextContent("Startpegel · 2,1 km");
expect(middle).toHaveTextContent("17 kn");
expect(middle).toHaveTextContent("-0.2 kn entlang Route");
expect(middle).toHaveTextContent("Mittelplate · 7,5 km");
expect(middle).toHaveTextContent(/HW.*1\.48 m/);
expect(middle).toHaveTextContent(/NW.*0\.18 m/);
expect(destination).toHaveTextContent("Zielpegel · 3,4 km");
});
it("keeps partial data visible while reporting current and route failures", () => {
const partialReport: ConditionsRouteWeatherReport = {
severity: "caution",
summary: "Streckenprognose ist unvollständig.",
unavailableSamples: 2,
samples: [routeSample("Start", { windSpeed: 9, waveHeightM: 0.4 })]
};
render(
<ConditionsPanel
forecast={forecast({ windSpeed: 9 })}
tide={null}
positionSource={{ kind: "fallback", label: "Letzte GPS-Position" }}
currentError="Tidendaten nicht erreichbar."
routeWeatherReport={partialReport}
routeTides={{ start: null, middle: null, destination: null }}
routeError="Streckentiden nur teilweise erreichbar."
now={NOW}
/>
);
expect(screen.getAllByRole("alert")).toHaveLength(2);
expect(screen.getByText("Tidendaten nicht erreichbar.")).toBeVisible();
expect(screen.getByText("Streckentiden nur teilweise erreichbar.")).toBeVisible();
const currentConditions = screen
.getByRole("heading", { name: "Aktuelle Bedingungen" })
.closest("section");
expect(currentConditions).not.toBeNull();
expect(within(currentConditions!).getByText("9 kn · 270° W")).toBeVisible();
expect(screen.getByText("Für diese Position fehlt eine passende Tidenstation.")).toBeVisible();
expect(screen.getByText("Für 2 Streckenpunkte fehlt die Prognose.")).toBeVisible();
expect(routeCard("Start")).toHaveTextContent("9 kn");
expect(routeCard("Mitte")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
expect(routeCard("Ziel")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
expect(screen.getAllByText(/Keine passende Tide für/)).toHaveLength(3);
});
it("shows useful empty states when neither position nor route conditions exist", () => {
render(
<ConditionsPanel
forecast={null}
tide={null}
positionSource={{ kind: "unknown" }}
now={NOW}
/>
);
expect(screen.getByText("Positionsquelle noch offen")).toBeVisible();
expect(
screen.getByText("Für diese Position liegen noch keine Wetter- oder Tidendaten vor.")
).toBeVisible();
expect(
screen.getByText("Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.")
).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
function routeCard(label: "Start" | "Mitte" | "Ziel") {
const card = screen.getByRole("heading", { name: label, level: 4 }).closest("article");
expect(card).not.toBeNull();
return card!;
}
function routeSample(
label: ConditionsRouteWeatherSample["label"],
overrides: Partial<MarineForecast>,
currentAlongRouteKn: number | null = 0.3
): ConditionsRouteWeatherSample {
return {
label,
plannedTime: `2026-07-23T${label === "Start" ? "10" : label === "Mitte" ? "12" : "14"}:00:00.000Z`,
currentAlongRouteKn,
forecast: forecast(overrides)
};
}
function forecast(overrides: Partial<MarineForecast> = {}): MarineForecast {
return {
waveHeightM: 0.8,
waveDirectionDeg: 225,
wavePeriodS: 5.6,
windSpeed: 12.4,
windDirectionDeg: 270,
temperatureC: 14.2,
oceanCurrentSpeedKn: 0.7,
oceanCurrentDirectionDeg: 90,
forecastTime: "2026-07-23T10:00:00.000Z",
source: "Test-Meteo",
updatedAt: "2026-07-23T09:45:00.000Z",
...overrides
};
}
function tideSummary(
station: string,
distanceKm: number,
highWaterM: number,
lowWaterM: number
): TideSummary {
return {
station,
distanceKm,
nextHigh: {
type: "high",
time: "2026-07-23T12:30:00.000Z",
heightM: highWaterM
},
nextLow: {
type: "low",
time: "2026-07-23T18:45:00.000Z",
heightM: lowWaterM
},
waterLevelCurve: [],
source: "Test-Tide",
updatedAt: "2026-07-23T09:50:00.000Z"
};
}
@@ -0,0 +1,108 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { useCourseAssistant, type CourseAssistantInput } from "../src/hooks/useCourseAssistant";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useCourseAssistant", () => {
it("never starts automatically and calculates only after an explicit start", () => {
const { result } = renderHook(() => useCourseAssistant(input()));
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
act(() => result.current.start());
expect(result.current.active).toBe(true);
expect(result.current.guidance?.status).toBe("on-route");
expect(result.current.guidance?.desiredCourseDeg).toBeCloseTo(90, 0);
});
it("stops deliberately when a different route is selected", () => {
const first = route("first", [[0, 0], [0.02, 0]]);
const second = route("second", [[0, 0], [0, 0.02]]);
const { result, rerender } = renderHook(
({ currentRoute }) => useCourseAssistant(input(currentRoute)),
{ initialProps: { currentRoute: first } }
);
act(() => result.current.start());
expect(result.current.active).toBe(true);
rerender({ currentRoute: second });
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
});
it("pauses guidance when no fresh GPS fix arrives", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-20T10:00:00.000Z"));
const fixTimestampMs = Date.now();
const { result } = renderHook(() => useCourseAssistant(input(routeFixture, fixTimestampMs)));
act(() => result.current.start());
expect(result.current.guidance).not.toBeNull();
act(() => vi.advanceTimersByTime(20_000));
expect(result.current.fixStale).toBe(true);
expect(result.current.guidance).toBeNull();
});
it("vibrates once when crossing into the off-route state", () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const { result, rerender } = renderHook(
({ lat }) => useCourseAssistant(input(routeFixture, Date.now(), lat)),
{ initialProps: { lat: 0 } }
);
act(() => result.current.start());
expect(vibrate).not.toHaveBeenCalled();
rerender({ lat: 0.003 });
expect(result.current.guidance?.status).toBe("off-route");
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({ lat: 0.0031 });
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
function input(
currentRoute: RouteResult = routeFixture,
fixTimestampMs = Date.now(),
lat = 0
): CourseAssistantInput {
return {
route: currentRoute,
position: { lon: 0.001, lat },
accuracyM: 5,
speedKn: 6,
headingDeg: 90,
fixTimestampMs
};
}
function route(id: string, coordinates: [number, number][]): RouteResult {
return {
id,
name: id,
geometry: { type: "LineString", coordinates },
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
}
const routeFixture = route("test-route", [[0, 0], [0.02, 0]]);
@@ -0,0 +1,83 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { calculateRouteGuidance } from "@watermaps/shared";
import { CourseAssistantPanel } from "../src/components/CourseAssistantPanel";
afterEach(cleanup);
describe("CourseAssistantPanel", () => {
it("shows the continuously calculated target course and correction", () => {
const guidance = calculateRouteGuidance({
route: [[0, 0], [0.02, 0]],
position: { lon: 0.001, lat: 0 },
headingDeg: 80,
speedKn: 6,
accuracyM: 5
});
const onStop = vi.fn();
render(
<CourseAssistantPanel
guidance={guidance}
gpsStatus="tracking"
headingDeg={80}
headingSource="COG"
accuracyM={5}
fixStale={false}
onStop={onStop}
/>
);
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveTextContent("090°T");
expect(screen.getByText("10° nach Steuerbord")).toBeVisible();
expect(screen.getByText("Auf Route Sollkurs wird mit jedem GPS-Fix angepasst.")).toBeVisible();
expect(screen.getByText("IST COG").parentElement).toHaveTextContent("080°T");
fireEvent.click(screen.getByRole("button", { name: "Kursassistent stoppen" }));
expect(onStop).toHaveBeenCalledTimes(1);
});
it("suppresses steering advice while GPS accuracy is insufficient", () => {
const guidance = calculateRouteGuidance({
route: [[0, 0], [0.02, 0]],
position: { lon: 0.001, lat: 0 },
headingDeg: 80,
speedKn: 6,
accuracyM: 250
});
render(
<CourseAssistantPanel
guidance={guidance}
gpsStatus="tracking"
headingDeg={80}
headingSource="COG"
accuracyM={250}
fixStale={false}
onStop={vi.fn()}
/>
);
expect(guidance?.status).toBe("gps-unreliable");
expect(screen.getByText("Keine verlässliche Steueranweisung")).toBeVisible();
expect(screen.getByText(/GPS zu ungenau/)).toBeVisible();
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveAttribute("data-alert", "true");
});
it("pauses when the latest GPS fix is stale", () => {
render(
<CourseAssistantPanel
guidance={null}
gpsStatus="tracking"
headingDeg={null}
headingSource="--"
accuracyM={null}
fixStale
onStop={vi.fn()}
/>
);
expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible();
expect(screen.getByText("---")).toBeVisible();
});
});
+219
View File
@@ -0,0 +1,219 @@
import { expect, test } from "@playwright/test";
test("renders the iPhone PWA shell without overlapping core controls", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.goto("/");
await page.getByRole("button", { name: "GPS starten" }).click();
await expect(page.getByText("Watermaps")).toBeVisible();
await expect(page.getByTestId("map-container")).toBeVisible();
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible();
await expect(page.getByLabel("Abfahrt")).toBeVisible();
await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click();
await expect(page.getByRole("region", { name: "Navigation und Offline-Route" })).toBeVisible();
await page.getByRole("button", { name: "Layer" }).click();
await expect(page.getByLabel("Layer Auswahl").getByText("Brücken")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Tiefen")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Schleusen")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Häfen")).toBeVisible();
});
test("sets start and destination only after explicit map picking actions", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.goto("/");
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
const mapBox = await page.getByTestId("map-container").boundingBox();
if (!mapBox) {
throw new Error("Map container not visible");
}
const mapTapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
await expect(page.getByText("Startpunkt auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, mapTapY);
await expect(page.getByText(/^Start \d/)).toBeVisible();
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
await expect(page.getByText("Ziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, mapTapY);
await expect(page.getByText(/^Ziel \d/)).toBeVisible();
await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click();
await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
await expect(page.getByText(/^Z1 /)).toBeVisible();
});
test("does not expose Borkum or Hamm as direct route shortcuts", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("button", { name: /Borkum/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Hamm/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
});
test("can hide and restore the route planner for a map-only view", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Routenfenster ausblenden" }).click();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
await expect(page.getByRole("button", { name: "Routenfenster einblenden" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
test("cycles the mobile route sheet through half, full, and compact heights", async ({ page }) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
const halfBox = await routePanel.boundingBox();
await page
.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" })
.click();
await expect(routePanel).toHaveAttribute("data-sheet-state", "full");
const fullBox = await routePanel.boundingBox();
expect(fullBox && halfBox && fullBox.height > halfBox.height + 80).toBe(true);
await page
.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" })
.click();
await expect(routePanel).toHaveAttribute("data-sheet-state", "compact");
const compactBox = await routePanel.boundingBox();
expect(compactBox && halfBox && compactBox.height < halfBox.height - 80).toBe(true);
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
await page.setViewportSize({ width: 900, height: 844 });
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
test("starts a visible dynamic course assistant only after route planning", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.route("**/api/routes", async (request) => {
await request.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "guidance-e2e",
name: "Testkurs Ost",
geometry: { type: "LineString", coordinates: [[12.09, 54.18], [12.11, 54.18]] },
distanceNm: 0.7,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["E2E"],
routingMode: "fairway"
})
});
});
await page.goto("/");
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
const mapBox = await page.getByTestId("map-container").boundingBox();
if (!mapBox) throw new Error("Map container not visible");
const tapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, tapY);
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, tapY);
await page.getByRole("button", { name: "Route berechnen" }).click();
const startAssistant = page.getByRole("button", { name: "Navigation starten" });
await expect(startAssistant).toBeVisible();
await startAssistant.click();
const assistant = page.getByRole("complementary", { name: "Kursassistent" });
await expect(assistant).toBeVisible();
await expect(assistant.getByText("SOLL ÜBER GRUND")).toBeVisible();
await expect(assistant.getByRole("button", { name: "Kursassistent stoppen" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
const assistantBox = await assistant.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(assistantBox && statusBox && assistantBox.y + assistantBox.height <= statusBox.y + 1).toBe(true);
});
test("sets and arms the anchor watch only through the explicit two-step flow", async ({ page, context }) => {
const now = Date.now();
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 53.2159, longitude: 6.5766 });
await page.route("**/api/tides/nearest?*", async (request) => {
await request.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
station: "Testpegel Emden",
distanceKm: 4.2,
nextHigh: null,
nextLow: null,
waterLevelCurve: [
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
{ time: new Date(now + 30 * 60 * 60_000).toISOString(), predictedM: 0.5 }
],
source: "E2E",
updatedAt: new Date(now).toISOString()
})
});
});
await page.goto("/");
await expect(page.getByRole("complementary", { name: "Ankerwache" })).toHaveCount(0);
await page.getByRole("button", { name: "GPS starten" }).click();
await page.getByRole("button", { name: "Ankerwache öffnen" }).click();
const panel = page.getByRole("complementary", { name: "Ankerwache" });
await expect(panel).toBeVisible();
await expect(panel.getByText(/GPS ±\d+ m/)).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
await panel.getByRole("button", { name: "Anker gefallen Position jetzt setzen" }).click();
await expect(panel.getByText("Ankerpunkt gespeichert")).toBeVisible();
await panel.getByLabel("Tiefe beim Setzen").fill("4");
await panel.getByLabel("Bugrolle über Wasser").fill("1");
await panel.getByLabel("Kette / Leine draußen").fill("40");
await panel.getByLabel("Gewähltes Verhältnis").selectOption("5");
await expect(panel.getByText(/Testpegel Emden · 4\.2 km entfernt/)).toBeVisible();
await expect(panel.getByText(/m Reserve/)).toBeVisible();
await panel.getByRole("button", { name: "Wache starten" }).click();
await expect(panel.getByText("Ankerwache aktiv")).toBeVisible();
await expect(panel.getByText("Im überwachten Schwojkreis")).toBeVisible();
const panelBox = await panel.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(panelBox && statusBox && panelBox.y + panelBox.height <= statusBox.y + 1).toBe(true);
await panel.getByRole("button", { name: "Ankerwache beenden" }).click();
await expect(panel.getByRole("button", { name: "Wirklich beenden" })).toBeVisible();
await panel.getByRole("button", { name: "Wirklich beenden" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
});
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
test("docks the route planner beside the map and releases the full map when collapsed", async ({
page
}) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
const map = page.getByTestId("map-container");
const status = page.getByLabel("Navigationsstatus");
await expect(routePanel).toBeVisible();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
const dockedPanelBox = await routePanel.boundingBox();
const dockedMapBox = await map.boundingBox();
const statusBox = await status.boundingBox();
expect(dockedPanelBox).not.toBeNull();
expect(dockedMapBox).not.toBeNull();
expect(statusBox).not.toBeNull();
expect(dockedPanelBox!.x).toBe(0);
expect(dockedPanelBox!.width).toBe(400);
expect(dockedPanelBox!.x + dockedPanelBox!.width).toBeLessThanOrEqual(dockedMapBox!.x);
expect(dockedPanelBox!.y + dockedPanelBox!.height).toBeLessThanOrEqual(statusBox!.y + 1);
await routePanel.getByRole("button", { name: "Routenfenster ausblenden" }).click();
await expect(routePanel).toBeHidden();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
await expect.poll(async () => {
return page.locator(".maplibregl-canvas").evaluate((node) => {
const canvas = node as HTMLCanvasElement;
return Math.abs(canvas.width / window.devicePixelRatio - canvas.clientWidth);
});
}).toBeLessThan(2);
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
});
test("switches from floating tablet panel to docked desktop sidebar at 1100 pixels", async ({
page
}) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
const map = page.getByTestId("map-container");
await page.setViewportSize({ width: 1099, height: 900 });
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
const floatingPanelBox = await routePanel.boundingBox();
expect(floatingPanelBox?.x).toBe(10);
expect(floatingPanelBox?.width).toBe(360);
await page.setViewportSize({ width: 1100, height: 900 });
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
const dockedPanelBox = await routePanel.boundingBox();
expect(dockedPanelBox?.x).toBe(0);
expect(dockedPanelBox?.width).toBe(400);
});
test("shows marine contact details as a right-hand desktop drawer", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
const requestedFeatureLayers: string[][] = [];
await page.route("**/api/features**", async (route) => {
const requestedLayers =
new URL(route.request().url()).searchParams.get("layers")?.split(",") ?? [];
requestedFeatureLayers.push(requestedLayers);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
type: "FeatureCollection",
features: requestedLayers.includes("harbours")
? [
{
type: "Feature",
id: "e2e-test-harbour",
geometry: { type: "Point", coordinates: [12.09, 54.18] },
properties: {
layer: "harbours",
name: "Testhafen Rostock",
phone: "+49 381 123456",
website: "https://example.com",
vhf: "Kanal 12",
source: "E2E"
}
}
]
: []
})
});
});
await page.goto("/");
await expect(page.locator(".data-badge")).toHaveAttribute("data-ready", "true");
await page.getByRole("button", { name: "GPS starten" }).click();
await page.getByRole("button", { name: "Position zentrieren" }).click();
await expect.poll(() => requestedFeatureLayers.flat().sort().join(",")).toContain("harbours");
const featureButton = page.getByRole("button", {
name: "Informationen zu Hafen Testhafen Rostock",
includeHidden: true
});
await expect(featureButton).toBeAttached({ timeout: 10_000 });
await featureButton.focus();
await featureButton.press("Enter");
const drawer = page.getByRole("dialog", { name: "Testhafen Rostock" });
await expect(drawer).toBeVisible();
await expect(drawer.getByRole("link", { name: /Testhafen Rostock anrufen/ })).toBeVisible();
const drawerBox = await drawer.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(drawerBox).not.toBeNull();
expect(statusBox).not.toBeNull();
expect(drawerBox!.x + drawerBox!.width).toBeGreaterThanOrEqual(1426);
expect(drawerBox!.y).toBeGreaterThanOrEqual(62);
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(statusBox!.y);
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(featureButton).toBeFocused();
});
+61
View File
@@ -0,0 +1,61 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useGeolocation } from "../src/hooks/useGeolocation";
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
afterEach(() => {
cleanup();
if (originalGeolocation) Object.defineProperty(navigator, "geolocation", originalGeolocation);
else Reflect.deleteProperty(navigator, "geolocation");
});
describe("useGeolocation", () => {
it("derives COG only after movement exceeds GPS noise and clears the watch on stop", () => {
let success: PositionCallback | undefined;
const watchPosition = vi.fn((callback: PositionCallback) => {
success = callback;
return 23;
});
const clearWatch = vi.fn();
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
});
const { result } = renderHook(() => useGeolocation());
act(() => result.current.start());
act(() => success?.(positionAt(53, 7, 5, 1_000)));
expect(result.current.courseDeg).toBeNull();
act(() => success?.(positionAt(53, 7.00001, 5, 2_000)));
expect(result.current.courseDeg).toBeNull();
const receivedAtMs = Date.now();
act(() => success?.(positionAt(53, 7.0001, 5, 3_000)));
expect(result.current.courseDeg).toBeCloseTo(90, 0);
expect(result.current.timestampMs).toBeGreaterThanOrEqual(receivedAtMs);
act(() => result.current.stop());
expect(clearWatch).toHaveBeenCalledWith(23);
expect(result.current.status).toBe("idle");
expect(result.current.position).toBeNull();
});
});
function positionAt(lat: number, lon: number, accuracy: number, timestamp: number): GeolocationPosition {
return {
coords: {
latitude: lat,
longitude: lon,
accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: 3,
toJSON: () => ({})
},
timestamp,
toJSON: () => ({})
};
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { createRouteGpx } from "../src/lib/gpx";
describe("GPX export", () => {
it("creates parseable GPX 1.1 route and track metadata", () => {
const gpx = createRouteGpx(routeFixture, {
name: "Emden & Hamm <Test>",
createdAt: "2026-07-19T12:00:00.000Z"
});
const document = new DOMParser().parseFromString(gpx, "application/xml");
const root = document.documentElement;
expect(document.querySelector("parsererror")).toBeNull();
expect(root.localName).toBe("gpx");
expect(root.getAttribute("version")).toBe("1.1");
expect(root.namespaceURI).toBe("http://www.topografix.com/GPX/1/1");
expect(document.getElementsByTagNameNS(root.namespaceURI, "metadata")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "rte")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "trk")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "rtept")).toHaveLength(3);
expect(document.getElementsByTagNameNS(root.namespaceURI, "trkpt")).toHaveLength(3);
expect(document.getElementsByTagNameNS(root.namespaceURI, "time")[0]?.textContent).toBe("2026-07-19T12:00:00.000Z");
expect(gpx).toContain("Emden &amp; Hamm &lt;Test&gt;");
expect(gpx).toContain("minlat=\"51.6814536\"");
});
it("rejects invalid or incomplete geometry", () => {
expect(() => createRouteGpx({
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53]] }
})).toThrow(/nicht genügend Punkte/);
expect(() => createRouteGpx({
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] }
})).toThrow(/ungültige Koordinaten/);
});
});
const routeFixture: RouteResult = {
id: "emden-hamm",
name: "Emden Hamm",
geometry: {
type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
},
distanceNm: 153.69,
eta: null,
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich & vor Ort prüfen" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OpenStreetMap <curated>"],
routingMode: "fairway"
};
+64
View File
@@ -0,0 +1,64 @@
import "@testing-library/jest-dom/vitest";
import { lazy } from "react";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LazyContent } from "../src/components/LazyContent";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("LazyContent", () => {
it("keeps the surrounding app shell visible while a feature chunk loads", async () => {
let resolveModule: ((module: { default: () => React.JSX.Element }) => void) | undefined;
const Feature = lazy(
() =>
new Promise<{ default: () => React.JSX.Element }>((resolve) => {
resolveModule = resolve;
})
);
render(
<main>
<h1>Watermaps</h1>
<LazyContent
pending={<p role="status">Modul wird geladen </p>}
failed={<p role="alert">Modul fehlt.</p>}
>
<Feature />
</LazyContent>
</main>
);
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("Modul wird geladen");
await act(async () => {
resolveModule?.({ default: () => <section>Funktion bereit</section> });
});
expect(await screen.findByText("Funktion bereit")).toBeVisible();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("contains a failed lazy import inside its local fallback", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const BrokenFeature = lazy(() => Promise.reject(new Error("Chunk fehlt")));
render(
<main>
<h1>Watermaps</h1>
<LazyContent
pending={<p role="status">Modul wird geladen </p>}
failed={<p role="alert">Modul konnte nicht geladen werden.</p>}
>
<BrokenFeature />
</LazyContent>
</main>
);
expect(await screen.findByRole("alert")).toHaveTextContent("nicht geladen");
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
});
});
+866
View File
@@ -0,0 +1,866 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppConfig, RouteResult } from "@watermaps/shared";
import { MarineFeatureInfo, type MarineFeatureDetails } from "../src/components/MarineFeatureInfo";
const maplibreState = vi.hoisted(() => ({ current: null as any, markerConstructions: 0 }));
const apiMocks = vi.hoisted(() => ({ getMapFeatures: vi.fn() }));
vi.mock("../src/api", () => apiMocks);
vi.mock("maplibre-gl", () => {
class MockLngLatBounds {
extend = vi.fn(() => this);
}
class MockMap {
handlers = new globalThis.Map<string, Set<(event?: any) => void>>();
sources = new globalThis.Map<
string,
{
setData: ReturnType<typeof vi.fn>;
getClusterExpansionZoom: ReturnType<typeof vi.fn>;
}
>();
sourceDefinitions = new globalThis.Map<string, any>();
layers = new Set<string>();
layerDefinitions = new globalThis.Map<string, any>();
renderedFeatures: any[] = [];
zoom = 13;
container: HTMLElement;
fitBounds = vi.fn(() => this);
easeTo = vi.fn(() => this);
flyTo = vi.fn(() => this);
setLayoutProperty = vi.fn();
moveLayer = vi.fn();
remove = vi.fn();
constructor(options: { container: HTMLElement }) {
this.container = options.container;
this.container.addEventListener("click", () => {
this.emit("click", { lngLat: { lng: 7.8, lat: 51.7 }, point: { x: 10, y: 10 } });
});
maplibreState.current = this;
}
addControl() {
return this;
}
on(eventName: string, handler: (event?: any) => void) {
this.handlers.set(eventName, new Set([...(this.handlers.get(eventName) ?? []), handler]));
return this;
}
off(eventName: string, handler: (event?: any) => void) {
this.handlers.get(eventName)?.delete(handler);
return this;
}
emit(eventName: string, event?: any) {
for (const handler of this.handlers.get(eventName) ?? []) {
handler(event);
}
}
addSource(id: string, definition: any) {
this.sourceDefinitions.set(id, definition);
this.sources.set(id, {
setData: vi.fn(),
getClusterExpansionZoom: vi.fn(async () => 15)
});
}
getSource(id: string) {
return this.sources.get(id);
}
addLayer(layer: { id: string }) {
this.layers.add(layer.id);
this.layerDefinitions.set(layer.id, layer);
}
getLayer(id: string) {
return this.layers.has(id) ? { id } : undefined;
}
getCanvas() {
return this.container;
}
getCanvasContainer() {
return this.container;
}
getContainer() {
return this.container;
}
unproject() {
return { lng: 7.8, lat: 51.7 };
}
project() {
return { x: 10, y: 10 };
}
queryRenderedFeatures() {
return this.renderedFeatures;
}
isStyleLoaded() {
return true;
}
getZoom() {
return this.zoom;
}
getBounds() {
return {
getWest: () => 7,
getSouth: () => 51,
getEast: () => 9,
getNorth: () => 54
};
}
}
class MockMarker {
element: HTMLElement;
constructor(options: { element: HTMLElement }) {
maplibreState.markerConstructions += 1;
this.element = options.element;
}
setLngLat() {
return this;
}
addTo(map: MockMap) {
map.container.append(this.element);
return this;
}
remove() {
this.element.remove();
return this;
}
}
class MockControl {}
return {
default: {
Map: MockMap,
Marker: MockMarker,
LngLatBounds: MockLngLatBounds,
AttributionControl: MockControl,
ScaleControl: MockControl
}
};
});
import { MapView } from "../src/components/MapView";
const config: AppConfig = {
appName: "Watermaps",
region: "Test",
disclaimer: "Test",
featureFlags: {},
layers: [
{
id: "base",
name: "Basiskarte",
kind: "style",
url: "https://example.test/style.json",
attribution: "Test",
defaultVisible: true
}
],
attribution: []
};
const marineFeatures = [
{
type: "Feature" as const,
id: "lock-1",
geometry: { type: "Point" as const, coordinates: [7.61, 52.01] },
properties: {
layer: "locks",
name: "Schleuse Nord",
"contact:phone": "+49 123 456",
website: "https://schleuse.example",
email: "schleuse@example.test",
vhf_channel: "Kanal 20",
opening_hours: "Mo-Su 06:00-22:00",
operator: "WSV",
"addr:street": "Am Kanal",
"addr:housenumber": "1",
"addr:postcode": "12345",
"addr:city": "Hafenstadt",
source: "OSM/Geofabrik"
}
},
{
type: "Feature" as const,
id: "harbour-1",
geometry: { type: "Point" as const, coordinates: [7.72, 51.82] },
properties: {
layer: "harbours",
name: "Stadthafen",
source: "Hafenbetreiber"
}
}
];
beforeEach(() => {
maplibreState.current = null;
maplibreState.markerConstructions = 0;
apiMocks.getMapFeatures.mockReset();
apiMocks.getMapFeatures.mockImplementation(async ({ layers }: { layers: string[] }) => ({
type: "FeatureCollection",
features: marineFeatures.filter((feature) => layers.includes(feature.properties.layer))
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("MapView marine feature information", () => {
it("loads clustered lock and harbour info layers only at a close zoom level", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
maplibreState.current.zoom = 9;
await act(async () => maplibreState.current.emit("load"));
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenLastCalledWith(
expect.objectContaining({ layers: expect.not.arrayContaining(["locks", "harbours"]) })
);
expect(maplibreState.current.sourceDefinitions.get("marine-contact-pois")).toEqual(
expect.objectContaining({ type: "geojson", cluster: true, clusterMaxZoom: 14 })
);
expect(maplibreState.current.layerDefinitions.get("lock-info-circles")).toEqual(
expect.objectContaining({ type: "circle", minzoom: 12, source: "marine-contact-pois" })
);
expect(maplibreState.current.layerDefinitions.get("contact-cluster-count")).toEqual(
expect.objectContaining({ type: "symbol", minzoom: 12, source: "marine-contact-pois" })
);
apiMocks.getMapFeatures.mockClear();
maplibreState.current.zoom = 12;
await act(async () => maplibreState.current.emit("zoomend"));
await act(async () => maplibreState.current.emit("moveend"));
expect(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
expect(maplibreState.markerConstructions).toBe(0);
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
maplibreState.current.zoom = 11;
await act(async () => maplibreState.current.emit("zoom"));
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
});
it("opens a rendered POI before route-coordinate picking and keeps static accessible buttons", async () => {
const onPickCoordinate = vi.fn();
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
expect.objectContaining({ layers: expect.arrayContaining(["locks", "harbours"]) })
);
maplibreState.current.renderedFeatures = [marineFeatures[0]];
await act(async () =>
maplibreState.current.emit("click", {
point: { x: 12, y: 18 },
lngLat: { lng: 7.61, lat: 52.01 }
})
);
expect(onPickCoordinate).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
expect(screen.getByRole("link", { name: /Schleuse Nord anrufen/i })).toHaveAttribute(
"href",
"tel:+49123456"
);
expect(screen.getByRole("link", { name: /Website von Schleuse Nord/i })).toHaveAttribute(
"href",
"https://schleuse.example/"
);
expect(screen.getByText("Kanal 20")).toBeVisible();
expect(screen.getByText("Am Kanal 1, 12345 Hafenstadt")).toBeVisible();
});
it("expands a contact cluster instead of treating it as a picked route coordinate", async () => {
const onPickCoordinate = vi.fn();
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
maplibreState.current.renderedFeatures = [
{
type: "Feature",
geometry: { type: "Point", coordinates: [7.7, 52] },
properties: { cluster: true, cluster_id: 42, point_count: 12 }
}
];
await act(async () =>
maplibreState.current.emit("click", {
point: { x: 20, y: 20 },
lngLat: { lng: 7.7, lat: 52 }
})
);
const contactSource = maplibreState.current.getSource("marine-contact-pois");
expect(contactSource.getClusterExpansionZoom).toHaveBeenCalledWith(42);
await waitFor(() =>
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [7.7, 52], zoom: 15, essential: true })
)
);
expect(onPickCoordinate).not.toHaveBeenCalled();
});
it("removes accessible POIs and closes their panel when a feature layer is disabled", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Hafen Stadthafen" }));
expect(screen.getByRole("dialog", { name: "Stadthafen" })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Layer" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Häfen ab Zoom 12" }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Informationen zu Hafen Stadthafen" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Stadthafen" })).not.toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeVisible();
});
it("replaces stale static POI controls after the visible map area is refreshed", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" }));
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
apiMocks.getMapFeatures.mockResolvedValue({
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "lock-2",
geometry: { type: "Point", coordinates: [8.1, 52.2] },
properties: { layer: "locks", name: "Schleuse Süd", source: "Test" }
}
]
});
await act(async () => maplibreState.current.emit("moveend"));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Schleuse Nord" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Süd" })).toBeVisible();
});
});
it("keeps map movement handlers constant for a dense contact response", async () => {
apiMocks.getMapFeatures.mockResolvedValue({
type: "FeatureCollection",
features: Array.from({ length: 250 }, (_, index) => ({
type: "Feature",
id: `lock-${index}`,
geometry: { type: "Point", coordinates: [7 + index / 10_000, 52] },
properties: { layer: "locks", name: `Schleuse ${index}` }
}))
});
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await waitFor(() => expect(maplibreState.current.getSource("marine-contact-pois").setData).toHaveBeenCalled());
expect(maplibreState.markerConstructions).toBe(0);
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
expect(maplibreState.current.handlers.get("moveend")?.size ?? 0).toBe(1);
});
it("aborts a superseded feature request", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
let firstSignal: AbortSignal | undefined;
apiMocks.getMapFeatures.mockImplementationOnce(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
firstSignal = signal;
signal?.addEventListener("abort", () => reject(new DOMException("Abgebrochen", "AbortError")), {
once: true
});
})
);
await act(async () => maplibreState.current.emit("moveend"));
await waitFor(() => expect(firstSignal).toBeDefined());
apiMocks.getMapFeatures.mockResolvedValueOnce({ type: "FeatureCollection", features: marineFeatures });
await act(async () => maplibreState.current.emit("moveend"));
expect(firstSignal?.aborted).toBe(true);
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
});
it("fits the map to a newly calculated route", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const route: RouteResult = {
geometry: {
type: "LineString",
coordinates: [
[7.18, 53.34],
[7.81, 51.68]
]
},
distanceNm: 120,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={route}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.fitBounds).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ maxZoom: 14, essential: true })
);
});
it("focuses a route event requested by the shared navigation workspace", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "lock:test:1",
coordinate: { lat: 52.1, lon: 7.4 },
zoom: 15
}}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [7.4, 52.1],
zoom: 15,
essential: true
});
});
it("applies an existing route-event focus after the map style has loaded", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "harbour:early:1",
coordinate: { lat: 53.2, lon: 6.9 },
zoom: 13
}}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
expect(maplibreState.current.flyTo).not.toHaveBeenCalled();
await act(async () => maplibreState.current.emit("load"));
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [6.9, 53.2],
zoom: 13,
essential: true
});
});
it("draws the live guidance vector and lookahead target without DOM markers", async () => {
render(
<MapView
config={config}
position={{ lat: 52, lon: 7 }}
accuracyM={6}
startPoint={{ lat: 52, lon: 7 }}
destination={{ lat: 52, lon: 7.1 }}
pickMode={null}
route={null}
guidanceActive
guidanceTarget={{ lat: 52.001, lon: 7.01 }}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
const definition = maplibreState.current.sourceDefinitions.get("route-guidance");
expect(definition).toEqual(expect.objectContaining({ type: "geojson" }));
expect(definition.data.features).toHaveLength(2);
expect(definition.data.features[0].geometry).toEqual({
type: "LineString",
coordinates: [[7, 52], [7.01, 52.001]]
});
expect(maplibreState.current.layerDefinitions.get("route-guidance-line")).toEqual(
expect.objectContaining({ type: "line", source: "route-guidance" })
);
expect(maplibreState.markerConstructions).toBe(0);
});
});
describe("MapView anchor watch", () => {
it("renders one geodesic metre-based alarm ring and updates it without markers", async () => {
const onPickCoordinate = vi.fn();
const onMapReady = vi.fn();
const { rerender } = render(
<MapView
config={config}
position={{ lat: 53.2001, lon: 7.1001 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={80}
anchorWatchActive
anchorAlarm={false}
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const initial = maplibreState.current.sourceDefinitions.get("anchor-watch").data;
const radius = initial.features.find((feature: any) => feature.properties.kind === "radius");
expect(radius.geometry.type).toBe("Polygon");
expect(radius.geometry.coordinates[0]).toHaveLength(65);
expect(initial.features.filter((feature: any) => feature.properties.kind === "anchor")).toHaveLength(1);
expect(initial.features.filter((feature: any) => feature.properties.kind === "distance")).toHaveLength(1);
expect(maplibreState.current.layerDefinitions.get("anchor-watch-radius-fill")).toEqual(
expect.objectContaining({ type: "fill", source: "anchor-watch" })
);
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [7.1, 53.2], zoom: 15 })
);
expect(maplibreState.markerConstructions).toBe(0);
rerender(
<MapView
config={config}
position={{ lat: 53.2015, lon: 7.1 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={100}
anchorWatchActive
anchorAlarm
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
const source = maplibreState.current.sources.get("anchor-watch");
await waitFor(() => expect(source.setData).toHaveBeenCalled());
const updated = source.setData.mock.calls.at(-1)?.[0];
expect(updated.features.every((feature: any) => feature.properties.alarm === true)).toBe(true);
expect(updated.features.find((feature: any) => feature.properties.kind === "radius").geometry.coordinates[0]).toHaveLength(65);
});
});
describe("MapView waypoints", () => {
it("creates a labelled waypoint source, updates it, and shows the waypoint picking hint", async () => {
const stableOnPickCoordinate = vi.fn();
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[
{ lat: 52.8, lon: 7.25 },
{ lat: 52.1, lon: 7.5 }
]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(screen.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await act(async () => maplibreState.current.emit("load"));
expect(maplibreState.current.sourceDefinitions.get("waypoints")).toEqual({
type: "geojson",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { label: "Z1" },
geometry: { type: "Point", coordinates: [7.25, 52.8] }
},
{
type: "Feature",
properties: { label: "Z2" },
geometry: { type: "Point", coordinates: [7.5, 52.1] }
}
]
}
});
expect(maplibreState.current.layerDefinitions.get("waypoint-label")).toEqual(
expect.objectContaining({ source: "waypoints" })
);
const waypointSource = maplibreState.current.getSource("waypoints");
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[{ lat: 52.4, lon: 7.65 }]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(waypointSource?.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { label: "Z1" },
geometry: { type: "Point", coordinates: [7.65, 52.4] }
}
]
});
});
});
describe("MarineFeatureInfo", () => {
it("summarizes missing contact information without rendering empty data rows and closes with Escape", () => {
const onClose = vi.fn();
const feature: MarineFeatureDetails = {
id: "harbour-missing",
layer: "harbours",
name: "Unbemannter Hafen",
typeLabel: "Hafen",
coordinate: { lat: 51.7, lon: 7.8 },
phone: null,
website: null,
email: null,
vhf: null,
openingHours: null,
operator: null,
address: null,
source: null,
updatedAt: null
};
render(<MarineFeatureInfo feature={feature} onClose={onClose} />);
expect(screen.getByText("Keine direkten Kontaktdaten hinterlegt.")).toBeVisible();
expect(screen.queryByText("Nicht hinterlegt")).not.toBeInTheDocument();
expect(screen.getByText("Daten & Quelle")).toBeVisible();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("links the enrichment provenance and explains merged map objects", () => {
const feature: MarineFeatureDetails = {
id: "lock-enriched",
layer: "locks",
name: "Schleuse Werries",
typeLabel: "Schleuse",
coordinate: { lat: 51.69508, lon: 7.86708 },
phone: "+49 2381 9019-290",
website: null,
email: null,
vhf: "22",
openingHours: null,
operator: "WSV",
address: null,
source: "EuRIS + OpenStreetMap",
sourceUrl: "https://www.eurisportal.eu/visuris/api/Locks_v2/GetLock?isrs=DEHMM00301LOCKS00404",
updatedAt: "2026-07-20T10:00:00.000Z",
memberCount: 4
};
render(<MarineFeatureInfo feature={feature} onClose={vi.fn()} />);
expect(screen.getByRole("link", { name: /Schleuse Werries anrufen/i })).toHaveAttribute(
"href",
"tel:+4923819019290"
);
fireEvent.click(screen.getByText("Daten & Quelle"));
expect(screen.getByRole("link", { name: "EuRIS + OpenStreetMap" })).toHaveAttribute(
"href",
expect.stringContaining("DEHMM00301LOCKS00404")
);
expect(screen.getByText("4 Kartenobjekte")).toBeVisible();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiMocks = vi.hoisted(() => ({
getMarineForecast: vi.fn(),
getNearestTide: vi.fn()
}));
vi.mock("../src/api", () => apiMocks);
import { useMarineData } from "../src/hooks/useMarineData";
beforeEach(() => {
apiMocks.getMarineForecast.mockResolvedValue({ source: "test", updatedAt: new Date().toISOString() });
apiMocks.getNearestTide.mockResolvedValue({
station: "test",
distanceKm: 1,
nextHigh: null,
nextLow: null,
waterLevelCurve: [],
source: "test",
updatedAt: new Date().toISOString()
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("useMarineData", () => {
it("does not refetch weather and tide for every GPS jitter inside the same rounded cell", async () => {
const { result, rerender } = renderHook(
({ position }) => useMarineData(position),
{ initialProps: { position: { lat: 53.201, lon: 7.101 } } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
rerender({ position: { lat: 53.203, lon: 7.103 } });
await Promise.resolve();
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
rerender({ position: { lat: 53.216, lon: 7.116 } });
await waitFor(() => expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(2));
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(2);
});
it("keeps partial data visible and exposes the failed source separately", async () => {
apiMocks.getMarineForecast.mockRejectedValueOnce(new Error("forecast offline"));
const { result } = renderHook(() =>
useMarineData({ lat: 53.2159, lon: 6.5766 })
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.forecast).toBeNull();
expect(result.current.tide?.station).toBe("test");
expect(result.current.forecastError).toBe("Wetterdaten nicht erreichbar");
expect(result.current.tideError).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.queryPosition).toEqual({ lat: 53.22, lon: 6.58 });
expect(result.current.refreshedAt).toEqual(expect.any(String));
});
});
@@ -0,0 +1,120 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
NavigationToolRail,
type NavigationToolId
} from "../src/components/NavigationToolRail";
import { NavigationWorkspace } from "../src/components/NavigationWorkspace";
afterEach(cleanup);
describe("NavigationToolRail", () => {
it("renders the fixed tool order with accessible state and badge information", () => {
const onSelect = vi.fn<(tool: NavigationToolId) => void>();
render(
<NavigationToolRail
activeTool="conditions"
onSelect={onSelect}
workspaceId="test-workspace"
statuses={{
anchor: "alarm",
conditions: "active",
upcoming: "caution",
route: "stale"
}}
badges={{ upcoming: 123 }}
/>
);
const rail = screen.getByRole("navigation", { name: "Kartenwerkzeuge" });
const buttons = within(rail).getAllByRole("button");
expect(buttons.map((button) => button.dataset.tool)).toEqual([
"anchor",
"conditions",
"upcoming",
"route"
]);
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
expect(anchor).toHaveAttribute("data-status", "alarm");
expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
expect(anchor).toHaveAttribute("aria-expanded", "false");
expect(anchor).toHaveAttribute("aria-pressed", "false");
const conditions = screen.getByRole("button", {
name: /Wetter und Tide, aktiv, geöffnet/
});
expect(conditions).toHaveAttribute("aria-expanded", "true");
expect(conditions).toHaveAttribute("aria-pressed", "true");
const upcoming = screen.getByRole("button", {
name: /Als Nächstes, Warnung, 99\+ Hinweise, öffnen/
});
expect(upcoming).toHaveAttribute("data-status", "caution");
expect(within(upcoming).getByText("99+")).toBeVisible();
const route = screen.getByRole("button", { name: /Route, Daten veraltet, öffnen/ });
fireEvent.click(route);
expect(onSelect).toHaveBeenCalledWith("route");
});
});
describe("NavigationWorkspace", () => {
it("keeps compact content inaccessible and exposes controlled size and close actions", () => {
const onSheetStateChange = vi.fn();
const onClose = vi.fn();
const onBack = vi.fn();
const { container, rerender } = render(
<NavigationWorkspace
id="test-workspace"
activeTool="route"
title="Routenübersicht"
summary="12 sm bis zum Ziel"
status="active"
sheetState="compact"
presentation="bottom-sheet"
onSheetStateChange={onSheetStateChange}
onBack={onBack}
onClose={onClose}
footer={<button type="button">Navigation starten</button>}
>
<button type="button">Route bearbeiten</button>
</NavigationWorkspace>
);
const workspace = screen.getByRole("complementary", { name: "Routenübersicht" });
expect(workspace).toHaveAttribute("data-tool", "route");
expect(workspace).toHaveAttribute("data-status", "active");
expect(workspace).toHaveAttribute("data-presentation", "bottom-sheet");
expect(workspace).toHaveAttribute("data-sheet-state", "compact");
expect(container.querySelector(".navigation-workspace-body")).toHaveAttribute("hidden");
expect(container.querySelector(".navigation-workspace-footer")).toHaveAttribute("hidden");
expect(screen.queryByRole("button", { name: "Route bearbeiten" })).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Arbeitsbereich auf halbe Höhe vergrößern" })
);
expect(onSheetStateChange).toHaveBeenCalledWith("half");
fireEvent.click(screen.getByRole("button", { name: "Zurück" }));
expect(onBack).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Route schließen" }));
expect(onClose).toHaveBeenCalledTimes(1);
rerender(
<NavigationWorkspace
id="test-workspace"
activeTool="route"
title="Routenübersicht"
sheetState="half"
onClose={onClose}
>
<button type="button">Route bearbeiten</button>
</NavigationWorkspace>
);
expect(screen.getByRole("button", { name: "Route bearbeiten" })).toBeVisible();
expect(container.querySelector(".navigation-workspace-body")).not.toHaveAttribute("hidden");
});
});
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import {
OFFLINE_VOYAGES_STORAGE_KEY,
createOfflineVoyageRecord,
deleteOfflineVoyage,
listOfflineVoyages,
loadOfflineVoyage,
saveOfflineVoyage
} from "../src/lib/offline-route";
beforeEach(() => localStorage.clear());
describe("offline voyage storage", () => {
it("stores and restores a validated route and voyage plan", () => {
const saved = saveOfflineVoyage(
{
route: routeFixture,
name: "Törn Emden Hamm",
plan: {
vesselProfile: {
draughtM: 1.4,
safetyReserveM: 0.5,
airDraftM: 2.8,
beamM: 3.2,
cruiseSpeedKn: 6
},
waypoints: [{ lat: 51.65, lon: 7.34 }],
departureAt: "2026-07-20T06:00:00.000Z",
notes: "Schleusen vor Abfahrt prüfen"
}
},
localStorage,
{ id: "voyage-test", savedAt: "2026-07-19T12:00:00.000Z" }
);
expect(saved.plan.start).toEqual({ lat: 53.344167, lon: 7.186111 });
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
expect(saved.route.durationMinutes).toBe(120);
expect(saved.route.alternatives).toBeUndefined();
expect(listOfflineVoyages(localStorage)).toHaveLength(1);
const restored = loadOfflineVoyage("voyage-test", localStorage);
expect(restored).toEqual(saved);
expect(restored?.route).toEqual(
expect.objectContaining({
departureTime: "2026-07-20T06:00:00.000Z",
durationMinutes: 120
})
);
expect(deleteOfflineVoyage("voyage-test", localStorage)).toBe(true);
expect(loadOfflineVoyage("voyage-test", localStorage)).toBeNull();
});
it("ignores corrupted or untrusted persisted values", () => {
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, "not-json");
expect(listOfflineVoyages(localStorage)).toEqual([]);
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify([{
schemaVersion: 1,
id: "bad",
name: "Bad",
savedAt: "no date",
plan: {},
route: { geometry: { type: "LineString", coordinates: [[999, 0], [0, 0]] } }
}]));
expect(listOfflineVoyages(localStorage)).toEqual([]);
});
it("rejects unsafe route coordinates before writing", () => {
expect(() => createOfflineVoyageRecord({
route: {
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53], [Number.NaN, 52]] }
}
})).toThrow(/ungültige Koordinaten|Zahlenwert/);
expect(localStorage.getItem(OFFLINE_VOYAGES_STORAGE_KEY)).toBeNull();
});
});
const routeFixture: RouteResult = {
id: "emden-hamm",
name: "Emden Hamm",
geometry: {
type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
},
distanceNm: 153.69,
departureTime: "2026-07-20T06:00:00.000Z",
durationMinutes: 120,
eta: "2026-07-20T08:00:00.000Z",
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"],
routingMode: "fairway",
alternatives: [{
id: "unused",
name: "Alternative",
geometry: { type: "LineString", coordinates: [[7, 53], [7.1, 52]] },
distanceNm: 160,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"]
}]
};
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { distanceToRouteM, evaluateRouteDeviation } from "../src/lib/route-deviation";
describe("route deviation", () => {
it("calculates the cross-track distance to a route segment", () => {
const distance = distanceToRouteM(
{ lat: 0.001, lon: 0.005 },
[[0, 0], [0.01, 0]]
);
expect(distance).toBeGreaterThan(110);
expect(distance).toBeLessThan(112.5);
});
it("uses the endpoint when the nearest point lies beyond the segment", () => {
const distance = distanceToRouteM(
{ lat: 0, lon: 0.02 },
[[0, 0], [0.01, 0]]
);
expect(distance).toBeGreaterThan(1_110);
expect(distance).toBeLessThan(1_113);
});
it("handles a short segment crossing the antimeridian", () => {
const distance = distanceToRouteM(
{ lat: 0.001, lon: 180 },
[[179.9, 0], [-179.9, 0]]
);
expect(distance).toBeGreaterThan(110);
expect(distance).toBeLessThan(113);
});
it("accounts conservatively for GPS accuracy and suppresses unreliable alarms", () => {
const route = [[0, 0], [0.01, 0]] as const;
const near = evaluateRouteDeviation({ lat: 0.001, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
const away = evaluateRouteDeviation({ lat: 0.002, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
const inaccurate = evaluateRouteDeviation({ lat: 0.01, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 500 });
expect(near?.isOffRoute).toBe(false);
expect(near?.conservativeDistanceM).toBeLessThan(100);
expect(away?.isOffRoute).toBe(true);
expect(inaccurate?.reliable).toBe(false);
expect(inaccurate?.isOffRoute).toBe(false);
});
it("returns null instead of alarming for invalid or missing route data", () => {
expect(distanceToRouteM({ lat: Number.NaN, lon: 7 }, [[7, 53], [7.1, 53.1]])).toBeNull();
expect(distanceToRouteM({ lat: 53, lon: 7 }, [])).toBeNull();
});
});
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import type {
RouteResult,
VoyageHarbour
} from "@watermaps/shared";
import {
nextRouteEventsByKind,
upcomingRouteEvents
} from "../src/routeEvents";
import type { RouteBridgeAssessment } from "../src/routeWeatherReport";
import type { RouteLock } from "../src/voyageHarbours";
const route: RouteResult = {
id: "eastbound",
geometry: {
type: "LineString",
coordinates: [
[0, 0],
[1, 0]
]
},
distanceNm: 60,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["test"]
};
describe("upcomingRouteEvents", () => {
it("projects mixed facilities, filters passed and off-corridor entries, and orders them along the route", () => {
const events = upcomingRouteEvents({
route,
progressNm: 12,
harbours: [
harbour("passed-harbour", 0.1, 0),
harbour("next-harbour", 0.6, 0),
harbour("off-route-harbour", 0.5, 0.05)
],
locks: [
lock("next-lock", 0.4, 0, 999, 0),
lock("off-route-lock", 0.5, 0.01, 1, 0)
],
bridges: [
bridge("next-bridge", 0.3, 0.0005, 99),
bridge("off-route-bridge", 0.35, 0.01, 0.001)
]
});
expect(events.map((event) => `${event.kind}:${event.id}`)).toEqual([
"bridge:next-bridge",
"lock:next-lock",
"harbour:next-harbour"
]);
expect(events.every((event) => event.routeDistanceNm >= 12)).toBe(true);
expect(events.every((event) => event.remainingNm >= 0)).toBe(true);
});
it("reprojects locks and bridges instead of trusting their existing distance fields", () => {
const [projectedLock, projectedBridge] = upcomingRouteEvents({
route,
locks: [lock("lock", 0.25, 0, 999, 777)],
bridges: [bridge("bridge", 0.75, 0, 999)]
});
expect(projectedLock?.kind).toBe("lock");
expect(projectedLock?.routeDistanceNm).toBeCloseTo(15, 0);
expect(projectedLock?.distanceFromRouteNm).toBeCloseTo(0, 6);
expect(projectedBridge?.kind).toBe("bridge");
expect(projectedBridge?.routeDistanceNm).toBeCloseTo(45, 0);
expect(projectedBridge?.distanceFromRouteNm).toBeCloseTo(0, 6);
});
it("uses a corridor override without changing the defaults for other kinds", () => {
const events = upcomingRouteEvents({
route,
corridorsNm: { harbour: 4 },
harbours: [harbour("detour", 0.5, 0.05)],
locks: [lock("off-route-lock", 0.5, 0.01, 0, 0)]
});
expect(events.map((event) => event.id)).toEqual(["detour"]);
});
it("calculates ETA and retains both speed and reference-time provenance", () => {
const [event] = upcomingRouteEvents({
route,
progressNm: 6,
locks: [lock("lock", 0.5, 0, 0, 0)],
etaBasis: {
speedKn: 6,
speedSource: "vessel-cruise-speed",
referenceTime: "2026-07-23T08:00:00.000Z",
referenceSource: "route-departure"
}
});
expect(event).toBeDefined();
expect(event!.remainingNm).toBeCloseTo(24, 0);
expect(event!.eta).toMatchObject({
speedKn: 6,
speedSource: "vessel-cruise-speed",
referenceTime: "2026-07-23T08:00:00.000Z",
referenceSource: "route-departure"
});
expect(event!.eta!.minutesFromProgress).toBeCloseTo(
(event!.remainingNm / 6) * 60,
8
);
expect(Date.parse(event!.eta!.estimatedAt)).toBeCloseTo(
Date.parse("2026-07-23T08:00:00.000Z") +
event!.eta!.minutesFromProgress * 60_000,
0
);
});
it("omits ETA for an invalid assumption rather than inventing a speed", () => {
const [event] = upcomingRouteEvents({
route,
harbours: [harbour("harbour", 0.5, 0)],
etaBasis: {
speedKn: 0,
speedSource: "gps-sog",
referenceTime: "not-a-time",
referenceSource: "current-time"
}
});
expect(event?.eta).toBeNull();
});
it("selects the first upcoming event of each kind from an ordered list", () => {
const events = upcomingRouteEvents({
route,
harbours: [
harbour("harbour-2", 0.8, 0),
harbour("harbour-1", 0.2, 0)
],
locks: [lock("lock-1", 0.3, 0, 0, 0)]
});
const next = nextRouteEventsByKind(events);
expect(next.harbour?.id).toBe("harbour-1");
expect(next.lock?.id).toBe("lock-1");
expect(next.bridge).toBeNull();
});
});
function harbour(
id: string,
lon: number,
lat: number
): VoyageHarbour {
return {
id,
name: id,
kind: "harbour",
coordinate: { lon, lat }
};
}
function lock(
id: string,
lon: number,
lat: number,
routeDistanceNm: number,
distanceFromRouteNm: number
): RouteLock {
return {
id,
name: id,
coordinate: { lon, lat },
routeDistanceNm,
distanceFromRouteNm,
openingHours: null,
phone: null,
vhf: null,
website: null
};
}
function bridge(
id: string,
lon: number,
lat: number,
distanceNm: number
): RouteBridgeAssessment {
return {
id,
name: id,
label: id,
coordinate: { lon, lat },
distanceNm,
clearanceM: null,
clearanceLabel: null,
requiredAirDraftM: null,
marginM: null,
status: "unknown",
source: "test"
};
}
+605
View File
@@ -0,0 +1,605 @@
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(
<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}
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),
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(
<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(),
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(
<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 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()}
onCollapse={vi.fn()}
/>
);
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(
<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: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
}}
routeOptions={[]}
weatherReport={weatherReportFixture}
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.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: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway" as const
};
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import type { MarineForecast, RouteResult } from "@watermaps/shared";
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
describe("route weather report", () => {
it("samples start, middle and destination forecasts", async () => {
const requested: Array<{ lat: number; lon: number }> = [];
const report = await createRouteWeatherReport(routeFixture, async (coordinate) => {
requested.push(coordinate);
return forecast({
windSpeed: coordinate.lon > 7.1 ? 18 : 10,
waveHeightM: coordinate.lon > 7.1 ? 1.2 : 0.4
});
});
expect(requested).toHaveLength(3);
expect(report.maxWindSpeedKn).toBe(18);
expect(report.maxWaveHeightM).toBe(1.2);
expect(report.severity).toBe("caution");
expect(report.summary).toContain("Aufmerksam fahren");
expect(report.bridgeReport).toBeNull();
});
it("projects forecasts along the route timeline and adjusts ETA for along-route current", async () => {
const requestedTimes: Array<string | undefined> = [];
const departureTime = "2026-07-20T06:00:00.000Z";
const timedRoute: RouteResult = {
...routeFixture,
geometry: {
type: "LineString",
coordinates: [[7, 52], [7.2, 52], [7.4, 52]]
},
distanceNm: 20,
departureTime,
durationMinutes: 240,
eta: "2026-07-20T10:00:00.000Z"
};
const report = await createRouteWeatherReport(
timedRoute,
{ draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 5 },
async (_coordinate, at) => {
requestedTimes.push(at);
return forecast({
oceanCurrentSpeedKn: 1,
oceanCurrentDirectionDeg: 90,
forecastTime: at
});
}
);
expect(requestedTimes).toEqual([
"2026-07-20T06:00:00.000Z",
"2026-07-20T08:00:00.000Z",
"2026-07-20T10:00:00.000Z"
]);
expect(report.samples.map((sample) => sample.plannedTime)).toEqual(requestedTimes);
expect(report.samples.map((sample) => sample.currentAlongRouteKn)).toEqual([1, 1, 1]);
expect(report.departureTime).toBe(departureTime);
expect(report.averageAlongRouteCurrentKn).toBe(1);
expect(report.currentAdjustmentMinutes).toBe(-40);
expect(report.adjustedEta).toBe("2026-07-20T09:20:00.000Z");
});
it("adds a bridge report and marks a route as blocked by a low bridge", async () => {
const report = await createRouteWeatherReport(
routeFixture,
{ draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 3 },
async () => forecast({}),
async (params) => {
expect(params.layers).toEqual(["bridges"]);
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "bridge-low",
properties: {
layer: "bridges",
name: "Niedrige Brücke",
clearance_m: 2.7,
clearance_label: "H 2.7 m",
label: "Niedrige Brücke H 2.7 m",
source: "OSM/Geofabrik"
},
geometry: {
type: "LineString",
coordinates: [
[6.99, 53.5],
[7.21, 53.5]
]
}
},
{
type: "Feature",
id: "bridge-high",
properties: {
layer: "bridges",
name: "Hohe Brücke",
clearance_m: 4.2,
clearance_label: "H 4.2 m",
label: "Hohe Brücke H 4.2 m",
source: "OSM/Geofabrik"
},
geometry: {
type: "LineString",
coordinates: [
[7.18, 53.68],
[7.22, 53.68]
]
}
}
]
};
}
);
expect(report.bridgeReport?.severity).toBe("critical");
expect(report.bridgeReport?.tooLowCount).toBe(1);
expect(report.bridgeReport?.checkedCount).toBe(2);
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
});
it("marks critical weather when wind or wave thresholds are exceeded", () => {
const report = summarizeRouteWeather([
{
label: "Mitte",
coordinate: { lat: 53.5, lon: 7.1 },
forecast: forecast({ windSpeed: 28, waveHeightM: 1.1 })
}
]);
expect(report.severity).toBe("critical");
expect(report.summary).toContain("Kritische Bedingungen");
});
});
const routeFixture: RouteResult = {
geometry: {
type: "LineString",
coordinates: [
[7, 53.3],
[7.1, 53.5],
[7.2, 53.7]
]
},
distanceNm: 25,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
function forecast(overrides: Partial<MarineForecast>): MarineForecast {
return {
waveHeightM: 0.4,
waveDirectionDeg: 280,
wavePeriodS: 4,
windSpeed: 10,
windDirectionDeg: 260,
weatherCode: 2,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z",
...overrides
};
}
+86
View File
@@ -0,0 +1,86 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { MarineForecast, RouteGuidanceResult } from "@watermaps/shared";
import { StatusBar } from "../src/components/StatusBar";
import type { GpsState } from "../src/hooks/useGeolocation";
afterEach(cleanup);
const idleGps: GpsState = {
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
};
describe("StatusBar", () => {
it("shows only three planning values without claiming that a route is safe", () => {
const forecast = {
windSpeed: 12,
waveHeightM: 0.8
} as MarineForecast;
const { container } = render(
<StatusBar
gps={idleGps}
forecast={forecast}
tide={null}
routeWarningCount={0}
mode="planning"
/>
);
expect(container.querySelectorAll(".status-item")).toHaveLength(3);
expect(screen.getByText("12 kn · 0.8 m")).toBeVisible();
expect(screen.queryByText("Route OK")).not.toBeInTheDocument();
});
it("prioritizes course, cross-track error and open warnings during guidance", () => {
const guidance = {
desiredCourseDeg: 87,
distanceToRouteM: 24,
crossTrackSide: "starboard"
} as RouteGuidanceResult;
render(
<StatusBar
gps={{ ...idleGps, status: "tracking", speedKn: 6.2 }}
forecast={null}
tide={null}
routeWarningCount={2}
mode="guidance"
guidance={guidance}
/>
);
expect(screen.getByText("087°T")).toBeVisible();
expect(screen.getByText("24 m Stb")).toBeVisible();
expect(screen.getByText("2 offen")).toBeVisible();
});
it("shows anchor drift, GPS and the expected tide rise in anchor mode", () => {
render(
<StatusBar
gps={{ ...idleGps, status: "tracking", position: { lat: 53.2, lon: 7.1 }, accuracyM: 5 }}
forecast={null}
tide={null}
routeWarningCount={0}
mode="anchor"
anchor={{
distanceFromAnchorM: 11.5,
alarmRadiusM: 40,
alarm: false,
maximumTideRiseM: 1.3
}}
/>
);
expect(screen.getByText("12 / 40 m")).toBeVisible();
expect(screen.getByText("±5 m")).toBeVisible();
expect(screen.getByText("+1.3 m")).toBeVisible();
});
});
@@ -0,0 +1,197 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { UpcomingRouteEvent } from "../src/routeEvents";
import { UpcomingEventsPanel } from "../src/components/UpcomingEventsPanel";
afterEach(cleanup);
describe("UpcomingEventsPanel", () => {
it("shows the nearest event first, preserves chronological list order and filters by kind", () => {
render(<UpcomingEventsPanel events={events} hasRoute />);
const hero = screen.getByText("Nächstes Ereignis").closest("article");
expect(hero).not.toBeNull();
expect(within(hero!).getByText("Schleuse Nah")).toBeVisible();
expect(within(hero!).getByText("3,0 sm")).toBeVisible();
expect(within(hero!).getByText(/ETA/)).toBeVisible();
const list = screen.getByRole("list");
expect(
within(list)
.getAllByRole("button")
.map((button) => button.textContent)
).toEqual([
expect.stringContaining("Schleuse Nah"),
expect.stringContaining("Brücke Mitte"),
expect.stringContaining("Hafen Weit")
]);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(screen.getAllByText("Brücke Mitte")).toHaveLength(2);
expect(screen.queryByText("Schleuse Nah")).not.toBeInTheDocument();
expect(screen.queryByText("Hafen Weit")).not.toBeInTheDocument();
expect(within(screen.getByRole("list")).getAllByRole("button")).toHaveLength(1);
});
it("opens a lock drilldown with direct contact, VHF, hours and map actions", () => {
const onShowOnMap = vi.fn();
render(
<UpcomingEventsPanel
events={events}
hasRoute
onShowOnMap={onShowOnMap}
/>
);
const hero = screen.getByText("Nächstes Ereignis").closest("article");
fireEvent.click(within(hero!).getByRole("button", { name: /Schleuse Nah/ }));
const detail = screen.getByRole("region", { name: "Schleuse Nah" });
expect(within(detail).getByText("MoFr 08:0018:00")).toBeVisible();
expect(within(detail).getByText("Kanal 12")).toBeVisible();
expect(within(detail).getByRole("link", { name: "Schleuse Nah anrufen" })).toHaveAttribute(
"href",
"tel:+4949123456"
);
expect(
within(detail).getByRole("link", { name: "Website von Schleuse Nah öffnen" })
).toHaveAttribute("href", "https://lock.example/");
fireEvent.click(within(detail).getByRole("button", { name: "Auf Karte zeigen" }));
expect(onShowOnMap).toHaveBeenCalledWith(events[0]);
fireEvent.click(within(detail).getByRole("button", { name: "Zurück zur Ereignisliste" }));
expect(screen.getByText("Nächstes Ereignis")).toBeVisible();
});
it("shows bridge clearance, required height and reserve in its drilldown", () => {
render(<UpcomingEventsPanel events={events} hasRoute />);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
const hero = screen.getByText("Nächstes Ereignis").closest("article");
fireEvent.click(within(hero!).getByRole("button", { name: /Brücke Mitte/ }));
const detail = screen.getByRole("region", { name: "Brücke Mitte" });
expect(within(detail).getByText("H 4,2 m")).toBeVisible();
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).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
});
it.each([
{
props: { hasRoute: false, events: [] as UpcomingRouteEvent[] },
text: "Noch keine Route",
role: undefined
},
{
props: { hasRoute: true, events: [] as UpcomingRouteEvent[], loading: true },
text: "Ereignisse werden geladen",
role: "status"
},
{
props: {
hasRoute: true,
events: [] as UpcomingRouteEvent[],
error: "Datenquelle antwortet nicht"
},
text: "Ereignisse nicht erreichbar",
role: "alert"
},
{
props: { hasRoute: true, events: [] as UpcomingRouteEvent[] },
text: "Keine bevorstehenden Ereignisse",
role: undefined
}
])("renders the state '$text'", ({ props, text, role }) => {
render(<UpcomingEventsPanel {...props} />);
expect(screen.getByText(text)).toBeVisible();
if (role) {
expect(screen.getByRole(role)).toBeVisible();
}
});
});
const events: UpcomingRouteEvent[] = [
{
kind: "lock",
id: "lock-near",
name: "Schleuse Nah",
coordinate: { lat: 53.1, lon: 7.1 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
remainingNm: 3,
eta: eta("2026-07-23T12:30:00.000Z", 30),
feature: {
id: "lock-near",
name: "Schleuse Nah",
coordinate: { lat: 53.1, lon: 7.1 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
openingHours: "MoFr 08:0018:00",
phone: "+49 49 123456",
vhf: "Kanal 12",
website: "lock.example"
}
},
{
kind: "harbour",
id: "harbour-far",
name: "Hafen Weit",
coordinate: { lat: 53.3, lon: 7.3 },
routeDistanceNm: 17,
distanceFromRouteNm: 0.4,
remainingNm: 12,
eta: eta("2026-07-23T14:00:00.000Z", 120),
feature: {
id: "harbour-far",
name: "Hafen Weit",
coordinate: { lat: 53.3, lon: 7.3 },
kind: "marina",
amenities: { water: "available", electricity: true },
phone: null,
website: null
}
},
{
kind: "bridge",
id: "bridge-middle",
name: "Brücke Mitte",
coordinate: { lat: 53.2, lon: 7.2 },
routeDistanceNm: 11,
distanceFromRouteNm: 0.01,
remainingNm: 6,
eta: eta("2026-07-23T13:00:00.000Z", 60),
feature: {
id: "bridge-middle",
name: "Brücke Mitte",
label: "Brücke Mitte H 4,2 m",
coordinate: { lat: 53.2, lon: 7.2 },
distanceNm: 0.01,
clearanceM: 4.2,
clearanceLabel: "H 4,2 m",
requiredAirDraftM: 3.8,
marginM: 0.4,
status: "tight",
source: "Test"
}
}
];
function eta(estimatedAt: string, minutesFromProgress: number) {
return {
estimatedAt,
minutesFromProgress,
speedKn: 6,
speedSource: "vessel-cruise-speed" as const,
referenceTime: "2026-07-23T12:00:00.000Z",
referenceSource: "current-time" as const
};
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import type { FeatureCollection, Geometry } from "geojson";
import {
routeFeatureBounds,
routeLocksFromFeatures,
voyageHarboursFromFeatures
} from "../src/voyageHarbours";
describe("voyage harbour feature adapter", () => {
it("turns normalized point features into amenity-aware harbour candidates", () => {
const collection: FeatureCollection<Geometry> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "hamm-marina",
geometry: { type: "Point", coordinates: [7.8, 51.68] },
properties: {
layer: "harbours",
name: "Marina Hamm",
leisure: "marina",
phone: "+49 2381 123",
website: "hamm.example",
email: "hafen@hamm.example",
vhf: "12",
opening_hours: "täglich 08:00-20:00",
operator: "Hafen Hamm",
"addr:street": "Uferweg",
"addr:housenumber": "4",
"addr:postcode": "59000",
"addr:city": "Hamm",
power_supply: "yes",
drinking_water: "yes",
guest_berths: 8
}
},
{
type: "Feature",
geometry: { type: "LineString", coordinates: [[7, 52], [8, 52]] },
properties: { layer: "harbours", name: "Keine Punktgeometrie" }
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [7.7, 51.7] },
properties: { layer: "locks", name: "Keine Marina" }
}
]
};
expect(voyageHarboursFromFeatures(collection)).toEqual([
expect.objectContaining({
id: "hamm-marina",
name: "Marina Hamm",
kind: "marina",
coordinate: { lat: 51.68, lon: 7.8 },
email: "hafen@hamm.example",
vhf: "12",
openingHours: "täglich 08:00-20:00",
operator: "Hafen Hamm",
address: "Uferweg 4, 59000 Hamm",
amenities: expect.objectContaining({
electricity: "available",
water: "available",
overnight: "available"
})
})
]);
});
it("calculates a nautical-mile padded feature request box", () => {
const bounds = routeFeatureBounds(
{
geometry: {
type: "LineString",
coordinates: [[7, 52], [8, 53]]
}
},
6
);
expect(bounds[0]).toBeLessThan(6.84);
expect(bounds[1]).toBeCloseTo(51.9, 5);
expect(bounds[2]).toBeGreaterThan(8.16);
expect(bounds[3]).toBeCloseTo(53.1, 5);
});
it("filters and orders route locks while preserving opening hours and phone", () => {
const collection: FeatureCollection<Geometry> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "lock-late",
geometry: { type: "Point", coordinates: [7.8, 52] },
properties: {
layer: "locks",
name: "Schleuse Ost",
openingHours: "Mo-Su 06:00-22:00",
phone: "+49 2381 200"
}
},
{
type: "Feature",
id: "lock-off-route",
geometry: { type: "Point", coordinates: [7.5, 52.02] },
properties: { layer: "locks", name: "Entfernte Schleuse" }
},
{
type: "Feature",
id: "lock-early",
geometry: { type: "Point", coordinates: [7.2, 52] },
properties: {
layer: "locks",
name: "Schleuse West",
opening_hours: "nach Anmeldung",
phone: "+49 2381 100"
}
},
{
type: "Feature",
id: "harbour-on-route",
geometry: { type: "Point", coordinates: [7.4, 52] },
properties: { layer: "harbours", name: "Kein Schleusenpunkt" }
}
]
};
const locks = routeLocksFromFeatures(collection, {
geometry: {
type: "LineString",
coordinates: [[7, 52], [8, 52]]
},
distanceNm: 41
});
expect(locks.map((lock) => lock.id)).toEqual(["lock-early", "lock-late"]);
expect(locks[0]).toEqual(
expect.objectContaining({
name: "Schleuse West",
openingHours: "nach Anmeldung",
phone: "+49 2381 100",
distanceFromRouteNm: 0
})
);
expect(locks[1]).toEqual(
expect.objectContaining({
name: "Schleuse Ost",
openingHours: "Mo-Su 06:00-22:00",
phone: "+49 2381 200",
distanceFromRouteNm: 0
})
);
expect(locks[0]!.routeDistanceNm).toBeLessThan(locks[1]!.routeDistanceNm);
});
});
@@ -0,0 +1,133 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { VoyageNavigationTools } from "../src/components/VoyageNavigationTools";
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
beforeEach(() => localStorage.clear());
afterEach(() => {
cleanup();
localStorage.clear();
restoreNavigatorProperty("geolocation", originalGeolocation);
restoreNavigatorProperty("vibrate", originalVibrate);
});
describe("VoyageNavigationTools", () => {
it("does not request geolocation before the skipper starts the alarm", () => {
const watchPosition = vi.fn(() => 17);
installGeolocation(watchPosition, vi.fn());
render(<VoyageNavigationTools route={routeFixture} />);
expect(watchPosition).not.toHaveBeenCalled();
expect(screen.getByText(/GPS wird erst nach/)).toBeInTheDocument();
});
it("does not open a second GPS watch while the course assistant is active", () => {
const watchPosition = vi.fn(() => 17);
installGeolocation(watchPosition, vi.fn());
render(<VoyageNavigationTools route={routeFixture} courseAssistantActive />);
expect(screen.getByRole("button", { name: "Kursalarm ist im Kursassistenten enthalten" })).toBeDisabled();
expect(screen.getByText(/Querabweichung.*Kursassistenten/)).toBeVisible();
expect(watchPosition).not.toHaveBeenCalled();
});
it("starts explicitly and warns when the vessel leaves the route", () => {
let success: PositionCallback | undefined;
const watchPosition = vi.fn((next: PositionCallback) => {
success = next;
return 42;
});
const clearWatch = vi.fn();
const vibrate = vi.fn();
installGeolocation(watchPosition, clearWatch);
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
render(<VoyageNavigationTools route={routeFixture} />);
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm starten" }));
expect(watchPosition).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Abweichungsalarm stoppen" })).toBeInTheDocument();
act(() => success?.(positionAt(53.01, 7.005, 5)));
expect(screen.getByRole("alert")).toHaveTextContent(/von der Route entfernt/);
expect(vibrate).toHaveBeenCalledWith([200, 100, 200]);
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm stoppen" }));
expect(clearWatch).toHaveBeenCalledWith(42);
});
it("saves and restores the route and plan on this device", () => {
const onLoad = vi.fn();
render(
<VoyageNavigationTools
route={routeFixture}
plan={{
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 },
departureAt: "2026-07-20T06:00:00.000Z"
}}
onLoadOfflineVoyage={onLoad}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route offline speichern" }));
expect(screen.getByText(/ist auf diesem Gerät offline verfügbar/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Offline-Route laden" }));
expect(onLoad).toHaveBeenCalledTimes(1);
expect(onLoad.mock.calls[0]?.[0].route.geometry).toEqual(routeFixture.geometry);
expect(onLoad.mock.calls[0]?.[0].plan.vesselProfile.cruiseSpeedKn).toBe(6);
});
});
function installGeolocation(watchPosition: typeof navigator.geolocation.watchPosition, clearWatch: typeof navigator.geolocation.clearWatch) {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
});
}
function restoreNavigatorProperty(name: "geolocation" | "vibrate", descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(navigator, name, descriptor);
} else {
Reflect.deleteProperty(navigator, name);
}
}
function positionAt(lat: number, lon: number, accuracy: number): GeolocationPosition {
return {
coords: {
latitude: lat,
longitude: lon,
accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({})
},
timestamp: Date.now(),
toJSON: () => ({})
};
}
const routeFixture: RouteResult = {
id: "test-route",
name: "Testfahrt",
geometry: { type: "LineString", coordinates: [[7, 53], [7.01, 53]] },
distanceNm: 0.4,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
+142
View File
@@ -0,0 +1,142 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { VoyagePlan as VoyagePlanResult } from "@watermaps/shared";
import { VoyagePlan } from "../src/components/VoyagePlan";
afterEach(() => cleanup());
describe("VoyagePlan", () => {
it("shows daily legs, waypoints, harbour supply and contacts", () => {
render(<VoyagePlan plan={plan()} />);
expect(screen.getByRole("heading", { name: "Etappenplan" })).toBeInTheDocument();
expect(screen.getByText(/2 Tage/)).toHaveTextContent("2 Tage · 72,5 sm · 12 h 05 min");
expect(screen.getByText("Start → Marina Mitte")).toBeInTheDocument();
expect(screen.getByText("Via: Schleuse Eins")).toBeInTheDocument();
const supply = screen.getByRole("list", { name: "Versorgung in Marina Mitte" });
expect(within(supply).getByLabelText("Strom: verfügbar")).toBeInTheDocument();
expect(within(supply).getByLabelText("Treibstoff: nicht verfügbar")).toBeInTheDocument();
expect(within(supply).getByLabelText("Entsorgung: unbekannt")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Hafen anrufen" })).toHaveAttribute("href", "tel:+4923811234");
expect(screen.getByRole("link", { name: "Website" })).toHaveAttribute("href", "https://hafen.example");
});
it("renders unconfirmed stops and planning warnings clearly", () => {
const result = plan();
result.legs[0] = {
...result.legs[0]!,
end: {
type: "route",
name: "Tagesziel auf der Route",
coordinate: { lat: 52, lon: 7.5 },
routeDistanceNm: 40,
distanceFromRouteNm: 0,
harbour: null
}
};
result.warnings = [
{
code: "NO_SUITABLE_HARBOUR",
severity: "caution",
message: "Kein geeigneter Hafen gefunden.",
day: 1
}
];
render(<VoyagePlan plan={result} />);
expect(screen.getByText("Kein bestätigter Liegeplatz")).toBeInTheDocument();
expect(screen.getByRole("list", { name: "Hinweise zum Etappenplan" })).toHaveTextContent(
"Kein geeigneter Hafen gefunden."
);
});
it("renders nothing until a plan exists", () => {
const { container } = render(<VoyagePlan plan={null} />);
expect(container).toBeEmptyDOMElement();
});
});
function plan(): VoyagePlanResult {
const start = {
type: "start" as const,
name: "Start",
coordinate: { lat: 52, lon: 7 },
routeDistanceNm: 0,
distanceFromRouteNm: 0,
harbour: null
};
const harbour = {
id: "marina-mitte",
name: "Marina Mitte",
kind: "marina" as const,
coordinate: { lat: 52, lon: 7.5 },
phone: "+49 (2381) 1234",
website: "hafen.example",
amenities: {
electricity: "available" as const,
water: "available" as const,
fuel: "unavailable" as const,
waste: "unknown" as const,
overnight: "available" as const
},
routeDistanceNm: 40,
distanceFromRouteNm: 0.25
};
const middle = {
type: "harbour" as const,
name: harbour.name,
coordinate: harbour.coordinate,
routeDistanceNm: harbour.routeDistanceNm,
distanceFromRouteNm: harbour.distanceFromRouteNm,
harbour
};
const destination = {
type: "destination" as const,
name: "Ziel",
coordinate: { lat: 52, lon: 8 },
routeDistanceNm: 72,
distanceFromRouteNm: 0,
harbour: null
};
return {
legs: [
{
day: 1,
start,
end: middle,
routeDistanceNm: 40,
distanceNm: 40.25,
durationHours: 6.708,
waypoints: [
{
id: "lock-one",
name: "Schleuse Eins",
coordinate: { lat: 52, lon: 7.2 },
sequence: 1,
routeDistanceNm: 12,
distanceFromRouteNm: 0
}
]
},
{
day: 2,
start: middle,
end: destination,
routeDistanceNm: 32,
distanceNm: 32.25,
durationHours: 5.375,
waypoints: []
}
],
orderedWaypoints: [],
warnings: [],
requiredAmenities: ["water", "overnight"],
totalRouteDistanceNm: 72,
totalDistanceNm: 72.5,
totalDurationHours: 12.0833,
maxCruisingDistancePerDayNm: 42
};
}