Files
dogfather-universe/server/pruef-chat-neu.mjs
T
DogFatherGitandClaude Opus 5 7a6749630c Das Rudel meint den ganzen Raum -- und die Kachelfarbe wird ein Ring
Zwei Auftraege vom 23.09.2026.

SCREEN 1: "da steht links immer noch der treff anstatt das rudel"

Und er hatte recht, auf eine Art, die niemand sehen konnte:
TREFF_NAME stand schon seit der Umbenennung auf "Das Rudel". Nur
setzt treffRaumId() den Namen ausschliesslich BEIM ANLEGEN -- der
Raum existierte laengst, also blieb die Zeile in der Datenbank auf
"Der Treff" stehen. Im Code das eine, auf dem Bildschirm das andere.

Dieselbe Falle stand direkt nebenan schon beschrieben ("drei Stellen,
von denen die dritte vergessen wird") -- die Vorsorge galt aber nur
fuer die Teilnehmer, nicht fuer den Namen. Eine halbe Selbstheilung
heilt die andere Haelfte nicht. Jetzt gleicht treffAngleichen() auch
den Namen ab; eine kuenftige Umbenennung ist wieder eine Zeile.
(IS NOT statt !=: Bei NULL ergaebe != in SQLite NULL, und die Zeile
bliebe unveraendert liegen.)

"und wenn wir da im chat @rudel machen will ich dass jeder in dem
chat markiert wird. nicht nur team ... und sehr wichtig ich rede nur
von dem chat wo die ganze community auch drin ist."

Bis heute erreichte der Ruf ueberall nur Team Dogi. Die Begruendung
stand im Code und war nicht falsch -- im Rudel-Raum sitzt die
Community. Genau das will Filipe jetzt, und zwar nur dort:

  im Rudel-Raum   -> alle, die drin sind
  ueberall sonst  -> Team Dogi, unveraendert

Was die alte Sorge entschaerft: RUFEN darf weiterhin nur Team Dogi --
kein Zuschauer kann alle wecken. Und die Nachtruhe gilt dort ohnehin.

darf_rudel folgt derselben Frage. Sonst entstuende ein stiller
Widerspruch: Ein Modi mit neun Zuschauern und ohne zweites
Teammitglied traefe mit @rudel neun Leute -- der Vorschlag beim
Tippen waere aber ausgeblendet. Die Funktion gaebe es, und niemand
faende sie.

Das Schild sagt jetzt die Wahrheit: "alle in diesem Chat" statt
"alle im Team". Stuende dort weiter das alte, waere es im Rudel-Raum
gelogen -- und zwar nach unten, also in die Richtung, in der man
leichtfertig drueckt.

Gegenprobe in pruef-erwaehnung: In der Testgruppe sitzen DogFather
und zwei Scouts; ein @rudel dort ruft NIEMANDEN. Waere die Regel
versehentlich im ganzen Haus aktiv, waeren die Scouts markiert.
"sonst nichts anfassen" ist damit gemessen, nicht versprochen.

SCREEN 2: "so eine art diagramm, wo man mit einem kreis herum gehen
kann und die farbe ganz genau selber auswaehlen kann"

Der Haken daran ist nicht die Optik. Die zwoelf Kacheln waren
GERECHNET, damit niemand sich unlesbar machen kann -- alle auf
derselben Leuchtdichte. Ein Farbkreis, der einfach den Farbton dreht,
wirft das weg: Ein gesaettigtes Gelb ist um ein Vielfaches heller als
ein gesaettigtes Blau, und helle Schrift ist darauf nicht mehr zu
lesen.

Der Ring ist deshalb KEINE neue Rechnung, sondern dieselbe in 360
Schritten statt in zwoelf. tools/chat-kacheln-rechnen.mjs --kreis
schreibt server/chat-kreis.js; buntest() und die Kontrastpruefung
darin gelten unveraendert. Ergebnis: ein Ring gleicher Helligkeit --
kein blendendes Gelb, kein abgesoffenes Blau. Das sieht ruhiger aus
als der uebliche Farbkreis, und genau das ist der Punkt.

Der Browser rechnet nichts. Er bekommt die 360 fertigen Farben und
legt sie in dieselbe Tabelle wie die Kacheln -- ab da ist "ton-214"
ein Schluessel wie "veilchen", und jede Stelle, die eine Farbe
nachschlaegt, funktioniert unveraendert.

Welche Kacheln neben dem Ring bleiben, wird ABGELEITET statt
aufgezaehlt: Grau hat keinen Farbton, Babyblau ist die eine helle mit
eigener Schrift. Nachgemessen liegen die elf bunten zwischen 0,0 und
2,4 von ihrer Ringfarbe entfernt, Grau bei 75,4 und Babyblau bei
192,9 -- zwischen 2,4 und 75 ist so viel Luft, dass die Schwelle
nicht knapp ist.

Gespeichert wird erst beim Loslassen. Wer einmal um den Ring fuehrt,
erzeugte sonst dreihundert Anfragen.

Gemessen (pruef-chat-neu, jetzt 32 Pruefungen, als Modi auf crew. mit
dem Finger): 265 px gross, aus 361 Farben gemalt, touch-action none
(sonst schiebt das Handy die Seite weg statt zu drehen), Drehen auf
drei Uhr ergibt genau Winkel 90, die Mitte zeigt exakt #675102,
vorgelesen als "Goldbraun, 90 Grad", in der Datenbank steht ton-90,
Pfeiltaste dreht ein Grad weiter. Und das eigentliche Versprechen:
alle 360 Toene tragen die Schrift der Blase, knappster Faktor 1,000.

Dabei zwei eigene Fehler, beide aufgeschrieben: Der erste Anlauf
wartete 30 Sekunden auf den Farbknopf -- am Handy weicht die Spalte
mit den Gespraechen zur Seite, sobald ein Chat offen ist. "Ist im
HTML" und "ist erreichbar" sind zwei Aussagen. Und die Schriftfarben
wurden an der falschen Klasse gemessen, was einen roten Haken an
einer heilen Stelle ergab.

Ausserdem: Das native confirm() beim Herausnehmen eines GIFs ist
weg -- meine eigene Abkuerzung vom Morgen. Gemeldet hat es
pruef-nachfrage, allerdings erst, nachdem der verlorene Backslash in
derselben Datei repariert war.

Zahlen: pruef-erwaehnung 122 (vorher 119), pruef-chat-neu 32
(vorher 19), pruef-nachfrage 46 bestanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 14:30:02 +02:00

473 lines
23 KiB
JavaScript

/* =====================================================================
pruef-chat-neu.mjs — Sprachnachricht, GIF-Kiste und der Ruf ans Rudel
Alle drei sind in der Nacht zum 23.09.2026 entstanden:
„dan will ich auch dass man im chat auch sprachnachrichten und
gifts reinschicken kann. also nur die modis rechte linke hand
und dogfather."
„dan will ich auch dass nur die modis, rechte hand, linke hand
und dogfather, alle auch auf einmal markieren koennen im chat
mit einem, @rudel ,dann sollen alle eine benarichtigung
bekommen."
---------------------------------------------------------------------
WARUM ES DIESE DATEI GIBT, obwohl jedes Stueck schon geprueft ist
Geprueft war jedes einzeln -- und jedes in einer anderen Lage als
der echten:
pruef-chat-anhaenge Sprachnachricht und GIF, aber als DogFather
auf der AGENTURadresse, mit der Maus
pruef-erwaehnung der Rudel-Ruf, aber gegen den Server
Der Fall, den Filipe wirklich hat -- ein MODI auf crew., mit dem
FINGER, und ein zweiter Mensch, der es bekommen soll -- war nie am
Stueck gemessen. Genau in solchen Luecken sitzen die Fehler, die
niemand sieht: Jeder Einzeltest ist gruen, und zusammen geht es
trotzdem nicht.
ZWEI BROWSER, ZWEI MENSCHEN. Eine Sprachnachricht, die nur beim
Absender im Verlauf steht, ist keine. Deshalb wartet jede der drei
Proben darauf, dass die ZWEITE Person sie von selbst bekommt --
ohne Neuladen.
---------------------------------------------------------------------
ZWEI EIGENE FEHLER BEIM BAUEN, beide aufgeschrieben, weil sie sich
wiederholen werden:
(1) OHNE NOTBREMSE. Der erste Lauf blieb stehen und sah von aussen
aus wie „laeuft noch". Ein Werkzeug, das haengt, ist schlimmer
als eines, das scheitert.
(2) MIT ENTER ABGESCHICKT. Auf einem Beruehrgeraet schickt Enter
ABSICHTLICH nicht ab -- sonst kaeme man nie zu einer zweiten
Zeile. Die Messung meldete daraufhin „kommt nicht an"; in
Wahrheit war nie etwas abgeschickt worden. Dasselbe Muster wie
beim `pointer: coarse` am selben Tag: Wer im falschen
Geraeteprofil misst, misst ein anderes Programm.
AUFRUF: node server/pruef-chat-neu.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { execFileSync } from "node:child_process";
import { createServer as httpsServer } from "node:https";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-chat-neu");
const CREW = "crew.dogfather-universe.com";
const ordner = mkdtempSync(join(tmpdir(), "ws-drei-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
process.env.TREFF_NACHT_AB = "0";
process.env.TREFF_NACHT_BIS = "0";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
/* NOTBREMSE. Ein Messwerkzeug, das HAENGT, ist schlimmer als eines,
das scheitert: Von aussen sieht Stillstand aus wie "laeuft noch".
Genau das ist mir hier gerade passiert. */
const { notbremse } = await import("./helfer-notbremse.mjs");
notbremse(240_000, "pruef-chat-neu");
await new Promise((r) => setTimeout(r, 1300));
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const k = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel,wert,geaendert,von) VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", k, jetzt);
const mach = (n, r, c) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(c, s, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,code_kennung,aktiv,erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(n, r, h, s, 32768, createHmac("sha256", k).update(c).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
const idDogi = mach("Filipe", "admin", "CODE-DOGI-0001");
mach("Rieke", "hand", "CODE-HAND-0001");
const idA = mach("Frida", "modi", "CODE-MODI-0001");
const idB = mach("Nele", "modi", "CODE-MODI-0002");
d.close();
const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl,
"-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
{ stdio: "ignore" });
const HP = PORT + 1;
const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, (von, nach) => {
const w = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
headers: { ...von.headers, host: `${CREW}:${HP}` } },
(a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
von.pipe(w);
});
await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
const SEITE = `https://${CREW}:${HP}`;
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
"--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] });
async function alsPerson(rolle, code) {
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 }, deviceScaleFactor: 2,
hasTouch: true, isMobile: true, reducedMotion: "reduce",
permissions: ["microphone"] });
const seite = await ctx.newPage();
const meckern = [];
seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) meckern.push(m.text().slice(0, 110)); });
seite.on("pageerror", (e) => meckern.push("Absturz: " + String(e).slice(0, 110)));
await seite.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForTimeout(1300);
return { ctx, seite, meckern };
}
let fehler = 0, geprueft = 0;
const ok = (b, t) => {
geprueft++;
console.log((b ? " ok " : " FEHL ") + t);
if (!b) fehler++;
};
const frida = await alsPerson("modi", "CODE-MODI-0001");
const nele = await alsPerson("modi", "CODE-MODI-0002");
/* Beide in den Treff -- dort sitzen alle. */
for (const p of [frida, nele]) {
await p.seite.goto(`${SEITE}/workspace/chat.html?raum=treff`, { waitUntil: "networkidle" });
await p.seite.waitForTimeout(1800);
}
console.log("\n=== 1. Sprachnachricht ===");
{
const vorher = await nele.seite.locator(".chat-nachricht").count();
const knopfDa = await frida.seite.locator("#ton-auf").isVisible();
ok(knopfDa, "der Modi sieht den Mikrofonknopf auf crew.");
await frida.seite.click("#ton-auf");
await frida.seite.waitForSelector("#tonleiste:not([hidden])", { timeout: 8000 });
await frida.seite.waitForTimeout(2400);
const zeit = (await frida.seite.locator("#ton-zeit").innerText()).trim();
ok(/^0:0[2-9]$/.test(zeit), `die Zeit laeuft (${zeit})`);
await frida.seite.click("#ton-senden");
await frida.seite.waitForTimeout(3200);
const blase = await frida.seite.evaluate(() => {
const t = document.querySelector(".chat-ton");
return t ? { marke: t.querySelector(".chat-ton__marke")?.textContent.trim(),
dauer: t.querySelector(".chat-ton__dauer")?.textContent.trim(),
breit: Math.round(t.getBoundingClientRect().width) } : null;
});
ok(!!blase, `sie steht in seinem Verlauf (${blase?.marke} ${blase?.dauer})`);
ok((blase?.breit || 0) >= 200 && (blase?.breit || 0) <= 390,
`und passt auf den Bildschirm (${blase?.breit} px)`);
/* UND KOMMT BEIM ANDEREN AN, ohne dass der etwas tut. */
const beiNele = await nele.seite.waitForFunction(
(v) => document.querySelectorAll(".chat-nachricht").length > v && document.querySelector(".chat-ton"),
vorher, { timeout: 12000 }).then(() => true).catch(() => false);
ok(beiNele, "die zweite Modi bekommt sie von selbst -- ohne Neuladen");
}
console.log("\n=== 2. GIF aus der Kiste ===");
{
const GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
ok(await frida.seite.locator("#gif-auf").isVisible(), "der Modi sieht den GIF-Knopf");
await frida.seite.click("#gif-auf");
await frida.seite.waitForSelector("#gif-tafel:not([hidden])", { timeout: 8000 });
const tafel = await frida.seite.evaluate(() => {
const t = document.getElementById("gif-tafel");
const r = t.getBoundingClientRect();
return { b: Math.round(r.width), h: Math.round(r.height),
imBild: r.left >= -1 && r.right <= innerWidth + 1 && r.top >= -1,
leer: (document.getElementById("gif-leer")?.textContent || "").trim().slice(0, 40) };
});
ok(tafel.imBild, `die Kiste passt auf den Bildschirm (${tafel.b}x${tafel.h})`);
ok(/noch leer/.test(tafel.leer), `und sagt, was zu tun ist ("${tafel.leer}…")`);
await frida.seite.setInputFiles("#gif-feld", {
name: "daumen.gif", mimeType: "image/gif", buffer: Buffer.from(GIF, "base64") });
await frida.seite.waitForTimeout(2400);
const kacheln = await frida.seite.locator(".gif-kachel").count();
ok(kacheln === 1, `ein GIF liegt in der Kiste (${kacheln})`);
const vorher2 = await nele.seite.locator(".chat-nachricht").count();
await frida.seite.click(".gif-kachel__knopf");
await frida.seite.waitForTimeout(2600);
const beiNele = await nele.seite.waitForFunction(
(v) => document.querySelectorAll(".chat-nachricht").length > v,
vorher2, { timeout: 12000 }).then(() => true).catch(() => false);
ok(beiNele, "das GIF kommt bei der zweiten Modi an");
/* Und die Kiste ist fuer BEIDE da -- sie gehoert dem Rudel. */
await nele.seite.click("#gif-auf");
await nele.seite.waitForTimeout(1600);
const beiIhr = await nele.seite.locator(".gif-kachel").count();
ok(beiIhr === 1, `die zweite Modi sieht dasselbe GIF in ihrer Kiste (${beiIhr})`);
await nele.seite.keyboard.press("Escape").catch(() => {});
}
console.log("\n=== 3. Der Ruf ans Rudel ===");
{
const vorher = await nele.seite.locator(".chat-nachricht").count();
await frida.seite.click("#text");
await frida.seite.type("#text", "@", { delay: 60 });
await frida.seite.waitForTimeout(700);
const liste = await frida.seite.evaluate(() => {
const l = document.getElementById("erwaehn-liste");
if (!l || l.hidden) return null;
const erste = l.querySelector(".erwaehn-liste__eintrag");
const r = erste?.getBoundingClientRect();
return { erster: l.querySelector(".erwaehn-liste__name")?.textContent.trim() || "",
zusatz: l.querySelector(".erwaehn-liste__rolle")?.textContent.trim() || "",
hoch: Math.round(r?.height || 0),
imBild: r ? (r.left >= -1 && r.right <= innerWidth + 1) : false };
});
ok(!!liste, "ein @ oeffnet die Liste");
ok(liste?.erster === "rudel", `der Ruf steht oben (${liste?.erster})`);
ok(/alle in diesem Chat/i.test(liste?.zusatz || ""), `mit der Erklaerung daneben (${liste?.zusatz})`);
ok((liste?.hoch || 0) >= 44 && liste?.imBild,
`am Daumen treffbar und im Bild (${liste?.hoch} px)`);
await frida.seite.keyboard.press("Enter");
await frida.seite.waitForTimeout(400);
await frida.seite.type("#text", "bitte alle kurz schauen", { delay: 10 });
/* NICHT MIT ENTER. Auf einem Beruehrgeraet schickt Enter
ABSICHTLICH nicht ab -- sonst kaeme man nie zu einer zweiten
Zeile (steht so in chat.html). Der erste Anlauf dieser Messung
hat genau das uebersehen und einen Fehler gemeldet, den es nicht
gibt: Die Nachricht war nie abgeschickt worden. */
await frida.seite.click("#senden");
await frida.seite.waitForTimeout(2800);
const beiNele = await nele.seite.waitForFunction(
(v) => document.querySelectorAll(".chat-nachricht").length > v,
vorher, { timeout: 12000 }).then(() => true).catch(() => false);
ok(beiNele, "die Nachricht kommt bei der zweiten Modi an");
const marke = await nele.seite.evaluate(() => {
const m = document.querySelector('.chat-erwaehnung[data-rudel="ja"]');
return m ? { wort: m.textContent.trim(),
gewicht: getComputedStyle(m).fontWeight } : null;
});
ok(!!marke, `und der Ruf leuchtet bei ihr (${marke?.wort}, Gewicht ${marke?.gewicht})`);
/* Und in der Datenbank steht, dass sie gemeint war. */
const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const gemeint = db2.prepare(`SELECT e.person_id AS wer FROM chat_erwaehnungen e
JOIN chat_nachrichten n ON n.id = e.nachricht_id WHERE n.rudel = 1`).all().map((x) => x.wer);
db2.close();
ok(gemeint.includes(idB) && gemeint.includes(idDogi) && !gemeint.includes(idA),
`gerufen wurden ${gemeint.length} -- die andere Modi und DogFather, nicht sie selbst`);
}
/* =====================================================================
4. DER FARBRING (23.09.2026)
Filipe: "anstatt sowas um die farben auszusuchen, kannst du nicht
so eine art diagramm machen wo man mit einem kreis herum gehen kann
und sie die farbe ganz genau selber auswaehlen kann?"
WAS HIER WIRKLICH AUF DEM SPIEL STEHT, ist nicht die Optik. Die
zwoelf Kacheln waren gerechnet, damit niemand sich unlesbar machen
kann -- alle auf derselben Leuchtdichte. Ein Ring mit freier Wahl
ist genau die Gelegenheit, dieses Versprechen zu verlieren, und
zwar unbemerkt: Die Farbe sieht huebsch aus, und erst die anderen
koennen den Text nicht mehr lesen.
Deshalb misst diese Probe zuerst den KONTRAST und dann die
Bedienung.
===================================================================== */
{
console.log("");
console.log("=== 4. Der Farbring ===");
const { KREIS } = await import("./chat-kreis.js");
/* ---- 4a. Das Versprechen, an allen 360 Toenen ------------------ */
const schriftFarben = await frida.seite.evaluate(() => {
/* AM ECHTEN ELEMENT MESSEN, nicht an einem selbstgebauten.
Erster Anlauf legte ein <div class="chat-nachricht__blase"> an
und las dort -- und bekam zweimal nichts zurueck. Die beiden
Werte stehen naemlich eine Ebene hoeher, auf .chat-nachricht,
und werden von dort vererbt. Ergebnis war ein roter Haken an
einer Stelle, an der nichts kaputt war: die Messung war falsch,
nicht das Programm.
Erst die Blase im Verlauf; gibt es keine, ein Ersatzstueck mit
der RICHTIGEN Klasse. */
const echt = document.querySelector(".chat-nachricht");
const ziel = echt || document.body.appendChild(
Object.assign(document.createElement("div"), { className: "chat-nachricht" }));
const s2 = getComputedStyle(ziel);
const raus = { text: s2.getPropertyValue("--blase-text").trim(),
leise: s2.getPropertyValue("--blase-leise").trim(),
woher: echt ? "aus dem Verlauf" : "aus einem Ersatzstueck" };
if (!echt) ziel.remove();
return raus;
});
const zuZahl = (f) => {
const t = /^#?([0-9a-f]{6})$/i.exec(String(f).trim());
if (t) return [0, 2, 4].map((i) => parseInt(t[1].slice(i, i + 2), 16));
const r = /rgba?\(([^)]+)\)/i.exec(String(f));
return r ? r[1].split(",").slice(0, 3).map((x) => Math.round(parseFloat(x))) : null;
};
const linear = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
const leucht = (rgb) => 0.2126 * linear(rgb[0] / 255) + 0.7152 * linear(rgb[1] / 255)
+ 0.0722 * linear(rgb[2] / 255);
const kontrast = (a, b) => {
const x = leucht(a), y = leucht(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
};
const textRgb = zuZahl(schriftFarben.text);
const leiseRgb = zuZahl(schriftFarben.leise);
ok(!!textRgb && !!leiseRgb,
`die Schriftfarben der Blase sind lesbar, ${schriftFarben.woher}`
+ ` (${schriftFarben.text || "nichts"} / ${schriftFarben.leise || "nichts"})`);
if (textRgb && leiseRgb) {
let schlimmster = Infinity, schlimmsterWinkel = -1, gemessen = 0;
for (let w = 0; w < KREIS.length; w++) {
const f = zuZahl(KREIS[w]);
if (!f) continue;
gemessen++;
for (const [s3, noetig] of [[textRgb, 7], [leiseRgb, 4.8]]) {
const v = kontrast(f, s3) / noetig;
if (v < schlimmster) { schlimmster = v; schlimmsterWinkel = w; }
}
}
/* DIE ANZAHL STEHT IN DER BEDINGUNG. Waere die Tabelle leer,
bliebe schlimmster auf Infinity und die Zeile waere gruen,
ohne eine einzige Farbe angesehen zu haben. */
ok(gemessen === 360 && schlimmster >= 1,
`alle ${gemessen} Toene des Rings tragen die Schrift der Blase`
+ ` (knappster: Winkel ${schlimmsterWinkel}, Faktor ${schlimmster.toFixed(3)})`);
}
/* ---- 4b. Steht der Ring da, und ist er zu treffen? ------------- */
/* ERST ZURUECK ZUR LISTE -- und das ist keine Umstaendlichkeit,
sondern der Weg, den ein Mensch am Handy geht. Der Farbknopf
steht in der Spalte mit den Gespraechen, und die weicht auf
einem schmalen Bildschirm zur Seite, sobald ein Gespraech offen
ist. Der erste Anlauf dieser Probe hat genau hier 30 Sekunden
lang auf einen Knopf gewartet, den es zwar gab, den aber niemand
sehen konnte -- ein gutes Beispiel dafuer, dass "ist im HTML"
und "ist erreichbar" zwei verschiedene Aussagen sind. */
const zurueck = frida.seite.locator("#zur-liste");
if (await zurueck.isVisible().catch(() => false)) {
await zurueck.click();
await frida.seite.waitForTimeout(600);
}
const knopfDa = await frida.seite.locator("#kachel-oeffnen").isVisible().catch(() => false);
ok(knopfDa, "der Farbknopf ist am Handy erreichbar (ueber die Gespraechsliste)");
if (!knopfDa) throw new Error("ohne den Knopf ist der Rest nicht messbar");
await frida.seite.click("#kachel-oeffnen");
await frida.seite.waitForTimeout(600);
const rad = await frida.seite.evaluate(() => {
const r = document.getElementById("kachelrad-ring");
if (!r) return null;
const k = r.getBoundingClientRect();
const g = getComputedStyle(r);
return {
breit: Math.round(k.width), hoch: Math.round(k.height),
links: Math.round(k.left), oben: Math.round(k.top),
imBild: k.left >= -1 && k.right <= innerWidth + 1
&& k.top >= -1 && k.bottom <= innerHeight + 1,
farben: (g.backgroundImage.match(/rgb\(/g) || []).length,
fassen: g.touchAction,
rolle: r.getAttribute("role"),
};
});
ok(!!rad, "der Ring ist da");
/* 180 px sind nicht geraten: Ein Ring, den man mit dem Daumen im
Kreis fuehren soll, braucht sichtbar mehr als die 44 px einer
Schaltflaeche -- sonst rutscht man beim Drehen heraus. */
ok((rad?.breit || 0) >= 180 && rad?.imBild,
`er passt auf den Bildschirm und ist gross genug (${rad?.breit}x${rad?.hoch} px)`);
/* Der Verlauf muss aus VIELEN Farben bestehen. Waere die Liste
nicht angekommen, stuende dort ein einzelner Ton -- und der Ring
saehe aus wie eine Scheibe. */
ok((rad?.farben || 0) >= 300, `er ist aus ${rad?.farben} Farben gemalt`);
/* OHNE DAS SCHIEBT DAS HANDY DIE SEITE WEG, statt den Ring zu
drehen. Genau diese Zeile ist es, die auf einem Beruehrgeraet
ueber brauchbar und unbrauchbar entscheidet. */
ok(rad?.fassen === "none", `Wischen dreht den Ring, statt zu scrollen (${rad?.fassen})`);
ok(rad?.rolle === "slider", `und eine Sprachausgabe versteht ihn (${rad?.rolle})`);
/* ---- 4c. Drehen -- und kommt die richtige Farbe heraus? -------- */
if (rad) {
/* Winkel 90 heisst DREI UHR: der Verlauf beginnt oben und laeuft
im Uhrzeigersinn. Waere die Rechnung im Browser um 90 Grad
verdreht (der uebliche atan2-Fehler), kaeme hier eine andere
Farbe heraus als die, die unter dem Finger liegt. */
const mx = rad.links + rad.breit / 2;
const my = rad.oben + rad.hoch / 2;
const r80 = (rad.breit / 2) * 0.8;
await frida.seite.mouse.move(mx, my - r80);
await frida.seite.mouse.down();
await frida.seite.mouse.move(mx + r80, my, { steps: 12 });
await frida.seite.mouse.up();
await frida.seite.waitForTimeout(900);
const stand = await frida.seite.evaluate(() => {
const r = document.getElementById("kachelrad-ring");
const m = document.getElementById("kachelrad-mitte");
return { winkel: Number(r?.getAttribute("aria-valuenow")),
wort: r?.getAttribute("aria-valuetext") || "",
mitte: m ? getComputedStyle(m).backgroundColor : "" };
});
ok(Math.abs((stand.winkel || 0) - 90) <= 2,
`nach dem Drehen auf drei Uhr steht der Ring bei ${stand.winkel} Grad (erwartet 90)`);
const erwartet = zuZahl(KREIS[stand.winkel] || "");
const gezeigt = zuZahl(stand.mitte);
ok(!!erwartet && !!gezeigt
&& Math.abs(erwartet[0] - gezeigt[0]) <= 1
&& Math.abs(erwartet[1] - gezeigt[1]) <= 1
&& Math.abs(erwartet[2] - gezeigt[2]) <= 1,
`die Mitte zeigt genau die Farbe dieses Winkels (${stand.mitte} gegen ${KREIS[stand.winkel]})`);
ok(/\d+ Grad/.test(stand.wort) && stand.wort.length > 8,
`und sie wird vorgelesen als "${stand.wort}"`);
/* ---- 4d. Wird es gespeichert? ------------------------------- */
const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const gespeichert = db3.prepare("SELECT chat_kachel FROM personen WHERE id = ?")
.get(idA)?.chat_kachel;
db3.close();
ok(gespeichert === "ton-" + stand.winkel,
`und in der Datenbank steht die Wahl (${gespeichert})`);
/* ---- 4e. Und mit der Tastatur? ------------------------------ */
await frida.seite.focus("#kachelrad-ring");
await frida.seite.keyboard.press("ArrowRight");
await frida.seite.waitForTimeout(200);
const nachher = await frida.seite.evaluate(() =>
Number(document.getElementById("kachelrad-ring")?.getAttribute("aria-valuenow")));
ok(nachher === (stand.winkel + 1) % 360,
`eine Pfeiltaste dreht um genau ein Grad weiter (${stand.winkel} -> ${nachher})`);
}
}
const alleMeldungen = [...frida.meckern, ...nele.meckern];
console.log("");
ok(alleMeldungen.length === 0,
alleMeldungen.length
? `ein Browser meldet etwas: ${alleMeldungen[0]}`
: "kein Browser meldet etwas");
await frida.ctx.close(); await nele.ctx.close();
await browser.close();
await new Promise((r) => vorbau.close(r));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
console.log("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
process.exit(fehler ? 1 : 0);