feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #36.
This commit is contained in:
2026-09-26 09:04:15 +00:00
parent 9bd198a531
commit fbbb634e56
104 changed files with 4527 additions and 299 deletions
+332
View File
@@ -0,0 +1,332 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { anmelden, geraetName, SEED_VORLAGE, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Der Hauptablauf des ganzen Auftrags, in einem Zug (#018).
*
* Anmelden → „PC Neuinstallation“ für das Beispielgerät starten → abarbeiten →
* einen Schritt mit Begründung überspringen → abschließen → archivieren → im
* Archiv über ein Wort aus der Begründung wiederfinden → und danach ändert sich
* nichts mehr.
*
* Zwei Dinge unterscheiden diesen Ablauf von einem Klickprotokoll:
*
* 1. **Geprüft wird gegen die Datenbank.** Nach jeder Änderung wird neu geladen
* und in der Datenbank nachgesehen. Ein Klick, der nur im Browser wirkt, sieht
* auf einem Screenshot genauso aus wie einer, der gespeichert wurde — und
* genau dieser Unterschied ist der Grund, warum es diese Datei gibt
* (CLAUDE.md, „Oberfläche prüfen“).
* 2. **Die Absage wird erzwungen.** „Nicht zutreffend“ ohne Begründung sperrt die
* Oberfläche nur über einen deaktivierten Knopf. Ein deaktivierter Knopf ist
* keine Zusage: dieser Ablauf entfernt ihn und schickt trotzdem ab, damit die
* Absage vom **Server** bewiesen ist und nicht von einem HTML-Attribut.
*
* Der Ablauf legt an, was er braucht (einen eigenen Lauf mit `ZZ E2E `-Titel) und
* räumt ihn hinterher weg. Vorausgesetzt wird nur der Seed-Bestand, den
* `global-setup.ts` prüft.
*/
const STEMPEL = Date.now();
const TITEL = `ZZ E2E Hauptablauf ${STEMPEL}`;
/** Der Schritt, der übersprungen wird — Pflicht, damit auch der Abschluss etwas davon hat. */
const UEBERSPRUNGEN = "Secure Boot und TPM 2.0 aktiv";
/** Der Schritt, in dem ein Wert erfasst wird. Typ TEXT in der Seed-Vorlage. */
const WERTSCHRITT = "Sondersoftware und Lizenzen des Benutzers notiert";
const WERT = `ZZ E2E Adobe Acrobat ${STEMPEL}`;
/**
* Das Wertfeld eines Schritts – ohne Annahme über das Element.
*
* `TEXT` mit `mehrzeilig` ist ein `textarea`, alles andere ein `input`. Ein
* `input[name="wert"]` lief deshalb in den Zeitablauf, und die Meldung sprach von
* einem fehlenden Element statt von der falschen Annahme.
*/
const WERTFELD = '[name="wert"]';
/**
* Ein Wort, das es in der ganzen Datenbank nur einmal gibt.
*
* Über dieses Wort wird der Lauf später im Archiv wiedergefunden. Ohne Stempel
* fände die Suche auch die Notizen früherer Durchläufe, und der Treffer sagte
* nichts mehr über diesen hier.
*/
const NOTIZWORT = `Fabrikneu${STEMPEL}`;
const NOTIZ = `${NOTIZWORT}: das Gerät kam versiegelt aus dem Karton, TPM war ab Werk aktiv.`;
/**
* Legt den Lauf über die Oberfläche an und gibt seine Adresse zurück.
*
* Bewusst nicht über Prisma: das Anlegen **ist** Teil des Hauptablaufs. Die
* Vorlage wird am Namen gewählt (`vorlageWaehlen`), das Gerät am angezeigten
* Namen — in beiden Betriebsmodi, denn im Modus DIENSTLEISTUNG heißt das
* Beispielgerät anders und der Dialog fragt zusätzlich nach dem Kunden.
*/
async function laufStarten(seite: Page): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
const gesucht = geraetName();
const geraete = await seite
.locator('select[name="geraetId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = geraete.find((eintrag) => eintrag.text.includes(gesucht));
if (!treffer) {
throw new Error(
`Das Gerät „${gesucht}“ steht nicht zur Auswahl. Zur Wahl stehen: ` +
`${geraete.map((eintrag) => eintrag.text).join(", ")}`,
);
}
await seite.selectOption('select[name="geraetId"]', treffer.wert);
/*
* Im Modus DIENSTLEISTUNG verlangt der Dialog einen Kunden, und die Datenbank
* verlangt, dass es **der Kunde des Geräts** ist: ein zusammengesetzter
* Fremdschlüssel lässt keinen Lauf mit fremdem Gerät zu. Ein „nimm den
* ersten“ wäre also nicht nur ungenau, sondern im Zweifel rot.
*/
const kundenWahl = seite.locator('select[name="kundeId"]');
if ((await kundenWahl.count()) > 0) {
const geraet = await prisma.geraet.findFirst({
where: { OR: [{ hostname: gesucht }, { bezeichnung: gesucht }] },
select: { kunde: { select: { name: true } } },
});
if (!geraet) throw new Error(`Das Gerät „${gesucht}“ steht nicht in der Datenbank.`);
await kundenWahl.selectOption({ label: geraet.kunde.name });
}
const weitere = seite.locator("details").filter({ hasText: "Titel, Ticket" });
await weitere.locator("summary").click();
await weitere.locator('input[name="titel"]').fill(TITEL);
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 60_000 });
return new URL(seite.url()).pathname;
}
/** Die Abschnitte der Abarbeitungsansicht – am aufklappbaren Kopf erkannt. */
function abschnitteVon(seite: Page): Locator {
return seite.locator("section").filter({ has: seite.locator("button[aria-expanded]") });
}
test.describe("Hauptablauf", () => {
test("starten, abarbeiten, abschließen, archivieren, wiederfinden", async ({ page }) => {
/*
* Die Seed-Vorlage hat über dreißig Schritte, und jeder speichert einzeln —
* so arbeitet ein Techniker, und so soll es geprüft werden. Das dauert; die
* Voreinstellung von 30 Sekunden reicht dafür nicht.
*/
test.setTimeout(420_000);
await anmelden(page);
const pfad = await laufStarten(page);
const laufId = pfad.split("/").pop() as string;
// Ein neuer Lauf ist **offen**: das ist die Zuweisung, nicht der Start.
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("OFFEN");
await page.getByRole("button", { name: "Lauf beginnen" }).click();
const abschliessen = page.getByRole("button", { name: "Lauf abschließen" });
await expect(abschliessen).toBeVisible();
await expect(abschliessen).toBeDisabled();
// ---- Ein Wert in einem Textschritt --------------------------------------
const wertzeile = page.getByRole("listitem").filter({ hasText: WERTSCHRITT });
await wertzeile.locator(WERTFELD).fill(WERT);
await wertzeile.getByRole("button", { name: "Wert speichern" }).click();
/*
* Auf die **Datenbank** warten, nicht auf den Bildschirm: das Feld trägt den
* Wert schon, bevor irgendetwas gespeichert ist — es ist ein kontrolliertes
* Eingabefeld. Und ein `reload` mitten in der Aktion bricht sie ab, also erst
* warten, dann neu laden.
*/
await expect
.poll(
async () =>
(
await prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: WERTSCHRITT } },
select: { wert: true },
})
)?.wert,
{ timeout: 30_000, message: `Der Wert des Schritts „${WERTSCHRITT}“ steht nicht in der Datenbank.` },
)
.toBe(WERT);
await page.reload();
await expect(wertzeile.locator(WERTFELD)).toHaveValue(WERT);
// ---- „Nicht zutreffend“ verlangt eine Begründung ------------------------
const zeile = page.getByRole("listitem").filter({ hasText: UEBERSPRUNGEN });
const pille = zeile.getByRole("button", { name: "nicht zutreffend" });
await pille.click();
const mitBegruendung = zeile.getByRole("button", { name: "Mit Begründung speichern" });
await expect(mitBegruendung).toBeVisible();
await expect(mitBegruendung).toBeDisabled();
/*
* Und jetzt der eigentliche Nachweis: der gesperrte Knopf wird entsperrt und
* das Pflichtfeld entschärft, dann wird abgeschickt. Ein deaktivierter Knopf
* ist Bequemlichkeit — die Zusage („im Archiv steht kein übersprungener
* Schritt ohne Grund“) muss der Server halten, und nur so ist bewiesen, dass
* er es tut.
*/
await zeile.locator('textarea[name="notiz"]').evaluate((feld) => feld.removeAttribute("required"));
await mitBegruendung.evaluate((knopf) => knopf.removeAttribute("disabled"));
await mitBegruendung.click();
await expect(zeile.getByRole("alert")).toContainText("braucht eine Begründung");
expect(await prisma.schrittergebnis.count({ where: { laufId, zustand: "NICHT_ZUTREFFEND" } })).toBe(0);
// Mit Begründung geht es – und danach steht sie in der Datenbank.
await zeile.locator('textarea[name="notiz"]').fill(NOTIZ);
await zeile.getByRole("button", { name: "Mit Begründung speichern" }).click();
await expect(pille).toHaveAttribute("aria-pressed", "true");
await expect
.poll(
async () =>
prisma.schrittergebnis.findFirst({
where: { laufId, vorlagenSchritt: { titel: UEBERSPRUNGEN } },
select: { zustand: true, notiz: true },
}),
{ timeout: 30_000, message: `Der Schritt „${UEBERSPRUNGEN}“ steht nicht als übersprungen da.` },
)
.toEqual({ zustand: "NICHT_ZUTREFFEND", notiz: NOTIZ });
await page.reload();
await expect(zeile.locator('textarea[name="notiz"]')).toHaveValue(NOTIZ);
// ---- Den Rest abhaken --------------------------------------------------
const abschnitte = abschnitteVon(page);
const anzahlAbschnitte = await abschnitte.count();
expect(anzahlAbschnitte).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlAbschnitte; nummer += 1) {
const abschnitt = abschnitte.nth(nummer);
const kopf = abschnitt.locator("button[aria-expanded]").first();
// Nach dem Neuladen ist zu, was fertig ist – erst aufklappen.
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
const zeilen = abschnitt.locator("> ul > li");
const anzahlZeilen = await zeilen.count();
for (let stelle = 0; stelle < anzahlZeilen; stelle += 1) {
const schritt = zeilen.nth(stelle);
// Was schon vom Tisch ist, bleibt es: ein zweiter Klick nähme es zurück.
if ((await schritt.locator('button[aria-pressed="true"]').count()) > 0) continue;
const haken = schritt.getByRole("button", { name: "erledigt", exact: true });
if ((await haken.count()) === 0) continue;
await haken.click();
// Auf den **gespeicherten** Stand warten, nicht auf den nächsten Klick:
// ohne das trifft er einen Knopf aus dem alten Baum.
await expect(haken).toHaveAttribute("aria-pressed", "true");
}
}
// ---- Abschließen und archivieren ---------------------------------------
await expect(abschliessen).toBeEnabled();
await abschliessen.click();
const archivieren = page.getByRole("button", { name: "archivieren" });
await expect(archivieren).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ABGESCHLOSSEN");
await archivieren.click();
await page.getByRole("button", { name: "Ja, archivieren" }).click();
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
expect((await prisma.lauf.findUniqueOrThrow({ where: { id: laufId } })).status).toBe("ARCHIVIERT");
// ---- Im Archiv wiederfinden --------------------------------------------
await page.goto("/archiv");
await page.getByLabel("Suchbegriff").fill(NOTIZWORT);
await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: TITEL });
await expect(treffer).toHaveCount(1);
// Und der Grund steht dabei – ein Treffer ohne sichtbare Fundstelle ist wertlos.
await expect(treffer.getByText("Notiz")).toBeVisible();
await expect(treffer.getByText(new RegExp(NOTIZWORT))).toBeVisible();
// ---- Und danach greift der Schreibschutz -------------------------------
await treffer.getByRole("link").first().click();
await page.waitForURL(`**/laeufe/${laufId}`);
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
/*
* **Erst aufklappen, dann prüfen.** An einem abgeschlossenen Lauf ist jeder
* Abschnitt zugeklappt, und in einem zugeklappten Abschnitt steht überhaupt
* nichts im Baum. Ein „keine Knöpfe gefunden“ wäre dort kein Nachweis für den
* Schreibschutz, sondern nur einer dafür, dass niemand hingesehen hat.
*/
const archiviert = abschnitteVon(page);
const anzahlArchiviert = await archiviert.count();
expect(anzahlArchiviert).toBeGreaterThan(0);
for (let nummer = 0; nummer < anzahlArchiviert; nummer += 1) {
const kopf = archiviert.nth(nummer).locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
await expect(kopf).toHaveAttribute("aria-expanded", "true");
}
for (const knopf of [
"erledigt",
"nicht zutreffend",
"fehlgeschlagen",
"Wert speichern",
"Kommentar hinzufügen",
"Abschnitt trifft nicht zu",
"Lauf beginnen",
"Lauf abschließen",
]) {
await expect(
page.getByRole("button", { name: knopf, exact: true }),
`Am archivierten Lauf steht noch der Knopf „${knopf}“.`,
).toHaveCount(0);
}
/*
* Genau **ein** Formular bleibt stehen, und das ist das Zurückholen: das Archiv
* ist die Zusage, dass sich ein abgeschlossener Lauf nicht mehr ändert, und nur
* ein Administrator darf sie aufheben. Gezählt wird deshalb auf eins und nicht
* auf null — sonst hieße „grün“ hier irgendwann, dass auch der eine legitime
* Weg verschwunden ist.
*/
await expect(page.getByRole("button", { name: "aus dem Archiv zurückholen" })).toBeVisible();
await expect(page.locator("main form")).toHaveCount(1);
/*
* Der Wert und die Begründung stehen weiter da – lesbar, nur nicht änderbar.
* Ein Archiv, das seine Inhalte beim Sperren verliert, wäre kein Archiv.
*/
await expect(page.getByText(WERT)).toBeVisible();
await expect(page.getByText(NOTIZ)).toBeVisible();
});
});
test.afterAll(async () => {
/*
* Aufgeräumt wird über die Datenbank: für einen Lauf gibt es in der Anwendung
* keinen Löschweg, und das ist richtig so. `Protokoll.laufId` steht auf
* `Restrict` — ohne diese Zeile bleibt der Lauf stehen und der Fremdschlüssel
* meldet sich.
*/
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Hauptablauf" } },
select: { id: true },
});
const kennungen = laeufe.map((lauf) => lauf.id);
if (kennungen.length > 0) {
await prisma.kommentar.deleteMany({ where: { laufId: { in: kennungen } } });
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});