Neun Kapitel unter docs/anleitung/ fuer Technik und Verwaltung, mit 35 Bildern aus `pnpm docs:screenshots` und einem Fehlerbehebungskapitel aus echten Meldungen. `pnpm docs:pdf` macht daraus 72 Seiten A4 — hell gedruckt, mit Deckblatt, Inhaltsverzeichnis und Seitenzahlen, ohne neue Abhaengigkeit. Die Ablaeufe pruefen ihre Voraussetzungen, bevor der erste startet: antwortet ein gebauter Stand, steht der Seed-Bestand, und liest der Server dieselbe Datenbank wie der Testprozess. Jeder Ablauf legt an, was er braucht, und raeumt es weg. Vier neue kommen dazu: Hauptablauf, Onboarding mit Vergleich der Zwischenablage, Rollen und 390 px auf achtzehn Seiten. Drei Fehler kamen dabei heraus. Das Projekt „handy" pruefte 412 px statt der verlangten 390. Ein angemeldeter Benutzer ohne Recht erfuhr nie, warum er abgewiesen wurde — die Meldung stand auf einer Seite, die ihn sofort weiterleitet; jetzt gibt es /kein-zugriff. Und der Verzeichnis-Ablauf schrieb die Attributzuordnung der ersten vorhandenen Quelle um, in einer echten Installation also der produktiven. 101 Ablaeufe in INTERN und 97 in DIENSTLEISTUNG, beide ohne Fehlschlag. Closes #18 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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();
|
||
});
|