feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.
Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.
Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.
Closes #15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Das Archiv am gebauten Stand (#015).
|
||||
*
|
||||
* Die Suche selbst prueft `tests/archiv.test.ts` gegen die Datenbank, bis in die
|
||||
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
|
||||
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
|
||||
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
|
||||
*/
|
||||
|
||||
const STEMPEL = Date.now();
|
||||
const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
|
||||
|
||||
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"]')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
|
||||
*
|
||||
* Direkt ueber Prisma und nicht ueber die Dienste: hier soll **das Archiv**
|
||||
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
|
||||
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
|
||||
* CHECK-Bedingungen in der Datenbank verlangen sie.
|
||||
*/
|
||||
async function archivierterLauf(): Promise<{ id: string; titel: string }> {
|
||||
const version = await prisma.vorlagenVersion.findFirstOrThrow({
|
||||
where: { status: "VEROEFFENTLICHT" },
|
||||
orderBy: { veroeffentlichtAm: "desc" },
|
||||
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
|
||||
});
|
||||
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
|
||||
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
|
||||
if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt.");
|
||||
|
||||
const jetzt = new Date();
|
||||
const titel = `ZZ E2E Archiv ${STEMPEL}`;
|
||||
const lauf = await prisma.lauf.create({
|
||||
data: {
|
||||
vorlagenVersionId: version.id,
|
||||
kundeId: kunde.id,
|
||||
titel,
|
||||
status: "ARCHIVIERT",
|
||||
gestartetAm: jetzt,
|
||||
abgeschlossenAm: jetzt,
|
||||
archiviertAm: jetzt,
|
||||
},
|
||||
});
|
||||
await prisma.schrittergebnis.create({
|
||||
data: {
|
||||
laufId: lauf.id,
|
||||
vorlagenSchrittId: schrittId,
|
||||
zustand: "NICHT_ZUTREFFEND",
|
||||
notiz: NOTIZ,
|
||||
},
|
||||
});
|
||||
return { id: lauf.id, titel };
|
||||
}
|
||||
|
||||
test.describe("Archiv", () => {
|
||||
test("findet einen Lauf über ein Wort aus einer Schritt-Notiz", async ({ page }) => {
|
||||
const lauf = await archivierterLauf();
|
||||
await anmelden(page);
|
||||
|
||||
// Am Handy steckt die Navigation im Menü – der Weg dorthin gehört zum Test.
|
||||
if (test.info().project.name === "handy") {
|
||||
await page.getByLabel("Menü").click();
|
||||
}
|
||||
await page.getByRole("link", { name: "Archiv" }).first().click();
|
||||
await page.waitForURL("**/archiv");
|
||||
await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`);
|
||||
await page.getByRole("button", { name: "Suchen" }).click();
|
||||
|
||||
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
|
||||
await expect(treffer).toHaveCount(1);
|
||||
// Und der Grund steht dabei: Fundstelle und Ausschnitt.
|
||||
await expect(treffer.getByText("Notiz")).toBeVisible();
|
||||
await expect(treffer.getByText(/Restposten/)).toBeVisible();
|
||||
|
||||
await treffer.getByRole("link").first().click();
|
||||
await page.waitForURL(`**/laeufe/${lauf.id}`);
|
||||
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("sagt, dass ein einzelnes Zeichen nicht sucht", async ({ page }) => {
|
||||
// Ein stilles Nichtstun wäre die schlechtere Antwort: der Suchende wartet.
|
||||
await anmelden(page);
|
||||
await page.goto("/archiv?suche=a");
|
||||
// Die Meldung **zum Ergebnis**, nicht der Hinweis am Formular: beide nennen
|
||||
// die zwei Zeichen, und nur die erste sagt, dass gerade nichts gesucht wurde.
|
||||
await expect(page.getByText(/ist als Suchbegriff zu kurz/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
await anmelden(page);
|
||||
await page.goto("/archiv?suche=Restposten");
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { titel: { startsWith: "ZZ E2E Archiv" } },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||||
if (kennungen.length > 0) {
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import "dotenv/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Uebersicht und Druckansicht am gebauten Stand (#015).
|
||||
*
|
||||
* Beide Seiten leben von ihrer Darstellung: die Uebersicht sortiert nach
|
||||
* Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt
|
||||
* erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts`
|
||||
* und `tests/druckansicht.test.ts`.
|
||||
*/
|
||||
|
||||
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"]')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt.
|
||||
*
|
||||
* Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg
|
||||
* zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv.
|
||||
*/
|
||||
async function laufMitPasswortschritt(): Promise<string> {
|
||||
const stempel = Date.now();
|
||||
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
|
||||
const vorlage = await prisma.vorlage.create({
|
||||
data: { name: `ZZ E2E Druck ${stempel}`, kategorie: "ZZ Test" },
|
||||
});
|
||||
const version = await prisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
|
||||
});
|
||||
const abschnitt = await prisma.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: "Konto" },
|
||||
});
|
||||
await prisma.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: abschnitt.id,
|
||||
titel: "Konto angelegt",
|
||||
typ: "BEFEHL",
|
||||
konfig: { shell: "powershell", befehl: 'New-ADUser -Password "{{generiertes_passwort}}"' },
|
||||
},
|
||||
});
|
||||
await prisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
});
|
||||
|
||||
const lauf = await prisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: `ZZ E2E Druck ${stempel}` },
|
||||
});
|
||||
return lauf.id;
|
||||
}
|
||||
|
||||
test.describe("Übersicht", () => {
|
||||
test("zeigt die vier Blöcke in ihrer Reihenfolge", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
const ueberschriften = await page.getByRole("heading", { level: 2 }).allInnerTexts();
|
||||
expect(ueberschriften).toEqual(["Überfällig", "Mir zugewiesen", "Zuletzt bearbeitet", "Schnellstart"]);
|
||||
});
|
||||
|
||||
test("führt von einer Zeile in den Lauf", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
const zuletzt = page
|
||||
.locator("section")
|
||||
.filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) });
|
||||
await zuletzt.getByRole("link").first().click();
|
||||
await page.waitForURL(/\/laeufe\/[^/]+$/);
|
||||
});
|
||||
|
||||
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
|
||||
test.skip(!isMobile, "Nur im Projekt „handy“.");
|
||||
await anmelden(page);
|
||||
const masse = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
sichtbar: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Druckansicht", () => {
|
||||
test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => {
|
||||
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
|
||||
await anmelden(page);
|
||||
const lauf = await prisma.lauf.findFirstOrThrow({ select: { id: true } });
|
||||
await page.goto(`/drucken/lauf/${lauf.id}`);
|
||||
|
||||
// Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt.
|
||||
await expect(page.getByRole("navigation", { name: "Hauptnavigation" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Scannen" })).toHaveCount(0);
|
||||
|
||||
// Hell heisst hell: der Grund des Belegs ist keine dunkle Fläche.
|
||||
const grund = await page
|
||||
.locator(".druck-hell")
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(grund).toBe("rgb(255, 255, 255)");
|
||||
|
||||
/*
|
||||
* A4 kommt aus `@page`, und nur ein echtes PDF zeigt, ob die Regel greift:
|
||||
* 210 × 297 mm sind 595 × 842 Punkte.
|
||||
*/
|
||||
const pdf = await page.pdf({ preferCSSPageSize: true });
|
||||
const text = pdf.toString("latin1");
|
||||
const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text);
|
||||
expect(treffer).not.toBeNull();
|
||||
expect(Number(treffer?.[3]) - Number(treffer?.[1])).toBeCloseTo(595, 0);
|
||||
expect(Number(treffer?.[4]) - Number(treffer?.[2])).toBeCloseTo(842, 0);
|
||||
});
|
||||
|
||||
test("ist vom Lauf aus erreichbar und erzeugt kein Passwort", async ({ page }) => {
|
||||
/*
|
||||
* Der Lauf bringt seine eigene Vorlage mit — eine **mit**
|
||||
* `{{generiertes_passwort}}` im Befehl. Vorher lief dieser Test gegen
|
||||
* NB-001, dessen Vorlage den Platzhalter gar nicht enthält: er hätte auch
|
||||
* dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund
|
||||
* zu #015).
|
||||
*/
|
||||
const laufId = await laufMitPasswortschritt();
|
||||
await anmelden(page);
|
||||
await page.goto(`/laeufe/${laufId}`);
|
||||
await page.getByRole("link", { name: "Drucken" }).click();
|
||||
await page.waitForURL(/\/drucken\/lauf\//);
|
||||
|
||||
const blatt = await page.locator("body").innerText();
|
||||
expect(blatt).toContain("[Passwort – nicht gespeichert]");
|
||||
expect(blatt).not.toContain("generiertes_passwort");
|
||||
|
||||
/*
|
||||
* Und der zweite Beweis, der ohne Kenntnis des Textes auskommt: ein
|
||||
* erzeugtes Passwort wäre bei jedem Aufbau ein anderes. Sind beide Blätter
|
||||
* Zeichen für Zeichen gleich, kann keines darin stecken.
|
||||
*/
|
||||
await page.reload();
|
||||
const nochmal = await page.locator("body").innerText();
|
||||
expect(nochmal.replace(/Gedruckt am[^\n]*/u, "")).toBe(blatt.replace(/Gedruckt am[^\n]*/u, ""));
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const laeufe = await prisma.lauf.findMany({
|
||||
where: { titel: { startsWith: "ZZ E2E Druck" } },
|
||||
select: { id: true },
|
||||
});
|
||||
const kennungen = laeufe.map((lauf) => lauf.id);
|
||||
if (kennungen.length > 0) {
|
||||
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
|
||||
await prisma.protokoll.deleteMany({
|
||||
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
|
||||
});
|
||||
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
|
||||
}
|
||||
await prisma.vorlage.deleteMany({ where: { name: { startsWith: "ZZ E2E Druck" } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user