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
+138
View File
@@ -0,0 +1,138 @@
import { expect, type Locator, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Was alle Ende-zu-Ende-Abläufe teilen (#018).
*
* Bis hierher hatte jede Datei ihr eigenes `anmelden()` — zehnmal dasselbe, und
* jede Verbesserung musste zehnmal nachgezogen werden. Wichtiger als die
* Wiederholung ist aber, was hier sonst noch steht: **die Voraussetzungen eines
* Laufs, geprüft statt im Kopfkommentar behauptet.**
*
* In #017 kostete beides einen halben Abend. Die Abläufe wählten ihre Vorlage
* über die Position in der Liste, und der Testprozess schrieb seine Daten in
* eine andere Datenbank, als der Server las. Beide Male sagte die Meldung nichts
* über die Ursache: einmal „Timeout 30000ms exceeded“, einmal „0 Treffer“.
*/
/** Die Vorlage, mit der die Abläufe arbeiten — aus dem Seed, nicht aus einem Skript. */
export const SEED_VORLAGE = "PC Neuinstallation";
/** Das Beispielgerät aus dem Seed. Sein Aufkleber steht im Abnahmekriterium. */
export const SEED_GERAET = "PC-001";
export const SEED_TAG = "LZ-7K3F9Q";
export async function anmelden(seite: Page): Promise<void> {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) {
throw new Error(
"SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen. Sie stehen in der .env und müssen zu dem " +
"Administrator passen, den der Seed angelegt hat.",
);
}
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
/**
* Wählt eine Vorlage im Start-Dialog **am Namen**.
*
* Nicht über `{ index: 1 }`: das ist eine Annahme über die Sortierung der
* Vorlagenliste, und sie hielt genau so lange, wie es eine einzige Vorlage gab.
* Steht die gesuchte nicht zur Auswahl, sagt die Meldung, welche fehlt — nicht
* „Timeout beim Warten auf die Adresse“.
*/
export async function vorlageWaehlen(seite: Page, name: string): Promise<void> {
const optionen = await seite
.locator('select[name="vorlagenVersionId"] option')
.evaluateAll((liste) =>
liste
.filter((eintrag): eintrag is HTMLOptionElement => eintrag instanceof HTMLOptionElement)
.map((eintrag) => ({ wert: eintrag.value, text: eintrag.textContent ?? "" })),
);
const treffer = optionen.find((eintrag) => eintrag.text.includes(name));
if (!treffer) {
const vorhanden = optionen.map((eintrag) => eintrag.text).join(", ");
throw new Error(`Die Vorlage „${name}“ steht nicht zur Auswahl. Zur Wahl stehen: ${vorhanden}`);
}
await seite.selectOption('select[name="vorlagenVersionId"]', treffer.wert);
// Die Variablenfelder kommen nach, sobald der Server die Vorlage geliefert hat.
await expect(seite.locator("fieldset")).toBeVisible();
}
/**
* Füllt alle Pflichtfelder des Start-Dialogs, die noch leer sind.
*
* Bewusst **nur leere**: ein Vorschlag aus den Namensregeln soll stehen bleiben,
* sonst prüfte der Ablauf danach seine eigene Eingabe statt der Regel.
*/
export async function variablenFuellen(seite: Page, ausnahmen: readonly string[] = []): Promise<void> {
const felder = seite.locator("fieldset label");
for (let nummer = 0; nummer < (await felder.count()); nummer += 1) {
const feld = felder.nth(nummer);
const beschriftung = (await feld.locator("span").first().innerText()).replace("*", "").trim();
if (ausnahmen.some((ausnahme) => beschriftung.includes(ausnahme))) continue;
const auswahl = feld.locator("select");
if ((await auswahl.count()) > 0) {
await auswahl.selectOption({ index: 1 });
continue;
}
const eingabe = feld.locator("input");
if ((await eingabe.count()) === 0) continue;
if ((await eingabe.inputValue()) !== "") continue;
const typ = await eingabe.getAttribute("type");
if (typ === "date") await eingabe.fill("2026-10-01");
else if (typ === "number") await eingabe.fill("16");
else await eingabe.fill("E2E");
}
}
/**
* Waehlt den Kunden, wo das Formular danach fragt.
*
* Im Modus INTERN gibt es das Feld nicht — die eigene Organisation ist gesetzt,
* und der Server verwirft eine mitgeschickte Kennung ohnehin (#008). Im Modus
* DIENSTLEISTUNG ist es Pflicht, und ohne Auswahl antwortet der Dienst mit
* „Im Modus Dienstleistung muss ein Kunde gewählt werden“.
*
* `kundeId` gibt man an, wenn es auf **diesen** Kunden ankommt: ein Lauf kann
* strukturell kein Geraet und keine Abteilung eines fremden Kunden waehlen, das
* halten zusammengesetzte Fremdschluessel in der Datenbank fest. Ohne Angabe
* nimmt der Helfer den ersten — richtig ueberall dort, wo nur irgendein Kunde
* gebraucht wird.
*/
export async function kundeWaehlen(
/**
* Seite **oder** der Bereich, in dem das Feld steht. Der Bereich zaehlt, wo
* mehrere Formulare nebeneinander stehen: die Geraeteseite hat eines zum
* Anlegen und eines zum Einlesen, und `.first()` traefe dann das falsche.
*/
bereich: Page | Locator,
kundeId?: string,
): Promise<boolean> {
const feld = bereich.locator('select[name="kundeId"]').first();
if ((await feld.count()) === 0 || !(await feld.isVisible())) return false;
await feld.selectOption(kundeId ?? { index: 1 });
return true;
}
/**
* Der Betriebsmodus der Installation unter Test.
*
* Gesetzt von `global-setup.ts` aus der Datenbank. Ein Ablauf, der nur in einem
* der beiden Modi Sinn ergibt, fragt hier und ueberspringt sich mit einem Grund
* — statt in einer Oberflaeche zu suchen, die es dort nicht gibt.
*/
export function betriebsmodus(): "INTERN" | "DIENSTLEISTUNG" {
return process.env["E2E_BETRIEBSMODUS"] === "DIENSTLEISTUNG" ? "DIENSTLEISTUNG" : "INTERN";
}
/** Das Beispielgerät heisst im Modus DIENSTLEISTUNG anders — dieselbe Regel wie im Seed. */
export function geraetName(): string {
return betriebsmodus() === "DIENSTLEISTUNG" ? "MUS-PC-001" : SEED_GERAET;
}