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]>
291 lines
15 KiB
JavaScript
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;
|