Phase 3: Einsatz- und Uebungserfassung

Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt
und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit
Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde,
Statistik und Uebungen mit Anwesenheitsliste.

Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung,
zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen
zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich
erreichbaren Endpunkte in FLORIAN.

Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein
freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt
das durch. Koordinaten liegen doppelt — als Dezimalgrad und als
PostGIS-Punkt, zusammengehalten von einem Trigger.

57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 13:50:58 +02:00
co-authored by Claude Opus 5
parent bc62a9c1e8
commit 91954c402b
100 changed files with 9667 additions and 38 deletions
+195
View File
@@ -0,0 +1,195 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 3.
*
* Geprüft wird der Weg vom Alarm bis zur Auswertung: schnell erfassen,
* Zeitstempel setzen, Mannschaft eintragen, melden — und am Ende die Zahlen,
* die die Gemeinde sehen will.
*
* Dazu der Portal-Link: er ist der einzige Weg in FLORIAN hinein, der ohne
* Anmeldung funktioniert, und wird deshalb ausdrücklich in einem Browser
* **ohne** Sitzung geprüft.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
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("/");
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Einsatzliste", () => {
test("zeigt die Einsätze der Wehr", async ({ page }) => {
await page.goto("/einsaetze");
await expect(page.getByRole("heading", { name: "Einsätze" })).toBeVisible();
// Aus dem Seed: acht Einsätze des laufenden Jahres.
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand")).toBeVisible();
});
test("filtert über die Suche", async ({ page }) => {
await page.goto("/einsaetze");
await page.getByLabel("Einsätze durchsuchen").fill("Ölspur");
await expect(page.getByText("THL 1 Ölspur")).toBeVisible();
await expect(page.getByText("B 3 Wohnhausbrand")).toHaveCount(0);
});
});
/**
* Nacheinander: die Tests bauen aufeinander auf — erst wird ein Einsatz
* erfasst, dann daran weitergearbeitet.
*/
test.describe.configure({ mode: "serial" });
test.describe("Einsatz erfassen und bearbeiten", () => {
/** Merkt sich die Adresse des in diesem Durchlauf angelegten Einsatzes. */
let einsatzAdresse = "";
test("legt einen Einsatz über die Schnellerfassung an", async ({ page }) => {
await page.goto("/einsaetze");
await page.getByRole("button", { name: "Einsatz erfassen" }).click();
await page
.getByLabel("Einsatzstichwort")
.fill(`THL 1 Testeinsatz ${test.info().project.name}`);
await page.getByLabel("Straße").fill("Teststraße");
await page.getByLabel("Ort", { exact: true }).fill("Musterhausen");
await page.getByRole("button", { name: "Einsatz anlegen" }).click();
// Danach steht die Detailseite offen.
await page.waitForURL(/\/einsaetze\/[0-9a-f-]{36}$/);
einsatzAdresse = page.url();
await expect(page.getByRole("heading", { name: /THL 1 Testeinsatz/ })).toBeVisible();
await expect(page.getByText("läuft")).toBeVisible();
});
test("setzt Zeitstempel und rechnet die Kennzahlen", async ({ page }) => {
await page.goto(einsatzAdresse);
await expect(page.getByRole("heading", { name: "Zeiten" })).toBeVisible();
await page.getByRole("button", { name: "ausgerückt jetzt" }).click();
await expect(page.getByRole("button", { name: "ausgerückt zurücknehmen" })).toBeVisible();
await page.getByRole("button", { name: "eingetroffen jetzt" }).click();
await expect(page.getByRole("button", { name: "eingetroffen zurücknehmen" })).toBeVisible();
// Die Hilfsfrist steht danach da — auch wenn sie null Minuten beträgt.
await expect(page.getByText("Hilfsfrist")).toBeVisible();
});
test("erfasst Mannschaft über den Vorschlag und zeigt die Stärke", async ({ page }) => {
await page.goto(einsatzAdresse);
await page.getByRole("button", { name: "Mannschaft erfassen" }).click();
await expect(page.getByText(/sortiert nach Einsätzen im letzten Jahr/)).toBeVisible();
// Die ersten drei Vorschläge übernehmen.
const vorschlaege = page.locator("button", { hasText: /\d+ Einsätze/ });
await vorschlaege.nth(0).click();
await expect(page.getByText(/Kräfte erfasst/)).toBeVisible();
// Die Stärke wird sofort angezeigt.
await expect(page.getByText(/^\d+\/\d+\/\d+\/\d+$/)).toBeVisible();
});
test("nimmt eine Lagemeldung auf", async ({ page }) => {
await page.goto(einsatzAdresse);
await page.getByLabel("Neue Lagemeldung").fill("Lage erkundet, keine Gefahr für Personen.");
await page.getByRole("button", { name: "Melden" }).click();
await expect(page.getByText("Lage erkundet, keine Gefahr für Personen.")).toBeVisible();
});
test("gibt einen Portal-Link aus, der ohne Anmeldung funktioniert", async ({ page, browser }) => {
await page.goto(einsatzAdresse);
await page.getByLabel("Für wen ist der Link?").fill("FF Nachbarhausen");
await page.getByRole("button", { name: "Erzeugen" }).click();
const linkfeld = page.locator('input[readonly]');
await expect(linkfeld).toBeVisible();
const adresse = await linkfeld.inputValue();
expect(adresse).toContain("/portal/");
// Jetzt ein Browser ohne Sitzung — genau so, wie ihn eine Fremdwehr hätte.
const fremd = await browser.newContext();
const fremdeSeite = await fremd.newPage();
await fremdeSeite.goto(adresse);
await expect(fremdeSeite.getByRole("heading", { name: /Testeinsatz/ })).toBeVisible();
await expect(fremdeSeite.getByText("Zugang für FF Nachbarhausen")).toBeVisible();
// Und eine Meldung absetzen.
await fremdeSeite.getByLabel("Wer meldet?").fill("Gruppenführer Huber");
await fremdeSeite.getByLabel("Meldung").fill("Wir sind mit einer Staffel eingetroffen.");
await fremdeSeite.getByRole("button", { name: "Melden" }).click();
await expect(fremdeSeite.getByText("Wir sind mit einer Staffel eingetroffen.")).toBeVisible();
// Kein Weg in den Rest der Anwendung.
await expect(fremdeSeite.getByRole("link", { name: "Geräte" })).toHaveCount(0);
await fremd.close();
});
test("weist einen erfundenen Portal-Link ab", async ({ browser }) => {
const fremd = await browser.newContext();
const seite = await fremd.newPage();
await seite.goto("/portal/3f2504e0-4f89-11d3-9a0c-0305e82c3301.abcdefghijklmnopqrstuvwxyz01");
await expect(seite.getByTestId("portalfehler")).toBeVisible();
await expect(seite.getByText(/nicht gültig/)).toBeVisible();
await fremd.close();
});
});
test.describe("Statistik", () => {
test("zeigt Hilfsfrist, Kategorien und Beteiligung", async ({ page }) => {
await page.goto("/einsaetze/statistik");
await expect(page.getByRole("heading", { name: "Einsatzstatistik" })).toBeVisible();
await expect(page.getByText("Hilfsfrist (80 %)")).toBeVisible();
await expect(page.getByRole("heading", { name: "Nach Kategorie" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Beteiligung" })).toBeVisible();
// Aus dem Seed: die Kategorien der acht Einsätze.
await expect(page.getByText("Brand groß").first()).toBeVisible();
});
});
test.describe("Übungen", () => {
test("zeigt die Übungen und ihre Teilnehmerzahl", async ({ page }) => {
await page.goto("/uebungen");
await expect(page.getByRole("heading", { name: "Übungen" })).toBeVisible();
await expect(page.getByText("Innenangriff mit Atemschutz")).toBeVisible();
});
test("öffnet eine Übung und zeigt die Anwesenheitsliste", async ({ page }) => {
await page.goto("/uebungen");
await page.getByRole("link", { name: /\d{4}-\d{4}/ }).first().click();
await expect(page.getByRole("heading", { name: "Anwesenheit" })).toBeVisible();
await expect(page.getByText(/von \d+ aktiven Mitgliedern/)).toBeVisible();
});
});
+304
View File
@@ -0,0 +1,304 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tokenZerlegen, tokenZusammensetzen } from "@/lib/einsatz/portal";
/**
* Was die Datenbank bei Einsätzen durchsetzt.
*
* Drei Dinge, die nicht im Anwendungscode stehen dürfen, weil ein vergessener
* Zweig dort ausreichen würde:
*
* 1. Mandantentrennung der acht neuen Tabellen
* 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt
* 3. Die Unveränderbarkeit freigegebener Einsatzberichte
*
* Dazu die Zerlegung des Portal-Tokens, die ohne Datenbank auskommt.
*
* Ohne erreichbare Datenbank überspringen sich die ersten drei Gruppen 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-einsatz-wehr";
let erreichbar = false;
let organisationId = "";
let personId = "";
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: "Einsatz-Testwehr", kurzname: "ETW" },
});
organisationId = organisation.id;
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "E001" } },
update: {},
create: { organisationId, mitgliedsnummer: "E001", vorname: "Erika", nachname: "Einsatz" },
});
personId = person.id;
});
afterAll(async () => {
if (erreichbar) {
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
let laufend = 0;
/** Legt einen Einsatz an — mit fortlaufender Nummer, damit Tests sich nicht stören. */
async function einsatzAnlegen(felder: Record<string, unknown> = {}) {
laufend += 1;
return system.einsatz.create({
data: {
organisationId,
einsatznummer: `2026-${String(9000 + laufend)}`,
stichwort: "Testeinsatz",
alarmiertUm: new Date("2026-06-15T01:42:00.000Z"),
...felder,
},
});
}
describe.runIf(process.env.DATABASE_URL)("Koordinaten", () => {
it("setzt den PostGIS-Punkt aus den Dezimalgraden", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ breitengrad: 48.137154, laengengrad: 11.576124 });
const [zeile] = await system.$queryRaw<Array<{ laenge: number; breite: number }>>`
SELECT ST_X("koordinaten"::geometry) AS laenge, ST_Y("koordinaten"::geometry) AS breite
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
`;
expect(zeile?.breite).toBeCloseTo(48.137154, 5);
expect(zeile?.laenge).toBeCloseTo(11.576124, 5);
});
it("räumt den Punkt wieder ab, wenn die Koordinaten entfallen", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ breitengrad: 48.1, laengengrad: 11.5 });
await system.einsatz.update({
where: { id: einsatz.id },
data: { breitengrad: null, laengengrad: null },
});
const [zeile] = await system.$queryRaw<Array<{ vorhanden: boolean }>>`
SELECT ("koordinaten" IS NOT NULL) AS vorhanden
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
`;
expect(zeile?.vorhanden).toBe(false);
});
it("weist Koordinaten ab, die es auf der Erde nicht gibt", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(einsatzAnlegen({ breitengrad: 95, laengengrad: 11.5 })).rejects.toThrow();
});
});
describe.runIf(process.env.DATABASE_URL)("Freigegebene Einsatzberichte", () => {
it("lässt sich bis zur Freigabe frei ändern", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
const geaendert = await system.einsatz.update({
where: { id: einsatz.id },
data: { bericht: "wird noch geschrieben" },
});
expect(geaendert.bericht).toBe("wird noch geschrieben");
});
it("weist jede Änderung nach der Freigabe ab", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({
status: "FREIGEGEBEN",
freigegebenAm: new Date(),
bericht: "So war es.",
});
await expect(
system.einsatz.update({ where: { id: einsatz.id }, data: { bericht: "Doch anders." } }),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.einsatz.update({ where: { id: einsatz.id }, data: { stichwort: "Anderes" } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt das Berichts-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
const datei = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${einsatz.id}.pdf`,
dateiname: "bericht.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(2048),
},
});
const mitPdf = await system.einsatz.update({
where: { id: einsatz.id },
data: { berichtDateiId: datei.id },
});
expect(mitPdf.berichtDateiId).toBe(datei.id);
});
it("sperrt den Umweg über die Einsatzkräfte", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
const kraft = await system.einsatzkraft.create({
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "TRUPPMANN" },
});
await system.einsatz.update({
where: { id: einsatz.id },
data: { status: "FREIGEGEBEN", freigegebenAm: new Date() },
});
await expect(
system.einsatzkraft.update({
where: { id: kraft.id },
data: { funktion: "GRUPPENFUEHRER" },
}),
).rejects.toThrow(/freigegeben/i);
await expect(
system.einsatzkraft.create({
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "MELDER" },
}),
).rejects.toThrow(/freigegeben/i);
});
it("lässt Lagemeldungen ausdrücklich weiter zu", async ({ skip }) => {
if (!erreichbar) return skip();
// Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
// geschrieben ist. Der Verlauf ist kein Bestandteil des Berichts.
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
const meldung = await system.lagemeldung.create({
data: {
organisationId,
einsatzId: einsatz.id,
text: "Nachtrag von der Nachbarwehr",
quelle: "PORTAL",
},
});
expect(meldung.id).toBeTruthy();
});
it("verhindert das Löschen freigegebener Berichte", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
await expect(system.einsatz.delete({ where: { id: einsatz.id } })).rejects.toThrow(
/nicht geloescht/i,
);
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.einsatz.delete({ where: { id: einsatz.id } });
});
expect(await system.einsatz.findUnique({ where: { id: einsatz.id } })).toBeNull();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Einsatztabellen", () => {
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
if (!erreichbar) return skip();
await einsatzAnlegen();
const gefunden = await app.$transaction(async (tx) => ({
einsaetze: await tx.einsatz.findMany(),
kraefte: await tx.einsatzkraft.findMany(),
meldungen: await tx.lagemeldung.findMany(),
zugaenge: await tx.portalZugang.findMany(),
uebungen: await tx.uebung.findMany(),
}));
expect(gefunden.einsaetze).toHaveLength(0);
expect(gefunden.kraefte).toHaveLength(0);
expect(gefunden.meldungen).toHaveLength(0);
expect(gefunden.zugaenge).toHaveLength(0);
expect(gefunden.uebungen).toHaveLength(0);
});
it("zeigt im Kontext der Wehr nur ihre eigenen Einsätze", async ({ skip }) => {
if (!erreichbar) return skip();
const einsaetze = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.einsatz.findMany();
});
expect(einsaetze.length).toBeGreaterThan(0);
expect(einsaetze.every((einsatz) => einsatz.organisationId === organisationId)).toBe(true);
});
});
describe("Portal-Token", () => {
it("setzt Kennung und Geheimnis zusammen und zerlegt sie wieder", () => {
const kennung = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
const geheimnis = "abcdefghijklmnopqrstuvwxyz012345";
const token = tokenZusammensetzen(kennung, geheimnis);
expect(tokenZerlegen(token)).toEqual({ zugangId: kennung, geheimnis });
});
it("weist alles ab, was nicht wie ein Token aussieht", () => {
expect(tokenZerlegen("")).toBeNull();
expect(tokenZerlegen("ohnetrenner")).toBeNull();
expect(tokenZerlegen(".nurgeheimnis")).toBeNull();
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.")).toBeNull();
// Keine gültige Kennung
expect(tokenZerlegen("nichtsGutes.abcdefghijklmnopqrstuvwxyz012345")).toBeNull();
// Geheimnis zu kurz
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.kurz")).toBeNull();
});
it("verträgt Trennzeichen im Geheimnis nicht als Verwechslung", () => {
// base64url enthält kein "." — der erste Punkt trennt also eindeutig.
const token = tokenZusammensetzen(
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"abc_def-ghi012345678901234567890",
);
expect(tokenZerlegen(token)?.geheimnis).toBe("abc_def-ghi012345678901234567890");
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
EINHEITEN,
staerkeAlsText,
staerkeBerechnen,
staerkeHinweise,
taktischeEinheit,
type Funktion,
} from "@/lib/einsatz/staerke";
/**
* Stärke nach FwDV 3.
*
* Die Zahlen stehen später im Einsatzbericht und in der Meldung an die
* Gemeinde. Wer die Zuordnung ändert, ändert damit die Statistik der ganzen
* Wehr — deshalb steht sie hier ausführlich unter Test.
*/
/** Die Besetzung einer Gruppe nach FwDV 3. */
const GRUPPE: Funktion[] = [
"GRUPPENFUEHRER",
"MASCHINIST",
"MELDER",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
];
/** Die Besetzung einer Staffel. */
const STAFFEL: Funktion[] = [
"GRUPPENFUEHRER",
"MASCHINIST",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
];
describe("staerkeBerechnen", () => {
it("zählt eine Gruppe als 1/3/5/9", () => {
const staerke = staerkeBerechnen(GRUPPE);
expect(staerke).toEqual({ fuehrer: 1, unterfuehrer: 3, mannschaft: 5, gesamt: 9 });
expect(staerkeAlsText(staerke)).toBe("1/3/5/9");
});
it("zählt eine Staffel als 1/2/3/6", () => {
expect(staerkeAlsText(staerkeBerechnen(STAFFEL))).toBe("1/2/3/6");
});
it("zählt einen Trupp als 0/1/1/2", () => {
expect(staerkeAlsText(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"]))).toBe("0/1/1/2");
});
it("zählt jede Führungsfunktion als Führer", () => {
const staerke = staerkeBerechnen([
"EINSATZLEITER",
"ZUGFUEHRER",
"GRUPPENFUEHRER",
"STAFFELFUEHRER",
]);
expect(staerke.fuehrer).toBe(4);
expect(staerke.mannschaft).toBe(0);
});
it("zählt den Maschinisten zur Mannschaft", () => {
// Regional unterschiedlich — dieser Test hält die getroffene Festlegung
// fest, damit eine Änderung auffällt statt durchzurutschen.
const staerke = staerkeBerechnen(["MASCHINIST"]);
expect(staerke.mannschaft).toBe(1);
expect(staerke.unterfuehrer).toBe(0);
});
it("liefert für niemanden lauter Nullen", () => {
expect(staerkeAlsText(staerkeBerechnen([]))).toBe("0/0/0/0");
});
});
describe("taktischeEinheit", () => {
it("erkennt die Einheiten der FwDV 3", () => {
expect(taktischeEinheit(staerkeBerechnen(GRUPPE)).einheit).toBe("Gruppe");
expect(taktischeEinheit(staerkeBerechnen(STAFFEL)).einheit).toBe("Staffel");
expect(taktischeEinheit(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"])).einheit).toBe("Trupp");
});
it("nennt die überzähligen Kräfte", () => {
const zehn = [...GRUPPE, "TRUPPMANN" as Funktion];
const ergebnis = taktischeEinheit(staerkeBerechnen(zehn));
expect(ergebnis.einheit).toBe("Gruppe");
expect(ergebnis.ueberzaehlig).toBe(1);
});
it("gibt bei einer einzelnen Kraft keine Einheit zurück", () => {
const ergebnis = taktischeEinheit(staerkeBerechnen(["GRUPPENFUEHRER"]));
expect(ergebnis.einheit).toBeNull();
expect(ergebnis.ueberzaehlig).toBe(1);
});
it("kennt die Sollstärken", () => {
expect(EINHEITEN.map((einheit) => einheit.staerke)).toEqual([2, 6, 9, 18]);
});
});
describe("staerkeHinweise", () => {
it("meldet, wenn niemand erfasst ist", () => {
expect(staerkeHinweise(staerkeBerechnen([]))[0]).toContain("noch niemand erfasst");
});
it("meldet eine fehlende Führungskraft", () => {
const hinweise = staerkeHinweise(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"]));
expect(hinweise.some((hinweis) => hinweis.includes("Führungskraft"))).toBe(true);
});
it("meldet den Alleingang", () => {
const hinweise = staerkeHinweise(staerkeBerechnen(["GRUPPENFUEHRER"]));
expect(hinweise.some((hinweis) => hinweis.includes("Alleine"))).toBe(true);
});
it("schweigt bei einer vollständigen Gruppe", () => {
expect(staerkeHinweise(staerkeBerechnen(GRUPPE))).toHaveLength(0);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
alsDauer,
anfahrtszeit,
ausrueckzeit,
einsatzdauer,
hilfsfrist,
kennzahlen,
naechsterStempel,
zeitHinweise,
} from "@/lib/einsatz/zeiten";
import { nummerBilden, nummerLesen, naechsteNummer } from "@/lib/nummernkreis";
/**
* Einsatzzeiten und Nummernkreis.
*
* Die Hilfsfrist ist die Zahl, über die eine Wehr mit ihrer Gemeinde
* diskutiert. Wenn sie falsch gerechnet wird, merkt es niemand — bis es
* darauf ankommt.
*/
function uhr(stunde: number, minute: number): Date {
return new Date(Date.UTC(2026, 5, 15, stunde, minute, 0));
}
const EINSATZ = {
alarmiertUm: uhr(3, 42),
ausgeruecktUm: uhr(3, 47),
eingetroffenUm: uhr(3, 51),
einsatzendeUm: uhr(6, 5),
eingeruecktUm: uhr(6, 30),
einsatzbereitUm: uhr(7, 0),
};
describe("Kennzahlen", () => {
it("rechnet Ausrückzeit, Anfahrt und Hilfsfrist", () => {
expect(ausrueckzeit(EINSATZ)).toBe(5);
expect(anfahrtszeit(EINSATZ)).toBe(4);
expect(hilfsfrist(EINSATZ)).toBe(9);
});
it("rechnet die Einsatzdauer bis zum Einrücken", () => {
expect(einsatzdauer(EINSATZ)).toBe(168);
});
it("nimmt für die Dauer ersatzweise das Einsatzende", () => {
expect(einsatzdauer({ ...EINSATZ, eingeruecktUm: null })).toBe(143);
});
it("liefert null, wo ein Stempel fehlt", () => {
expect(hilfsfrist({ alarmiertUm: uhr(3, 42) })).toBeNull();
expect(ausrueckzeit({})).toBeNull();
});
it("liefert alle vier Zahlen auf einmal", () => {
expect(kennzahlen(EINSATZ)).toEqual({
ausrueckzeit: 5,
anfahrtszeit: 4,
hilfsfrist: 9,
dauer: 168,
});
});
it("gibt keine negative Zeit zurück, wenn Stempel verdreht sind", () => {
// Die Reihenfolge stimmt nicht — dann lieber keine Zahl als eine falsche.
expect(ausrueckzeit({ alarmiertUm: uhr(4, 0), ausgeruecktUm: uhr(3, 50) })).toBeNull();
});
});
describe("zeitHinweise", () => {
it("schweigt bei einem stimmigen Ablauf", () => {
expect(zeitHinweise(EINSATZ)).toHaveLength(0);
});
it("meldet eine verdrehte Reihenfolge", () => {
const hinweise = zeitHinweise({ ...EINSATZ, eingetroffenUm: uhr(3, 40) });
expect(hinweise.length).toBeGreaterThan(0);
expect(hinweise[0]).toContain("eingetroffen");
});
it("meldet eine unplausibel lange Anfahrt", () => {
const hinweise = zeitHinweise({
alarmiertUm: uhr(3, 0),
eingetroffenUm: uhr(5, 0),
});
expect(hinweise.some((hinweis) => hinweis.includes("120 Minuten"))).toBe(true);
});
});
describe("naechsterStempel", () => {
it("nennt den ersten offenen Stempel", () => {
expect(naechsterStempel({})).toBe("alarmiertUm");
expect(naechsterStempel({ alarmiertUm: uhr(3, 42) })).toBe("ausgeruecktUm");
});
it("gibt null zurück, wenn alles gesetzt ist", () => {
expect(naechsterStempel(EINSATZ)).toBeNull();
});
});
describe("alsDauer", () => {
it("schreibt Minuten unter einer Stunde als Minuten", () => {
expect(alsDauer(9)).toBe("9 min");
expect(alsDauer(59)).toBe("59 min");
});
it("schreibt längere Zeiten als Stunden", () => {
expect(alsDauer(60)).toBe("1:00 h");
expect(alsDauer(168)).toBe("2:48 h");
});
it("kommt mit fehlenden Werten zurecht", () => {
expect(alsDauer(null)).toBe("—");
expect(alsDauer(undefined)).toBe("—");
});
});
describe("Nummernkreis", () => {
it("bildet und liest Nummern", () => {
expect(nummerBilden(2026, 7)).toBe("2026-0007");
expect(nummerLesen("2026-0007")).toEqual({ jahr: 2026, laufend: 7 });
expect(nummerLesen("Unsinn")).toBeNull();
});
it("zählt von der bisher höchsten Nummer weiter", () => {
expect(naechsteNummer(2026, "2026-0041")).toBe("2026-0042");
});
it("beginnt im leeren Jahr bei eins", () => {
expect(naechsteNummer(2027, null)).toBe("2027-0001");
});
it("übernimmt keine Nummer aus einem anderen Jahr", () => {
// Der Aufrufer sucht die höchste Nummer des Jahres; bekommt er keine,
// beginnt das neue Jahr wieder bei eins.
expect(naechsteNummer(2027, undefined)).toBe("2027-0001");
});
});