Files
dogfather-universe/pruef-suche.mjs
T
DogFatherGitandClaude Opus 5 2ae77fc54f Verwaltung: eine Suche ueber alles, mit Tastatur bedienbar
Bisher gab es genau ein Suchfeld, und es durchsuchte nur die
Anfragenliste. Wer den Namen eines Kunden im Kopf hatte, musste raten,
in welchem Reiter er nachsehen muss: War das eine Anfrage, ein laufendes
Projekt, eine offene Rechnung? Bei drei Vorgaengen merkt man sich das,
bei dreissig nicht mehr.

Jetzt: Strg+K von ueberall, auf dem Handy der Lupenknopf oben. Ein
Aufruf durchsucht Anfragen, Kunden, Projekte und Zahlungen; jeder
Treffer traegt seinen Zusammenhang (Nummer, Kunde, Betrag, Liefertermin)
und fuehrt per Enter in den passenden Reiter, bei einer Anfrage direkt in
die Detailansicht.

Gebaut nach dem ARIA-Muster 'Combobox mit Listbox-Popup' aus den W3C
Authoring Practices -- nachgeschlagen, nicht aus dem Gedaechtnis:
role=combobox am Eingabefeld, aria-expanded, aria-controls,
aria-activedescendant, role=listbox, role=option mit aria-selected. Der
Fokus bleibt dabei im Eingabefeld, damit man weitertippen kann; die
Auswahl wandert ueber aria-activedescendant. Ohne diese Auszeichnung
waere ein Feld, das Vorschlaege einblendet, fuer einen Screenreader
stumm -- das sieht man beim Testen mit den Augen nie.

Vier Fallen ausdruecklich behandelt:
- Nicht bei jedem Tastendruck suchen (180 ms Wartezeit): 'Musterbau'
  haette sonst neun Abfragen ausgeloest, acht davon veraltet.
- Das Wettrennen der Antworten: Jede Abfrage bekommt eine laufende
  Nummer, nur die neueste darf zeichnen. Sonst ueberschreibt eine spaet
  eintreffende alte Antwort die neue.
- Leer, laedt und Fehler sind eigene Zustaende. Ein Kasten, der bei
  einem Serverfehler leer bleibt, sieht aus wie 'nichts gefunden'.
- LIKE-Sonderzeichen: '%' waere ein Platzhalter, '_' ein beliebiges
  Zeichen -- und Unterstriche stehen regelmaessig in E-Mail-Adressen.
  Der Fehler ist tueckisch, weil die Suche trotzdem Treffer liefert, nur
  die falschen.

Ausserdem zum dritten Mal dieselbe Spezifitaetsfalle gefunden: '.wd p'
schlug die eigene Regel, der Tastaturhinweis erschien in 17,9px statt
11,5px -- so gross wie der Inhalt, den er erklaert. Jetzt festgenagelt
durch eine Pruefung, die Groessenverhaeltnisse vergleicht.

Geprueft: 28 gegen eine echte Datenbank (darunter alle
LIKE-Sonderzeichen), 64 im Browser auf Computer und Handy inkl. der
ARIA-Vorgaben, des Wettrennens und des Fehlerzustands.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:13:23 +02:00

291 lines
15 KiB
JavaScript

/* =====================================================================
pruef-suche.mjs — die Suche über alles
Geprüft wird gegen das ARIA-Muster "Combobox mit Listbox-Popup" aus den
W3C Authoring Practices, nicht gegen mein Gefühl. Die Vorgaben dort
sind konkret: role=combobox am EINGABEFELD, aria-expanded, aria-controls,
aria-activedescendant auf die ausgewählte Option, role=listbox am
Popup, role=option mit aria-selected an den Einträgen.
Warum das hier wirklich zählt: Ohne diese Auszeichnung ist ein Feld,
das Vorschläge einblendet, für einen Screenreader stumm. Man tippt, es
erscheinen Treffer, angesagt wird nichts. Das sieht man beim Testen
mit den Augen nie.
Zusätzlich die drei Zustände, die man beim Bauen gern vergisst: leer,
lädt, Fehler. Ein Kasten, der bei einem Serverfehler leer bleibt,
sieht aus wie "nichts gefunden" — und man sucht das Problem an der
falschen Stelle.
===================================================================== */
import { chromium, devices } from "playwright";
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { join, extname } from "node:path";
const W = process.cwd();
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
const srv = createServer((q, r) => {
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
if (existsSync(p) && !p.endsWith("/")) {
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
return r.end(readFileSync(p));
}
r.writeHead(404).end("");
});
await new Promise((r) => srv.listen(4353, r));
const ergebnisse = [];
function pruefe(name, ok, zusatz) {
ergebnisse.push({ name, ok });
console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : ""));
}
const TREFFER = [
{ art: "anfrage", id: "a1", titel: "Anna Muster", unten: "A-2608-0001 · [email protected]", marke: "neu", ziel: "anfragen" },
{ art: "kunde", id: "k1", titel: "Anna Muster", unten: "Musterbau GmbH · [email protected]", marke: "aktiv", ziel: "kunden" },
{ art: "projekt", id: "p1", titel: "Website für Musterbau", unten: "P-2608-0001 · Anna Muster", marke: "design", ziel: "projekte" },
{ art: "zahlung", id: "z1", titel: "Anzahlung Website", unten: "Z-2608-0001 · Anna Muster · 300,00 €", marke: "offen", ziel: "zahlungen" },
];
for (const [wo, angaben] of [
["Computer", { viewport: { width: 1440, height: 950 } }],
["Handy", { ...devices["iPhone 14 Pro"] }],
]) {
const browser = await chromium.launch();
const kontext = await browser.newContext(angaben);
const seite = await kontext.newPage();
const protokoll = [];
seite.on("pageerror", (e) => protokoll.push(e.message));
let abfragen = [], langsamMachen = false, fehlerWerfen = false;
await seite.route("**/webdesign/admin/**", async (route) => {
const u = new URL(route.request().url());
if (u.pathname.endsWith("/admin/suche")) {
const q = u.searchParams.get("q") || "";
abfragen.push(q);
if (fehlerWerfen) return route.fulfill({ status: 500, json: { ok: false, error: "Serverfehler" } });
if (langsamMachen) await new Promise((r) => setTimeout(r, 600));
if (q.trim().length < 2) return route.fulfill({ json: { ok: true, begriff: q, zuKurz: true, treffer: [], anzahl: 0 } });
if (/nix/.test(q)) return route.fulfill({ json: { ok: true, begriff: q, zuKurz: false, treffer: [], anzahl: 0 } });
return route.fulfill({ json: { ok: true, begriff: q, zuKurz: false, treffer: TREFFER,
anzahl: TREFFER.length, begrenzt: false, grenzeJeBereich: 6 } });
}
if (u.pathname.endsWith("/admin/uebersicht")) {
return route.fulfill({ json: { ok: true,
beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 0 },
beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0,00 €" },
geld: { monat: { n: 0, brutto: 0, netto: 0, bruttoText: "0,00 €", nettoText: "0,00 €" },
gesamt: { n: 0, bruttoText: "0,00 €" }, gebuehrenText: "0,00 €" },
projekte: [], verlauf: [], bestand: {} } });
}
return route.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], zahlungen: [], verlaeufe: [] } });
});
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } }));
await seite.addInitScript(() => {
sessionStorage.setItem("wd-vw-token", "t");
sessionStorage.setItem("wd-sitzung-aktiv", "1");
});
await seite.goto("http://127.0.0.1:4353/webdesign/verwaltung.html");
await seite.waitForTimeout(700);
console.log("\n=== " + wo + " ===");
/* ---- 1. Öffnen ---- */
pruefe(wo + ": die Palette ist zunaechst zu",
!(await seite.locator("#vw-palette").isVisible()));
await seite.click("#vw-suche-auf");
await seite.waitForTimeout(300);
pruefe(wo + ": der Lupenknopf oeffnet sie", await seite.locator("#vw-palette").isVisible());
pruefe(wo + ": der Fokus steht sofort im Suchfeld",
await seite.evaluate(() => document.activeElement.id === "vw-pal-feld"),
await seite.evaluate(() => document.activeElement.id));
/* ---- 2. ARIA im Ruhezustand ---- */
const ruhe = await seite.evaluate(() => {
const f = document.getElementById("vw-pal-feld");
return { rolle: f.getAttribute("role"), auf: f.getAttribute("aria-expanded"),
steuert: f.getAttribute("aria-controls"), auto: f.getAttribute("aria-autocomplete"),
listenRolle: document.getElementById("vw-pal-liste").getAttribute("role") };
});
pruefe(wo + ": das Eingabefeld traegt role=combobox", ruhe.rolle === "combobox", ruhe.rolle);
pruefe(wo + ": aria-expanded ist ohne Treffer false", ruhe.auf === "false", ruhe.auf);
pruefe(wo + ": aria-controls zeigt auf die Liste", ruhe.steuert === "vw-pal-liste", ruhe.steuert);
pruefe(wo + ": aria-autocomplete=list ist gesetzt", ruhe.auto === "list", ruhe.auto);
pruefe(wo + ": die Liste traegt role=listbox", ruhe.listenRolle === "listbox", ruhe.listenRolle);
/* ---- 3. Zu kurz ist ein eigener Zustand ---- */
await seite.fill("#vw-pal-feld", "a");
await seite.waitForTimeout(450);
pruefe(wo + ": ein einzelner Buchstabe zeigt einen Hinweis, keine leere Liste",
/Mindestens zwei/.test(await seite.locator("#vw-pal-liste").innerText()),
await seite.locator("#vw-pal-liste").innerText());
/* ---- 4. Suchen und ARIA bei Treffern ---- */
abfragen = [];
await seite.fill("#vw-pal-feld", "Muster");
await seite.waitForTimeout(500);
const treffer = await seite.locator('#vw-pal-liste [role="option"]').count();
pruefe(wo + ": alle vier Treffer erscheinen", treffer === 4, treffer);
pruefe(wo + ": jede Art ist erkennbar",
(await seite.locator("#vw-pal-liste").innerText()).includes("ANFRAGE")
|| (await seite.locator("#vw-pal-liste").innerText()).toLowerCase().includes("anfrage"),
(await seite.locator("#vw-pal-liste").innerText()).slice(0, 60));
const mitTreffern = await seite.evaluate(() => {
const f = document.getElementById("vw-pal-feld");
const erste = document.querySelector('#vw-pal-liste [role="option"]');
return { auf: f.getAttribute("aria-expanded"), aktiv: f.getAttribute("aria-activedescendant"),
ersteId: erste.id, gewaehlt: erste.getAttribute("aria-selected") };
});
pruefe(wo + ": aria-expanded steht jetzt auf true", mitTreffern.auf === "true", mitTreffern.auf);
pruefe(wo + ": aria-activedescendant zeigt auf den ersten Treffer",
mitTreffern.aktiv === mitTreffern.ersteId, mitTreffern.aktiv + " vs. " + mitTreffern.ersteId);
pruefe(wo + ": der erste Treffer ist aria-selected", mitTreffern.gewaehlt === "true", mitTreffern.gewaehlt);
pruefe(wo + ": die Trefferzahl wird angesagt (role=status)",
/4 Treffer/.test(await seite.locator("#vw-pal-zahl").innerText()),
await seite.locator("#vw-pal-zahl").innerText());
/* ---- 4b. Groessenverhaeltnisse ----
Der Tastaturhinweis unten darf nicht so gross sein wie der Inhalt,
den er erklaert. Genau das passierte, solange die Regel .wd p die
eigene ueberstimmte: 17,9px statt 11,5px. Dieselbe Falle wie schon
dreimal zuvor in diesem Projekt, deshalb hier festgenagelt. */
const groessen = await seite.evaluate(() => ({
fuss: parseFloat(getComputedStyle(document.querySelector(".vw-palette-fuss")).fontSize),
zahl: parseFloat(getComputedStyle(document.querySelector(".vw-palette-zahl")).fontSize),
titel: parseFloat(getComputedStyle(document.querySelector(".vw-pal-titel")).fontSize),
}));
pruefe(wo + ": der Tastaturhinweis ist kleiner als ein Treffer",
groessen.fuss < groessen.titel,
"Hinweis " + groessen.fuss + "px, Treffer " + groessen.titel + "px");
pruefe(wo + ": der Zaehler ebenfalls",
groessen.zahl < groessen.titel,
"Zaehler " + groessen.zahl + "px, Treffer " + groessen.titel + "px");
/* ---- 5. Nicht bei jedem Tastendruck suchen ---- */
pruefe(wo + ": es ging genau EINE Abfrage hinaus, nicht sechs",
abfragen.length === 1, abfragen.length + ": " + abfragen.join(","));
/* ---- 6. Tastaturbedienung ---- */
await seite.keyboard.press("ArrowDown");
await seite.waitForTimeout(150);
let nach = await seite.evaluate(() => ({
aktiv: document.getElementById("vw-pal-feld").getAttribute("aria-activedescendant"),
fokus: document.activeElement.id,
}));
pruefe(wo + ": Pfeil runter waehlt den zweiten Treffer", nach.aktiv === "vw-pal-o1", nach.aktiv);
/* Der wichtigste Punkt des ARIA-Musters: Der Fokus bleibt im Feld,
damit man weitertippen kann. */
pruefe(wo + ": der Fokus bleibt dabei im Eingabefeld", nach.fokus === "vw-pal-feld", nach.fokus);
await seite.keyboard.press("ArrowUp");
await seite.keyboard.press("ArrowUp");
await seite.waitForTimeout(150);
nach = await seite.evaluate(() => document.getElementById("vw-pal-feld").getAttribute("aria-activedescendant"));
pruefe(wo + ": Pfeil hoch laeuft am Anfang zum Ende um", nach === "vw-pal-o3", nach);
/* ---- 7. Enter springt in den richtigen Reiter ---- */
await seite.fill("#vw-pal-feld", "Muster");
await seite.waitForTimeout(500);
await seite.keyboard.press("ArrowDown");
await seite.keyboard.press("ArrowDown"); // Projekt
await seite.keyboard.press("Enter");
await seite.waitForTimeout(600);
pruefe(wo + ": die Palette schliesst sich nach der Auswahl",
!(await seite.locator("#vw-palette").isVisible()));
pruefe(wo + ": und der passende Reiter ist offen",
(await seite.locator("#vw-titel").innerText()).trim() === "Projekte",
await seite.locator("#vw-titel").innerText());
/* ---- 8. Escape und Fokusrueckgabe ---- */
await seite.click("#vw-suche-auf");
await seite.waitForTimeout(250);
await seite.keyboard.press("Escape");
await seite.waitForTimeout(250);
pruefe(wo + ": Escape schliesst", !(await seite.locator("#vw-palette").isVisible()));
pruefe(wo + ": der Fokus kehrt zum Lupenknopf zurueck",
await seite.evaluate(() => document.activeElement.id === "vw-suche-auf"),
await seite.evaluate(() => document.activeElement.id));
/* ---- 9. Nichts gefunden ---- */
await seite.click("#vw-suche-auf");
await seite.fill("#vw-pal-feld", "nixdagibtes");
await seite.waitForTimeout(500);
const leer = await seite.locator("#vw-pal-liste").innerText();
pruefe(wo + ": 'nichts gefunden' steht ausdruecklich da", /Nichts gefunden/.test(leer), leer);
pruefe(wo + ": und aria-expanded geht wieder auf false",
(await seite.evaluate(() => document.getElementById("vw-pal-feld").getAttribute("aria-expanded"))) === "false");
/* ---- 10. Ein Serverfehler darf NICHT wie 'nichts gefunden' aussehen ---- */
fehlerWerfen = true;
await seite.fill("#vw-pal-feld", "Muster");
await seite.waitForTimeout(600);
const fehlerText = await seite.locator("#vw-pal-liste").innerText();
pruefe(wo + ": ein Serverfehler wird als Fehler benannt",
/fehlgeschlagen/i.test(fehlerText), fehlerText);
pruefe(wo + ": und sieht anders aus als 'nichts gefunden'",
!/Nichts gefunden/.test(fehlerText), fehlerText);
fehlerWerfen = false;
/* ---- 11. Das Wettrennen der Antworten ---- */
/* Die erste Abfrage wird kuenstlich langsam gemacht. Kommt sie NACH
der zweiten an, darf sie nicht mehr zeichnen -- sonst sieht man
beim Weitertippen ploetzlich wieder die alten Treffer. */
await seite.fill("#vw-pal-feld", "");
await seite.waitForTimeout(300);
langsamMachen = true;
await seite.fill("#vw-pal-feld", "langsam");
await seite.waitForTimeout(250);
langsamMachen = false;
await seite.fill("#vw-pal-feld", "nixdagibtes");
await seite.waitForTimeout(1200);
const zuletzt = await seite.locator("#vw-pal-liste").innerText();
pruefe(wo + ": die spaet eintreffende alte Antwort ueberschreibt die neue NICHT",
/Nichts gefunden/.test(zuletzt), zuletzt.slice(0, 80));
await seite.keyboard.press("Escape");
pruefe(wo + ": kein Skriptfehler", protokoll.length === 0, protokoll.join(" | "));
/* ---- 12. Bild ---- */
await seite.click("#vw-suche-auf");
await seite.fill("#vw-pal-feld", "Muster");
await seite.waitForTimeout(500);
await seite.screenshot({ path: "bild-suche-" + wo.toLowerCase() + ".png" });
await browser.close();
}
/* Strg+K nur auf dem Computer -- ein Handy hat keine Strg-Taste. */
{
const browser = await chromium.launch();
const seite = await browser.newPage({ viewport: { width: 1440, height: 950 } });
await seite.route("**/webdesign/admin/**", (route) => route.fulfill({ json: { ok: true,
beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 0 },
beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0 €" },
geld: { monat: { n: 0, bruttoText: "0 €", nettoText: "0 €" }, gesamt: { n: 0, bruttoText: "0 €" }, gebuehrenText: "0 €" },
projekte: [], verlauf: [], bestand: {}, treffer: [], anfragen: [], kunden: [] } }));
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true } }));
await seite.addInitScript(() => {
sessionStorage.setItem("wd-vw-token", "t");
sessionStorage.setItem("wd-sitzung-aktiv", "1");
});
await seite.goto("http://127.0.0.1:4353/webdesign/verwaltung.html");
await seite.waitForTimeout(700);
console.log("\n=== Tastenkuerzel ===");
await seite.keyboard.press("Control+k");
await seite.waitForTimeout(300);
pruefe("Strg+K oeffnet die Suche von ueberall",
await seite.locator("#vw-palette").isVisible());
await seite.keyboard.press("Control+k");
await seite.waitForTimeout(300);
pruefe("und schliesst sie wieder", !(await seite.locator("#vw-palette").isVisible()));
await browser.close();
}
srv.close();
const f = ergebnisse.filter((e) => !e.ok).length;
console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`);
process.exitCode = f ? 1 : 0;