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
+284
View File
@@ -0,0 +1,284 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { SEED_TAG, SEED_VORLAGE, anmelden, betriebsmodus } from "./helfer";
/**
* Am Handy scrollt nichts nach rechts (#018).
*
* Die Zusage steht in CLAUDE.md und im Auftrag: **bei 390 px kein horizontales
* Scrollen**, auf keiner Hauptseite. Sie ist keine Geschmacksfrage. Ein Techniker
* arbeitet einen Lauf einhändig neben einem aufgeschraubten Rechner ab; eine
* Seite, die seitwärts wegläuft, kostet ihn bei jedem Haken einen Griff.
*
* Geprüft wird das schmalste Maß, das es gibt: `scrollWidth <= clientWidth` am
* `html`-Element. Und geprüft wird es **auf allen** Hauptseiten, nicht auf einer
* Auswahl — die eine Seite, die jemand vergisst, ist die, auf der es bricht.
*
* Die Meldung nennt die Seite, die Zahlen und das breiteste Element. „expected
* true“ beschreibt zwar denselben Fehler, aber niemand kann damit etwas anfangen:
* bei einer Seite mit dreihundert Kästen ist die Frage nicht *ob*, sondern *wo*.
*
* Der Ablauf legt an, was er braucht — einen Lauf für die Abarbeitungsansicht und
* einen Entwurf für den Editor — und räumt beides weg. Ein Ablauf, der sich
* darauf verlässt, dass zufällig ein Lauf in der Datenbank steht, scheitert gegen
* eine frische Installation mit „No record was found“, und das sagt nichts über
* die Ursache (Befund aus #017).
*/
const STEMPEL = Date.now();
const LAUF_TITEL = `ZZ E2E Mobil ${STEMPEL}`;
const ENTWURF_NAME = `ZZ E2E Mobil Entwurf ${STEMPEL}`;
let laufId = "";
let geraetId = "";
let mitarbeiterId = "";
let vorlageId = "";
let entwurfId = "";
let entwurfVorlageId = "";
/*
* Nur im Projekt „handy“. Auf dem Desktop prüfte derselbe Ablauf 1440 px gegen
* 1440 px und wäre immer grün — grün ohne Aussage ist schlimmer als rot.
*/
test.skip(({ isMobile }) => !isMobile, "Nur im Projekt „handy“ — 390 px ist die Zusage.");
/**
* **390 × 844, ausdrücklich.**
*
* Das Projekt „handy“ benutzt `devices["Pixel 7"]`, und das sind 412 × 839. Die
* Zusage lautet aber auf 390 px (CLAUDE.md, „Gestaltung“; Abnahmekriterium in
* #018), und 22 px sind kein Rundungsfehler: eine Seite, die bei 390 px
* ausbricht, kann bei 412 px tadellos aussehen. Die Zahl, auf die es ankommt,
* steht deshalb hier und nicht in einem Gerätenamen, dessen Maße sich mit der
* nächsten Playwright-Fassung ändern dürfen.
*/
test.use({ viewport: { width: 390, height: 844 } });
test.beforeAll(async () => {
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
geraetId = geraet.id;
const person = await prisma.mitarbeiter.findFirst({
where: { aktiv: true, kundeId: geraet.kundeId },
orderBy: { anzeigename: "asc" },
select: { id: true },
});
if (!person) throw new Error("Es gibt keine aktive Person — pnpm db:seed laufen lassen.");
mitarbeiterId = person.id;
const vorlage = await prisma.vorlage.findFirstOrThrow({
where: { name: SEED_VORLAGE },
select: {
id: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: { id: true },
},
},
});
vorlageId = vorlage.id;
const versionId = vorlage.versionen[0]?.id;
if (!versionId) throw new Error(`Die Vorlage „${SEED_VORLAGE}“ hat keine veröffentlichte Version.`);
/*
* Ein Lauf in Arbeit: er trägt die langen Befehlszeilen und die Hilfetexte, und
* das sind die beiden Stellen, an denen eine Seite am Handy nach rechts
* ausbricht. Der Kunde kommt vom Gerät — ein zusammengesetzter Fremdschlüssel
* lässt kein Gerät eines fremden Kunden zu.
*/
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: versionId,
kundeId: geraet.kundeId,
geraetId: geraet.id,
mitarbeiterId,
titel: LAUF_TITEL,
status: "IN_ARBEIT",
gestartetAm: new Date(),
},
select: { id: true },
});
laufId = lauf.id;
/*
* Ein eigener Entwurf für den Editor. Nicht „irgendein Entwurf“ aus der
* Datenbank: eine frische Installation bringt bewusst keinen mit (so in #017
* entschieden), und die Seed-Vorlagen zum Bearbeiten zu öffnen hinterließe
* einen Entwurf, den niemand angelegt hat.
*/
const eigene = await prisma.vorlage.create({
data: { name: ENTWURF_NAME, kategorie: "ZZ Test" },
select: { id: true },
});
entwurfVorlageId = eigene.id;
const entwurf = await prisma.vorlagenVersion.create({
data: { vorlageId: eigene.id, version: 1, status: "ENTWURF" },
select: { id: true },
});
entwurfId = entwurf.id;
await prisma.vorlagenVariable.create({
data: {
versionId: entwurf.id,
schluessel: "hostname",
beschriftung: "Hostname des neuen Geräts",
position: 0,
pflicht: true,
},
});
const abschnitt = await prisma.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: "Vorbereitung", position: 0 },
select: { id: true },
});
await prisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Rechner umbenennen",
position: 0,
typ: "BEFEHL",
pflicht: true,
// Absichtlich lang: ein kurzer Befehl bricht keine Seite auf.
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostname}}" -DomainCredential (Get-Credential) -Restart -Force',
},
},
});
});
/** Eine Hauptseite. Die Adresse kommt als Funktion, weil die Kennungen erst in `beforeAll` entstehen. */
type Hauptseite = {
readonly name: string;
readonly pfad: () => string;
/** Nur in diesem Betriebsmodus vorhanden. Sonst gibt es die Seite nicht. */
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
};
const SEITEN: readonly Hauptseite[] = [
{ name: "Übersicht", pfad: () => "/" },
{ name: "Läufe", pfad: () => "/laeufe" },
{ name: "Lauf-Detail", pfad: () => `/laeufe/${laufId}` },
{ name: "Lauf starten", pfad: () => "/laeufe/starten" },
{ name: "Vorlagen", pfad: () => "/vorlagen" },
{ name: "Vorlagen-Detail", pfad: () => `/vorlagen/${vorlageId}` },
{ name: "Vorlagen-Editor", pfad: () => `/vorlagen/version/${entwurfId}` },
{ name: "Mitarbeiter", pfad: () => "/mitarbeiter" },
{ name: "Mitarbeiter-Detail", pfad: () => `/mitarbeiter/${mitarbeiterId}` },
{ name: "Geräte", pfad: () => "/geraete" },
{ name: "Geräteseite", pfad: () => `/geraete/${geraetId}` },
{ name: "Archiv", pfad: () => "/archiv" },
{ name: "Tags", pfad: () => "/tags" },
{ name: "Scannen", pfad: () => "/scannen" },
{ name: "Admin: Einstellungen", pfad: () => "/admin" },
{ name: "Admin: Benutzer", pfad: () => "/admin/benutzer" },
{ name: "Admin: Abteilungen", pfad: () => "/admin/abteilungen" },
/*
* Das Verzeichnis gibt es nur im Modus INTERN (#008); im Modus DIENSTLEISTUNG
* leitet die Seite auf `/admin` um. Sie dort zu messen hiesse, die Umleitung
* zu messen — die scrollt garantiert nicht.
*/
{ name: "Admin: Verzeichnis", pfad: () => "/admin/verzeichnis", nurModus: "INTERN" },
{ name: "Kunden", pfad: () => "/kunden", nurModus: "DIENSTLEISTUNG" },
];
type Messung = {
readonly scroll: number;
readonly sichtbar: number;
readonly uebeltaeter: readonly string[];
};
/**
* Misst die Seite und benennt, was zu weit rechts endet.
*
* Gemeldet wird nur das **äußerste** Element je Kette: ein Kind, dessen Elter
* schon zu breit ist, sagt nichts Neues und würde die Meldung mit dreißig
* Einträgen zuschütten, die alle dieselbe Ursache haben.
*/
async function messen(seite: Page): Promise<Messung> {
return seite.evaluate(() => {
const wurzel = document.documentElement;
const sichtbar = wurzel.clientWidth;
const gemeldet: Element[] = [];
for (const element of Array.from(document.body.querySelectorAll("*"))) {
const kasten = element.getBoundingClientRect();
if (kasten.width === 0 && kasten.height === 0) continue;
// Ein Pixel Spielraum: gebrochene Bildschirmbreiten runden.
if (kasten.right <= sichtbar + 1) continue;
if (gemeldet.some((frueher) => frueher.contains(element))) continue;
gemeldet.push(element);
}
const benennen = (element: Element): string => {
const klassen = (element.getAttribute("class") ?? "")
.split(/\s+/u)
.filter(Boolean)
.slice(0, 3)
.join(".");
const text = (element.textContent ?? "").trim().replace(/\s+/gu, " ").slice(0, 40);
const breite = Math.round(element.getBoundingClientRect().right);
return `<${element.tagName.toLowerCase()}${klassen ? `.${klassen}` : ""}> endet bei ${breite} px${
text ? ` („${text}“)` : ""
}`;
};
return {
scroll: wurzel.scrollWidth,
sichtbar,
uebeltaeter: gemeldet.slice(0, 3).map(benennen),
};
});
}
test.describe("Am Handy bei 390 px", () => {
for (const hauptseite of SEITEN) {
test(`scrollt „${hauptseite.name}“ nicht horizontal`, async ({ page }) => {
test.slow();
test.skip(
hauptseite.nurModus !== undefined && hauptseite.nurModus !== betriebsmodus(),
`„${hauptseite.name}“ gibt es nur im Modus ${hauptseite.nurModus}.`,
);
await anmelden(page);
const pfad = hauptseite.pfad();
await page.goto(pfad);
/*
* Erst prüfen, dass die Seite überhaupt die gemeinte ist: eine
* Weiterleitung auf `/anmelden` scrollt garantiert nicht, und der Ablauf
* hätte grün gemeldet, ohne die Seite gesehen zu haben.
*/
expect(new URL(page.url()).pathname, `${pfad} hat weitergeleitet.`).toBe(pfad);
await page.waitForLoadState("load");
await expect(page.locator("main, body").first()).toBeVisible();
const masse = await messen(page);
expect(
masse.scroll,
`„${hauptseite.name}“ (${pfad}) scrollt horizontal: erwartet <= ${masse.sichtbar}, ` +
`war ${masse.scroll} — ${masse.scroll - masse.sichtbar} px zu breit.` +
(masse.uebeltaeter.length > 0 ? `\n Zu weit rechts: ${masse.uebeltaeter.join("\n ")}` : ""),
).toBeLessThanOrEqual(masse.sichtbar);
});
}
});
test.afterAll(async () => {
if (laufId) {
await prisma.kommentar.deleteMany({ where: { laufId } });
await prisma.schrittergebnis.deleteMany({ where: { laufId } });
// `Protokoll.laufId` steht auf `Restrict` – sonst bleibt der Lauf stehen.
await prisma.protokoll.deleteMany({ where: { OR: [{ laufId }, { entitaetId: laufId }] } });
await prisma.lauf.deleteMany({ where: { id: laufId } });
}
if (entwurfVorlageId) {
// Ein Entwurf lässt sich löschen; eine veröffentlichte Version nie. Deshalb
// wird hier einer angelegt und keiner veröffentlicht.
await prisma.vorlage.deleteMany({ where: { id: entwurfVorlageId } });
}
await prisma.$disconnect();
});