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,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();
|
||||
});
|
||||
Reference in New Issue
Block a user