feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+178
View File
@@ -0,0 +1,178 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Der Vorlagen-Editor durch die Oberflaeche (#010).
*
* Diese Datei gibt es, weil ein Screenshot ueber Bedienbarkeit **nichts**
* aussagt: die Seiten werden serverseitig gerendert und sehen auch dann richtig
* aus, wenn React sich nie anhaengt. Geprueft wird deshalb, was nur mit
* angehaengtem React gelingt — anlegen, tippen, sortieren, speichern — und
* anschliessend, ob es wirklich in der Datenbank steht: durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
* „Oberflaeche pruefen".
*
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
* nicht ueber einen Kaskadenloeschvorgang — der Trigger aus #009 verweigert das
* ausdruecklich, und genau das ist die Zusage, an der das ganze Archiv haengt.
* Wer diese Tests oft laufen laesst, braucht deshalb eine Wegwerf-Datenbank
* (kommt mit #018).
*/
const NAME = `ZZ E2E Editor ${Date.now()}`;
async function anmelden(seite: Page) {
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.");
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"]')]);
}
/** Legt die Vorlage an und fuehrt in ihren Entwurf. Gibt die Adresse des Editors zurueck. */
async function entwurfAnlegen(seite: Page, name: string): Promise<string> {
await seite.goto("/vorlagen");
await seite.getByLabel("Name").fill(name);
await seite.getByRole("button", { name: "Vorlage anlegen" }).click();
await expect(seite.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
await seite.goto("/vorlagen");
await seite.getByRole("link", { name }).click();
await seite.getByRole("button", { name: /bearbeiten/i }).click();
await seite.waitForURL(/\/vorlagen\/version\//);
return seite.url();
}
async function aufraeumen(seite: Page, name: string) {
await seite.goto("/vorlagen");
const verweis = seite.getByRole("link", { name });
if ((await verweis.count()) === 0) return;
await verweis.click();
await seite.getByRole("button", { name: "archivieren" }).click();
await expect(seite.getByRole("button", { name: "wieder freigeben" })).toBeVisible();
}
test.describe("Vorlagen-Editor", () => {
test("legt an, baut auf, speichert und veröffentlicht", async ({ page }) => {
test.slow();
const name = `${NAME} A`;
await anmelden(page);
await entwurfAnlegen(page, name);
// Ein Abschnitt mit einem Befehlsschritt.
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").fill("Vorbereitung");
const leiste = page.locator("form.sticky");
await expect(leiste).toContainText("Nicht gespeicherte Änderungen");
await page.getByRole("button", { name: "Schritt hinzufügen" }).click();
await page.getByLabel("Titel", { exact: true }).last().fill("Rechner umbenennen");
await leiste.getByRole("button", { name: "Speichern" }).click();
await expect(leiste).toContainText("Entwurf gespeichert.");
await expect(leiste).toContainText("Alles gespeichert");
// Der Beweis, dass es nicht nur im Browser steht: neu laden.
await page.reload();
await expect(page.getByLabel("Titel des Abschnitts")).toHaveValue("Vorbereitung");
await expect(page.locator("form.sticky")).toContainText("Alles gespeichert");
// Veröffentlichen macht die Version unveränderlich.
await page.getByLabel("Änderungsnotiz (optional)").fill("Erste Fassung");
await page.getByRole("button", { name: "Veröffentlichen", exact: true }).click();
/*
* Die Erfolgsmeldung steht im Veroeffentlichen-Formular – und genau das ist
* nach dem `router.refresh()` verschwunden. Geprueft wird deshalb der
* Zustand danach, nicht die Meldung: das ist ohnehin die haerteren Zusage.
*/
await expect(page.locator("form.sticky")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Verschieben" })).toHaveCount(0);
await expect(page.getByText(/ist veröffentlicht und wird nicht mehr verändert/)).toBeVisible();
await aufraeumen(page, name);
});
test("warnt vor einem unbekannten Platzhalter, ohne zu blockieren", async ({ page }) => {
test.slow();
const name = `${NAME} B`;
await anmelden(page);
await entwurfAnlegen(page, name);
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").fill("Installation");
await page.getByRole("button", { name: "Schritt hinzufügen" }).click();
await page.getByLabel("Titel", { exact: true }).last().fill("{{hostnmae}} setzen");
await expect(page.getByText(/gehört zu keiner Variablen/)).toBeVisible();
await expect(page.getByText("{{hostnmae}}", { exact: true })).toBeVisible();
// Die Warnung ist eine Warnung: Speichern bleibt möglich.
const leiste = page.locator("form.sticky");
await leiste.getByRole("button", { name: "Speichern" }).click();
await expect(leiste).toContainText("Entwurf gespeichert.");
// Ein eingebauter Platzhalter loest keine Warnung aus.
await page.getByLabel("Titel", { exact: true }).last().fill("{{hostname}} setzen");
await expect(page.getByText(/gehört zu keiner Variablen/)).toHaveCount(0);
await aufraeumen(page, name);
});
test("sortiert am Handy mit dem Finger", async ({ page, isMobile, viewport }) => {
test.skip(!isMobile, "Nur im Projekt „handy“ – am Schreibtisch gibt es keinen Finger.");
test.slow();
const name = `${NAME} C`;
await anmelden(page);
await entwurfAnlegen(page, name);
for (const titel of ["Erster", "Zweiter"]) {
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").last().fill(titel);
}
const werte = async () =>
page
.getByLabel("Titel des Abschnitts")
.evaluateAll((felder) => felder.map((feld) => (feld as HTMLInputElement).value));
expect(await werte()).toEqual(["Erster", "Zweiter"]);
// Der Anfasser ist mindestens 44 px hoch – sonst trifft ihn niemand.
const anfasser = page.getByRole("button", { name: "Verschieben" }).first();
const kasten = await anfasser.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(kasten?.width ?? 0).toBeGreaterThanOrEqual(44);
// Halten, ziehen, loslassen. Der TouchSensor wartet bewusst 200 ms, damit
// eine Wischgeste ein Bildlauf bleibt und kein Verschieben.
const x = kasten!.x + kasten!.width / 2;
const y = kasten!.y + kasten!.height / 2;
const sitzung = await page.context().newCDPSession(page);
await sitzung.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
await page.waitForTimeout(350);
for (let schritt = 1; schritt <= 12; schritt += 1) {
await sitzung.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y: y + schritt * 25 }],
});
await page.waitForTimeout(40);
}
await sitzung.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(werte).toEqual(["Zweiter", "Erster"]);
// Und dabei wird nicht horizontal gescrollt.
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
expect(viewport?.width).toBeLessThanOrEqual(430);
await aufraeumen(page, name);
});
});