Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
+225
View File
@@ -0,0 +1,225 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 2.
*
* Geprüft wird der Weg, den der Gerätewart wirklich geht: morgens ins
* Fristen-Dashboard schauen, eine Vorlage nachsehen, eine Prüfung durchführen
* und am Ende ein Protokoll in der Hand haben.
*
* Hinweis: Der Wizard-Test legt echte Prüfprotokolle an. Die lassen sich
* absichtlich nicht wieder löschen — genau das ist der Sinn der Sache. Für die
* Beispielwehr ist das unschädlich; vor einer Vorführung hilft ein frischer
* Seed.
*
* 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("Fristen-Dashboard", () => {
test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible();
// Die vier Töpfe sind gleichzeitig Filter.
for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) {
await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible();
}
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
// Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe.
await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible();
});
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
await page.getByRole("button", { name: /überfällig/ }).first().click();
await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible();
});
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => {
await page.goto("/pruefungen");
await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible();
await page.getByLabel("Bündelung wählen").click();
await page.getByRole("option", { name: "nach Gerätetyp" }).click();
// Nach dem Umschalten stehen Gerätetypen in der Verteilungstabelle. Der
// Name steht dort allein in der Zelle — in der Terminliste darunter immer
// zusammen mit der Inventarnummer, deshalb "exact".
await expect(
page.getByRole("cell", { name: "Feuerwehrleine 30 m", exact: true }),
).toBeVisible();
});
});
test.describe("Wartungsvorlagen", () => {
test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible();
await expect(
page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }),
).toBeVisible();
await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible();
});
test("öffnet die Checkliste einer Vorlage", async ({ page }) => {
await page.goto("/pruefungen/vorlagen");
await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible();
// Der zulässige Bereich steht am Punkt.
await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible();
});
});
/**
* Nacheinander statt parallel: der Wizard setzt einen vorhandenen Entwurf fort,
* statt einen zweiten anzulegen. Zwei gleichzeitig laufende Tests würden sich
* denselben Entwurf teilen — und der zweite liefe in die Sperre für
* abgeschlossene Protokolle.
*
* Aus demselben Grund bekommt jedes Testprojekt ein eigenes Gerät: Desktop und
* Tablet laufen parallel, aber unter demselben Benutzer.
*/
test.describe.configure({ mode: "serial" });
/** Welches der Leinen-Geräte dieses Projekt prüft. */
function geraetIndex(): number {
return test.info().project.name === "tablet" ? 1 : 0;
}
/** Von der Geräteliste in den Wizard — der Weg des Gerätewarts. */
async function pruefungStarten(page: Page): Promise<void> {
await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine");
await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click();
await page.getByRole("link", { name: "Prüfung durchführen" }).click();
}
test.describe("Prüfwizard", () => {
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => {
await pruefungStarten(page);
await page.waitForURL(/\/pruefungen\/neu/);
// Die Checkliste der Leinenprüfung hat vier Punkte: drei Ja/Nein und einen
// Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst
// klickt der Test schneller, als die Oberfläche umschaltet.
for (const nummer of [1, 2, 3, 4]) {
await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible();
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true });
if ((await inOrdnung.count()) > 0) await inOrdnung.click();
const befund = page.getByLabel("Befund");
if ((await befund.count()) > 0) {
await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund.");
}
await page.getByRole("button", { name: "Weiter" }).click();
}
// Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch.
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
// Fenster, nicht auf das Dokument. Liegt das Feld unterhalb, zeichnet der
// Test sonst ins Leere.
const feld = page.getByLabel(/Unterschriftenfeld/);
await feld.scrollIntoViewIfNeeded();
const rahmen = await feld.boundingBox();
expect(rahmen).not.toBeNull();
await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2);
await page.mouse.down();
await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, { steps: 8 });
await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 });
await page.mouse.up();
// Sobald etwas auf dem Feld steht, lässt es sich löschen und übernehmen.
await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled();
await page.getByRole("button", { name: "Übernehmen" }).click();
await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible();
await page.getByRole("button", { name: "Prüfung abschließen" }).click();
// Danach steht das Protokoll da — mit Nummer und Ergebnis.
await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/);
await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible();
// Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld
// "Abgeschlossen" mit dem Zeitpunkt.
await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
});
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => {
await pruefungStarten(page);
// Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an.
await expect(page.getByText(/Schritt 1 von \d/)).toBeVisible();
// Dann bis zum Abschluss durchklicken, ohne etwas zu beantworten.
for (let schritt = 0; schritt < 8; schritt += 1) {
const weiter = page.getByRole("button", { name: "Weiter" });
if (!(await weiter.isVisible())) break;
await weiter.click();
}
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
});
});
test.describe("Mängel und Rückrufe", () => {
test("zeigt den Mangel aus der Beispielprüfung", async ({ page }) => {
await page.goto("/maengel");
await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible();
await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible();
});
test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => {
await page.goto("/rueckrufe");
await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible();
await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click();
await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible();
});
});
test.describe("Protokolle", () => {
test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => {
await page.goto("/pruefungen/verlauf");
await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible();
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
});
});
@@ -0,0 +1,338 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Die Unveränderbarkeit abgeschlossener Prüfprotokolle — geprüft dort, wo sie
* durchgesetzt wird: in der Datenbank.
*
* Ein Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse. Wäre es
* nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
* deshalb in einem Trigger (Migration 20260903000300) und nicht nur im
* Anwendungscode — ein vergessener Zustandscheck im Router würde sonst reichen,
* um sie auszuhebeln.
*
* Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt
* er sich selbst, damit "npm run test" auch ohne Docker durchläuft.
*/
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-pruefprotokoll-wehr";
let erreichbar = false;
let organisationId = "";
let geraetId = "";
let vorlageId = "";
let punktId = "";
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: "Prüfprotokoll-Testwehr", kurzname: "PPT" },
});
organisationId = organisation.id;
const standort = await system.standort.upsert({
where: { organisationId_name: { organisationId, name: "Gerätehaus" } },
update: {},
create: { organisationId, name: "Gerätehaus", art: "GERAETEHAUS" },
});
const geraet = await system.geraet.upsert({
where: { organisationId_inventarnummer: { organisationId, inventarnummer: "PPT-0001" } },
update: {},
create: {
organisationId,
inventarnummer: "PPT-0001",
bezeichnung: "Prüfling",
standortId: standort.id,
},
});
geraetId = geraet.id;
const vorlage = await system.wartungsvorlage.upsert({
where: { organisationId_name: { organisationId, name: "Testvorlage" } },
update: {},
create: { organisationId, name: "Testvorlage", intervallMonate: 12 },
});
vorlageId = vorlage.id;
const punkt = await system.checklistenpunkt.create({
data: { organisationId, vorlageId, text: "Sichtprüfung", typ: "JA_NEIN" },
});
punktId = punkt.id;
});
afterAll(async () => {
if (erreichbar) {
// Der Schalter ist zweimal nötig: für die Audit-Einträge und für die
// abgeschlossenen Prüfungen, die derselbe Aufbewahrungslauf abräumt.
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()]);
});
/** Legt eine abgeschlossene Prüfung an — so wie es der Wizard tut. */
async function abgeschlossenePruefung(nummer: string) {
const pruefung = await system.pruefung.create({
data: {
organisationId,
geraetId,
vorlageId,
durchgefuehrtAm: new Date("2026-06-01T00:00:00.000Z"),
prueferName: "Testprüfer",
status: "ENTWURF",
ergebnisse: { create: [{ organisationId, punktId, jaNein: true, inOrdnung: true }] },
signaturen: { create: [{ organisationId, name: "Testprüfer" }] },
},
});
return system.pruefung.update({
where: { id: pruefung.id },
data: {
status: "ABGESCHLOSSEN",
ergebnis: "BESTANDEN",
protokollNummer: nummer,
abgeschlossenAm: new Date(),
},
});
}
describe.runIf(process.env.DATABASE_URL)("Abgeschlossene Prüfprotokolle", () => {
it("lässt Entwürfe uneingeschränkt ändern", async ({ skip }) => {
if (!erreichbar) return skip();
const entwurf = await system.pruefung.create({
data: {
organisationId,
geraetId,
vorlageId,
durchgefuehrtAm: new Date(),
status: "ENTWURF",
},
});
const geaendert = await system.pruefung.update({
where: { id: entwurf.id },
data: { bemerkung: "noch in Arbeit" },
});
expect(geaendert.bemerkung).toBe("noch in Arbeit");
await system.pruefung.delete({ where: { id: entwurf.id } });
});
it("weist jede inhaltliche Änderung am abgeschlossenen Protokoll ab", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0001");
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { ergebnis: "NICHT_BESTANDEN" },
}),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { bemerkung: "nachträglich ergänzt" },
}),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { durchgefuehrtAm: new Date("2020-01-01T00:00:00.000Z") },
}),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt das Protokoll-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0002");
const datei = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${pruefung.id}.pdf`,
dateiname: "protokoll.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(1024),
},
});
// Das ist der Weg des Hintergrundprozesses — er muss offen bleiben.
const mitPdf = await system.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: datei.id },
});
expect(mitPdf.protokollDateiId).toBe(datei.id);
// Ein zweites Mal aber nicht: sonst ließe sich das Protokoll austauschen.
const zweite = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${pruefung.id}-zwei.pdf`,
dateiname: "anderes.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(2048),
},
});
await expect(
system.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: zweite.id },
}),
).rejects.toThrow(/unveraenderlich/i);
});
it("erlaubt genau einen Zustandswechsel: abgeschlossen wird ersetzt", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0003");
const ersetzt = await system.pruefung.update({
where: { id: pruefung.id },
data: { status: "ERSETZT" },
});
expect(ersetzt.status).toBe("ERSETZT");
// Zurück in den Entwurf geht es nicht.
await expect(
system.pruefung.update({ where: { id: pruefung.id }, data: { status: "ENTWURF" } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("sperrt auch den Umweg über die Einzelergebnisse", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0004");
const ergebnis = await system.pruefergebnis.findFirstOrThrow({
where: { pruefungId: pruefung.id },
});
await expect(
system.pruefergebnis.update({
where: { id: ergebnis.id },
data: { jaNein: false, inOrdnung: false },
}),
).rejects.toThrow(/abgeschlossen/i);
await expect(
system.pruefergebnis.delete({ where: { id: ergebnis.id } }),
).rejects.toThrow(/abgeschlossen/i);
await expect(
system.pruefergebnis.create({
data: { organisationId, pruefungId: pruefung.id, punktId, jaNein: false },
}),
).rejects.toThrow(/abgeschlossen/i);
});
it("sperrt nachträgliche Unterschriften", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0005");
await expect(
system.signatur.create({
data: { organisationId, pruefungId: pruefung.id, name: "Nachzügler" },
}),
).rejects.toThrow(/abgeschlossen/i);
});
it("verhindert das Löschen abgeschlossener Protokolle", async ({ skip }) => {
if (!erreichbar) return skip();
const pruefung = await abgeschlossenePruefung("T-0006");
await expect(system.pruefung.delete({ where: { id: pruefung.id } })).rejects.toThrow(
/nicht geloescht/i,
);
// Nur der ausdrückliche Aufbewahrungslauf darf es.
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.pruefung.delete({ where: { id: pruefung.id } });
});
expect(await system.pruefung.findUnique({ where: { id: pruefung.id } })).toBeNull();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Wartungstabellen", () => {
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
if (!erreichbar) return skip();
const gefunden = await app.$transaction(async (tx) => ({
vorlagen: await tx.wartungsvorlage.findMany(),
termine: await tx.wartungstermin.findMany(),
pruefungen: await tx.pruefung.findMany(),
maengel: await tx.mangel.findMany(),
rueckrufe: await tx.rueckruf.findMany(),
}));
expect(gefunden.vorlagen).toHaveLength(0);
expect(gefunden.termine).toHaveLength(0);
expect(gefunden.pruefungen).toHaveLength(0);
expect(gefunden.maengel).toHaveLength(0);
expect(gefunden.rueckrufe).toHaveLength(0);
});
it("zeigt im Kontext der Wehr nur ihre eigenen Vorlagen", async ({ skip }) => {
if (!erreichbar) return skip();
const vorlagen = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.wartungsvorlage.findMany();
});
expect(vorlagen.length).toBeGreaterThan(0);
expect(vorlagen.every((vorlage) => vorlage.organisationId === organisationId)).toBe(true);
});
it("lässt keine Vorlage für eine fremde Wehr anlegen", async ({ skip }) => {
if (!erreichbar) return skip();
const fremde = await system.organisation.upsert({
where: { slug: "test-pruefprotokoll-fremd" },
update: {},
create: { slug: "test-pruefprotokoll-fremd", name: "Fremde Wehr", kurzname: "FW" },
});
await expect(
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.wartungsvorlage.create({
data: { organisationId: fremde.id, name: "Schmuggelvorlage", intervallMonate: 12 },
});
}),
).rejects.toThrow();
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: "test-pruefprotokoll-fremd" } });
});
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import {
beanstandetePunkte,
beantwortet,
dauerInMinuten,
ergebnisVorschlag,
geraetStatusNachPruefung,
messwertInOrdnung,
offenePflichtpunkte,
punktInOrdnung,
type Antwort,
type Punkt,
} from "@/lib/wartung/bewertung";
/**
* Bewertung einer Prüfung.
*
* Der Kern ist eine einzige fachliche Regel: ein durchgefallener Pflichtpunkt
* bedeutet "nicht bestanden", ein durchgefallener Kann-Punkt "bestanden mit
* Mängeln". Daran hängt, ob ein Gerät im Einsatz sein darf — deshalb steht das
* hier so ausführlich unter Test.
*/
const SICHTPRUEFUNG: Punkt = {
id: "p1",
text: "Trageeinrichtung ohne Beschädigung",
typ: "JA_NEIN",
pflicht: true,
};
const WARNDRUCK: Punkt = {
id: "p2",
text: "Ansprechdruck der Warneinrichtung",
typ: "MESSWERT",
pflicht: true,
minWert: 50,
maxWert: 60,
};
const BEUTEL: Punkt = {
id: "p3",
text: "Leinenbeutel unbeschädigt",
typ: "JA_NEIN",
pflicht: false,
};
const BEFUND: Punkt = {
id: "p4",
text: "Auffälligkeiten",
typ: "TEXT",
pflicht: false,
};
const FOTO: Punkt = { id: "p5", text: "Foto nach der Reinigung", typ: "FOTO", pflicht: false };
function antwort(punktId: string, teil: Partial<Antwort> = {}): Antwort {
return { punktId, ...teil };
}
describe("messwertInOrdnung", () => {
it("prüft gegen beide Grenzen", () => {
expect(messwertInOrdnung(55, 50, 60)).toBe(true);
expect(messwertInOrdnung(50, 50, 60)).toBe(true);
expect(messwertInOrdnung(60, 50, 60)).toBe(true);
expect(messwertInOrdnung(49.9, 50, 60)).toBe(false);
expect(messwertInOrdnung(61, 50, 60)).toBe(false);
});
it("behandelt eine fehlende Grenze als offen", () => {
expect(messwertInOrdnung(300, 270, null)).toBe(true);
expect(messwertInOrdnung(0.2, null, 0.5)).toBe(true);
expect(messwertInOrdnung(0.8, null, 0.5)).toBe(false);
});
it("wertet einen fehlenden Wert nicht als in Ordnung", () => {
expect(messwertInOrdnung(null, 50, 60)).toBe(false);
expect(messwertInOrdnung(undefined)).toBe(false);
expect(messwertInOrdnung(Number.NaN, 0, 10)).toBe(false);
});
});
describe("beantwortet", () => {
it("erkennt jede Antwortart", () => {
expect(beantwortet(SICHTPRUEFUNG, antwort("p1", { jaNein: false }))).toBe(true);
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: 0 }))).toBe(true);
expect(beantwortet(BEFUND, antwort("p4", { text: "ohne Befund" }))).toBe(true);
expect(beantwortet(FOTO, antwort("p5", { fotoDateiId: "abc" }))).toBe(true);
});
it("erkennt fehlende Antworten", () => {
expect(beantwortet(SICHTPRUEFUNG, undefined)).toBe(false);
expect(beantwortet(SICHTPRUEFUNG, antwort("p1"))).toBe(false);
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: null }))).toBe(false);
expect(beantwortet(BEFUND, antwort("p4", { text: " " }))).toBe(false);
});
it("wertet die Null als Antwort — sie ist ein Messwert wie jeder andere", () => {
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: 0 }))).toBe(true);
});
});
describe("punktInOrdnung", () => {
it("folgt bei Ja/Nein der Antwort", () => {
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: true }))).toBe(true);
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: false }))).toBe(false);
});
it("rechnet bei Messwerten den Bereich aus", () => {
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 55 }))).toBe(true);
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 45 }))).toBe(false);
});
it("lässt die ausdrückliche Angabe des Prüfers gewinnen", () => {
// Wert in der Toleranz, der Prüfer beanstandet trotzdem.
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 55, inOrdnung: false }))).toBe(
false,
);
// Und umgekehrt.
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: false, inOrdnung: true }))).toBe(
true,
);
});
});
describe("offenePflichtpunkte", () => {
it("nennt nur unbeantwortete Pflichtpunkte", () => {
const offen = offenePflichtpunkte(
[SICHTPRUEFUNG, WARNDRUCK, BEUTEL],
[antwort("p1", { jaNein: true })],
);
expect(offen.map((punkt) => punkt.id)).toEqual(["p2"]);
});
it("ist leer, wenn alle Pflichtpunkte beantwortet sind", () => {
const offen = offenePflichtpunkte(
[SICHTPRUEFUNG, WARNDRUCK, BEUTEL],
[antwort("p1", { jaNein: true }), antwort("p2", { zahlwert: 55 })],
);
expect(offen).toHaveLength(0);
});
});
describe("ergebnisVorschlag", () => {
const punkte = [SICHTPRUEFUNG, WARNDRUCK, BEUTEL, BEFUND];
it("bestanden, wenn nichts beanstandet ist", () => {
const antworten = [
antwort("p1", { jaNein: true }),
antwort("p2", { zahlwert: 55 }),
antwort("p3", { jaNein: true }),
];
expect(ergebnisVorschlag(punkte, antworten)).toBe("BESTANDEN");
});
it("mit Mängeln, wenn nur ein Kann-Punkt durchfällt", () => {
const antworten = [
antwort("p1", { jaNein: true }),
antwort("p2", { zahlwert: 55 }),
antwort("p3", { jaNein: false }),
];
expect(ergebnisVorschlag(punkte, antworten)).toBe("BESTANDEN_MIT_MAENGELN");
expect(beanstandetePunkte(punkte, antworten).map((punkt) => punkt.id)).toEqual(["p3"]);
});
it("nicht bestanden, sobald ein Pflichtpunkt durchfällt", () => {
const antworten = [
antwort("p1", { jaNein: true }),
// Messwert außerhalb der Toleranz — Pflichtpunkt.
antwort("p2", { zahlwert: 42 }),
antwort("p3", { jaNein: false }),
];
expect(ergebnisVorschlag(punkte, antworten)).toBe("NICHT_BESTANDEN");
});
it("wertet unbeantwortete Punkte nicht als Beanstandung", () => {
// Sonst wäre jede halb ausgefüllte Prüfung sofort "nicht bestanden".
expect(ergebnisVorschlag(punkte, [antwort("p1", { jaNein: true })])).toBe("BESTANDEN");
});
});
describe("geraetStatusNachPruefung", () => {
it("sperrt nur bei nicht bestanden", () => {
expect(geraetStatusNachPruefung("NICHT_BESTANDEN")).toBe("GESPERRT");
expect(geraetStatusNachPruefung("BESTANDEN_MIT_MAENGELN")).toBe("EINSATZBEREIT");
expect(geraetStatusNachPruefung("BESTANDEN")).toBe("EINSATZBEREIT");
});
});
describe("dauerInMinuten", () => {
it("rundet auf volle Minuten", () => {
const beginn = new Date("2026-06-15T09:00:00.000Z");
const ende = new Date("2026-06-15T09:24:40.000Z");
expect(dauerInMinuten(beginn, ende)).toBe(25);
});
it("gibt ohne Beginn nichts zurück", () => {
expect(dauerInMinuten(null, new Date())).toBeNull();
});
it("liefert keine negative Dauer, wenn die Uhr gesprungen ist", () => {
const beginn = new Date("2026-06-15T10:00:00.000Z");
const ende = new Date("2026-06-15T09:00:00.000Z");
expect(dauerInMinuten(beginn, ende)).toBeNull();
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
erinnerungsstufe,
fensterGrenzen,
innerhalbPlanungshorizont,
naechsteFaelligkeit,
protokollNummer,
protokollNummerLesen,
terminFenster,
} from "@/lib/wartung/fristen";
/**
* Fristenlogik der Wartung.
*
* Alle Tests rechnen mit einem festen "heute", nicht mit der Systemuhr. Sonst
* wären sie am 31. Dezember rot und am 1. Januar wieder grün — und niemand
* wüsste, warum.
*/
/** Ein Kalendertag als UTC-Mitternacht, so wie er aus einer DATE-Spalte kommt. */
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
}
/** Mittags, damit die Zeitzonenumrechnung nicht zufällig aufgeht. */
const HEUTE = new Date("2026-06-15T12:00:00.000Z");
describe("terminFenster", () => {
it("erkennt Überfälligkeit ab dem Vortag", () => {
expect(terminFenster(tag(2026, 6, 14), HEUTE)).toBe("ueberfaellig");
expect(terminFenster(tag(2026, 1, 1), HEUTE)).toBe("ueberfaellig");
});
it("zählt den heutigen Tag zur laufenden Woche, nicht zur Überfälligkeit", () => {
expect(terminFenster(tag(2026, 6, 15), HEUTE)).toBe("diese_woche");
});
it("staffelt Woche, Monat und Quartal", () => {
expect(terminFenster(tag(2026, 6, 22), HEUTE)).toBe("diese_woche");
expect(terminFenster(tag(2026, 6, 23), HEUTE)).toBe("dieser_monat");
expect(terminFenster(tag(2026, 7, 15), HEUTE)).toBe("dieser_monat");
expect(terminFenster(tag(2026, 7, 16), HEUTE)).toBe("naechste_90_tage");
expect(terminFenster(tag(2026, 9, 13), HEUTE)).toBe("naechste_90_tage");
expect(terminFenster(tag(2026, 9, 14), HEUTE)).toBe("spaeter");
});
});
describe("fensterGrenzen", () => {
it("liefert die Grenzen als UTC-Kalendertage", () => {
const grenzen = fensterGrenzen(HEUTE);
expect(grenzen.heute.toISOString()).toBe("2026-06-15T00:00:00.000Z");
expect(grenzen.inSiebenTagen.toISOString()).toBe("2026-06-22T00:00:00.000Z");
expect(grenzen.inDreissigTagen.toISOString()).toBe("2026-07-15T00:00:00.000Z");
expect(grenzen.inNeunzigTagen.toISOString()).toBe("2026-09-13T00:00:00.000Z");
});
it("rechnet auch kurz nach Mitternacht Berliner Zeit den richtigen Tag", () => {
// 00:30 Uhr in Berlin ist nach UTC noch der Vortag.
const kurzNachMitternacht = new Date("2026-06-15T22:30:00.000Z");
expect(fensterGrenzen(kurzNachMitternacht).heute.toISOString()).toBe(
"2026-06-16T00:00:00.000Z",
);
});
});
describe("naechsteFaelligkeit", () => {
it("rechnet ab der letzten Prüfung", () => {
const faellig = naechsteFaelligkeit({
letztePruefung: tag(2026, 3, 10),
intervallMonate: 12,
});
expect(faellig?.toISOString()).toBe("2027-03-10T00:00:00.000Z");
});
it("nimmt ersatzweise die Indienststellung", () => {
const faellig = naechsteFaelligkeit({
indienststellung: tag(2024, 5, 1),
intervallMonate: 6,
});
expect(faellig?.toISOString()).toBe("2024-11-01T00:00:00.000Z");
});
it("klemmt überzählige Tage auf das Monatsende", () => {
const faellig = naechsteFaelligkeit({
letztePruefung: tag(2026, 1, 31),
intervallMonate: 1,
});
expect(faellig?.toISOString()).toBe("2026-02-28T00:00:00.000Z");
});
it("gibt ohne Anhaltspunkt oder Intervall nichts zurück", () => {
expect(naechsteFaelligkeit({ intervallMonate: 12 })).toBeNull();
expect(
naechsteFaelligkeit({ letztePruefung: tag(2026, 1, 1), intervallMonate: 0 }),
).toBeNull();
});
});
describe("erinnerungsstufe", () => {
it("meldet Überfälligkeit", () => {
expect(erinnerungsstufe(tag(2026, 6, 14), 30, HEUTE)).toBe("UEBERFAELLIG");
});
it("meldet den Fälligkeitstag selbst", () => {
expect(erinnerungsstufe(tag(2026, 6, 15), 30, HEUTE)).toBe("T_0");
});
it("staffelt die Vorwarnungen", () => {
expect(erinnerungsstufe(tag(2026, 6, 20), 30, HEUTE)).toBe("T_7");
expect(erinnerungsstufe(tag(2026, 7, 5), 30, HEUTE)).toBe("T_30");
expect(erinnerungsstufe(tag(2026, 8, 1), 30, HEUTE)).toBeNull();
});
it("richtet die erste Stufe nach der Vorwarnzeit der Vorlage", () => {
// 60 Tage Vorwarnzeit: ein Termin in 45 Tagen wird schon erinnert.
expect(erinnerungsstufe(tag(2026, 7, 30), 60, HEUTE)).toBe("T_30");
// Mit der Standardvorwarnzeit dagegen noch nicht.
expect(erinnerungsstufe(tag(2026, 7, 30), 30, HEUTE)).toBeNull();
});
it("erinnert auch bei spät angelegten Terminen — kleiner-gleich statt genau", () => {
// Der Tag T-30 ist längst vorbei, die erste Erinnerung darf trotzdem raus.
expect(erinnerungsstufe(tag(2026, 6, 30), 30, HEUTE)).toBe("T_30");
});
});
describe("innerhalbPlanungshorizont", () => {
it("plant nur, was in den Horizont fällt", () => {
expect(innerhalbPlanungshorizont(tag(2026, 9, 1), 180, HEUTE)).toBe(true);
expect(innerhalbPlanungshorizont(tag(2027, 6, 1), 180, HEUTE)).toBe(false);
});
it("bezieht Vergangenes immer ein — Überfälliges muss geplant bleiben", () => {
expect(innerhalbPlanungshorizont(tag(2025, 1, 1), 180, HEUTE)).toBe(true);
});
});
describe("Protokollnummern", () => {
it("füllt die laufende Nummer auf vier Stellen", () => {
expect(protokollNummer(2026, 1)).toBe("2026-0001");
expect(protokollNummer(2026, 137)).toBe("2026-0137");
});
it("liest die Nummer wieder auseinander", () => {
expect(protokollNummerLesen("2026-0042")).toEqual({ jahr: 2026, laufend: 42 });
expect(protokollNummerLesen("kaputt")).toBeNull();
expect(protokollNummerLesen(null)).toBeNull();
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
fruehesteFaelligkeit,
zuPlanendeTermine,
type PlanungsGeraet,
} from "@/lib/wartung/planung";
/**
* Terminplanung des nächtlichen Laufs.
*
* Der Lauf entscheidet, was im Fristen-Dashboard steht. Ein Fehler hier fällt
* niemandem auf — bis eine Prüfung fehlt, die hätte stattfinden müssen.
*/
const HEUTE = new Date("2026-06-15T02:15:00.000Z");
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
}
const VORLAGE = { id: "v1", intervallMonate: 12 };
function geraet(teil: Partial<PlanungsGeraet> = {}): PlanungsGeraet {
return {
id: "g1",
inventarnummer: "INV-0001",
status: "EINSATZBEREIT",
hatOffenenTermin: false,
...teil,
};
}
describe("zuPlanendeTermine", () => {
it("rechnet ab der letzten Prüfung nach dieser Vorlage", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(1);
expect(geplant[0]!.faelligAm.toISOString()).toBe("2026-08-01T00:00:00.000Z");
expect(geplant[0]!.ohneBasisdatum).toBe(false);
});
it("nimmt ersatzweise das Prüfdatum am Gerät, dann die Indienststellung", () => {
const ausGeraet = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefung: tag(2025, 7, 1), indienststellung: tag(2020, 1, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(ausGeraet[0]!.faelligAm.toISOString()).toBe("2026-07-01T00:00:00.000Z");
const ausIndienststellung = zuPlanendeTermine(
{ id: "v1", intervallMonate: 6 },
[geraet({ indienststellung: tag(2026, 2, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(ausIndienststellung[0]!.faelligAm.toISOString()).toBe("2026-08-01T00:00:00.000Z");
});
it("setzt Geräte ohne jedes Datum sofort fällig", () => {
const geplant = zuPlanendeTermine(VORLAGE, [geraet()], {
horizontTage: 180,
heute: HEUTE,
});
expect(geplant).toHaveLength(1);
expect(geplant[0]!.ohneBasisdatum).toBe(true);
expect(geplant[0]!.faelligAm.toISOString()).toBe("2026-06-15T00:00:00.000Z");
});
it("plant nichts für ausgesonderte Geräte", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ status: "AUSGESONDERT", letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(0);
});
it("plant gesperrte Geräte weiter — die sollen ja wieder in Dienst", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ status: "GESPERRT", letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(1);
});
it("legt keinen zweiten Termin an, solange einer offen ist", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ hatOffenenTermin: true, letztePruefungZurVorlage: tag(2025, 8, 1) })],
{ horizontTage: 180, heute: HEUTE },
);
expect(geplant).toHaveLength(0);
});
it("plant nur innerhalb des Horizonts", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefungZurVorlage: tag(2026, 5, 1) })],
// Fällig am 01.05.2027 — mit 30 Tagen Horizont noch nicht zu planen.
{ horizontTage: 30, heute: HEUTE },
);
expect(geplant).toHaveLength(0);
});
it("plant Überfälliges immer, egal wie lange es her ist", () => {
const geplant = zuPlanendeTermine(
VORLAGE,
[geraet({ letztePruefungZurVorlage: tag(2019, 1, 1) })],
{ horizontTage: 30, heute: HEUTE },
);
expect(geplant).toHaveLength(1);
expect(geplant[0]!.faelligAm.toISOString()).toBe("2020-01-01T00:00:00.000Z");
});
});
describe("fruehesteFaelligkeit", () => {
it("nimmt den dringendsten von mehreren Terminen", () => {
const frueheste = fruehesteFaelligkeit([
{ faelligAm: tag(2026, 9, 1) },
{ faelligAm: tag(2026, 7, 1) },
{ faelligAm: tag(2027, 1, 1) },
]);
expect(frueheste?.toISOString()).toBe("2026-07-01T00:00:00.000Z");
});
it("gibt ohne Termine nichts zurück", () => {
expect(fruehesteFaelligkeit([])).toBeNull();
});
});