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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2f247b3617
commit
a2330ef682
@@ -0,0 +1,721 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { SchrittZustand, VariablenTyp, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { laufZumDrucken } from "@/server/queries/druck";
|
||||
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
|
||||
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
|
||||
import { versionMitAufbau } from "@/server/queries/vorlagen";
|
||||
import {
|
||||
entwurfHolenOderErzeugen,
|
||||
entwurfSpeichern,
|
||||
versionVeroeffentlichen,
|
||||
vorlageAnlegen,
|
||||
vorlageDuplizieren,
|
||||
} from "@/server/services/vorlagen";
|
||||
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Bedingte Abschnitte und Schritte gegen die Datenbank (#039).
|
||||
*
|
||||
* Die reine Auswertung steht in `tests/bedingungen.test.ts`. Hier geht es um die
|
||||
* Frage, die eine reine Funktion nicht beantworten kann: **greift sie überall?**
|
||||
*
|
||||
* Drei Schichten brauchen sie — der Dienst beim Abschluss, das Lesemodell der
|
||||
* Abarbeitung und das der Druckansicht. Vergisst eine davon die Auswertung,
|
||||
* sieht der Techniker andere Schritte als der Ausdruck, oder ein unsichtbarer
|
||||
* Pflichtschritt hält den Abschluss auf. Beides fällt in einer Suite nicht auf,
|
||||
* die nur die Logik prüft.
|
||||
*/
|
||||
|
||||
type AbschnittsVorgabe = {
|
||||
readonly titel: string;
|
||||
readonly bedingung?: unknown;
|
||||
readonly schritte: readonly {
|
||||
readonly titel: string;
|
||||
readonly pflicht?: boolean;
|
||||
readonly bedingung?: unknown;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt eine Vorlage an und veröffentlicht sie.
|
||||
*
|
||||
* `nachtragen` wird mit den Schritt-Kennungen gerufen, **bevor** veröffentlicht
|
||||
* wird — genau wie im Editor: eine Bedingung auf einem Schritt braucht dessen
|
||||
* Kennung, und eine veröffentlichte Version ist unveränderlich (#009). Der
|
||||
* Trigger `…_bedingung_gleiche_version` verlangt zusätzlich, dass der Schritt
|
||||
* zur selben Version gehört.
|
||||
*/
|
||||
async function aufbau(
|
||||
abschnitte: readonly AbschnittsVorgabe[],
|
||||
nachtragen?: (schrittIds: ReadonlyMap<string, string>, versionId: string) => Promise<void>,
|
||||
) {
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||||
const admin = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||
});
|
||||
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
await testPrisma.vorlagenVariable.create({
|
||||
data: {
|
||||
versionId: version.id,
|
||||
schluessel: "konto_typ",
|
||||
beschriftung: "Kontotyp",
|
||||
typ: VariablenTyp.AUSWAHL,
|
||||
optionen: ["AD", "ENTRA"],
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const schrittIds = new Map<string, string>();
|
||||
for (const [position, abschnitt] of abschnitte.entries()) {
|
||||
const angelegt = await testPrisma.vorlagenAbschnitt.create({
|
||||
data: {
|
||||
versionId: version.id,
|
||||
titel: abschnitt.titel,
|
||||
position,
|
||||
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung as never }),
|
||||
},
|
||||
});
|
||||
for (const [stelle, schritt] of abschnitt.schritte.entries()) {
|
||||
const angelegterSchritt = await testPrisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: angelegt.id,
|
||||
titel: schritt.titel,
|
||||
pflicht: schritt.pflicht ?? false,
|
||||
position: stelle,
|
||||
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung as never }),
|
||||
},
|
||||
});
|
||||
schrittIds.set(schritt.titel, angelegterSchritt.id);
|
||||
}
|
||||
}
|
||||
await nachtragen?.(schrittIds, version.id);
|
||||
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
kunde,
|
||||
admin: { id: admin.id, rolle: admin.rolle },
|
||||
versionId: version.id,
|
||||
schrittIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Der echte Fall aus dem Seed: beide Konto-Abschnitte, eine Variable entscheidet. */
|
||||
const BEIDE_KONTEN: readonly AbschnittsVorgabe[] = [
|
||||
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
|
||||
{
|
||||
titel: "Konto anlegen (Active Directory)",
|
||||
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
|
||||
schritte: [
|
||||
{ titel: "New-ADUser", pflicht: true },
|
||||
{ titel: "Gruppen setzen", pflicht: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: "Konto anlegen (Entra ID)",
|
||||
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
|
||||
schritte: [{ titel: "New-MgUser", pflicht: true }],
|
||||
},
|
||||
];
|
||||
|
||||
const titel = (abschnitte: readonly { readonly titel: string }[]): readonly string[] =>
|
||||
abschnitte.map((abschnitt) => abschnitt.titel);
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit bedingten Abschnitten", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("zeigt bei konto_typ = AD nur den AD-Abschnitt", async () => {
|
||||
/*
|
||||
* **Der Fall, für den dieser Meilenstein da ist.** Die Onboarding-Vorlage im
|
||||
* Seed trägt beide Konto-Abschnitte, und `konto_typ` sagt längst, welcher
|
||||
* gilt — bis hierher wertete das niemand aus, und ein Techniker klickte neun
|
||||
* Schritte weg.
|
||||
*/
|
||||
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
|
||||
|
||||
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
|
||||
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Active Directory)"]);
|
||||
});
|
||||
|
||||
it("zeigt bei konto_typ = ENTRA nur den Entra-Abschnitt", async () => {
|
||||
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
|
||||
admin,
|
||||
);
|
||||
|
||||
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
|
||||
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Entra ID)"]);
|
||||
});
|
||||
|
||||
it("zeigt bei nicht gesetzter Variable beide – und blendet nichts aus", async () => {
|
||||
/*
|
||||
* Eine Bedingung, die sich nicht entscheiden lässt, blendet nichts aus. Das
|
||||
* ist genau das Verhalten von **vor** diesem Meilenstein, für jeden Lauf —
|
||||
* eine Verschlechterung ist es also nicht. Eine erfundene Antwort wäre eine.
|
||||
*/
|
||||
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||||
|
||||
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
|
||||
expect(titel(daten!.abschnitte)).toEqual([
|
||||
"Vorbereitung",
|
||||
"Konto anlegen (Active Directory)",
|
||||
"Konto anlegen (Entra ID)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("nummeriert die sichtbaren Abschnitte lückenlos", async () => {
|
||||
/*
|
||||
* `01 · Vorbereitung` zählt, was der Techniker **sieht**. Ein ausgeblendeter
|
||||
* Abschnitt darf keine Lücke hinterlassen — die Nummer kommt aus der
|
||||
* Position und wird nie gespeichert (CLAUDE.md, „Gestaltung“).
|
||||
*/
|
||||
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
|
||||
admin,
|
||||
);
|
||||
|
||||
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
|
||||
expect(daten!.abschnitte.map((abschnitt) => abschnitt.nummer)).toEqual(["01", "02"]);
|
||||
});
|
||||
|
||||
it("hält den Abschluss nicht an einem ausgeblendeten Pflichtschritt auf", async () => {
|
||||
/*
|
||||
* **Die zweite Kernzusage.** Der Entra-Abschnitt ist ausgeblendet, sein
|
||||
* Schritt ist Pflicht. Blockierte er, wäre der Lauf unabschließbar: abhaken
|
||||
* lässt sich nur, was in der Abarbeitung steht.
|
||||
*/
|
||||
const { admin, versionId, schrittIds } = await aufbau(BEIDE_KONTEN);
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
|
||||
|
||||
for (const name of ["Papiere", "New-ADUser", "Gruppen setzen"]) {
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: schrittIds.get(name)!, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(laufAbschliessen(lauf.id, admin)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("zeigt im Druck genau dieselben Abschnitte wie in der Abarbeitung", async () => {
|
||||
/*
|
||||
* Ein Ausdruck, der andere Schritte zeigt als der Bildschirm, ist das
|
||||
* Gegenteil eines Beweismittels. Geprüft wird deshalb gegen die **andere**
|
||||
* Schicht und nicht gegen eine erwartete Liste.
|
||||
*/
|
||||
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
|
||||
|
||||
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin, "Tester");
|
||||
const druck = await laufZumDrucken(lauf.id, admin);
|
||||
expect(titel(druck!.abschnitte)).toEqual(titel(abarbeitung!.abschnitte));
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Eine Bedingung auf einem Schrittergebnis", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
/**
|
||||
* Setzt am Abschnitt „Nacharbeit“ die Bedingung „Prüfen ist fehlgeschlagen“.
|
||||
*
|
||||
* Als Nachtrag im **Entwurf**, weil die Bedingung die Kennung des Schritts
|
||||
* braucht und der erst beim Anlegen entsteht. Danach wird veröffentlicht, und
|
||||
* danach ändert sich an der Version nichts mehr (#009).
|
||||
*/
|
||||
const nacharbeitWennFehlgeschlagen = async (
|
||||
schrittIds: ReadonlyMap<string, string>,
|
||||
versionId: string,
|
||||
): Promise<void> => {
|
||||
await testPrisma.vorlagenAbschnitt.updateMany({
|
||||
where: { versionId, titel: "Nacharbeit" },
|
||||
data: {
|
||||
bedingung: {
|
||||
art: "schritt",
|
||||
schrittId: schrittIds.get("Prüfen")!,
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("erscheint erst, wenn die Bedingung während des Laufs umschlägt", async () => {
|
||||
/*
|
||||
* **Der Grund, warum laufend ausgewertet wird und nicht beim Start.** Die
|
||||
* Antwort auf „ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit;
|
||||
* festgeschrieben wie bei #031 wäre sie beim Start immer „nein“.
|
||||
*/
|
||||
// Erst anlegen, um die Schritt-Kennung zu bekommen, dann die Bedingung setzen:
|
||||
// der Trigger verlangt einen Schritt derselben Version.
|
||||
const { admin, versionId, schrittIds } = await aufbau(
|
||||
[
|
||||
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
|
||||
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
|
||||
],
|
||||
nacharbeitWennFehlgeschlagen,
|
||||
);
|
||||
const pruefenId = schrittIds.get("Prüfen")!;
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||||
|
||||
// Zu Beginn ist „Prüfen“ OFFEN. Das ist eine **Antwort**, nicht eine Lücke:
|
||||
// der Abschnitt gilt nicht.
|
||||
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
|
||||
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: pruefenId,
|
||||
zustand: SchrittZustand.FEHLGESCHLAGEN,
|
||||
notiz: "Sicherung nicht lesbar",
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual([
|
||||
"Prüfung",
|
||||
"Nacharbeit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lässt ein Schrittergebnis mit Notiz stehen, wenn seine Bedingung umschlägt", async () => {
|
||||
/*
|
||||
* Ausgeblendet heißt **nicht** gelöscht. Jemand hat eine Notiz geschrieben;
|
||||
* sie zu verlieren, weil eine Bedingung kippt, wäre Datenverlust durch eine
|
||||
* Anzeigeregel. Schlägt die Bedingung zurück, ist alles wieder da.
|
||||
*/
|
||||
const { admin, versionId, schrittIds } = await aufbau(
|
||||
[
|
||||
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
|
||||
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen" }] },
|
||||
],
|
||||
nacharbeitWennFehlgeschlagen,
|
||||
);
|
||||
const pruefenId = schrittIds.get("Prüfen")!;
|
||||
const ticketId = schrittIds.get("Ticket aufmachen")!;
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
|
||||
admin,
|
||||
);
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: ticketId, zustand: SchrittZustand.ERLEDIGT, notiz: "TIC-4711" },
|
||||
admin,
|
||||
);
|
||||
|
||||
// Bedingung kippt: der Abschnitt verschwindet aus der Ansicht …
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.ERLEDIGT },
|
||||
admin,
|
||||
);
|
||||
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
|
||||
|
||||
// … die Zeile bleibt aber in der Datenbank.
|
||||
const zeile = await testPrisma.schrittergebnis.findFirstOrThrow({
|
||||
where: { laufId: lauf.id, vorlagenSchrittId: ticketId },
|
||||
});
|
||||
expect(zeile.notiz).toBe("TIC-4711");
|
||||
|
||||
// Und schlägt sie zurück, ist alles wieder da.
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "y" },
|
||||
admin,
|
||||
);
|
||||
const wieder = await laufZumAbarbeiten(lauf.id, admin, "T");
|
||||
expect(titel(wieder!.abschnitte)).toEqual(["Prüfung", "Nacharbeit"]);
|
||||
expect(wieder!.abschnitte[1]?.schritte[0]?.notiz).toBe("TIC-4711");
|
||||
});
|
||||
|
||||
it("hält den Abschluss auf, solange ein sichtbarer Pflichtschritt offen ist", async () => {
|
||||
// Die Gegenrichtung, und sie ist nötig: eine Abschlussregel, die **immer**
|
||||
// durchlässt, sähe in den Tests oben genauso aus.
|
||||
const { admin, versionId, schrittIds } = await aufbau(
|
||||
[
|
||||
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
|
||||
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
|
||||
],
|
||||
nacharbeitWennFehlgeschlagen,
|
||||
);
|
||||
const pruefenId = schrittIds.get("Prüfen")!;
|
||||
|
||||
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
|
||||
admin,
|
||||
);
|
||||
|
||||
// „Ticket aufmachen“ ist jetzt sichtbar und Pflicht – der Abschluss wartet.
|
||||
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
|
||||
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Ticket aufmachen/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Eine Bedingung an einem einzelnen Schritt", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("blendet den Schritt aus, nicht den ganzen Abschnitt", async () => {
|
||||
const { admin, versionId } = await aufbau([
|
||||
{
|
||||
titel: "Konto",
|
||||
schritte: [
|
||||
{ titel: "Immer" },
|
||||
{
|
||||
titel: "Nur bei AD",
|
||||
pflicht: true,
|
||||
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
|
||||
admin,
|
||||
);
|
||||
|
||||
const daten = await laufZumAbarbeiten(lauf.id, admin, "T");
|
||||
expect(titel(daten!.abschnitte)).toEqual(["Konto"]);
|
||||
expect(daten!.abschnitte[0]?.schritte.map((schritt) => schritt.titel)).toEqual(["Immer"]);
|
||||
// Und der ausgeblendete Pflichtschritt hält den Abschluss nicht auf.
|
||||
expect(daten!.anzahlPflichtOffen).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Der Weg durch den Editor", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
it("übersteht Speichern, Neuladen und Veröffentlichen", async () => {
|
||||
/*
|
||||
* **Der Weg, auf dem eine Bedingung verloren gehen würde.**
|
||||
* `entwurfSpeichern` löscht den Entwurf und schreibt ihn neu — die Schritte
|
||||
* bekommen dabei neue Kennungen. Eine Bedingung, die auf eine Kennung zeigt,
|
||||
* überlebt das nicht; in der Struktur steht deshalb ein **Titel-Pfad**, so
|
||||
* wie in einer Austauschdatei, und derselbe Code löst beide auf.
|
||||
*
|
||||
* Dreimal gespeichert, weil einmal nichts beweist: der Pfad muss **jedes**
|
||||
* Neuschreiben überstehen.
|
||||
*/
|
||||
const kunde = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||||
const redakteur = await testPrisma.benutzer.create({
|
||||
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
|
||||
});
|
||||
const handelnder = { id: redakteur.id, rolle: redakteur.rolle };
|
||||
|
||||
const { vorlage } = await vorlageAnlegen({ name: "Mit Bedingung" }, redakteur.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
|
||||
|
||||
const struktur = {
|
||||
variablen: [
|
||||
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
|
||||
],
|
||||
abschnitte: [
|
||||
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
|
||||
{
|
||||
titel: "Nacharbeit",
|
||||
bedingung: {
|
||||
art: "schritt",
|
||||
abschnitt: "Prüfung",
|
||||
schritt: "Prüfen",
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
},
|
||||
schritte: [
|
||||
{
|
||||
titel: "Nur bei AD",
|
||||
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
for (let runde = 1; runde <= 3; runde += 1) {
|
||||
await entwurfSpeichern(entwurf.id, struktur as never);
|
||||
|
||||
// Zurückgelesen steht wieder der Pfad da, nicht die Kennung.
|
||||
const gelesen = await versionMitAufbau(entwurf.id, handelnder);
|
||||
expect(gelesen!.struktur.abschnitte[1]?.bedingung, `Runde ${runde}`).toEqual({
|
||||
art: "schritt",
|
||||
abschnitt: "Prüfung",
|
||||
schritt: "Prüfen",
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
});
|
||||
expect(gelesen!.struktur.abschnitte[1]?.schritte[0]?.bedingung).toEqual({
|
||||
art: "variable",
|
||||
schluessel: "konto_typ",
|
||||
operator: "ist",
|
||||
wert: "AD",
|
||||
});
|
||||
|
||||
// Und in der Datenbank steht die **aktuelle** Kennung.
|
||||
const zeilen = await testPrisma.vorlagenAbschnitt.findMany({
|
||||
where: { versionId: entwurf.id },
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: true },
|
||||
});
|
||||
expect(zeilen[1]?.bedingung).toMatchObject({
|
||||
art: "schritt",
|
||||
schrittId: zeilen[0]!.schritte[0]!.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Veröffentlichen lässt die Bedingung stehen – und ein Lauf wertet sie aus.
|
||||
await versionVeroeffentlichen(entwurf.id, null, redakteur.id);
|
||||
const lauf = await laufAnlegen(
|
||||
{ vorlagenVersionId: entwurf.id, kundeId: kunde.id, variablen: { konto_typ: "AD" } },
|
||||
handelnder,
|
||||
);
|
||||
expect(titel((await laufZumAbarbeiten(lauf.id, handelnder, "T"))!.abschnitte)).toEqual(["Prüfung"]);
|
||||
});
|
||||
|
||||
it("lässt eine Bedingung weg, deren Titel-Pfad es nicht gibt", async () => {
|
||||
/*
|
||||
* Ein Abschnitt, dessen Zielschritt gelöscht wurde: beim nächsten Speichern
|
||||
* findet der Pfad nichts. Weggelassen heißt „gilt immer“ — sichtbar in der
|
||||
* Oberfläche. Eine Bedingung mit einem Verweis ins Leere wäre dasselbe
|
||||
* Ergebnis, nur unerklärlich.
|
||||
*/
|
||||
await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||||
const redakteur = await testPrisma.benutzer.create({
|
||||
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
|
||||
});
|
||||
|
||||
const { vorlage } = await vorlageAnlegen({ name: "Verwaister Verweis" }, redakteur.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
|
||||
await entwurfSpeichern(entwurf.id, {
|
||||
variablen: [],
|
||||
abschnitte: [
|
||||
{ titel: "Eins", schritte: [{ titel: "A" }] },
|
||||
{
|
||||
titel: "Zwei",
|
||||
bedingung: {
|
||||
art: "schritt",
|
||||
abschnitt: "Gibt es nicht",
|
||||
schritt: "Auch nicht",
|
||||
zustaende: ["ERLEDIGT"],
|
||||
},
|
||||
schritte: [{ titel: "B" }],
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||
where: { versionId: entwurf.id, titel: "Zwei" },
|
||||
});
|
||||
expect(zeile.bedingung).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Die tiefe Kopie", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
});
|
||||
|
||||
/**
|
||||
* Legt eine veröffentlichte Version mit **allen** Struktureigenschaften an.
|
||||
*
|
||||
* Absichtlich vollgepackt: der Test darunter vergleicht Kopie und Original
|
||||
* Feld für Feld, und was hier fehlt, prüft er nicht.
|
||||
*/
|
||||
async function reicheVersion() {
|
||||
await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||||
const redakteur = await testPrisma.benutzer.create({
|
||||
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
|
||||
});
|
||||
|
||||
const { vorlage } = await vorlageAnlegen({ name: "Vollgepackt" }, redakteur.id);
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
|
||||
await entwurfSpeichern(entwurf.id, {
|
||||
variablen: [
|
||||
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
|
||||
],
|
||||
abschnitte: [
|
||||
{
|
||||
titel: "Prüfung",
|
||||
nurFuerAbteilungen: ["Buchhaltung"],
|
||||
schritte: [
|
||||
{
|
||||
titel: "Prüfen",
|
||||
pflicht: true,
|
||||
hilfetext: "Mit Blick aufs Protokoll.",
|
||||
dokuUrl: "https://intern.example/pruefen",
|
||||
typ: "ZAHL",
|
||||
konfig: { min: 1, max: 9, einheit: "GB" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: "Nacharbeit",
|
||||
nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"],
|
||||
bedingung: {
|
||||
art: "schritt",
|
||||
abschnitt: "Prüfung",
|
||||
schritt: "Prüfen",
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
},
|
||||
schritte: [
|
||||
{
|
||||
titel: "Nur bei AD",
|
||||
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
await versionVeroeffentlichen(entwurf.id, "Erste", redakteur.id);
|
||||
return { vorlageId: vorlage.id, versionId: entwurf.id, redakteurId: redakteur.id };
|
||||
}
|
||||
|
||||
/** Die Struktur einer Version, ohne alles, was sich bei einer Kopie ändern darf. */
|
||||
async function struktur(versionId: string) {
|
||||
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
});
|
||||
// Kennungen wandern bei einer Kopie – verglichen wird der Inhalt.
|
||||
const ohneKennung = (bedingung: unknown): unknown => {
|
||||
if (typeof bedingung !== "object" || bedingung === null) return bedingung;
|
||||
const kopie = { ...(bedingung as Record<string, unknown>) };
|
||||
if ("schrittId" in kopie) kopie["schrittId"] = "<verweist>";
|
||||
return kopie;
|
||||
};
|
||||
return abschnitte.map((abschnitt) => ({
|
||||
titel: abschnitt.titel,
|
||||
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
|
||||
bedingung: ohneKennung(abschnitt.bedingung),
|
||||
schritte: abschnitt.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
bedingung: ohneKennung(schritt.bedingung),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
it("ist bei „Bearbeiten“ inhaltsgleich – mit allem", async () => {
|
||||
/*
|
||||
* **Der Test, der zwei stille Datenverluste gefunden hätte.** Die tiefe
|
||||
* Kopie nahm `nurFuerAbteilungen` (seit #031) und `bedingung` (seit #039)
|
||||
* nicht mit: „Bearbeiten“ verlor alle Abteilungseinschränkungen, die neue
|
||||
* Version galt für jeden Lauf. Also mehr Schritte statt weniger, und niemand
|
||||
* merkt es.
|
||||
*
|
||||
* Die Ursache war ein von Hand getippter Zwischentyp, der weniger Felder
|
||||
* nannte als die Abfrage liefert. Gefunden erst bei der Klickprobe am
|
||||
* gebauten Stand.
|
||||
*
|
||||
* Verglichen wird deshalb **Feld für Feld und nicht stichprobenweise**: so
|
||||
* fällt auch das nächste Feld auf, das jemand am Modell ergänzt und beim
|
||||
* Kopieren vergisst.
|
||||
*/
|
||||
const { vorlageId, versionId, redakteurId } = await reicheVersion();
|
||||
const vorher = await struktur(versionId);
|
||||
|
||||
// „Bearbeiten“ in der Oberfläche ruft `entwurfHolenOderErzeugen`: das ist
|
||||
// die tiefe Kopie der letzten veröffentlichten Version (#009).
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
|
||||
expect(await struktur(entwurf.id)).toEqual(vorher);
|
||||
});
|
||||
|
||||
it("ist beim Duplizieren inhaltsgleich – mit allem", async () => {
|
||||
// Dieselbe Prüfung für den zweiten Weg: er hatte denselben Fehler, weil er
|
||||
// dieselbe Kopie ausgeschrieben hatte. Jetzt teilen beide eine Stelle.
|
||||
const { vorlageId, versionId, redakteurId } = await reicheVersion();
|
||||
const vorher = await struktur(versionId);
|
||||
|
||||
const kopie = await vorlageDuplizieren(vorlageId, "Vollgepackt (Kopie)", redakteurId);
|
||||
const neueVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({
|
||||
where: { vorlageId: kopie.id },
|
||||
});
|
||||
expect(await struktur(neueVersion.id)).toEqual(vorher);
|
||||
});
|
||||
|
||||
it("schreibt die Schritt-Bedingung auf die neue Kennung um", async () => {
|
||||
/*
|
||||
* Die Kennungen sind neu; ein Verweis auf die alte wäre nach dem
|
||||
* Veröffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
|
||||
* stillschweigend immer. Der Trigger in der Datenbank verlangt dasselbe.
|
||||
*/
|
||||
const { vorlageId, redakteurId } = await reicheVersion();
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
|
||||
|
||||
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
|
||||
where: { versionId: entwurf.id },
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: true },
|
||||
});
|
||||
expect(abschnitte[1]?.bedingung).toMatchObject({
|
||||
art: "schritt",
|
||||
// Die Kennung des Schritts der **neuen** Version.
|
||||
schrittId: abschnitte[0]!.schritte[0]!.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Auswertung sitzt an jeder Stelle, die filtert", () => {
|
||||
it("ruft geltendeStruktur überall, wo nurGeltende gerufen wird", () => {
|
||||
/*
|
||||
* **Strukturell geprüft, weil eine vergessene Stelle nicht auffällt.** Drei
|
||||
* Schichten dampfen die Struktur eines Laufs ein: der Dienst beim Abschluss,
|
||||
* die Abarbeitung und die Druckansicht. Vergisst eine davon die Bedingungen,
|
||||
* sieht der Techniker andere Schritte als der Ausdruck — oder ein
|
||||
* unsichtbarer Pflichtschritt hält den Abschluss auf.
|
||||
*
|
||||
* Dieselbe Art Test wie „jede Seite prüft selbst“ (#038): die Zusage steht
|
||||
* nicht in einer Prüfung, sondern in der Vollständigkeit einer Liste, und die
|
||||
* muss jemand bewachen.
|
||||
*/
|
||||
const dateien = [
|
||||
"src/server/services/laeufe.ts",
|
||||
"src/server/queries/laeufe.ts",
|
||||
"src/server/queries/druck.ts",
|
||||
];
|
||||
const befunde: string[] = [];
|
||||
for (const datei of dateien) {
|
||||
const quelle = readFileSync(datei, "utf8");
|
||||
if (!quelle.includes("nurGeltende(")) {
|
||||
befunde.push(`${datei} ruft nurGeltende nicht mehr – ist die Liste noch richtig?`);
|
||||
continue;
|
||||
}
|
||||
if (!quelle.includes("geltendeStruktur(")) {
|
||||
befunde.push(`${datei} filtert nach #031, wertet aber keine Bedingungen aus (#039).`);
|
||||
}
|
||||
}
|
||||
expect(befunde).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,17 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
*/
|
||||
"Mitarbeiter_konto_mitziehen",
|
||||
"VorlagenAbschnitt_baustein_veroeffentlicht",
|
||||
/*
|
||||
* Eine Schritt-Bedingung zeigt auf einen Schritt **derselben Version**
|
||||
* (#039). Ein CHECK kann das nicht: es verlangt einen Blick in zwei andere
|
||||
* Tabellen. Ohne diese Pruefung zeigt eine Bedingung nach dem
|
||||
* Veroeffentlichen ins Leere — „Bearbeiten“ erzeugt eine tiefe Kopie mit
|
||||
* neuen Kennungen —, und der Abschnitt erscheint dann stillschweigend
|
||||
* immer.
|
||||
*/
|
||||
"VorlagenAbschnitt_bedingung_gleiche_version",
|
||||
"VorlagenAbschnitt_unveraenderlich",
|
||||
"VorlagenSchritt_bedingung_gleiche_version",
|
||||
"VorlagenSchritt_unveraenderlich",
|
||||
"VorlagenVariable_unveraenderlich",
|
||||
"VorlagenVersion_status_einbahn",
|
||||
@@ -159,6 +169,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
"Verzeichnisquelle_scimtoken_gehasht",
|
||||
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
|
||||
"Verzeichnisquelle_scimtoken_mit_zeitpunkt",
|
||||
/*
|
||||
* Die Form einer Bedingung (#039). Eine mit unbekannter `art` wuerde beim
|
||||
* Lesen als „keine Bedingung“ gelten — der Abschnitt waere sichtbar, und
|
||||
* niemand koennte sagen, warum seine Bedingung nicht wirkt.
|
||||
*/
|
||||
"VorlagenAbschnitt_bedingung_form",
|
||||
"VorlagenSchritt_bedingung_form",
|
||||
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||
"Zustellung_bezug_passt_zum_anlass",
|
||||
"Zustellung_empfaenger_nicht_leer",
|
||||
|
||||
+40
-6
@@ -150,13 +150,47 @@ describe("Die sieben Vorlagen", () => {
|
||||
expect(gruppen).toContain('-Members "{{sam}}"');
|
||||
});
|
||||
|
||||
it("enthaelt beide Konto-Abschnitte des Onboardings", () => {
|
||||
// Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht
|
||||
// zutreffende als Ganzes auf „nicht zutreffend“ gesetzt.
|
||||
it("enthaelt beide Konto-Abschnitte des Onboardings – jeden mit seiner Bedingung", () => {
|
||||
/*
|
||||
* **Der Fall, für den #039 gebaut wurde.** Beide Abschnitte bleiben in der
|
||||
* Vorlage — sie schließen sich über `konto_typ` gegenseitig aus. Bis Phase 3
|
||||
* standen sie ohne Bedingung drin, und ein Techniker setzte den nicht
|
||||
* zutreffenden von Hand auf „nicht zutreffend“: neun Schritte bei jedem
|
||||
* Onboarding.
|
||||
*
|
||||
* Geprüft wird hier die **Vorlage**; dass die Auswertung greift, steht in
|
||||
* `tests/bedingte-laeufe.test.ts`.
|
||||
*/
|
||||
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
|
||||
const titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel);
|
||||
expect(titel).toContain("Konto anlegen (Active Directory)");
|
||||
expect(titel).toContain("Konto anlegen (Entra ID)");
|
||||
const abschnitte = onboarding?.dokument.struktur.abschnitte ?? [];
|
||||
const nach = (titel: string) => abschnitte.find((abschnitt) => abschnitt.titel === titel);
|
||||
|
||||
const optionen =
|
||||
onboarding?.dokument.struktur.variablen.find((variable) => variable.schluessel === "konto_typ")
|
||||
?.optionen ?? [];
|
||||
|
||||
for (const [titel, wert] of [
|
||||
["Konto anlegen (Active Directory)", "Active Directory"],
|
||||
["Konto anlegen (Entra ID)", "Entra ID"],
|
||||
] as const) {
|
||||
const abschnitt = nach(titel);
|
||||
expect(abschnitt, titel).toBeDefined();
|
||||
expect(abschnitt?.bedingung, titel).toEqual({
|
||||
art: "variable",
|
||||
schluessel: "konto_typ",
|
||||
operator: "ist",
|
||||
wert,
|
||||
});
|
||||
/*
|
||||
* **Der Wert muss eine der Optionen sein.** Ein Tippfehler hier wäre
|
||||
* stillschweigend: die Bedingung träfe nie zu, der Abschnitt erschiene
|
||||
* nie, und die Hälfte der Checkliste fehlte — ohne Fehlermeldung.
|
||||
*/
|
||||
expect(optionen, `${titel}: „${wert}“ ist keine Option von konto_typ`).toContain(wert);
|
||||
}
|
||||
|
||||
// Und der gemeinsame Abschnitt hat keine: er gilt immer.
|
||||
expect(nach("Konto, gemeinsam")?.bedingung).toBeUndefined();
|
||||
});
|
||||
|
||||
it("speichert nirgends ein Passwort im Klartext", () => {
|
||||
|
||||
@@ -34,7 +34,15 @@ async function redakteurAnlegen() {
|
||||
* der Export, dass er nach `position` sortiert und nicht danach, wie die
|
||||
* Datenbank die Zeilen gerade zurueckgibt.
|
||||
*/
|
||||
async function beispielVersionAnlegen(): Promise<string> {
|
||||
/**
|
||||
* Legt die Beispielvorlage an und veröffentlicht sie.
|
||||
*
|
||||
* `nachtragen` wird **vor** dem Veröffentlichen gerufen: an einer
|
||||
* veröffentlichten Version lässt sich nichts mehr ändern, dafür sorgen Trigger
|
||||
* in der Datenbank (#009). Wer eine Abteilungseinschränkung oder eine Bedingung
|
||||
* braucht, setzt sie dort — genau wie im Editor.
|
||||
*/
|
||||
async function beispielVersionAnlegen(nachtragen?: (versionId: string) => Promise<void>): Promise<string> {
|
||||
const vorlage = await testPrisma.vorlage.create({
|
||||
data: {
|
||||
name: "Onboarding Notebook",
|
||||
@@ -128,6 +136,8 @@ async function beispielVersionAnlegen(): Promise<string> {
|
||||
],
|
||||
});
|
||||
|
||||
await nachtragen?.(version.id);
|
||||
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
@@ -136,6 +146,15 @@ async function beispielVersionAnlegen(): Promise<string> {
|
||||
return version.id;
|
||||
}
|
||||
|
||||
/** Die Abschnitte einer Version samt Schritten, in Reihenfolge. */
|
||||
async function abschnitteVon(versionId: string) {
|
||||
return testPrisma.vorlagenAbschnitt.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
|
||||
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -503,6 +522,142 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
|
||||
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
|
||||
});
|
||||
|
||||
it("bewahrt die Abteilungseinschränkung eines Abschnitts", async () => {
|
||||
/*
|
||||
* **Das ging stillschweigend verloren.** Der Export schreibt
|
||||
* `nurFuerAbteilungen` seit #031, das Schema nimmt es an — und der Import
|
||||
* verwarf es. Eine Vorlage, die jemand in eine zweite Installation
|
||||
* brachte, verlor ihre Abteilungsabschnitte: sie galten dort für **alle**.
|
||||
* Also mehr Schritte statt weniger, und niemand merkt es. Gefunden bei
|
||||
* #039, als derselbe Weg für Bedingungen gebaut wurde.
|
||||
*/
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen(async (versionId) => {
|
||||
await testPrisma.vorlagenAbschnitt.updateMany({
|
||||
where: { versionId, position: 0 },
|
||||
data: { nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
|
||||
});
|
||||
});
|
||||
|
||||
const erst = await vorlageExportieren(quelle);
|
||||
expect(erst.struktur.abschnitte[0]?.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
|
||||
|
||||
const { versionId } = await vorlageImportieren(erst, redakteur.id);
|
||||
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||
where: { versionId, position: 0 },
|
||||
});
|
||||
expect(zeile.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
|
||||
});
|
||||
|
||||
it("schreibt eine Schritt-Bedingung als Titel-Pfad und löst sie wieder auf", async () => {
|
||||
/*
|
||||
* Eine Bedingung zeigt gespeichert auf eine **Kennung**, und die bedeutet
|
||||
* in einer anderen Installation nichts. Im Austausch steht deshalb ein
|
||||
* Titel-Pfad; beim Import wird daraus die neue Kennung — sonst wäre die
|
||||
* Bedingung dort nicht entscheidbar, und der Abschnitt erschiene immer,
|
||||
* ohne dass jemand sagen könnte, warum (#039).
|
||||
*/
|
||||
const redakteur = await redakteurAnlegen();
|
||||
let abschnitte: Awaited<ReturnType<typeof abschnitteVon>> = [];
|
||||
const quelle = await beispielVersionAnlegen(async (versionId) => {
|
||||
abschnitte = await abschnitteVon(versionId);
|
||||
await testPrisma.vorlagenAbschnitt.update({
|
||||
where: { id: abschnitte[1]!.id },
|
||||
data: {
|
||||
bedingung: {
|
||||
art: "schritt",
|
||||
schrittId: abschnitte[0]!.schritte[0]!.id,
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const ziel = abschnitte[0]!.schritte[0]!;
|
||||
|
||||
// Im Export steht der Pfad, nicht die Kennung.
|
||||
const erst = await vorlageExportieren(quelle);
|
||||
const ausgetauscht = erst.struktur.abschnitte[1]?.bedingung;
|
||||
expect(ausgetauscht).toEqual({
|
||||
art: "schritt",
|
||||
abschnitt: abschnitte[0]!.titel,
|
||||
schritt: ziel.titel,
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
});
|
||||
expect(JSON.stringify(erst)).not.toContain(ziel.id);
|
||||
|
||||
// Und beim Import wird daraus die neue Kennung.
|
||||
const { versionId } = await vorlageImportieren(erst, redakteur.id);
|
||||
const neu = await testPrisma.vorlagenAbschnitt.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
});
|
||||
expect(neu[1]?.bedingung).toEqual({
|
||||
art: "schritt",
|
||||
schrittId: neu[0]!.schritte[0]!.id,
|
||||
zustaende: ["FEHLGESCHLAGEN"],
|
||||
});
|
||||
});
|
||||
|
||||
it("übergeht eine Bedingung, deren Titel-Pfad in der Datei fehlt", async () => {
|
||||
/*
|
||||
* Eine von Hand geschriebene Datei kann sich vertippen. Die ganze Vorlage
|
||||
* deswegen abzuweisen wäre die teurere Antwort — übergangen heißt: der
|
||||
* Abschnitt gilt immer, und das steht sichtbar in der Oberfläche.
|
||||
*/
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
const erst = await vorlageExportieren(quelle);
|
||||
|
||||
const { versionId } = await vorlageImportieren(
|
||||
{
|
||||
...erst,
|
||||
name: "Mit Tippfehler",
|
||||
struktur: {
|
||||
...erst.struktur,
|
||||
abschnitte: erst.struktur.abschnitte.map((abschnitt, stelle) =>
|
||||
stelle === 1
|
||||
? {
|
||||
...abschnitt,
|
||||
bedingung: {
|
||||
art: "schritt",
|
||||
abschnitt: "Gibt es nicht",
|
||||
schritt: "Auch nicht",
|
||||
zustaende: ["ERLEDIGT"],
|
||||
},
|
||||
}
|
||||
: abschnitt,
|
||||
),
|
||||
},
|
||||
},
|
||||
redakteur.id,
|
||||
);
|
||||
|
||||
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||
where: { versionId, position: 1 },
|
||||
});
|
||||
expect(zeile.bedingung).toBeNull();
|
||||
});
|
||||
|
||||
it("bewahrt eine Variablen-Bedingung unverändert", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const bedingung = { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" };
|
||||
const quelle = await beispielVersionAnlegen(async (versionId) => {
|
||||
await testPrisma.vorlagenAbschnitt.updateMany({
|
||||
where: { versionId, position: 0 },
|
||||
data: { bedingung },
|
||||
});
|
||||
});
|
||||
|
||||
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
|
||||
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||
where: { versionId, position: 0 },
|
||||
});
|
||||
// Ein Variablenschluessel ist ein Name, den die Vorlage selbst mitbringt —
|
||||
// er wandert unveraendert.
|
||||
expect(zeile.bedingung).toEqual(bedingung);
|
||||
});
|
||||
|
||||
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
|
||||
Reference in New Issue
Block a user