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();
|
||
});
|