feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung
zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines
anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt
beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und
`konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die
anderen neun bei jedem Onboarding weg.

Entscheidungen, alle im Issue begruendet:

- Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die
  Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht
  — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der
  Arbeit. Ein struktureller Test prueft, dass alle drei Schichten
  (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen.
- Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide
  Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und
  korrigierbar, ein fehlender faellt niemandem auf.
- OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein
  bedingter Abschnitt von Anfang an sichtbar.
- Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder.
- Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben
  eine Struktur neu und geben neue Kennungen.

Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein:

- Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das
  Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage
  verlor ihre Abteilungsabschnitte und galt dort fuer alle.
- Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage
  alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp,
  der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma
  abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie
  und Original Feld fuer Feld.

Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor
stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben
liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen.

1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen.

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-23 14:54:10 +02:00
co-authored by Claude Opus 5
parent 2f247b3617
commit a2330ef682
29 changed files with 3051 additions and 146 deletions
+335
View File
@@ -0,0 +1,335 @@
import { describe, expect, it } from "vitest";
import {
BedingungSchema,
type Bedingung,
bedingungErfuellt,
bedingungLesen,
bedingungText,
geltendeTeile,
umgebungBauen,
zeigtNachVorne,
} from "@/lib/bedingungen";
/**
* Bedingte Abschnitte und Schritte: die reine Logik (#039).
*
* Ohne Datenbank, weil hier alles entschieden wird, was zählt — und weil die
* Sonderfälle nur so vollständig durchgehen. Dass die Auswertung auch im Dienst,
* in der Abarbeitung und im Druck greift, steht in `tests/bedingte-laeufe.test.ts`.
*
* **Die wichtigste Zusage dieses Meilensteins:** eine Bedingung, die sich nicht
* entscheiden lässt, blendet **nichts** aus. Ein Abschnitt zu viel ist sichtbar
* und korrigierbar; ein fehlender fällt niemandem auf, und das ist bei einer
* Checkliste der teure Fall.
*/
const leer = umgebungBauen({}, []);
describe("Die Form einer Bedingung", () => {
it("nimmt die beiden Arten an", () => {
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" })
.success,
).toBe(true);
expect(
BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }).success,
).toBe(true);
});
it("verlangt bei „ist“ einen Wert", () => {
/*
* Ohne Wert wäre „gilt, wenn konto_typ gleich “ ist“ eine Bedingung, die
* niemand lesen kann — und sie träfe auf einen leeren Wert zu, also genau
* dann, wenn nichts angegeben wurde. Das ist nicht gemeint.
*/
for (const operator of ["ist", "istNicht"]) {
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator }).success,
operator,
).toBe(false);
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator, wert: " " }).success,
`${operator} mit Leerraum`,
).toBe(false);
}
// `gesetzt` und `leer` brauchen keinen.
expect(BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator: "gesetzt" }).success).toBe(
true,
);
});
it("verlangt mindestens einen Zustand", () => {
// Eine leere Liste wäre eine Bedingung, die nie zutrifft — also ein
// Abschnitt, der für immer unsichtbar bleibt.
expect(BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: [] }).success).toBe(false);
});
it("weist eine unbekannte Art und einen unbekannten Zustand ab", () => {
expect(BedingungSchema.safeParse({ art: "geraet", typ: "PC" }).success).toBe(false);
expect(
BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: ["VIELLEICHT"] }).success,
).toBe(false);
});
it("liest beim Lesen nachsichtig: was nicht passt, ist keine Bedingung", () => {
/*
* Hier zu werfen hieße, dass eine einzige unlesbare Zeile eine ganze Vorlage
* unbenutzbar macht. Die Richtung, in die der Fehler dann fällt, ist die
* harmlose: ohne Bedingung wird angezeigt.
*/
expect(bedingungLesen(null)).toBeNull();
expect(bedingungLesen(undefined)).toBeNull();
expect(bedingungLesen({})).toBeNull();
expect(bedingungLesen("Unsinn")).toBeNull();
expect(bedingungLesen({ art: "variable" })).toBeNull();
expect(bedingungLesen({ art: "variable", schluessel: "k", operator: "ist", wert: "AD" })).toEqual({
art: "variable",
schluessel: "k",
operator: "ist",
wert: "AD",
});
});
});
describe("Eine Bedingung auf einer Variable", () => {
const umgebung = umgebungBauen({ konto_typ: "AD", standort: "", anzahl: 3, aktiv: true }, []);
it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => {
// Die Werte kommen aus einem Auswahlfeld oder einem Formular; „AD“ ist
// derselbe Wert wie „ad“ und wie „ AD “.
const bedingung = (wert: string): Bedingung => ({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert,
});
expect(bedingungErfuellt(bedingung("AD"), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung("ad"), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung(" AD "), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung("ENTRA"), umgebung)).toBe(false);
});
it("liest Zahlen und Wahrheitswerte als Text", () => {
// `Lauf.variablen` ist ein Json; eine ZAHL-Variable kommt als Zahl zurück.
expect(
bedingungErfuellt({ art: "variable", schluessel: "anzahl", operator: "ist", wert: "3" }, umgebung),
).toBe(true);
expect(
bedingungErfuellt({ art: "variable", schluessel: "aktiv", operator: "ist", wert: "true" }, umgebung),
).toBe(true);
});
it("unterscheidet gesetzt von leer", () => {
expect(
bedingungErfuellt({ art: "variable", schluessel: "konto_typ", operator: "gesetzt" }, umgebung),
).toBe(true);
// Ein leerer Text zählt als nicht gesetzt — so kommt ein unausgefülltes
// Formularfeld an.
expect(
bedingungErfuellt({ art: "variable", schluessel: "standort", operator: "gesetzt" }, umgebung),
).toBe(false);
expect(bedingungErfuellt({ art: "variable", schluessel: "standort", operator: "leer" }, umgebung)).toBe(
true,
);
expect(bedingungErfuellt({ art: "variable", schluessel: "gibtsnicht", operator: "leer" }, umgebung)).toBe(
true,
);
});
it("ist bei einer nicht gesetzten Variable nicht entscheidbar", () => {
/*
* **Die Zusage dieses Meilensteins.** Ein Lauf, bei dem niemand `konto_typ`
* angegeben hat, soll beide Konto-Abschnitte zeigen: auszublenden hieße,
* eine Antwort zu erfinden, die niemand gegeben hat.
*
* `undefined` und nicht `false` — der Unterschied entscheidet in
* `geltendeTeile` darüber, ob ausgeblendet wird.
*/
expect(
bedingungErfuellt({ art: "variable", schluessel: "gibtsnicht", operator: "ist", wert: "AD" }, leer),
).toBeUndefined();
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "standort", operator: "istNicht", wert: "Nord" },
umgebung,
),
).toBeUndefined();
});
it("kehrt istNicht wirklich um", () => {
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "konto_typ", operator: "istNicht", wert: "ENTRA" },
umgebung,
),
).toBe(true);
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "konto_typ", operator: "istNicht", wert: "AD" },
umgebung,
),
).toBe(false);
});
});
describe("Eine Bedingung auf einem Schritt", () => {
const umgebung = umgebungBauen({}, [
{ vorlagenSchrittId: "s1", zustand: "ERLEDIGT" },
{ vorlagenSchrittId: "s2", zustand: "OFFEN" },
{ vorlagenSchrittId: "s3", zustand: "NICHT_ZUTREFFEND" },
]);
it("trifft zu, wenn der Zustand in der Liste steht", () => {
expect(bedingungErfuellt({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
true,
);
expect(
bedingungErfuellt(
{ art: "schritt", schrittId: "s3", zustaende: ["ERLEDIGT", "NICHT_ZUTREFFEND"] },
umgebung,
),
).toBe(true);
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
false,
);
});
it("liest OFFEN als Antwort und nicht als Lücke", () => {
/*
* **Der Unterschied zur Variable**, und er ist wichtig: jedes
* Schrittergebnis hat einen Zustand. `OFFEN` heißt „noch nicht erfüllt“ und
* nicht „weiß nicht“. Ein Abschnitt, der erst nach einem erledigten Schritt
* erscheint, ist der Sinn der Sache — wäre `OFFEN` nicht entscheidbar, wäre
* er von Anfang an sichtbar.
*/
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
false,
);
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["OFFEN"] }, umgebung)).toBe(true);
});
it("ist bei einem Schritt, den es in diesem Lauf nicht gibt, nicht entscheidbar", () => {
// Etwa ein Schritt, den #031 ausgefiltert hat, oder ein Verweis aus einer
// anderen Version. Dann gibt es nichts zu entscheiden – und nichts
// auszublenden.
expect(
bedingungErfuellt({ art: "schritt", schrittId: "gibtsnicht", zustaende: ["ERLEDIGT"] }, umgebung),
).toBeUndefined();
});
});
describe("Welche Teile gelten", () => {
it("nimmt Teile ohne Bedingung immer", () => {
expect(
geltendeTeile(
[
{ id: "a", bedingung: null },
{ id: "b", bedingung: {} },
],
leer,
),
).toEqual(["a", "b"]);
});
it("blendet aus, was nachweislich nicht zutrifft", () => {
const umgebung = umgebungBauen({ konto_typ: "AD" }, []);
const teile = [
{ id: "ad", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" } },
{
id: "entra",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
},
{ id: "immer", bedingung: null },
];
expect(geltendeTeile(teile, umgebung)).toEqual(["ad", "immer"]);
});
it("blendet bei nicht entscheidbarer Bedingung nichts aus", () => {
/*
* Der echte Fall: ein Lauf ohne `konto_typ`. Beide Konto-Abschnitte
* erscheinen, und jemand setzt den falschen auf NICHT_ZUTREFFEND — genau so,
* wie es vor diesem Meilenstein für **jeden** Lauf war. Eine Verschlechterung
* ist das also nicht; eine erfundene Antwort wäre eine.
*/
const teile = [
{ id: "ad", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" } },
{
id: "entra",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
},
];
expect(geltendeTeile(teile, leer)).toEqual(["ad", "entra"]);
});
it("behält die Reihenfolge der Eingabe", () => {
// Die Reihenfolge ist die der Version; sie darf hier nicht durcheinander
// geraten, sonst stimmt die Abschnittsnummerierung nicht.
const teile = [
{ id: "eins", bedingung: null },
{ id: "zwei", bedingung: { art: "variable", schluessel: "x", operator: "gesetzt" } },
{ id: "drei", bedingung: null },
];
expect(geltendeTeile(teile, umgebungBauen({ x: "ja" }, []))).toEqual(["eins", "zwei", "drei"]);
});
});
describe("Hilfen für den Editor", () => {
const reihenfolge = ["s1", "s2", "s3", "s4"];
it("erkennt eine Bedingung, die nach vorne zeigt", () => {
/*
* Fast immer ein Versehen: der bedingte Abschnitt wäre zunächst unsichtbar
* und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also
* rückwärts.
*/
expect(
zeigtNachVorne({ art: "schritt", schrittId: "s4", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBe(true);
expect(
zeigtNachVorne({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBe(false);
});
it("warnt nicht, wo es nichts zu warnen gibt", () => {
expect(
zeigtNachVorne({ art: "variable", schluessel: "k", operator: "gesetzt" }, reihenfolge, "s2"),
).toBeUndefined();
expect(
zeigtNachVorne({ art: "schritt", schrittId: "fremd", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBeUndefined();
});
it("schreibt eine Bedingung als Satz", () => {
const namen = (kennung: string) =>
({ konto_typ: "Kontotyp", s1: "Benutzer anlegen" })[kennung] ?? kennung;
expect(
bedingungText({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, namen),
).toBe("gilt, wenn „Kontotyp“ gleich „AD“ ist");
expect(bedingungText({ art: "variable", schluessel: "konto_typ", operator: "leer" }, namen)).toBe(
"gilt, wenn „Kontotyp“ leer ist",
);
expect(
bedingungText({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT", "NICHT_ZUTREFFEND"] }, namen),
).toBe("gilt, wenn „Benutzer anlegen“ erledigt oder nicht zutreffend ist");
});
});
describe("Die Umgebung", () => {
it("verträgt ein Json, das kein Objekt ist", () => {
// `Lauf.variablen` ist ein Json-Feld und kann alles enthalten.
for (const roh of [null, undefined, "Text", 42, ["a"]]) {
expect(umgebungBauen(roh, []).variablen).toEqual({});
}
});
it("übergeht einen Zustand, den das Enum nicht kennt", () => {
// Eine Zeile aus einer späteren Fassung soll die Auswertung nicht anhalten.
const umgebung = umgebungBauen({}, [
{ vorlagenSchrittId: "s1", zustand: "ERLEDIGT" },
{ vorlagenSchrittId: "s2", zustand: "WARTET_AUF_AKTION" },
]);
expect(umgebung.zustaende.get("s1")).toBe("ERLEDIGT");
expect(umgebung.zustaende.has("s2")).toBe(false);
});
});