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("—"); }); });