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]>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/* =====================================================================
|
||||
test-suche.mjs — die Suche über alles
|
||||
|
||||
Der heikelste Teil sind die Sonderzeichen von LIKE. Ein Prozentzeichen
|
||||
im Suchbegriff wäre ohne Behandlung ein Platzhalter, ein Unterstrich
|
||||
ebenfalls -- und Unterstriche stehen regelmässig in E-Mail-Adressen.
|
||||
Der Fehler ist tückisch, weil die Suche trotzdem Treffer liefert, nur
|
||||
die falschen. Man merkt es nie.
|
||||
===================================================================== */
|
||||
import { mkdtempSync, rmSync, writeSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes, createHmac } from "node:crypto";
|
||||
|
||||
const L = (...a) => writeSync(1, a.join(" ") + "\n");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "wd-suche-"));
|
||||
process.env.DB_PATH = join(tmp, "t.db");
|
||||
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
||||
process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex");
|
||||
|
||||
const { initDb, db } = await import("./db.js");
|
||||
initDb();
|
||||
const geheim = await import("./lib/webdesign-geheimnisse.js");
|
||||
await geheim.geheimnisseLaden();
|
||||
const { suche } = await import("./routes/webdesign-suche.js");
|
||||
|
||||
let ok = 0, fehler = 0;
|
||||
const pruefe = (n, b, z) => b
|
||||
? (ok++, L(" ok " + n))
|
||||
: (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
|
||||
|
||||
function ausweis(gueltigMs = 60_000) {
|
||||
const n = Buffer.from(JSON.stringify({
|
||||
bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + gueltigMs,
|
||||
})).toString("base64url");
|
||||
return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url");
|
||||
}
|
||||
async function such(q, kopf) {
|
||||
let out = null;
|
||||
await suche(
|
||||
{ headers: kopf || { authorization: "Bearer " + ausweis() }, cookies: {}, query: { q }, params: {} },
|
||||
{ status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
const jetzt = new Date().toISOString();
|
||||
db.prepare(`INSERT INTO wd_kunden (id,email,name,firma,erstellt_am) VALUES ('k1','[email protected]','Anna Muster','Musterbau GmbH',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am) VALUES ('k2','[email protected]','Bert Meier',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am,gesperrt) VALUES ('k3','[email protected]','Clara Sperr',?,1)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am)
|
||||
VALUES ('a1','A-2608-0001','Anna Muster','[email protected]','website','neu',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am)
|
||||
VALUES ('a2','A-2608-0002','Dora 50% Rabatt','[email protected]','shop','neu',?)`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_projekte (id,nummer,kunde_id,titel,paket,status,erstellt_am,termin_am)
|
||||
VALUES ('p1','P-2608-0001','k1','Website für Musterbau','website','design',?, '2026-10-15')`).run(jetzt);
|
||||
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,status,erstellt_am)
|
||||
VALUES ('z1','Z-2608-0001','k1','anzahlung','Anzahlung Website',30000,'offen',?)`).run(jetzt);
|
||||
|
||||
L("\n1. UEBER ALLE BEREICHE HINWEG");
|
||||
let r = await such("Muster");
|
||||
pruefe("die Suche antwortet", r && r.ok === true, JSON.stringify(r).slice(0, 100));
|
||||
const arten = new Set(r.treffer.map((t) => t.art));
|
||||
pruefe("sie findet die Anfrage", arten.has("anfrage"));
|
||||
pruefe("sie findet den Kunden", arten.has("kunde"));
|
||||
pruefe("sie findet das Projekt", arten.has("projekt"));
|
||||
pruefe("sie findet die Zahlung ueber den KUNDENNAMEN", arten.has("zahlung"));
|
||||
/* Vier Bereiche in einem Aufruf. Die erste Fassung erwartete drei und
|
||||
uebersah, dass die Zahlung ueber den Namen des Kunden mitgefunden wird
|
||||
-- was genau richtig ist: Wer "Muster" sucht, will auch dessen offene
|
||||
Rechnung sehen, ohne sie ueber die Zahlungsnummer zu kennen. */
|
||||
pruefe("alle vier Bereiche in EINEM Aufruf", arten.size === 4, Array.from(arten).join(","));
|
||||
|
||||
L("\n2. JEDER TREFFER TRAEGT SEINEN ZUSAMMENHANG");
|
||||
const projektTreffer = r.treffer.find((t) => t.art === "projekt");
|
||||
pruefe("das Projekt nennt Nummer und Kunde",
|
||||
/P-2608-0001/.test(projektTreffer.unten) && /Anna Muster/.test(projektTreffer.unten),
|
||||
projektTreffer.unten);
|
||||
pruefe("und den Liefertermin", /2026-10-15/.test(projektTreffer.unten), projektTreffer.unten);
|
||||
pruefe("jeder Treffer weiss, wohin er fuehrt",
|
||||
r.treffer.every((t) => !!t.ziel), JSON.stringify(r.treffer.map((t) => t.ziel)));
|
||||
pruefe("jeder Treffer hat eine Marke fuer seinen Stand",
|
||||
r.treffer.every((t) => !!t.marke), JSON.stringify(r.treffer.map((t) => t.marke)));
|
||||
|
||||
L("\n3. SUCHE UEBER DEN KUNDENNAMEN FINDET SEIN PROJEKT");
|
||||
/* Man merkt sich den Menschen, nicht die Projektnummer. */
|
||||
r = await such("Musterbau");
|
||||
pruefe("die Firma des Kunden fuehrt zum Kunden",
|
||||
r.treffer.some((t) => t.art === "kunde" && t.titel === "Anna Muster"));
|
||||
|
||||
L("\n4. ZAHLUNGEN");
|
||||
r = await such("Anzahlung");
|
||||
pruefe("die Zahlung wird gefunden", r.treffer.some((t) => t.art === "zahlung"));
|
||||
const z = r.treffer.find((t) => t.art === "zahlung");
|
||||
pruefe("mit lesbarem Betrag", /300,00/.test(z.unten), z.unten);
|
||||
|
||||
L("\n5. LIKE-SONDERZEICHEN — hier entstehen falsche Treffer");
|
||||
/* Ohne Behandlung waere "%" ein Platzhalter und faende ALLES. */
|
||||
r = await such("%");
|
||||
pruefe("ein einzelnes Prozentzeichen ist zu kurz und sucht gar nicht",
|
||||
r.zuKurz === true, JSON.stringify(r).slice(0, 80));
|
||||
r = await such("50%");
|
||||
pruefe("nach '50%' wird woertlich gesucht",
|
||||
r.treffer.length === 1 && /Dora/.test(r.treffer[0].titel),
|
||||
r.treffer.length + " Treffer: " + r.treffer.map((t) => t.titel).join(", "));
|
||||
/* Ohne Behandlung waere "_" ein beliebiges Zeichen: "bert_meier" faende
|
||||
dann auch "bertXmeier". Geprueft wird umgekehrt -- ein Muster mit
|
||||
Unterstrich darf NICHT auf etwas anderes passen. */
|
||||
r = await such("bert_meier");
|
||||
pruefe("ein Unterstrich wird woertlich genommen",
|
||||
r.treffer.length === 1 && r.treffer[0].art === "kunde",
|
||||
r.treffer.length + " Treffer");
|
||||
r = await such("bertxmeier");
|
||||
pruefe("und passt nicht auf ein beliebiges Zeichen",
|
||||
r.treffer.length === 0, r.treffer.length + " Treffer");
|
||||
|
||||
L("\n6. ZU KURZ WIRD GAR NICHT GESUCHT");
|
||||
r = await such("a");
|
||||
pruefe("ein einzelner Buchstabe sucht nicht", r.zuKurz === true && r.treffer.length === 0);
|
||||
r = await such("");
|
||||
pruefe("leer ebenfalls nicht", r.zuKurz === true);
|
||||
r = await such(" ");
|
||||
pruefe("nur Leerzeichen ebenfalls nicht", r.zuKurz === true);
|
||||
|
||||
L("\n7. NICHTS GEFUNDEN IST EIN EIGENER ZUSTAND");
|
||||
r = await such("gibtesnichtxyz");
|
||||
pruefe("die Antwort ist gueltig", r.ok === true);
|
||||
pruefe("mit null Treffern und NICHT zuKurz",
|
||||
r.anzahl === 0 && r.zuKurz === false, JSON.stringify(r).slice(0, 90));
|
||||
|
||||
L("\n8. GESPERRTE KUNDEN WERDEN GEZEIGT, ABER GEKENNZEICHNET");
|
||||
/* Verstecken waere falsch: Man sucht sie ja gerade, WEIL etwas nicht
|
||||
stimmt. Ohne Kennzeichnung wuerde man sich aber wundern. */
|
||||
r = await such("Sperr");
|
||||
pruefe("der gesperrte Kunde erscheint", r.treffer.some((t) => t.art === "kunde"));
|
||||
pruefe("und ist als gesperrt gekennzeichnet",
|
||||
r.treffer.find((t) => t.art === "kunde").marke === "gesperrt",
|
||||
r.treffer.find((t) => t.art === "kunde").marke);
|
||||
|
||||
L("\n9. ARCHIVIERTES TAUCHT NICHT AUF");
|
||||
db.prepare(`UPDATE wd_anfragen SET archiviert = 1 WHERE id = 'a1'`).run();
|
||||
r = await such("A-2608-0001");
|
||||
pruefe("eine archivierte Anfrage wird nicht mehr gefunden",
|
||||
!r.treffer.some((t) => t.art === "anfrage"), JSON.stringify(r.treffer));
|
||||
|
||||
L("\n10. DIE RECHTEPRUEFUNG GREIFT");
|
||||
for (const [name, kopf] of [
|
||||
["ohne Ausweis kein Ergebnis", {}],
|
||||
["mit gefaelschter Unterschrift kein Ergebnis",
|
||||
{ authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }],
|
||||
["mit abgelaufenem Ausweis kein Ergebnis",
|
||||
{ authorization: "Bearer " + ausweis(-1000) }],
|
||||
]) {
|
||||
const a = await such("Muster", kopf);
|
||||
pruefe(name, a && a.ok === false, JSON.stringify(a).slice(0, 70));
|
||||
}
|
||||
|
||||
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
Reference in New Issue
Block a user