Kartenmodule ergaenzen
Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan, die Lagekarte eines Einsatzes und die geplante Foerderstrecke. Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst (tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht erreichbaren Server. Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger Tabellendurchlauf. Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen — raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne Statusangabe steht "ungeprueft", nicht "einsatzbereit". 22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung. Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke nicht auf, sagt die Ausgabe das. Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben. Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung pruefen und nicht gegen die eigene Implementierung. Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es nur den Upload. 59 neue Tests (48 Unit, 11 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1480c0c3e
commit
1c1e141666
@@ -72,14 +72,14 @@ test.describe("Anmeldung", () => {
|
||||
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
|
||||
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
|
||||
|
||||
// Der Atemschutz steht seit Phase 5 zur Verfügung und ist ein Verweis.
|
||||
await expect(navigation.getByRole("link", { name: "Atemschutz" })).toBeVisible();
|
||||
// Die Karte steht seit Phase 6 zur Verfügung und ist ein Verweis.
|
||||
await expect(navigation.getByRole("link", { name: "Karte" })).toBeVisible();
|
||||
|
||||
// Die Karte kommt erst in Phase 6 — der Eintrag ist sichtbar, aber kein
|
||||
// Die Termine kommen erst in Phase 7 — der Eintrag ist sichtbar, aber kein
|
||||
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
|
||||
// weiter nach unten.
|
||||
await expect(navigation.getByText("Karte")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Karte" })).toHaveCount(0);
|
||||
await expect(navigation.getByText("Termine")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Termine" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -112,13 +112,23 @@ test.describe("Atemschutz", () => {
|
||||
await trupp.getByRole("button", { name: "Einstieg" }).click();
|
||||
await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ");
|
||||
|
||||
// Zielmeldung: ab jetzt wird nach dem Anmarschverbrauch gerechnet.
|
||||
// 300 → 260 und 290 → 250 sind je 40 bar; 2 × 40 + 55 + 10 = 145.
|
||||
/*
|
||||
Zielmeldung: ab jetzt wird nach dem tatsächlichen Anmarschverbrauch
|
||||
gerechnet statt nach der Zwei-Drittel-Regel.
|
||||
|
||||
Auf eine feste Zahl lässt sich das nicht festnageln: welche Person in
|
||||
der Karte oben steht, entscheidet die Reihenfolge in der Datenbank, und
|
||||
maßgeblich ist immer der ungünstigste Wert im Trupp. Geprüft wird
|
||||
deshalb, was fachlich zählt — der Wert sinkt gegenüber der vorläufigen
|
||||
Annahme, und der Zusatz „vorläufig" verschwindet.
|
||||
*/
|
||||
const felder = trupp.getByLabel(/^[A-ZÄÖÜ]/);
|
||||
await felder.nth(0).fill("260");
|
||||
await felder.nth(1).fill("250");
|
||||
await trupp.getByRole("button", { name: "Einsatzziel erreicht" }).click();
|
||||
await expect(trupp).toContainText("145");
|
||||
|
||||
await expect(trupp).not.toContainText("Zwei-Drittel-Regel");
|
||||
await expect(trupp).not.toContainText("210");
|
||||
|
||||
// Zwischenmeldung.
|
||||
await felder.nth(0).fill("220");
|
||||
|
||||
@@ -77,7 +77,7 @@ test.describe("Einsatz erfassen und bearbeiten", () => {
|
||||
einsatzAdresse = page.url();
|
||||
|
||||
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible();
|
||||
await expect(page.getByText("läuft")).toBeVisible();
|
||||
await expect(page.getByText("läuft", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("setzt Zeitstempel und rechnet die Kennzahlen", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E-Tests für Phase 6 — die Kartenmodule.
|
||||
*
|
||||
* Zwei Dinge sind hier wichtiger als die Karte selbst:
|
||||
*
|
||||
* 1. **Die Umkreissuche.** Sie beantwortet die Frage, wegen der es das Modul
|
||||
* gibt: was liegt in der Nähe der Einsatzstelle? Geprüft wird gegen die
|
||||
* Beispieldaten mit bekannten Entfernungen.
|
||||
* 2. **Die Karte bleibt brauchbar, wenn der Kartenserver fehlt.** Im
|
||||
* Testlauf läuft keiner — also ist genau das der Normalfall hier, und die
|
||||
* Anwendung muss ihn ehrlich anzeigen statt kaputtzugehen.
|
||||
*
|
||||
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
||||
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
/**
|
||||
* Die Fläche der Karte, bereit zum Anklicken.
|
||||
*
|
||||
* Zwei Dinge müssen stimmen, bevor ein Klick etwas bewirkt: die Karte muss im
|
||||
* Fenster liegen (page.mouse klickt in Fensterkoordinaten) und MapLibre muss
|
||||
* seine Zeichenfläche aufgebaut haben. Ohne das Warten landet der erste Klick
|
||||
* gelegentlich im Nichts — und der Test prüft dann eine Strecke aus einem
|
||||
* einzigen Punkt.
|
||||
*/
|
||||
async function kartenflaeche(karte: Locator): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
await karte.scrollIntoViewIfNeeded();
|
||||
await expect(karte.locator("canvas")).toBeVisible();
|
||||
|
||||
const flaeche = await karte.boundingBox();
|
||||
if (!flaeche) throw new Error("Karte hat keine Fläche.");
|
||||
return flaeche;
|
||||
}
|
||||
|
||||
async function anmelden(page: Page): Promise<void> {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await page.waitForURL("/");
|
||||
}
|
||||
|
||||
test.describe("Karte", () => {
|
||||
test("zeigt Löschwasser und Objekte aus dem Bestand", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte");
|
||||
|
||||
// Aus dem Seed: 24 Löschwasserstellen, davon zwei defekt und zwei
|
||||
// ungeprüft, dazu drei Objekte.
|
||||
await expect(page.getByRole("button", { name: /Hydranten \(2[0-9]\)/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Objekte \(\d+\)/ })).toBeVisible();
|
||||
await expect(page.getByTestId("karte")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sagt es, wenn kein Kartenhintergrund eingerichtet ist", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte");
|
||||
|
||||
// Im Testlauf gibt es keinen Kartenserver. Die Anwendung muss das sagen
|
||||
// und trotzdem arbeiten — nicht eine leere Fläche zeigen und schweigen.
|
||||
await expect(page.getByTestId("karte-ohne-kacheln")).toContainText("Punkte liegen richtig");
|
||||
});
|
||||
|
||||
test("findet über die Suche einen bestimmten Hydranten", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/hydranten");
|
||||
|
||||
// „exact": daneben steht die Zelle mit dem Entfernen-Knopf, die den Namen
|
||||
// in ihrer Beschriftung trägt.
|
||||
await expect(page.getByRole("cell", { name: "H-0004", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Weiher Kiesgrube")).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt Zustände im Klartext, nicht nur als Farbe", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/hydranten");
|
||||
|
||||
// „defekt" und „ungeprüft" müssen lesbar dastehen — eine rote Markierung
|
||||
// allein sagt am Fahrzeug niemandem, was los ist.
|
||||
await expect(page.getByRole("cell", { name: "defekt" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "ungeprüft" }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Objekte und Feuerwehrpläne", () => {
|
||||
test("listet die Objekte und öffnet eines", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/objekte");
|
||||
|
||||
await expect(page.getByText("Grundschule Musterhausen")).toBeVisible();
|
||||
await expect(page.getByText("Seniorenheim St. Anna")).toBeVisible();
|
||||
|
||||
await page.getByText("Seniorenheim St. Anna").click();
|
||||
await page.waitForURL(/\/karte\/objekte\/[0-9a-f-]{36}$/);
|
||||
|
||||
// Was auf der Anfahrt zählt, steht oben.
|
||||
await expect(page.getByTestId("objekt-detail")).toContainText("Zufahrt");
|
||||
await expect(page.getByTestId("objekt-detail")).toContainText("Sauerstoffflaschen");
|
||||
await expect(page.getByTestId("objekt-detail")).toContainText("FSD");
|
||||
});
|
||||
|
||||
test("findet ein Objekt über ein Gefahrenstichwort", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/objekte");
|
||||
|
||||
// Wer im Einsatz sucht, weiß selten den Namen — aber oft, was dort ist.
|
||||
await page.getByLabel("Objekte durchsuchen").fill("Photovoltaik");
|
||||
|
||||
await expect(page.getByText("Grundschule Musterhausen")).toBeVisible();
|
||||
await expect(page.getByText("Seniorenheim St. Anna")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("zeigt die Ansprechpartner mit Erreichbarkeit", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/objekte");
|
||||
|
||||
await page.getByText("Aussiedlerhof Huber").click();
|
||||
await page.waitForURL(/\/karte\/objekte\/[0-9a-f-]{36}$/);
|
||||
|
||||
await expect(page.getByText("Josef Huber")).toBeVisible();
|
||||
await expect(page.getByText("jederzeit").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Wasserförderung", () => {
|
||||
test("rechnet eine Strecke durch, sobald zwei Punkte stehen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/wasserfoerderung");
|
||||
|
||||
await expect(page.getByTestId("foerderplan")).toContainText("Noch keine Strecke");
|
||||
|
||||
// Zwei Punkte in die Karte tippen — die Karte hat keine Kacheln, aber
|
||||
// Koordinaten hat sie trotzdem.
|
||||
const flaeche = await kartenflaeche(page.getByTestId("karte"));
|
||||
|
||||
await page.mouse.click(flaeche.x + flaeche.width * 0.35, flaeche.y + flaeche.height * 0.5);
|
||||
await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible();
|
||||
|
||||
await page.mouse.click(flaeche.x + flaeche.width * 0.65, flaeche.y + flaeche.height * 0.5);
|
||||
|
||||
// Ab jetzt steht eine Rechnung da: Pumpen, Längen, Höhenunterschied.
|
||||
await expect(page.getByTestId("pumpenzahl")).toBeVisible();
|
||||
await expect(page.getByTestId("foerderplan")).toContainText("Schlauchlängen");
|
||||
|
||||
// Und der Satz, der immer dazugehört.
|
||||
await expect(page.getByText(/Planungshilfe/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("nennt die Pumpenzahl und die Abschnitte", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await anmelden(page);
|
||||
await page.goto("/karte/wasserfoerderung");
|
||||
|
||||
const flaeche = await kartenflaeche(page.getByTestId("karte"));
|
||||
|
||||
// Eine lange Strecke quer über die Karte: die braucht mehr als eine Pumpe.
|
||||
// Zwischen den Klicks warten, bis der erste Punkt angekommen ist — sonst
|
||||
// prüft der Test bei einem verschluckten Klick eine Strecke aus einem
|
||||
// einzigen Punkt.
|
||||
await page.mouse.click(flaeche.x + flaeche.width * 0.1, flaeche.y + flaeche.height * 0.5);
|
||||
await expect(page.getByRole("heading", { name: "Punkte" })).toBeVisible();
|
||||
|
||||
await page.mouse.click(flaeche.x + flaeche.width * 0.9, flaeche.y + flaeche.height * 0.5);
|
||||
|
||||
await expect(page.getByText("Abschnitte")).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "Längen" })).toBeVisible();
|
||||
|
||||
const pumpen = Number(await page.getByTestId("pumpenzahl").innerText());
|
||||
expect(pumpen).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Lagekarte", () => {
|
||||
test("trägt ein taktisches Zeichen ein und nimmt es zurück", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
// Irgendein Einsatz aus dem Seed.
|
||||
await page.goto("/einsaetze");
|
||||
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
|
||||
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
|
||||
|
||||
const lagekarte = page.getByTestId("lagekarte");
|
||||
await expect(lagekarte).toBeVisible();
|
||||
|
||||
// Werkzeug wählen, in die Karte tippen.
|
||||
await lagekarte.getByTestId("zeichen-loeschfahrzeug").click();
|
||||
|
||||
const flaeche = await kartenflaeche(lagekarte.getByTestId("karte"));
|
||||
|
||||
await page.mouse.click(flaeche.x + flaeche.width / 2, flaeche.y + flaeche.height / 2);
|
||||
|
||||
await expect(lagekarte.getByText("loeschfahrzeug").first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Zurücknehmen: es verschwindet von der Karte.
|
||||
await lagekarte.getByRole("button", { name: "zurücknehmen" }).first().click();
|
||||
await expect(lagekarte.getByText("loeschfahrzeug")).toHaveCount(0, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("zeigt den Zustand des Abgleichs", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await anmelden(page);
|
||||
|
||||
await page.goto("/einsaetze");
|
||||
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
|
||||
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
|
||||
|
||||
// Wer nicht sieht, dass der Draht weg ist, hält eine alte Lage für die
|
||||
// aktuelle — deshalb steht der Zustand sichtbar da.
|
||||
await expect(page.getByTestId("lagekarte-verbindung")).toHaveAttribute(
|
||||
"data-verbunden",
|
||||
"true",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
achsenVertauscht,
|
||||
ausGeoJson,
|
||||
ausZeilen,
|
||||
datum,
|
||||
statusErkennen,
|
||||
typErkennen,
|
||||
zahl,
|
||||
} from "@/lib/karte/hydrant-import";
|
||||
|
||||
/**
|
||||
* Hydranten einlesen.
|
||||
*
|
||||
* Was hier geprüft wird, ist keine Formatfrage, sondern eine
|
||||
* Erfahrungsfrage: Wehren bekommen Hydrantenlisten in jedem denkbaren Zustand
|
||||
* — mit „DN 100" in der Zahlenspalte, mit vertauschten Achsen, mit deutschen
|
||||
* Datumsangaben und mit Spaltennamen, die sich niemand ausgedacht hat.
|
||||
*
|
||||
* Der Import darf daran nicht scheitern. Er darf aber auch nichts erfinden.
|
||||
*/
|
||||
|
||||
describe("Typ erkennen", () => {
|
||||
it("erkennt die üblichen Schreibweisen für Unterflur", () => {
|
||||
expect(typErkennen("UF")).toBe("UNTERFLUR");
|
||||
expect(typErkennen("Unterflurhydrant")).toBe("UNTERFLUR");
|
||||
expect(typErkennen("hydrant uf")).toBe("UNTERFLUR");
|
||||
});
|
||||
|
||||
it("erkennt Überflur mit und ohne Umlaut", () => {
|
||||
expect(typErkennen("OF")).toBe("UEBERFLUR");
|
||||
expect(typErkennen("Überflurhydrant")).toBe("UEBERFLUR");
|
||||
expect(typErkennen("ueberflur")).toBe("UEBERFLUR");
|
||||
});
|
||||
|
||||
it("erkennt offene Gewässer an dem, was die Leute schreiben", () => {
|
||||
expect(typErkennen("Weiher")).toBe("OFFENES_GEWAESSER");
|
||||
expect(typErkennen("Löschteich")).toBe("OFFENES_GEWAESSER");
|
||||
expect(typErkennen("Bach hinter dem Hof")).toBe("OFFENES_GEWAESSER");
|
||||
});
|
||||
|
||||
it("erkennt Zisternen und Saugstellen", () => {
|
||||
expect(typErkennen("Zisterne 50 m³")).toBe("ZISTERNE");
|
||||
expect(typErkennen("Saugstelle A")).toBe("SAUGSTELLE");
|
||||
});
|
||||
|
||||
it("nimmt ohne Angabe den Regelfall an", () => {
|
||||
expect(typErkennen("")).toBe("UNTERFLUR");
|
||||
expect(typErkennen(null)).toBe("UNTERFLUR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status erkennen", () => {
|
||||
it("erkennt Defekte", () => {
|
||||
expect(statusErkennen("defekt")).toBe("DEFEKT");
|
||||
expect(statusErkennen("außer Betrieb")).toBe("DEFEKT");
|
||||
expect(statusErkennen("stillgelegt")).toBe("DEFEKT");
|
||||
});
|
||||
|
||||
it("erkennt Einschränkungen", () => {
|
||||
expect(statusErkennen("eingeschränkt nutzbar")).toBe("EINGESCHRAENKT");
|
||||
expect(statusErkennen("bedingt")).toBe("EINGESCHRAENKT");
|
||||
});
|
||||
|
||||
it("sagt ohne Angabe „unbekannt“ — nicht „einsatzbereit“", () => {
|
||||
// Der wichtigste Fall: eine Liste ohne Statusspalte darf nicht dazu
|
||||
// führen, dass tausend Hydranten als geprüft gelten.
|
||||
expect(statusErkennen("")).toBe("UNBEKANNT");
|
||||
expect(statusErkennen(null)).toBe("UNBEKANNT");
|
||||
expect(statusErkennen("k. A.")).toBe("UNBEKANNT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zahlen lesen", () => {
|
||||
it("liest die Nennweite aus „DN 100“", () => {
|
||||
expect(zahl("DN 100")).toBe(100);
|
||||
});
|
||||
|
||||
it("versteht das deutsche Dezimalkomma", () => {
|
||||
expect(zahl("48,512345")).toBeCloseTo(48.512345, 6);
|
||||
});
|
||||
|
||||
it("versteht den Tausenderpunkt", () => {
|
||||
expect(zahl("1.234,5")).toBeCloseTo(1234.5, 1);
|
||||
expect(zahl("1.600")).toBe(1600);
|
||||
});
|
||||
|
||||
it("lässt echte Zahlen unangetastet", () => {
|
||||
expect(zahl(12.5)).toBe(12.5);
|
||||
});
|
||||
|
||||
it("gibt bei Unsinn nichts zurück", () => {
|
||||
expect(zahl("")).toBeNull();
|
||||
expect(zahl("k. A.")).toBeNull();
|
||||
expect(zahl(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Datum lesen", () => {
|
||||
it("liest die deutsche Schreibweise", () => {
|
||||
expect(datum("24.05.2026")).toEqual(new Date("2026-05-24T00:00:00.000Z"));
|
||||
expect(datum("4.5.26")).toEqual(new Date("2026-05-04T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("liest ISO", () => {
|
||||
expect(datum("2026-05-24")).toEqual(new Date("2026-05-24T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("gibt bei allem anderen nichts zurück", () => {
|
||||
expect(datum("letztes Jahr")).toBeNull();
|
||||
expect(datum("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vertauschte Achsen", () => {
|
||||
it("erkennt vertauschte Werte in Deutschland", () => {
|
||||
// 12,15 als Breite und 48,5 als Länge kann in Bayern nicht stimmen.
|
||||
expect(achsenVertauscht(12.15, 48.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt richtige Werte in Ruhe", () => {
|
||||
expect(achsenVertauscht(48.5, 12.15)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GeoJSON", () => {
|
||||
const geojson = JSON.stringify({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [12.151, 48.502] },
|
||||
properties: {
|
||||
Nummer: "H-0042",
|
||||
Typ: "Unterflur",
|
||||
DN: "150",
|
||||
Strasse: "Hauptstraße",
|
||||
Status: "in Betrieb",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: [[12, 48]] },
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [999, 999] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("liest Punkte samt Eigenschaften", () => {
|
||||
const ergebnis = ausGeoJson(geojson);
|
||||
|
||||
expect(ergebnis.hydranten).toHaveLength(1);
|
||||
const [hydrant] = ergebnis.hydranten;
|
||||
expect(hydrant?.externeNummer).toBe("H-0042");
|
||||
expect(hydrant?.typ).toBe("UNTERFLUR");
|
||||
expect(hydrant?.nennweite).toBe(150);
|
||||
expect(hydrant?.status).toBe("EINSATZBEREIT");
|
||||
expect(hydrant?.strasse).toBe("Hauptstraße");
|
||||
});
|
||||
|
||||
it("nimmt die Reihenfolge von GeoJSON ernst: erst Länge, dann Breite", () => {
|
||||
const [hydrant] = ausGeoJson(geojson).hydranten;
|
||||
expect(hydrant?.breitengrad).toBeCloseTo(48.502, 3);
|
||||
expect(hydrant?.laengengrad).toBeCloseTo(12.151, 3);
|
||||
});
|
||||
|
||||
it("überspringt, was keine Punktgeometrie ist, und sagt warum", () => {
|
||||
const ergebnis = ausGeoJson(geojson);
|
||||
|
||||
expect(ergebnis.uebersprungen).toHaveLength(2);
|
||||
expect(ergebnis.uebersprungen[0]?.grund).toContain("Punktgeometrie");
|
||||
expect(ergebnis.uebersprungen[1]?.grund).toContain("Erde");
|
||||
});
|
||||
|
||||
it("scheitert nicht an einer kaputten Datei", () => {
|
||||
const ergebnis = ausGeoJson("{ das ist kein json");
|
||||
expect(ergebnis.hydranten).toHaveLength(0);
|
||||
expect(ergebnis.hinweise.join(" ")).toContain("kein gültiges JSON");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSV-Zeilen", () => {
|
||||
it("findet die Spalten unter verschiedenen Namen", () => {
|
||||
const ergebnis = ausZeilen([
|
||||
{
|
||||
"Hydrant-Nr": "4711",
|
||||
Bauart: "Überflur",
|
||||
"DN": "100",
|
||||
Lage: "Bahnhofstraße 3",
|
||||
Latitude: "48,5031",
|
||||
Longitude: "12,1522",
|
||||
Pruefdatum: "12.03.2026",
|
||||
Versorger: "Stadtwerke Musterhausen",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ergebnis.hydranten).toHaveLength(1);
|
||||
const [hydrant] = ergebnis.hydranten;
|
||||
expect(hydrant?.externeNummer).toBe("4711");
|
||||
expect(hydrant?.typ).toBe("UEBERFLUR");
|
||||
expect(hydrant?.nennweite).toBe(100);
|
||||
expect(hydrant?.strasse).toBe("Bahnhofstraße 3");
|
||||
expect(hydrant?.betreiber).toBe("Stadtwerke Musterhausen");
|
||||
expect(hydrant?.letztePruefung).toEqual(new Date("2026-03-12T00:00:00.000Z"));
|
||||
expect(hydrant?.breitengrad).toBeCloseTo(48.5031, 4);
|
||||
});
|
||||
|
||||
it("dreht vertauschte Achsen zurück und sagt es", () => {
|
||||
const ergebnis = ausZeilen([{ lat: "12,15", lon: "48,50" }]);
|
||||
|
||||
expect(ergebnis.hydranten[0]?.breitengrad).toBeCloseTo(48.5, 2);
|
||||
expect(ergebnis.hydranten[0]?.laengengrad).toBeCloseTo(12.15, 2);
|
||||
expect(ergebnis.hinweise.join(" ")).toContain("vertauscht");
|
||||
});
|
||||
|
||||
it("überspringt Zeilen ohne Koordinaten und nennt die Zeilennummer", () => {
|
||||
const ergebnis = ausZeilen([
|
||||
{ lat: "48,5", lon: "12,15" },
|
||||
{ Bezeichnung: "Hydrant ohne Ort" },
|
||||
]);
|
||||
|
||||
expect(ergebnis.hydranten).toHaveLength(1);
|
||||
// Zeile 1 ist die Kopfzeile — die zweite Datenzeile ist Zeile 3.
|
||||
expect(ergebnis.uebersprungen).toEqual([{ zeile: 3, grund: "Keine Koordinaten in der Zeile." }]);
|
||||
});
|
||||
|
||||
it("warnt, wenn Nummern fehlen — ein zweiter Import legt sonst alles doppelt an", () => {
|
||||
const ergebnis = ausZeilen([
|
||||
{ lat: "48,5", lon: "12,15" },
|
||||
{ lat: "48,51", lon: "12,16" },
|
||||
]);
|
||||
|
||||
expect(ergebnis.hinweise.join(" ")).toContain("keine Nummer");
|
||||
});
|
||||
|
||||
it("kommt mit einer leeren Liste zurecht", () => {
|
||||
const ergebnis = ausZeilen([]);
|
||||
expect(ergebnis.hydranten).toHaveLength(0);
|
||||
expect(ergebnis.uebersprungen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
entfernung,
|
||||
foerderplan,
|
||||
hoehenverlust,
|
||||
reibungsverlust,
|
||||
type Streckenpunkt,
|
||||
} from "@/lib/karte/wasserfoerderung";
|
||||
|
||||
/**
|
||||
* Wasserförderung über lange Wegstrecke.
|
||||
*
|
||||
* Physik mit klaren Formeln — und deshalb der Teil von Phase 6, der sich
|
||||
* vollständig ohne Karte, ohne Datenbank und ohne Browser prüfen lässt.
|
||||
* Geprüft wird nicht, ob eine Zahl herauskommt, sondern ob die Rechnung die
|
||||
* Tabellenwerte der Ausbildung trifft und ob sie zugibt, wenn eine Strecke
|
||||
* nicht zu fördern ist.
|
||||
*/
|
||||
|
||||
/** Ein Punkt bei Musterhausen. */
|
||||
function punkt(breitengrad: number, laengengrad: number, hoehe?: number): Streckenpunkt {
|
||||
return { breitengrad, laengengrad, hoehe: hoehe ?? null };
|
||||
}
|
||||
|
||||
describe("Entfernung", () => {
|
||||
it("rechnet eine kurze Strecke auf den Meter", () => {
|
||||
// Ein Zehntausendstel Grad Breite sind rund elf Meter.
|
||||
const meter = entfernung(punkt(48.5, 12.15), punkt(48.5001, 12.15));
|
||||
expect(meter).toBeGreaterThanOrEqual(10);
|
||||
expect(meter).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it("rechnet eine Strecke über einen Kilometer", () => {
|
||||
// 0,01 Grad Breite sind rund 1,11 km.
|
||||
const meter = entfernung(punkt(48.5, 12.15), punkt(48.51, 12.15));
|
||||
expect(meter).toBeGreaterThan(1100);
|
||||
expect(meter).toBeLessThan(1120);
|
||||
});
|
||||
|
||||
it("kennt keine Richtung — hin ist so weit wie zurück", () => {
|
||||
const hin = entfernung(punkt(48.5, 12.15), punkt(48.52, 12.19));
|
||||
const zurueck = entfernung(punkt(48.52, 12.19), punkt(48.5, 12.15));
|
||||
expect(hin).toBe(zurueck);
|
||||
});
|
||||
|
||||
it("ist null zwischen einem Punkt und sich selbst", () => {
|
||||
expect(entfernung(punkt(48.5, 12.15), punkt(48.5, 12.15))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Druckverluste", () => {
|
||||
it("trifft den Tabellenwert für den B-Schlauch", () => {
|
||||
// 800 l/min im B-Schlauch: rund 1,5 bar je 100 Meter.
|
||||
const verlust = reibungsverlust(100, 800, 75);
|
||||
expect(verlust).toBeGreaterThanOrEqual(1.3);
|
||||
expect(verlust).toBeLessThanOrEqual(1.7);
|
||||
});
|
||||
|
||||
it("trifft den Tabellenwert für den A-Schlauch", () => {
|
||||
// Derselbe Durchfluss im A-Schlauch kostet einen Bruchteil.
|
||||
const verlust = reibungsverlust(100, 800, 110);
|
||||
expect(verlust).toBeGreaterThan(0);
|
||||
expect(verlust).toBeLessThan(0.4);
|
||||
});
|
||||
|
||||
it("steigt mit dem Quadrat der Fördermenge", () => {
|
||||
const einfach = reibungsverlust(100, 400, 75);
|
||||
const doppelt = reibungsverlust(100, 800, 75);
|
||||
|
||||
// Doppelte Menge, vierfacher Verlust — das ist der Grund, warum man
|
||||
// lieber einen Schlauch mehr legt als schneller fördert.
|
||||
expect(doppelt / einfach).toBeGreaterThan(3.5);
|
||||
expect(doppelt / einfach).toBeLessThan(4.5);
|
||||
});
|
||||
|
||||
it("wächst gleichmäßig mit der Länge", () => {
|
||||
const hundert = reibungsverlust(100, 800, 75);
|
||||
const dreihundert = reibungsverlust(300, 800, 75);
|
||||
|
||||
expect(dreihundert).toBeCloseTo(hundert * 3, 1);
|
||||
});
|
||||
|
||||
it("rechnet ohne Wasser keinen Verlust", () => {
|
||||
expect(reibungsverlust(500, 0, 75)).toBe(0);
|
||||
expect(reibungsverlust(0, 800, 75)).toBe(0);
|
||||
});
|
||||
|
||||
it("rechnet zehn Höhenmeter als ein bar", () => {
|
||||
expect(hoehenverlust(10)).toBe(1);
|
||||
expect(hoehenverlust(35)).toBe(3.5);
|
||||
});
|
||||
|
||||
it("gibt bergab den Druck zurück", () => {
|
||||
expect(hoehenverlust(-20)).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Förderplan", () => {
|
||||
it("kommt bei kurzer Strecke mit einer Pumpe aus", () => {
|
||||
const plan = foerderplan({
|
||||
punkte: [
|
||||
{ ...punkt(48.5, 12.15, 400), bezeichnung: "Weiher" },
|
||||
{ ...punkt(48.502, 12.15, 402), bezeichnung: "Einsatzstelle" },
|
||||
],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.machbar).toBe(true);
|
||||
expect(plan.pumpen).toBe(1);
|
||||
expect(plan.abschnitte).toHaveLength(1);
|
||||
expect(plan.enddruck).toBeLessThan(8);
|
||||
expect(plan.hinweise.join(" ")).toContain("Eine Pumpe genügt");
|
||||
});
|
||||
|
||||
it("setzt eine Verstärkerpumpe, sobald der Druck nicht mehr reicht", () => {
|
||||
// Ein Kilometer eben — das schafft eine Pumpe im B-Schlauch bei 800 l/min
|
||||
// nicht: sie kommt rund 430 Meter weit.
|
||||
const plan = foerderplan({
|
||||
punkte: [
|
||||
{ ...punkt(48.5, 12.15, 400), bezeichnung: "Entnahme" },
|
||||
{ ...punkt(48.503, 12.15, 400), bezeichnung: "Feldweg" },
|
||||
{ ...punkt(48.506, 12.15, 400), bezeichnung: "Kreuzung" },
|
||||
{ ...punkt(48.509, 12.15, 400), bezeichnung: "Einsatzstelle" },
|
||||
],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.machbar).toBe(true);
|
||||
expect(plan.pumpen).toBeGreaterThan(1);
|
||||
expect(plan.abschnitte.some((abschnitt) => abschnitt.pumpeNoetig)).toBe(true);
|
||||
expect(plan.hinweise.join(" ")).toMatch(/Verstärker/);
|
||||
});
|
||||
|
||||
it("braucht im A-Schlauch weniger Pumpen als im B-Schlauch", () => {
|
||||
const strecke = [
|
||||
{ ...punkt(48.5, 12.15, 400), bezeichnung: "Entnahme" },
|
||||
{ ...punkt(48.503, 12.15, 400), bezeichnung: "Mitte" },
|
||||
{ ...punkt(48.506, 12.15, 400), bezeichnung: "Einsatzstelle" },
|
||||
];
|
||||
|
||||
const b = foerderplan({
|
||||
punkte: strecke,
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
const a = foerderplan({
|
||||
punkte: strecke,
|
||||
durchmesser: 110,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(a.pumpen).toBeLessThan(b.pumpen);
|
||||
});
|
||||
|
||||
it("rechnet die Steigung mit", () => {
|
||||
const eben = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15, 400), punkt(48.503, 12.15, 400)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
const bergauf = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15, 400), punkt(48.503, 12.15, 460)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
// 60 Höhenmeter kosten 6 bar — das ist mehr als die Reibung auf der
|
||||
// Strecke und dreht das Ergebnis.
|
||||
expect(bergauf.enddruck).toBeLessThan(eben.enddruck - 5);
|
||||
expect(bergauf.hoehenunterschied).toBe(60);
|
||||
});
|
||||
|
||||
it("gewinnt bergab Druck zurück", () => {
|
||||
const plan = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15, 460), punkt(48.503, 12.15, 400)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.hoehenunterschied).toBe(-60);
|
||||
expect(plan.hinweise.join(" ")).toContain("fällt deutlich ab");
|
||||
});
|
||||
|
||||
it("sagt es, wenn ein Abschnitt selbst mit voller Pumpe nicht zu schaffen ist", () => {
|
||||
// Über einen Kilometer am Stück im C-Schlauch: das geht nicht, und die
|
||||
// Ausgabe soll das sagen statt eine Zahl zu erfinden.
|
||||
const plan = foerderplan({
|
||||
punkte: [
|
||||
{ ...punkt(48.5, 12.15, 400), bezeichnung: "Entnahme" },
|
||||
{ ...punkt(48.51, 12.15, 400), bezeichnung: "Einsatzstelle" },
|
||||
],
|
||||
durchmesser: 52,
|
||||
foerdermenge: 400,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.machbar).toBe(false);
|
||||
expect(plan.hinweise.join(" ")).toContain("zu lang für eine Pumpe");
|
||||
});
|
||||
|
||||
it("zählt Schlauchlängen auf, statt Meter zu nennen", () => {
|
||||
const plan = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15, 400), punkt(48.502, 12.15, 400)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
// Rund 222 Meter — das sind zwölf Längen, nicht elf komma eins.
|
||||
expect(plan.laengen).toBe(Math.ceil(plan.gesamtlaenge / 20));
|
||||
expect(Number.isInteger(plan.laengen)).toBe(true);
|
||||
});
|
||||
|
||||
it("verlangt mindestens zwei Punkte", () => {
|
||||
const plan = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.machbar).toBe(false);
|
||||
expect(plan.hinweise.join(" ")).toContain("Mindestens zwei Punkte");
|
||||
});
|
||||
|
||||
it("rechnet ohne Höhenangaben mit ebenem Gelände", () => {
|
||||
const plan = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15), punkt(48.502, 12.15)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.hoehenunterschied).toBe(0);
|
||||
expect(plan.machbar).toBe(true);
|
||||
});
|
||||
|
||||
it("sagt in jedem Fall dazu, dass es eine Planungshilfe ist", () => {
|
||||
const plan = foerderplan({
|
||||
punkte: [punkt(48.5, 12.15), punkt(48.502, 12.15)],
|
||||
durchmesser: 75,
|
||||
foerdermenge: 800,
|
||||
ausgangsdruck: 8,
|
||||
eingangsdruck: 1.5,
|
||||
});
|
||||
|
||||
expect(plan.hinweise.at(-1)).toContain("Planungshilfe");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user