Atemschutzueberwachung nach FwDV 7 ergaenzen
Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt — abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten. Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus. Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht verlieren laesst. Server und Browser rechnen mit denselben Funktionen. Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist damit die vorsichtigere Annahme. Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern. Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine Richtung, und so bleibt das Deployment bei "next start". Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag "Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1. 68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 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
b2b31ed00a
commit
4e2daaafd4
@@ -72,9 +72,14 @@ test.describe("Anmeldung", () => {
|
||||
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
|
||||
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
|
||||
|
||||
// Atemschutz kommt erst in Phase 3 — der Eintrag ist sichtbar, aber kein Link.
|
||||
await expect(navigation.getByText("Atemschutz")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Atemschutz" })).toHaveCount(0);
|
||||
// Der Atemschutz steht seit Phase 5 zur Verfügung und ist ein Verweis.
|
||||
await expect(navigation.getByRole("link", { name: "Atemschutz" })).toBeVisible();
|
||||
|
||||
// Die Karte kommt erst in Phase 6 — 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E-Tests für Phase 5 — die Atemschutzüberwachung.
|
||||
*
|
||||
* Phase-5.md nennt die Anforderung, an der dieses Modul steht oder fällt:
|
||||
*
|
||||
* > Timer müssen einen Browser-Reload und einen Verbindungsabbruch
|
||||
* > überstehen. Das ist die wichtigste Testanforderung.
|
||||
*
|
||||
* Genau darauf laufen die beiden Tests im Abschnitt „Die Uhr" hinaus. Sie
|
||||
* prüfen nicht, ob eine Zahl erscheint, sondern ob **dieselbe** Zahl erscheint
|
||||
* — vor und nach dem Neuladen, mit und ohne Verbindung. Ein Zähler im Browser
|
||||
* würde daran scheitern; Zeitstempel vom Server tun es nicht.
|
||||
*
|
||||
* 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";
|
||||
|
||||
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("/");
|
||||
}
|
||||
|
||||
/** Eröffnet eine Überwachung und gibt deren Adresse zurück. */
|
||||
async function ueberwachungEroeffnen(page: Page, bezeichnung: string): Promise<string> {
|
||||
await page.goto("/atemschutz");
|
||||
await page.getByLabel("Bezeichnung").fill(bezeichnung);
|
||||
await page.getByRole("button", { name: "Eröffnen" }).click();
|
||||
|
||||
// Sie erscheint unter „Läuft gerade" — von dort geht es hinein.
|
||||
const eintrag = page.getByRole("link", { name: new RegExp(bezeichnung) }).first();
|
||||
await expect(eintrag).toBeVisible();
|
||||
await eintrag.click();
|
||||
|
||||
await page.waitForURL(/\/atemschutz\/[0-9a-f-]{36}$/);
|
||||
return page.url();
|
||||
}
|
||||
|
||||
/** Legt einen Trupp mit den ersten beiden wählbaren Personen an. */
|
||||
async function truppAnlegen(page: Page, funkrufname: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Trupp anlegen" }).first().click();
|
||||
|
||||
const formular = page.getByTestId("trupp-anlegen");
|
||||
await expect(formular).toBeVisible();
|
||||
|
||||
await formular.getByLabel("Funkrufname").fill(funkrufname);
|
||||
await formular.getByLabel("Auftrag").fill("Menschenrettung 1. OG");
|
||||
|
||||
// Zwei Personen wählen — die Knöpfe tragen den Namen der Person.
|
||||
const auswahl = formular.locator("button[aria-pressed]");
|
||||
await auswahl.nth(0).click();
|
||||
await auswahl.nth(1).click();
|
||||
|
||||
const druckfelder = formular.getByLabel("Eingangsdruck");
|
||||
await druckfelder.nth(0).fill("300");
|
||||
await druckfelder.nth(1).fill("290");
|
||||
|
||||
await formular.getByRole("button", { name: "Trupp anlegen" }).click();
|
||||
await expect(formular).toBeHidden();
|
||||
}
|
||||
|
||||
test.describe("Atemschutz", () => {
|
||||
test("zeigt den Sicherheitshinweis auf jeder Ansicht", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
|
||||
await page.goto("/atemschutz");
|
||||
await expect(page.getByTestId("atemschutz-hinweis")).toContainText(
|
||||
"analoge Atemschutzüberwachung bleibt führend",
|
||||
);
|
||||
|
||||
await page.goto("/atemschutz/nachweise");
|
||||
await expect(page.getByTestId("atemschutz-hinweis")).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt die Tauglichkeit mit Klartext, nicht nur mit Farbe", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/atemschutz");
|
||||
|
||||
// Aus dem Seed: einer hat eine abgelaufene G26, einem fehlt die
|
||||
// Belastungsübung. Beides muss lesbar dastehen.
|
||||
await expect(page.getByText("G26.3 ist abgelaufen").first()).toBeVisible();
|
||||
await expect(page.getByText(/Belastungsübung in diesem Jahr fehlt/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("führt eine Überwachung von der Eröffnung bis zum Protokoll", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
const adresse = await ueberwachungEroeffnen(
|
||||
page,
|
||||
`E2E Durchlauf ${test.info().project.name} ${Date.now()}`,
|
||||
);
|
||||
await truppAnlegen(page, "Angriffstrupp");
|
||||
|
||||
const trupp = page.locator("[data-testid^='trupp-']").first();
|
||||
await expect(trupp).toBeVisible();
|
||||
await expect(trupp).toHaveAttribute("data-status", "BEREIT");
|
||||
|
||||
// Der Rückzugsdruck steht schon vor dem Einstieg — vorerst nach der
|
||||
// Zwei-Drittel-Regel: 300 × 2/3 + 10 = 210.
|
||||
await expect(trupp).toContainText("210");
|
||||
await expect(trupp).toContainText("Zwei-Drittel-Regel");
|
||||
|
||||
// Einstieg — ab hier läuft die Uhr.
|
||||
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.
|
||||
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");
|
||||
|
||||
// Zwischenmeldung.
|
||||
await felder.nth(0).fill("220");
|
||||
await felder.nth(1).fill("205");
|
||||
await trupp.getByRole("button", { name: "Druck melden" }).click();
|
||||
await expect(trupp).toContainText("205");
|
||||
|
||||
// Rückweg und heraus.
|
||||
await trupp.getByRole("button", { name: "Rückweg angetreten" }).click();
|
||||
await expect(trupp).toHaveAttribute("data-status", "RUECKWEG");
|
||||
|
||||
await felder.nth(0).fill("160");
|
||||
await felder.nth(1).fill("140");
|
||||
await trupp.getByRole("button", { name: "Trupp ist draußen" }).click();
|
||||
await expect(trupp).toHaveAttribute("data-status", "DRAUSSEN");
|
||||
|
||||
// Beenden geht jetzt — vorher hätte es der Server abgelehnt.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Überwachung beenden" }).click();
|
||||
|
||||
// Großzügig: das Beenden stellt zusätzlich den Auftrag für das PDF in die
|
||||
// Warteschlange, und unter Last dauert die Runde über Redis.
|
||||
await expect(page.getByRole("button", { name: "Überwachung beenden" })).toBeHidden({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Und das Protokoll gibt es als PDF. Innerhalb der Ansicht gesucht: in der
|
||||
// Seitenleiste steht ebenfalls ein „Protokoll".
|
||||
const protokoll = page.getByTestId("ueberwachung").getByRole("link", { name: "Protokoll" });
|
||||
await expect(protokoll).toBeVisible();
|
||||
|
||||
const antwort = await page.request.get(
|
||||
`/api/atemschutz/protokoll?id=${adresse.split("/").pop()}`,
|
||||
);
|
||||
expect(antwort.status()).toBe(200);
|
||||
expect(antwort.headers()["content-type"]).toContain("application/pdf");
|
||||
});
|
||||
|
||||
test("lässt eine Überwachung mit einem Trupp im Einsatz nicht beenden", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
await ueberwachungEroeffnen(page, `E2E Offen ${test.info().project.name} ${Date.now()}`);
|
||||
await truppAnlegen(page, "Sicherheitstrupp");
|
||||
|
||||
const trupp = page.locator("[data-testid^='trupp-']").first();
|
||||
await trupp.getByRole("button", { name: "Einstieg" }).click();
|
||||
await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ");
|
||||
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Überwachung beenden" }).click();
|
||||
|
||||
// Eine beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
|
||||
// gefährlichste Zeile im ganzen Protokoll.
|
||||
await expect(page.getByTestId("ueberwachungsfehler")).toContainText("nicht als draußen");
|
||||
await expect(page.getByRole("button", { name: "Überwachung beenden" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("meldet und hebt einen Notfall auf", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
await ueberwachungEroeffnen(page, `E2E Mayday ${test.info().project.name} ${Date.now()}`);
|
||||
await truppAnlegen(page, "Notfalltrupp");
|
||||
|
||||
const trupp = page.locator("[data-testid^='trupp-']").first();
|
||||
await trupp.getByRole("button", { name: "Einstieg" }).click();
|
||||
|
||||
await trupp.getByRole("button", { name: "Mayday" }).click();
|
||||
await expect(page.getByTestId("mayday-anzeige")).toBeVisible();
|
||||
await expect(trupp).toHaveAttribute("data-mayday", "true");
|
||||
|
||||
await trupp.getByRole("button", { name: "Notfall aufheben" }).click();
|
||||
await expect(page.getByTestId("mayday-anzeige")).toBeHidden();
|
||||
await expect(trupp).toHaveAttribute("data-mayday", "false");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Uhr.
|
||||
*
|
||||
* Der Kern der Phase: die Anzeige darf sich durch nichts aus dem Tritt bringen
|
||||
* lassen, was zwischen Browser und Server passiert.
|
||||
*/
|
||||
test.describe("Die Uhr übersteht alles", () => {
|
||||
test("zeigt nach einem Neuladen dieselbe Einsatzzeit", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
await ueberwachungEroeffnen(page, `E2E Neuladen ${test.info().project.name} ${Date.now()}`);
|
||||
await truppAnlegen(page, "Uhrtrupp");
|
||||
|
||||
const trupp = page.locator("[data-testid^='trupp-']").first();
|
||||
await trupp.getByRole("button", { name: "Einstieg" }).click();
|
||||
await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ");
|
||||
|
||||
// Ein paar Sekunden laufen lassen, damit es etwas zu verlieren gibt.
|
||||
await expect
|
||||
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(5);
|
||||
|
||||
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
|
||||
|
||||
// --- Neuladen ------------------------------------------------------------
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("ueberwachung")).toBeVisible();
|
||||
|
||||
const nachher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
|
||||
|
||||
// Die Uhr muss weitergelaufen sein — nicht zurückgesprungen, nicht bei
|
||||
// null angefangen. Ein Zähler im Browser hätte hier neu begonnen.
|
||||
expect(nachher).toBeGreaterThanOrEqual(vorher);
|
||||
expect(nachher).toBeLessThan(vorher + 60);
|
||||
});
|
||||
|
||||
test("läuft weiter, wenn der Server nicht mehr antwortet", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
await ueberwachungEroeffnen(page, `E2E Netzverlust ${test.info().project.name} ${Date.now()}`);
|
||||
await truppAnlegen(page, "Netzverlusttrupp");
|
||||
|
||||
const trupp = page.locator("[data-testid^='trupp-']").first();
|
||||
await trupp.getByRole("button", { name: "Einstieg" }).click();
|
||||
await expect(trupp).toHaveAttribute("data-status", "IM_EINSATZ");
|
||||
|
||||
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
|
||||
|
||||
// --- Netz weg ------------------------------------------------------------
|
||||
await context.setOffline(true);
|
||||
|
||||
// Die Uhr braucht den Server nicht: sie rechnet aus dem Einstiegszeitpunkt,
|
||||
// den sie schon hat. Ein heruntergezählter Wert wäre hier stehen geblieben.
|
||||
await expect
|
||||
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(vorher + 3);
|
||||
|
||||
// --- Netz zurück ---------------------------------------------------------
|
||||
await context.setOffline(false);
|
||||
|
||||
// Und der Server bestätigt, was der Browser gerechnet hat.
|
||||
const nachher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
|
||||
expect(nachher).toBeGreaterThan(vorher);
|
||||
});
|
||||
|
||||
test("sagt es sichtbar, wenn der Ereignisstrom abreißt", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await anmelden(page);
|
||||
|
||||
await ueberwachungEroeffnen(page, `E2E Stromabriss ${test.info().project.name} ${Date.now()}`);
|
||||
await truppAnlegen(page, "Abrisstrupp");
|
||||
|
||||
const trupp = page.locator("[data-testid^='trupp-']").first();
|
||||
await trupp.getByRole("button", { name: "Einstieg" }).click();
|
||||
await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "true", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(4);
|
||||
|
||||
const vorher = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
|
||||
|
||||
/*
|
||||
Den Strom abschneiden.
|
||||
|
||||
`context.setOffline()` genügt dafür nicht: eine bereits offene Verbindung
|
||||
liefert in Chromium weiter, und das ist auch richtig so — sie ist ja noch
|
||||
da. Für einen echten Abriss wird die Route abgewiesen und die Seite neu
|
||||
geladen; der neue Ereignisstrom kommt dann gar nicht erst zustande. Damit
|
||||
prüft dieser Test beides auf einmal: Neuladen **und** toter Strom.
|
||||
*/
|
||||
await page.route("**/api/atemschutz/strom**", (route) => route.abort("connectionreset"));
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByTestId("ueberwachung")).toBeVisible();
|
||||
await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "false", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Ereignisstrom ist unterbrochen")).toBeVisible();
|
||||
|
||||
// Trotz totem Strom stimmt die Uhr: der Stand kommt weiter über die
|
||||
// normale Abfrage, und gerechnet wird aus Zeitstempeln.
|
||||
const nachAbriss = alsSekunden(await page.getByTestId("trupp-dauer").innerText());
|
||||
expect(nachAbriss).toBeGreaterThanOrEqual(vorher);
|
||||
|
||||
await expect
|
||||
.poll(async () => alsSekunden(await page.getByTestId("trupp-dauer").innerText()), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(nachAbriss + 2);
|
||||
|
||||
// --- Strom zurück --------------------------------------------------------
|
||||
await page.unroute("**/api/atemschutz/strom**");
|
||||
|
||||
// EventSource verbindet sich von selbst neu — ohne eine Zeile dafür.
|
||||
await expect(page.getByTestId("verbindung")).toHaveAttribute("data-verbunden", "true", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Wandelt „mm:ss" oder „h:mm:ss" in Sekunden. */
|
||||
function alsSekunden(text: string): number {
|
||||
const teile = text.trim().split(":").map(Number);
|
||||
if (teile.some((zahl) => Number.isNaN(zahl))) return 0;
|
||||
|
||||
return teile.length === 3
|
||||
? teile[0]! * 3600 + teile[1]! * 60 + teile[2]!
|
||||
: teile[0]! * 60 + teile[1]!;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
abfrageUeberfaellig,
|
||||
alsUhr,
|
||||
ampel,
|
||||
einsatzdauer,
|
||||
naechsteAbfrage,
|
||||
niedrigsterDruck,
|
||||
restzeitMinuten,
|
||||
rueckzugsdruck,
|
||||
statuswechselErlaubt,
|
||||
verbrauchProMinute,
|
||||
} from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Die Rechenregeln der Atemschutzüberwachung.
|
||||
*
|
||||
* Das sicherheitsrelevanteste Stück Code im Projekt — deshalb wird hier nicht
|
||||
* geprüft, ob eine Zahl herauskommt, sondern ob die richtige herauskommt, und
|
||||
* zwar auch in den Fällen, die im Einsatz wirklich vorkommen: eine fehlende
|
||||
* Meldung, ein Trupp mit ungleichem Verbrauch, ein Wert, der steigt statt zu
|
||||
* fallen.
|
||||
*/
|
||||
|
||||
const T0 = new Date("2026-06-15T10:00:00.000Z");
|
||||
|
||||
/** Zeitpunkt X Minuten nach T0. */
|
||||
function nach(minuten: number): Date {
|
||||
return new Date(T0.getTime() + minuten * 60_000);
|
||||
}
|
||||
|
||||
describe("Rückzugsdruck", () => {
|
||||
it("rechnet den doppelten Anmarschverbrauch, sobald das Ziel gemeldet ist", () => {
|
||||
// 300 rein, 240 am Ziel: 60 bar für den Weg hin, also 60 für zurück.
|
||||
// 120 + 55 Warndruck + 10 Zuschlag = 185.
|
||||
const ergebnis = rueckzugsdruck({
|
||||
eingangsdruck: 300,
|
||||
zieldruck: 240,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 10,
|
||||
});
|
||||
|
||||
expect(ergebnis).toEqual({ druck: 185, regel: "anmarschverbrauch" });
|
||||
});
|
||||
|
||||
it("greift ohne Zielmeldung auf die Zwei-Drittel-Regel zurück", () => {
|
||||
const ergebnis = rueckzugsdruck({
|
||||
eingangsdruck: 300,
|
||||
zieldruck: null,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 10,
|
||||
});
|
||||
|
||||
// 300 × 2/3 = 200, plus Zuschlag.
|
||||
expect(ergebnis).toEqual({ druck: 210, regel: "zweidrittel" });
|
||||
});
|
||||
|
||||
it("ist ohne Zielmeldung vorsichtiger als mit", () => {
|
||||
const ohne = rueckzugsdruck({
|
||||
eingangsdruck: 300,
|
||||
zieldruck: null,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 10,
|
||||
});
|
||||
const mit = rueckzugsdruck({
|
||||
eingangsdruck: 300,
|
||||
zieldruck: 240,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 10,
|
||||
});
|
||||
|
||||
// Genau darum ist der Rückfall vertretbar: er liegt höher, nicht tiefer.
|
||||
expect(ohne.druck).toBeGreaterThan(mit.druck);
|
||||
});
|
||||
|
||||
it("kommt bei langem Anmarsch nie über den Eingangsdruck hinaus", () => {
|
||||
// Halbe Flasche für den Hinweg — rechnerisch wären 355 bar nötig. Das
|
||||
// gibt es nicht; der Trupp hätte gar nicht erst so weit gehen dürfen.
|
||||
const ergebnis = rueckzugsdruck({
|
||||
eingangsdruck: 300,
|
||||
zieldruck: 150,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 10,
|
||||
});
|
||||
|
||||
expect(ergebnis.druck).toBe(300);
|
||||
});
|
||||
|
||||
it("ignoriert einen Zieldruck über dem Eingangsdruck", () => {
|
||||
// Ein Zahlendreher beim Ablesen darf nicht dazu führen, dass der
|
||||
// Rückzugsdruck sinkt.
|
||||
const ergebnis = rueckzugsdruck({
|
||||
eingangsdruck: 200,
|
||||
zieldruck: 280,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 10,
|
||||
});
|
||||
|
||||
expect(ergebnis.regel).toBe("zweidrittel");
|
||||
});
|
||||
|
||||
it("nimmt den Sicherheitszuschlag des Kommandanten mit", () => {
|
||||
const streng = rueckzugsdruck({
|
||||
eingangsdruck: 300,
|
||||
zieldruck: 240,
|
||||
warndruck: 55,
|
||||
sicherheitszuschlag: 30,
|
||||
});
|
||||
|
||||
expect(streng.druck).toBe(205);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Niedrigster Druck im Trupp", () => {
|
||||
it("nimmt je Person die letzte Messung und davon die kleinste", () => {
|
||||
const ergebnis = niedrigsterDruck([
|
||||
{ personId: "a", druck: 300, zeitpunkt: nach(0) },
|
||||
{ personId: "b", druck: 300, zeitpunkt: nach(0) },
|
||||
{ personId: "a", druck: 220, zeitpunkt: nach(10) },
|
||||
{ personId: "b", druck: 190, zeitpunkt: nach(10) },
|
||||
]);
|
||||
|
||||
expect(ergebnis?.personId).toBe("b");
|
||||
expect(ergebnis?.druck).toBe(190);
|
||||
});
|
||||
|
||||
it("lässt sich von einer alten Messung nicht täuschen", () => {
|
||||
// Person b hatte einmal 100 bar gemeldet — das war aber vorher, und
|
||||
// inzwischen steht ein neuer Wert. Der zählt.
|
||||
const ergebnis = niedrigsterDruck([
|
||||
{ personId: "a", druck: 210, zeitpunkt: nach(10) },
|
||||
{ personId: "b", druck: 100, zeitpunkt: nach(5) },
|
||||
{ personId: "b", druck: 240, zeitpunkt: nach(10) },
|
||||
]);
|
||||
|
||||
expect(ergebnis?.personId).toBe("a");
|
||||
expect(ergebnis?.druck).toBe(210);
|
||||
});
|
||||
|
||||
it("gibt null zurück, solange nichts gemeldet ist", () => {
|
||||
expect(niedrigsterDruck([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ampel", () => {
|
||||
it("steht auf grün, solange Luft ist", () => {
|
||||
expect(ampel({ aktuellerDruck: 250, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("gruen");
|
||||
});
|
||||
|
||||
it("warnt, bevor der Rückzugsdruck erreicht ist", () => {
|
||||
// Zehn Prozent über der Grenze: der Trupp soll losgehen, nicht warten.
|
||||
expect(ampel({ aktuellerDruck: 200, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("gelb");
|
||||
});
|
||||
|
||||
it("steht auf rot, sobald die Grenze erreicht ist", () => {
|
||||
expect(ampel({ aktuellerDruck: 185, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("rot");
|
||||
expect(ampel({ aktuellerDruck: 120, rueckzugsdruck: 185, status: "RUECKWEG" })).toBe("rot");
|
||||
});
|
||||
|
||||
it("lässt einen Trupp, der draußen ist, in Ruhe", () => {
|
||||
expect(ampel({ aktuellerDruck: 40, rueckzugsdruck: 185, status: "DRAUSSEN" })).toBe("gruen");
|
||||
});
|
||||
|
||||
it("bewertet nichts, solange nichts gemeldet ist", () => {
|
||||
expect(ampel({ aktuellerDruck: null, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("gruen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Einsatzdauer", () => {
|
||||
it("rechnet aus zwei Zeitstempeln, nicht aus einem Zähler", () => {
|
||||
expect(einsatzdauer({ einstiegUm: T0, drausenUm: null }, nach(12))).toBe(720);
|
||||
});
|
||||
|
||||
it("bleibt nach dem Herauskommen stehen", () => {
|
||||
expect(einsatzdauer({ einstiegUm: T0, drausenUm: nach(18) }, nach(90))).toBe(1080);
|
||||
});
|
||||
|
||||
it("zählt erst ab dem Einstieg", () => {
|
||||
expect(einsatzdauer({ einstiegUm: null, drausenUm: null }, nach(30))).toBeNull();
|
||||
});
|
||||
|
||||
it("überlebt eine Uhr, die zurückspringt", () => {
|
||||
// Sommerzeit, eine korrigierte Systemuhr: die Dauer wird nie negativ.
|
||||
expect(einsatzdauer({ einstiegUm: nach(10), drausenUm: null }, T0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Druckabfrage", () => {
|
||||
it("wird ab der letzten Meldung gerechnet, nicht ab dem Einstieg", () => {
|
||||
const faellig = naechsteAbfrage({
|
||||
einstiegUm: T0,
|
||||
letzteMessungUm: nach(7),
|
||||
intervallSekunden: 600,
|
||||
});
|
||||
|
||||
expect(faellig).toEqual(nach(17));
|
||||
});
|
||||
|
||||
it("ist überfällig, sobald das Intervall abgelaufen ist", () => {
|
||||
const angaben = {
|
||||
einstiegUm: T0,
|
||||
letzteMessungUm: T0,
|
||||
intervallSekunden: 600,
|
||||
status: "IM_EINSATZ" as const,
|
||||
};
|
||||
|
||||
expect(abfrageUeberfaellig({ ...angaben, jetzt: nach(9) })).toBe(false);
|
||||
expect(abfrageUeberfaellig({ ...angaben, jetzt: nach(10) })).toBe(true);
|
||||
});
|
||||
|
||||
it("erinnert nicht an Trupps, die noch nicht oder nicht mehr drin sind", () => {
|
||||
const angaben = {
|
||||
einstiegUm: T0,
|
||||
letzteMessungUm: T0,
|
||||
intervallSekunden: 600,
|
||||
jetzt: nach(60),
|
||||
};
|
||||
|
||||
expect(abfrageUeberfaellig({ ...angaben, status: "BEREIT" })).toBe(false);
|
||||
expect(abfrageUeberfaellig({ ...angaben, status: "DRAUSSEN" })).toBe(false);
|
||||
expect(abfrageUeberfaellig({ ...angaben, status: "RUECKWEG" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verbrauch und Restzeit", () => {
|
||||
it("rechnet den Verbrauch je Minute aus erster und letzter Messung", () => {
|
||||
const rate = verbrauchProMinute([
|
||||
{ druck: 300, zeitpunkt: T0 },
|
||||
{ druck: 240, zeitpunkt: nach(10) },
|
||||
]);
|
||||
|
||||
expect(rate).toBe(6);
|
||||
});
|
||||
|
||||
it("rechnet nichts aus einer einzelnen Messung", () => {
|
||||
expect(verbrauchProMinute([{ druck: 300, zeitpunkt: T0 }])).toBeNull();
|
||||
});
|
||||
|
||||
it("rechnet nichts aus zwei Messungen im Sekundenabstand", () => {
|
||||
const rate = verbrauchProMinute([
|
||||
{ druck: 300, zeitpunkt: T0 },
|
||||
{ druck: 295, zeitpunkt: new Date(T0.getTime() + 20_000) },
|
||||
]);
|
||||
|
||||
// 15 bar je Minute wären hochgerechneter Unsinn.
|
||||
expect(rate).toBeNull();
|
||||
});
|
||||
|
||||
it("rechnet nichts, wenn der Druck gestiegen ist", () => {
|
||||
const rate = verbrauchProMinute([
|
||||
{ druck: 200, zeitpunkt: T0 },
|
||||
{ druck: 260, zeitpunkt: nach(5) },
|
||||
]);
|
||||
|
||||
// Flasche gewechselt oder falsch abgelesen — beides ist keine Grundlage.
|
||||
expect(rate).toBeNull();
|
||||
});
|
||||
|
||||
it("schätzt die Restzeit bis zum Rückzugsdruck", () => {
|
||||
expect(
|
||||
restzeitMinuten({ aktuellerDruck: 245, rueckzugsdruck: 185, verbrauchProMinute: 6 }),
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it("meldet null Minuten, wenn die Grenze erreicht ist", () => {
|
||||
expect(
|
||||
restzeitMinuten({ aktuellerDruck: 185, rueckzugsdruck: 185, verbrauchProMinute: 6 }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("gibt lieber keine Zahl aus als eine erfundene", () => {
|
||||
expect(
|
||||
restzeitMinuten({ aktuellerDruck: 245, rueckzugsdruck: 185, verbrauchProMinute: null }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
restzeitMinuten({ aktuellerDruck: null, rueckzugsdruck: 185, verbrauchProMinute: 6 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Statusfolge", () => {
|
||||
it("lässt den üblichen Weg zu", () => {
|
||||
expect(statuswechselErlaubt("BEREIT", "IM_EINSATZ")).toBe(true);
|
||||
expect(statuswechselErlaubt("IM_EINSATZ", "RUECKWEG")).toBe(true);
|
||||
expect(statuswechselErlaubt("RUECKWEG", "DRAUSSEN")).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt einen Trupp auch ohne gemeldeten Rückweg herauskommen", () => {
|
||||
// Kommt vor: kurzer Auftrag, der Trupp steht plötzlich wieder da.
|
||||
expect(statuswechselErlaubt("IM_EINSATZ", "DRAUSSEN")).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt niemanden zurückspringen", () => {
|
||||
expect(statuswechselErlaubt("DRAUSSEN", "IM_EINSATZ")).toBe(false);
|
||||
expect(statuswechselErlaubt("RUECKWEG", "IM_EINSATZ")).toBe(false);
|
||||
expect(statuswechselErlaubt("IM_EINSATZ", "BEREIT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Uhrzeitanzeige", () => {
|
||||
it("zeigt Minuten und Sekunden", () => {
|
||||
expect(alsUhr(0)).toBe("00:00");
|
||||
expect(alsUhr(65)).toBe("01:05");
|
||||
expect(alsUhr(599)).toBe("09:59");
|
||||
});
|
||||
|
||||
it("nimmt die Stunde dazu, sobald es länger dauert", () => {
|
||||
expect(alsUhr(3661)).toBe("1:01:01");
|
||||
});
|
||||
|
||||
it("zeigt einen Strich, solange nichts läuft", () => {
|
||||
expect(alsUhr(null)).toBe("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Was die Datenbank beim Atemschutz durchsetzt.
|
||||
*
|
||||
* Dieses Modul ist das sicherheitsrelevanteste im Projekt, und im Schadensfall
|
||||
* ist das Protokoll das Papier, nach dem gefragt wird. Deshalb liegen die
|
||||
* Regeln nicht (nur) im Anwendungscode:
|
||||
*
|
||||
* 1. Druckmessungen und Ereignisse lassen sich nicht ändern und nicht
|
||||
* löschen — weder über die Anwendung noch über den Systemzugang.
|
||||
* 2. Unmögliche Werte werden abgewiesen: negativer Druck, ein Rückzug vor
|
||||
* dem Einstieg, ein Ende vor dem Beginn.
|
||||
* 3. Genau ein Truppführer je Trupp.
|
||||
* 4. Mandantentrennung der sieben neuen Tabellen.
|
||||
*
|
||||
* Ohne erreichbare Datenbank überspringen sich die Tests selbst.
|
||||
*/
|
||||
|
||||
const APP_URL = process.env.DATABASE_URL ?? "";
|
||||
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
|
||||
|
||||
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
|
||||
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
||||
|
||||
const SLUG = "test-atemschutz-wehr";
|
||||
|
||||
let erreichbar = false;
|
||||
let organisationId = "";
|
||||
let personA = "";
|
||||
let personB = "";
|
||||
let ueberwachungId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await system.$queryRaw`SELECT 1`;
|
||||
erreichbar = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisation = await system.organisation.upsert({
|
||||
where: { slug: SLUG },
|
||||
update: {},
|
||||
create: { slug: SLUG, name: "Atemschutz-Testwehr", kurzname: "ATW" },
|
||||
});
|
||||
organisationId = organisation.id;
|
||||
|
||||
const anlegen = async (nummer: string, vorname: string, nachname: string) => {
|
||||
const person = await system.person.upsert({
|
||||
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: nummer } },
|
||||
update: {},
|
||||
create: { organisationId, mitgliedsnummer: nummer, vorname, nachname },
|
||||
});
|
||||
return person.id;
|
||||
};
|
||||
|
||||
personA = await anlegen("A001", "Anton", "Atemschutz");
|
||||
personB = await anlegen("A002", "Berta", "Atemschutz");
|
||||
|
||||
const ueberwachung = await system.atemschutzeinsatz.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Testüberwachung",
|
||||
ueberwacherId: personA,
|
||||
beginnUm: new Date("2026-06-01T10:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
ueberwachungId = ueberwachung.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
// Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf
|
||||
// den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz
|
||||
// abgeschaltet; im Betrieb passiert das nie.
|
||||
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" DISABLE TRIGGER USER');
|
||||
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" DISABLE TRIGGER USER');
|
||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" ENABLE TRIGGER USER');
|
||||
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" ENABLE TRIGGER USER');
|
||||
});
|
||||
}
|
||||
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
||||
});
|
||||
|
||||
/** Legt einen Trupp mit zwei Personen an. */
|
||||
async function truppAnlegen(funkrufname: string, felder: Record<string, unknown> = {}) {
|
||||
return system.atemschutztrupp.create({
|
||||
data: {
|
||||
organisationId,
|
||||
atemschutzeinsatzId: ueberwachungId,
|
||||
funkrufname,
|
||||
angelegtUm: new Date("2026-06-01T10:05:00.000Z"),
|
||||
mitglieder: {
|
||||
create: [
|
||||
{ organisationId, personId: personA, truppfuehrer: true },
|
||||
{ organisationId, personId: personB, truppfuehrer: false },
|
||||
],
|
||||
},
|
||||
...felder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Das Protokoll ist unveränderlich", () => {
|
||||
it("nimmt eine Druckmessung an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Messtrupp");
|
||||
|
||||
const messung = await system.druckmessung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
truppId: trupp.id,
|
||||
personId: personA,
|
||||
druck: 300,
|
||||
art: "EINGANG",
|
||||
},
|
||||
});
|
||||
|
||||
expect(messung.druck).toBe(300);
|
||||
});
|
||||
|
||||
it("weist jede Änderung einer Druckmessung ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Änderungstrupp");
|
||||
const messung = await system.druckmessung.create({
|
||||
data: { organisationId, truppId: trupp.id, personId: personA, druck: 280, art: "ZWISCHEN" },
|
||||
});
|
||||
|
||||
// Ein Tippfehler wird durch eine neue Messung richtiggestellt — nicht
|
||||
// durch Überschreiben. Sonst ließe sich jede Aufzeichnung glätten.
|
||||
await expect(
|
||||
system.druckmessung.update({ where: { id: messung.id }, data: { druck: 180 } }),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("weist das Löschen einer Druckmessung ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Löschtrupp");
|
||||
const messung = await system.druckmessung.create({
|
||||
data: { organisationId, truppId: trupp.id, personId: personA, druck: 260, art: "ZWISCHEN" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.druckmessung.delete({ where: { id: messung.id } }),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("weist jede Änderung eines Ereignisses ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const ereignis = await system.atemschutzEreignis.create({
|
||||
data: {
|
||||
organisationId,
|
||||
atemschutzeinsatzId: ueberwachungId,
|
||||
art: "MAYDAY",
|
||||
text: "MAYDAY — Angriffstrupp",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.atemschutzEreignis.update({
|
||||
where: { id: ereignis.id },
|
||||
data: { text: "war doch nichts" },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Werte, die es nicht geben darf", () => {
|
||||
it("weist einen negativen Druck ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Minustrupp");
|
||||
|
||||
await expect(
|
||||
system.druckmessung.create({
|
||||
data: { organisationId, truppId: trupp.id, personId: personA, druck: -5, art: "ZWISCHEN" },
|
||||
}),
|
||||
).rejects.toThrow(/druckmessung_druck_moeglich/i);
|
||||
});
|
||||
|
||||
it("weist einen Druck jenseits jeder Flasche ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Zahlendrehertrupp");
|
||||
|
||||
// 3000 statt 300 — der klassische Tippfehler unter Zeitdruck.
|
||||
await expect(
|
||||
system.druckmessung.create({
|
||||
data: { organisationId, truppId: trupp.id, personId: personA, druck: 3000, art: "EINGANG" },
|
||||
}),
|
||||
).rejects.toThrow(/druckmessung_druck_moeglich/i);
|
||||
});
|
||||
|
||||
it("weist einen Rückzug vor dem Einstieg ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
truppAnlegen("Verdrehttrupp", {
|
||||
einstiegUm: new Date("2026-06-01T10:30:00.000Z"),
|
||||
rueckzugUm: new Date("2026-06-01T10:20:00.000Z"),
|
||||
}),
|
||||
).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i);
|
||||
});
|
||||
|
||||
it("weist einen Rückzug ohne Einstieg ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Ein Trupp, der zurückgeht, ohne je drin gewesen zu sein, ist kein
|
||||
// Trupp, sondern ein Erfassungsfehler.
|
||||
await expect(
|
||||
truppAnlegen("Geistertrupp", { rueckzugUm: new Date("2026-06-01T10:20:00.000Z") }),
|
||||
).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i);
|
||||
});
|
||||
|
||||
it("weist eine Aufhebung ohne Notfall ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
truppAnlegen("Scheintrupp", {
|
||||
maydayAufgehobenUm: new Date("2026-06-01T10:40:00.000Z"),
|
||||
}),
|
||||
).rejects.toThrow(/trupp_mayday_aufhebung_danach/i);
|
||||
});
|
||||
|
||||
it("weist ein Ende vor dem Beginn ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.atemschutzeinsatz.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Rückwärtsüberwachung",
|
||||
ueberwacherId: personA,
|
||||
beginnUm: new Date("2026-06-01T12:00:00.000Z"),
|
||||
endeUm: new Date("2026-06-01T11:00:00.000Z"),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/atemschutz_ende_nach_beginn/i);
|
||||
});
|
||||
|
||||
it("weist ein unsinniges Abfrageintervall ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.atemschutzeinsatz.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bezeichnung: "Sekundentakt",
|
||||
ueberwacherId: personA,
|
||||
beginnUm: new Date("2026-06-01T12:00:00.000Z"),
|
||||
abfrageIntervall: 5,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/atemschutz_intervall_sinnvoll/i);
|
||||
});
|
||||
|
||||
it("weist eine G26 ab, die vor der Untersuchung endet", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.g26Untersuchung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: personA,
|
||||
untersuchtAm: new Date("2026-06-01T00:00:00.000Z"),
|
||||
gueltigBis: new Date("2025-06-01T00:00:00.000Z"),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/g26_gueltig_nach_untersuchung/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Genau ein Truppführer", () => {
|
||||
it("lässt einen zweiten Truppführer nicht zu", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Doppelfuehrertrupp");
|
||||
|
||||
// Im Protokoll muss stehen, wer verantwortlich war — genau einer. Der
|
||||
// Teilindex truppmitglied_ein_fuehrer setzt das durch; Prisma meldet ihn
|
||||
// als verletzte Eindeutigkeit auf truppId.
|
||||
await expect(
|
||||
system.truppmitglied.update({
|
||||
where: { truppId_personId: { truppId: trupp.id, personId: personB } },
|
||||
data: { truppfuehrer: true },
|
||||
}),
|
||||
).rejects.toThrow(/unique constraint failed.*truppId/i);
|
||||
});
|
||||
|
||||
it("lässt dieselbe Person nicht zweimal im Trupp zu", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Doppeltrupp");
|
||||
|
||||
await expect(
|
||||
system.truppmitglied.create({
|
||||
data: { organisationId, truppId: trupp.id, personId: personA },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung des Atemschutzes", () => {
|
||||
it("zeigt ohne gesetzte Wehr nichts", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const gefunden = await app.$transaction(async (tx) => ({
|
||||
ueberwachungen: await tx.atemschutzeinsatz.findMany(),
|
||||
trupps: await tx.atemschutztrupp.findMany(),
|
||||
mitglieder: await tx.truppmitglied.findMany(),
|
||||
messungen: await tx.druckmessung.findMany(),
|
||||
ereignisse: await tx.atemschutzEreignis.findMany(),
|
||||
untersuchungen: await tx.g26Untersuchung.findMany(),
|
||||
uebungen: await tx.belastungsuebung.findMany(),
|
||||
}));
|
||||
|
||||
for (const [tabelle, zeilen] of Object.entries(gefunden)) {
|
||||
expect(zeilen, `${tabelle} darf ohne Mandantenkontext nichts zeigen`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("zeigt im Kontext der Wehr nur deren eigene Überwachungen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const ueberwachungen = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.atemschutzeinsatz.findMany();
|
||||
});
|
||||
|
||||
expect(ueberwachungen.length).toBeGreaterThan(0);
|
||||
expect(ueberwachungen.every((eintrag) => eintrag.organisationId === organisationId)).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt den Anwendungszugang eine Messung anlegen, aber nicht ändern", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const trupp = await truppAnlegen("Rechtetrupp");
|
||||
|
||||
const messungId = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
const messung = await tx.druckmessung.create({
|
||||
data: { organisationId, truppId: trupp.id, personId: personA, druck: 210, art: "ZWISCHEN" },
|
||||
});
|
||||
return messung.id;
|
||||
});
|
||||
|
||||
expect(messungId).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.$executeRaw`UPDATE "druckmessung" SET "druck" = 999 WHERE "id" = ${messungId}::uuid`;
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { g26FristVorschlag, tauglichkeit } from "@/lib/atemschutz/tauglichkeit";
|
||||
|
||||
/**
|
||||
* Wer darf unter Atemschutz?
|
||||
*
|
||||
* Die Frage entscheidet der Einheitsführer, nicht diese Funktion. Was sie
|
||||
* leisten muss: keinen der drei Nachweise unterschlagen und im Klartext sagen,
|
||||
* welcher fehlt. Eine Ampel, die rot ist, ohne zu sagen warum, hilft am
|
||||
* Fahrzeug niemandem.
|
||||
*/
|
||||
|
||||
const HEUTE = new Date("2026-06-15T08:00:00.000Z");
|
||||
|
||||
function datum(text: string): Date {
|
||||
return new Date(`${text}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Der Regelfall: alles vorhanden und gültig. */
|
||||
function vollstaendig() {
|
||||
return {
|
||||
untersuchungen: [{ gueltigBis: datum("2028-03-01"), ergebnis: "TAUGLICH" as const }],
|
||||
uebungen: [
|
||||
{ datum: datum("2026-02-10"), art: "UEBUNGSSTRECKE" as const },
|
||||
{ datum: datum("2026-04-20"), art: "EINSATZUEBUNG" as const },
|
||||
],
|
||||
einsaetzeUnterPa: [] as Date[],
|
||||
istAtemschutzgeraetetraeger: true,
|
||||
jetzt: HEUTE,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Tauglichkeit nach FwDV 7", () => {
|
||||
it("erklärt jemanden mit allen drei Nachweisen für einsetzbar", () => {
|
||||
const ergebnis = tauglichkeit(vollstaendig());
|
||||
|
||||
expect(ergebnis.stufe).toBe("tauglich");
|
||||
expect(ergebnis.gruende).toEqual([]);
|
||||
expect(ergebnis.g26GueltigBis).toEqual(datum("2028-03-01"));
|
||||
});
|
||||
|
||||
it("nennt eine fehlende G26 beim Namen", () => {
|
||||
const ergebnis = tauglichkeit({ ...vollstaendig(), untersuchungen: [] });
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
expect(ergebnis.gruende).toContain("Keine G26.3-Untersuchung hinterlegt");
|
||||
});
|
||||
|
||||
it("erkennt eine abgelaufene G26", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
untersuchungen: [{ gueltigBis: datum("2026-01-31"), ergebnis: "TAUGLICH" }],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
expect(ergebnis.gruende).toContain("G26.3 ist abgelaufen");
|
||||
});
|
||||
|
||||
it("warnt drei Monate vor Ablauf, ohne die Person schon auszuschließen", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
untersuchungen: [{ gueltigBis: datum("2026-08-01"), ergebnis: "TAUGLICH" }],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("laeuft_ab");
|
||||
expect(ergebnis.gruende.join(" ")).toMatch(/läuft in \d+ Tagen ab/);
|
||||
});
|
||||
|
||||
it("nimmt die neueste Untersuchung, nicht die erstbeste", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
untersuchungen: [
|
||||
{ gueltigBis: datum("2024-05-01"), ergebnis: "TAUGLICH" },
|
||||
{ gueltigBis: datum("2029-05-01"), ergebnis: "TAUGLICH" },
|
||||
{ gueltigBis: datum("2026-05-01"), ergebnis: "TAUGLICH" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("tauglich");
|
||||
expect(ergebnis.g26GueltigBis).toEqual(datum("2029-05-01"));
|
||||
});
|
||||
|
||||
it("lässt eine befristete Tauglichkeit gelten", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
untersuchungen: [{ gueltigBis: datum("2027-01-01"), ergebnis: "BEFRISTET_TAUGLICH" }],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("tauglich");
|
||||
});
|
||||
|
||||
it("zählt eine Nachuntersuchung nicht als Nachweis", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
untersuchungen: [{ gueltigBis: datum("2027-01-01"), ergebnis: "NACHUNTERSUCHUNG" }],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
});
|
||||
|
||||
it("sagt es deutlich, wenn der Arzt „nicht tauglich“ geschrieben hat", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
untersuchungen: [{ gueltigBis: datum("2028-01-01"), ergebnis: "NICHT_TAUGLICH" }],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
expect(ergebnis.gruende.join(" ")).toContain("nicht tauglich");
|
||||
});
|
||||
|
||||
it("verlangt die Belastungsübung im laufenden Jahr", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
uebungen: [
|
||||
// Letztes Jahr reicht nicht.
|
||||
{ datum: datum("2025-11-02"), art: "UEBUNGSSTRECKE" },
|
||||
{ datum: datum("2026-04-20"), art: "EINSATZUEBUNG" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
expect(ergebnis.gruende).toContain("Belastungsübung in diesem Jahr fehlt");
|
||||
});
|
||||
|
||||
it("lässt einen echten Einsatz unter Atemschutz für die Einsatzübung gelten", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
uebungen: [{ datum: datum("2026-02-10"), art: "UEBUNGSSTRECKE" }],
|
||||
einsaetzeUnterPa: [datum("2026-05-03")],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("tauglich");
|
||||
expect(ergebnis.einsatzuebungAm).toEqual(datum("2026-05-03"));
|
||||
});
|
||||
|
||||
it("lässt einen Einsatz aus dem Vorjahr nicht gelten", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
uebungen: [{ datum: datum("2026-02-10"), art: "UEBUNGSSTRECKE" }],
|
||||
einsaetzeUnterPa: [datum("2025-12-30")],
|
||||
});
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
expect(ergebnis.gruende).toContain("Einsatzübung unter Atemschutz in diesem Jahr fehlt");
|
||||
});
|
||||
|
||||
it("zählt eine Unterweisung nicht als Belastungsübung", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
...vollstaendig(),
|
||||
uebungen: [
|
||||
{ datum: datum("2026-03-01"), art: "UNTERWEISUNG" },
|
||||
{ datum: datum("2026-04-20"), art: "EINSATZUEBUNG" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(ergebnis.gruende).toContain("Belastungsübung in diesem Jahr fehlt");
|
||||
});
|
||||
|
||||
it("verlangt die Qualifikation als Geräteträger", () => {
|
||||
const ergebnis = tauglichkeit({ ...vollstaendig(), istAtemschutzgeraetetraeger: false });
|
||||
|
||||
expect(ergebnis.stufe).toBe("nicht_tauglich");
|
||||
expect(ergebnis.gruende).toContain("Keine Qualifikation als Atemschutzgeräteträger");
|
||||
});
|
||||
|
||||
it("nennt alle fehlenden Nachweise, nicht nur den ersten", () => {
|
||||
const ergebnis = tauglichkeit({
|
||||
untersuchungen: [],
|
||||
uebungen: [],
|
||||
einsaetzeUnterPa: [],
|
||||
istAtemschutzgeraetetraeger: false,
|
||||
jetzt: HEUTE,
|
||||
});
|
||||
|
||||
// Wer nur „nicht tauglich" liest, weiß nicht, was zu tun ist.
|
||||
expect(ergebnis.gruende).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fristvorschlag für die G26.3", () => {
|
||||
it("schlägt drei Jahre vor, solange jemand unter fünfzig ist", () => {
|
||||
const frist = g26FristVorschlag(datum("1990-04-01"), datum("2026-06-15"));
|
||||
expect(frist).toEqual(datum("2029-06-15"));
|
||||
});
|
||||
|
||||
it("schlägt ein Jahr vor, sobald jemand fünfzig ist", () => {
|
||||
const frist = g26FristVorschlag(datum("1972-01-10"), datum("2026-06-15"));
|
||||
expect(frist).toEqual(datum("2027-06-15"));
|
||||
});
|
||||
|
||||
it("nimmt ohne Geburtsdatum den längeren Zeitraum — überschreibbar bleibt er ohnehin", () => {
|
||||
const frist = g26FristVorschlag(null, datum("2026-06-15"));
|
||||
expect(frist).toEqual(datum("2029-06-15"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user