feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
This commit was merged in pull request #36.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user