Files
Laufzettel/tests/bedingungen.test.ts
T
Florian WildeandClaude Opus 5 a2330ef682
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(vorlagen): bedingte Abschnitte und Schritte (#039)
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>
2026-10-23 14:54:10 +02:00

336 lines
12 KiB
TypeScript
Raw 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 {
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);
});
});