Initial Watermaps import
This commit is contained in:
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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: () => ({})
|
||||
};
|
||||
}
|
||||
@@ -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 & Hamm <Test>");
|
||||
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"
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}]
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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("Mo–Fr 08:00–18: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: "Mo–Fr 08:00–18: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
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user