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