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