Files
Laufzettel/tests/editor-aufbau.test.ts
T
Florian Wilde b8166e10ea
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Abschnitte nur fuer bestimmte Abteilungen
Beim Onboarding in der Buchhaltung gehoeren Schritte dazu, die sonst niemand
braucht: DATEV, Drucker fuer Formulare, Bankportal. Bisher hiess die Wahl: eine
Vorlage je Abteilung pflegen, oder Schritte, die fuer die meisten "nicht
zutreffend" sind.

Jeder Abschnitt kann jetzt nennen, fuer welche Abteilungen er gilt -- als
**Namen**, nicht als Kennungen. Der Abschnitt gehoert zur Version und muss ein
Umbenennen oder Archivieren einer Abteilung ueberleben; dieselbe Entscheidung
wie bei Mitarbeiter.abteilung. Deshalb wandert die Einschraenkung auch durch
Export und Import.

Welche Abschnitte ein Lauf hat, wird beim Start festgeschrieben. Ein spaeterer
Abteilungswechsel aendert ihn nicht -- sonst verschwaende ein Schritt, an dem
schon eine Notiz haengt, und ein archivierter Lauf muss genau das zeigen, was
damals abgearbeitet wurde.

Leer heisst alle: Laeufe von vor dieser Fassung verhalten sich unveraendert,
ohne dass eine Migration Daten nachfuehren muesste. Damit das eindeutig bleibt,
wird ein Lauf abgewiesen, fuer den kein Abschnitt uebrig bleibt -- es gaebe
ohnehin nichts abzuarbeiten.

Die Abschlussregel zaehlt nur geltende Pflichtschritte. Vorher waere der
Abschluss unerreichbar gewesen: abhaken laesst sich nur, was in der Abarbeitung
steht. Der Test hat das sofort gezeigt.

Die Regel liegt in src/lib, weil drei Schichten sie brauchen -- der Dienst beim
Start und beim Abschluss, die Lesemodelle der Abarbeitung und des Drucks. In
einem Lesemodell waere sie eine Abhaengigkeit in die falsche Richtung.

Im gebauten Stand durchgeklickt: einschraenken, speichern, veroeffentlichen,
zwei Laeufe mit verschiedenen Abteilungen starten -- 6 Abschnitte mit 23
Schritten fuer die Buchhaltung, 5 mit 20 fuer den Vertrieb. Dabei einen eigenen
Numerusfehler im Hinweistext gefunden und behoben.

Closes #51
2026-10-04 20:04:14 +02:00

327 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 {
BEISPIEL_PASSWORT,
SCHLUESSEL_REGEL,
abschnittNummer,
alsEditorAufbau,
alsStruktur,
beispielwerte,
doppelteSchluessel,
konfigFuerTyp,
mitBeispielwerten,
neueKennung,
schluesselFehler,
titelOhneNummer,
umsortieren,
verwendetePlatzhalter,
type EditorAufbau,
type EditorVariable,
bausteineAnreichern,
neuerBausteinAbschnitt,
} from "@/lib/editor-aufbau";
import { EINGEBAUTE_PLATZHALTER } from "@/lib/platzhalter";
import { StrukturSchema } from "@/lib/vorlagen-format";
/** Die reine Logik des Vorlagen-Editors (#010). Fuer Komponenten gibt es keine DOM-Umgebung. */
function variable(teil: Partial<EditorVariable> = {}): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "vorname",
beschriftung: "Vorname",
typ: "TEXT",
optionen: [],
pflicht: false,
...teil,
};
}
describe("umsortieren", () => {
it("schiebt einen Eintrag nach unten und laesst die Vorlage unberuehrt", () => {
const liste = ["a", "b", "c"];
expect(umsortieren(liste, 0, 2)).toEqual(["b", "c", "a"]);
expect(liste).toEqual(["a", "b", "c"]);
});
it("schiebt einen Eintrag nach oben", () => {
expect(umsortieren(["a", "b", "c"], 2, 1)).toEqual(["a", "c", "b"]);
});
it("gibt bei gleicher Position und ausserhalb der Liste dieselbe Liste zurueck", () => {
const liste = ["a", "b"];
expect(umsortieren(liste, 1, 1)).toBe(liste);
expect(umsortieren(liste, 0, 2)).toBe(liste);
expect(umsortieren(liste, -1, 0)).toBe(liste);
});
});
describe("Abschnittsnummer", () => {
it("nummeriert zweistellig ab eins", () => {
expect(abschnittNummer(0)).toBe("01");
expect(abschnittNummer(9)).toBe("10");
});
it("entfernt eine Nummer, die mit Mittelpunkt im Titel steht", () => {
expect(titelOhneNummer("01 · Vorbereitung")).toBe("Vorbereitung");
expect(titelOhneNummer("2·Konto")).toBe("Konto");
});
it("laesst eine Servicezeit in Ruhe — „24 · 7“ ist keine Nummerierung", () => {
// Gefunden im Review: die Regel griff auch, wenn hinter dem Mittelpunkt eine
// Ziffer stand. Aus „24 · 7 Bereitschaft“ wurde „7 Bereitschaft“, und beim
// naechsten Speichern stand das dauerhaft in der Datenbank.
expect(titelOhneNummer("24 · 7 Bereitschaft")).toBe("24 · 7 Bereitschaft");
expect(titelOhneNummer("08 · 17 Servicezeit")).toBe("08 · 17 Servicezeit");
});
it("laesst jeden anderen Trenner in Ruhe — „2. Etage“ ist eine Etage", () => {
expect(titelOhneNummer("2. Etage prüfen")).toBe("2. Etage prüfen");
expect(titelOhneNummer("02) Konto")).toBe("02) Konto");
expect(titelOhneNummer("3 - Abschluss")).toBe("3 - Abschluss");
expect(titelOhneNummer("10 Jahre Aufbewahrung")).toBe("10 Jahre Aufbewahrung");
expect(titelOhneNummer("Vorbereitung")).toBe("Vorbereitung");
});
});
describe("konfig beim Typwechsel", () => {
it("laesst fallen, was dem alten Typ gehoert", () => {
const konfig = konfigFuerTyp({ shell: "bash", befehl: "echo {{hostname}}" }, "HAKEN");
expect(konfig).toEqual({});
});
it("behaelt, was der neue Typ braucht", () => {
expect(konfigFuerTyp({ optionen: ["a", "b"], min: 1 }, "AUSWAHL")).toEqual({
optionen: ["a", "b"],
});
expect(konfigFuerTyp({ min: 1, max: 9, einheit: "GB", mehrzeilig: true }, "ZAHL")).toEqual({
min: 1,
max: 9,
einheit: "GB",
});
});
it("behaelt unbekannte Schluessel, damit ein Import aus einer spaeteren Fassung nichts verliert", () => {
expect(konfigFuerTyp({ befehl: "echo", aktionsname: "ad-anlegen" }, "HAKEN")).toEqual({
aktionsname: "ad-anlegen",
});
});
});
describe("Zustand und Struktur", () => {
const struktur = StrukturSchema.parse({
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname", pflicht: true },
{ schluessel: "raum", beschriftung: "Raum", typ: "AUSWAHL", optionen: ["EG", "OG"] },
],
abschnitte: [
{
titel: "01 · Vorbereitung",
schritte: [
{ titel: "Gerät auspacken", pflicht: true },
{
titel: "Konto anlegen",
typ: "BEFEHL",
konfig: { shell: "powershell", befehl: "New-ADUser {{vorname}}" },
dokuUrl: "https://intern.example/ad",
},
],
},
],
});
it("uebernimmt die Struktur und gibt sie unveraendert zurueck — ohne die Nummer im Titel", () => {
const aufbau = alsEditorAufbau(struktur);
expect(aufbau.abschnitte[0]?.titel).toBe("Vorbereitung");
expect(aufbau.abschnitte[0]?.schritte).toHaveLength(2);
const zurueck = alsStruktur(aufbau);
expect(zurueck.abschnitte[0]?.titel).toBe("Vorbereitung");
expect(zurueck.abschnitte[0]?.schritte[1]?.konfig).toEqual({
shell: "powershell",
befehl: "New-ADUser {{vorname}}",
});
// Das Schema muss den Rueckweg annehmen, sonst scheitert erst das Speichern.
expect(StrukturSchema.safeParse(zurueck).success).toBe(true);
});
it("laesst leere Textfelder weg, statt sie als leere Zeichenkette zu schicken", () => {
const aufbau: EditorAufbau = {
variablen: [],
abschnitte: [
{
kennung: "a",
titel: "Vorbereitung",
nurFuerAbteilungen: [],
schritte: [
{
kennung: "s",
titel: "Gerät auspacken",
hilfetext: " ",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: " https://intern.example/x ",
},
],
},
],
};
const schritt = alsStruktur(aufbau).abschnitte[0]?.schritte[0];
expect(schritt?.hilfetext).toBeUndefined();
expect(schritt?.dokuUrl).toBe("https://intern.example/x");
});
it("schickt Optionen nur fuer eine Auswahl-Variable mit", () => {
const aufbau: EditorAufbau = {
variablen: [variable({ typ: "TEXT", optionen: ["alt", "vergessen"] })],
abschnitte: [],
};
expect(alsStruktur(aufbau).variablen[0]?.optionen).toEqual([]);
});
it("findet die verwendeten Platzhalter", () => {
expect([...verwendetePlatzhalter(alsEditorAufbau(struktur))]).toEqual(["vorname"]);
});
});
describe("Variablenschluessel", () => {
it("nimmt gueltige Schluessel an", () => {
for (const schluessel of ["vorname", "ad_gruppe", "raum2"]) {
expect(schluesselFehler(schluessel), schluessel).toBeUndefined();
}
});
it("lehnt ab, was der Platzhalter nicht wiedererkennen koennte", () => {
for (const schluessel of ["Vorname", "mit-strich", "2te_wahl", ""]) {
expect(schluesselFehler(schluessel), schluessel).toBeDefined();
}
});
it("nennt die Regel als Satz, damit der Hinweis am Feld stehen kann", () => {
expect(SCHLUESSEL_REGEL).toContain("Kleinbuchstaben");
});
it("findet doppelte Schluessel und ignoriert leere", () => {
expect(
doppelteSchluessel([
variable({ schluessel: "vorname" }),
variable({ schluessel: "vorname" }),
variable({ schluessel: "" }),
variable({ schluessel: "" }),
]),
).toEqual(["vorname"]);
});
});
describe("Beispielwerte der Vorschau", () => {
const werte = beispielwerte([], "24.09.2026");
it("hat fuer jeden eingebauten Platzhalter einen Wert", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(werte[platzhalter.schluessel], platzhalter.schluessel).toBeTruthy();
}
});
it("erzeugt fuer das generierte Passwort nie ein echtes, sondern immer denselben Beispieltext", () => {
expect(werte["generiertes_passwort"]).toBe(BEISPIEL_PASSWORT);
expect(beispielwerte([], "24.09.2026")["generiertes_passwort"]).toBe(BEISPIEL_PASSWORT);
});
it("nimmt das Datum von aussen, damit Server und Browser dasselbe rendern", () => {
expect(werte["datum"]).toBe("24.09.2026");
});
it("zieht fuer einen Schluessel, den es als Variable gibt, dessen Beispielwert vor", () => {
const eigene = beispielwerte(
[variable({ schluessel: "abteilung", typ: "AUSWAHL", optionen: ["IT", "Einkauf"] })],
"24.09.2026",
);
expect(eigene["abteilung"]).toBe("IT");
});
it("ersetzt Platzhalter auch mit Leerzeichen in den Klammern", () => {
expect(mitBeispielwerten("Hallo {{ vorname }} ({{sam}})", werte)).toBe("Hallo Anna (a.beispiel)");
});
it("laesst einen unbekannten Platzhalter stehen, so wie es der Lauf auch tut", () => {
expect(mitBeispielwerten("{{gibtsnicht}}", werte)).toBe("{{gibtsnicht}}");
});
it("setzt einen Wert mit $& woertlich ein", () => {
// Gefunden im Review: `String.replace` deutet im Ersatztext `$&`, `` $` ``,
// `$'` und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ setzte
// den gefundenen Platzhalter wieder ein. In #012 baut derselbe Weg einen
// PowerShell-Befehl zusammen — dort waere es ein verfaelschter Befehl.
for (const wert of ["Kosten $& Nutzen", "vor $` danach", "A $' B", "Gruppe $1"]) {
expect(mitBeispielwerten("Raum: {{raum}}", { raum: wert }), wert).toBe(`Raum: ${wert}`);
}
});
});
describe("Bausteine im Editor-Aufbau", () => {
const BAUSTEIN = {
bausteinVersionId: "bv-1",
name: "Datensicherung prüfen",
titel: "Datensicherung",
version: 3,
anzahlSchritte: 2,
};
it("schickt die Platzhalter-Schritte eines Baustein-Abschnitts nicht mit", () => {
/*
* Der Fehler, den erst das Durchklicken des gebauten Stands gezeigt hat
* (#030): die Schritte im Zustand sind nur da, damit die Anzahl in der Karte
* stimmt — sie haben keinen Titel. Mitgeschickt wies `SchrittSchema` den
* ganzen Entwurf ab, und das Speichern scheiterte.
*/
const abschnitt = neuerBausteinAbschnitt(BAUSTEIN);
expect(abschnitt.schritte).toHaveLength(2);
expect(abschnitt.schritte.every((schritt) => schritt.titel === "")).toBe(true);
const struktur = alsStruktur({ variablen: [], abschnitte: [abschnitt] });
expect(struktur.abschnitte[0]?.schritte).toEqual([]);
expect(struktur.abschnitte[0]?.bausteinVersionId).toBe("bv-1");
// Und das Ergebnis muss durch das Schema gehen – genau das tat es vorher nicht.
expect(() => StrukturSchema.parse(struktur)).not.toThrow();
});
it("nimmt den Verweis mit und gibt ihn zurück", () => {
const struktur = StrukturSchema.parse({
variablen: [],
abschnitte: [
{ titel: "Eigen", schritte: [{ titel: "Eins" }] },
{ titel: "x", bausteinVersionId: "bv-1", schritte: [] },
],
});
const aufbau = alsEditorAufbau(struktur);
expect(aufbau.abschnitte[0]?.baustein).toBeUndefined();
expect(aufbau.abschnitte[1]?.baustein?.versionId).toBe("bv-1");
expect(alsStruktur(aufbau).abschnitte[1]?.bausteinVersionId).toBe("bv-1");
});
it("trägt Name und Fassung nach und benennt einen Verweis, der nicht mehr gilt", () => {
const struktur = StrukturSchema.parse({
variablen: [],
abschnitte: [
{ titel: "a", bausteinVersionId: "bv-1", schritte: [] },
{ titel: "b", bausteinVersionId: "bv-weg", schritte: [] },
],
});
const aufbau = bausteineAnreichern(alsEditorAufbau(struktur), [BAUSTEIN]);
expect(aufbau.abschnitte[0]?.baustein).toEqual({
versionId: "bv-1",
name: "Datensicherung prüfen",
version: 3,
});
// Ein Verweis, dessen Baustein nicht mehr einsetzbar ist, behält seine
// Kennung – verschwinden darf er nicht, sonst wäre er beim nächsten
// Speichern still weg.
expect(aufbau.abschnitte[1]?.baustein).toEqual({
versionId: "bv-weg",
name: "(nicht mehr verfügbar)",
version: 0,
});
expect(alsStruktur(aufbau).abschnitte[1]?.bausteinVersionId).toBe("bv-weg");
});
});