Files
FireAdministration/tests/unit/atemschutz-berechnung.test.ts
Florian WildeandClaude Opus 5 4e2daaafd4 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>
2026-09-03 23:19:05 +02:00

315 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("—");
});
});