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
@@ -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