Files
watermaps/apps/web/tests/conditions-panel.test.tsx
2026-07-24 11:29:24 +02:00

230 lines
8.0 KiB
TypeScript

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"
};
}