Files
dogfather-universe/server/pruef-handy.mjs
T
DogFatherGitandClaude Opus 5 6c48579f66 Die Kopfleiste bleibt auch in einer fremden Sicht in EINER Reihe
Filipe, mit Bildschirmfoto aus der installierten App: "es ist noch
nicht alles in einer zeile und irgendwie funktionieren nicht alle
knoepfe."

DER GRUND WAR NICHT DIE BREITE SEINES GERAETS, SONDERN DER ZUSTAND.
In der eigenen Sicht klappt der Umschalter auf 36 px zusammen; sobald
man die Sicht einer anderen Person uebernimmt, stand der Name darin und
er war 152 px breit. Gemessen waren es dann zwei Zeilen bei 320, 360,
390, 412 UND 430 px -- ausnahmslos. Auf seinem Bild stand "Miesmus..."
im Umschalter und der goldene Rahmen um die Seite: er war in einer
fremden Sicht.

MEINE PRUEFUNGEN HABEN DEN FALSCHEN ZUSTAND GEMESSEN. pruef-handy lief
ausschliesslich in der eigenen Sicht und war deshalb gruen, waehrend
es beim Nutzer zweizeilig war. Ein gruener Haken sagt nur, dass die
Bedingung erfuellt war -- nicht, dass sie den Zustand geprueft hat, in
dem der Nutzer ist. Die Pruefung wechselt jetzt selbst in eine fremde
Sicht (92 -> 99 Pruefungen).

WAS SICH AENDERT
- Auf dem Handy ist der Umschalter auch in fremder Sicht ein
  Zeichenknopf: Auge + Anfangsbuchstabe, 46-48 statt 152 px, in der
  ROLLENFARBE der Person, deren Sicht laeuft. Die Farbe kommt aus
  `data-rolle` -- dieselbe Zuordnung, die gate.css ohnehin hat, keine
  zweite Farbliste.
- Der volle Name wandert in ein Band unter die Leiste, zusammen mit
  "Zurueck zu meiner Sicht" als ganzem Satz statt als 28-px-Kreuz. Er
  steht dort GANZ statt als "Miesmus...". Am Rechner bleibt alles wie
  bisher; dort ist Platz.
- Der Rahmen um die Seite nimmt dieselbe Farbe an. Man sieht damit
  nicht nur DASS eine fremde Sicht laeuft, sondern WESSEN.
- Das `:not([data-fremd="ja"])` faellt an beiden Stellen weg. Es war
  der ganze Fehler: eine Regel, die den wichtigeren Fall ausnahm.

UND DER BLOCK FUER SCHMALE GERAETE STAND AN DER FALSCHEN STELLE
Er galt bis 340 px und stand 3600 Zeilen VOR dem 560er-Block -- bei
gleicher Spezifitaet verliert er damit. Gewirkt hat er nur, weil sein
Selektor zufaellig ein `:not()` trug. Jetzt steht er direkt hinter dem
560er und gilt bis 400 px (34 px je Knopf, 4 px Abstand). Damit bleibt
auch bei 360 px auf den UNTERSEITEN alles in einer Reihe -- dort stehen
zusaetzlich der Zurueck-Knopf und die Glocke.

Gemessen nach dem Umbau, eigene und fremde Sicht, Start- und
Unterseite: 360, 390, 412 und 430 px alle einzeilig. Offen bleibt
320 px (iPhone SE 1. Generation bzw. Anzeige-Zoom) -- dort passt es
ohne das Weglassen einer Funktion nicht, das ist eine Entscheidung
und kein Handgriff.

DER SICHERE BEREICH (auf Filipes Zusage)
Jede der 20 Seiten sagt `viewport-fit=cover`, aber im ganzen Workspace
stand kein einziges `env(safe-area-inset-*)`. Sein Android ist NICHT
betroffen (im Bildschirmfoto nachgesehen), ein iPhone waere es: Von
einem 36-px-Knopf blieben unter einer 47 px hohen Statusleiste
rechnerisch 5 px zum Antippen. Einmal zentral benannt (gate.css) und an
den vier Stellen angewandt, wo etwas am Rand klebt: Kopfleiste (oben und
seitlich), Inhalt (seitlich und unten), Speichern-Leiste im Profil,
Chat-Eingabe. Auf Geraeten ohne Aussparung sind alle vier Werte 0.

Nebenbei: chat.js misst die Hoehe ueber der Chatflaeche jetzt
einschliesslich des Bandes -- sonst stuende das Eingabefeld genau um
dessen Hoehe unter dem Bildschirmrand. Derselbe Fehler wie mit den
festen 62 px, nur mit einem anderen Element.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 19:44:25 +02:00

592 lines
30 KiB
JavaScript

/* ===================================================================
Die Handy-Pruefung.
Auf einem Telefon geht anderes kaputt als am Rechner, und man sieht
es beim Entwickeln nie: Ein Knopf steht 3 px aus dem Bild, eine
Ueberschrift ist auf halbem Wort abgeschnitten, ein Feld ist so
schmal, dass man es nicht trifft. Alles davon faellt erst auf, wenn
jemand das Geraet in der Hand hat.
Geprueft wird auf DREI echten Geraetegroessen, jede Seite einzeln:
1. Nichts steht ueber waagerechtes Scrollen ist auf einem Handy
der schlimmste Fehler -- die Seite ruckelt
und man verliert den Rand
2. Kein Text abgeschnitten gemessen wird die tatsaechliche
Textbreite gegen die verfuegbare, nicht
"sieht ganz aus"
3. Beruehrziele gross genug 24x24 CSS-Pixel ist das Mindestmass
(WCAG 2.2, Kriterium 2.5.8). Darunter
trifft man mit dem Daumen daneben
4. Nichts ueberlappt zwei Bedienelemente duerfen sich nicht
ueberdecken -- man traefe das falsche
5. Keine Fehler in der Konsole
=================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-handy-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4198";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
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 }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
notbremse(480_000, "pruef-handy");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4198";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 400_000).unref?.();
/* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des
Benutzers; toISOString() liefert dagegen UTC und damit zwischen
Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese
Pruefung fehl, ohne dass etwas kaputt war. */
const tagText = (d = new Date()) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
/* Echte Daten statt leerer Seiten: Eine leere Liste kann nicht
ueberlaufen. Geprueft wird mit LANGEN Namen und vollen Listen --
genau dort bricht es. */
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const heute = tagText();
const gestern = tagText(new Date(Date.now() - 86400_000));
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idScout = anlegen("Patrick-Maximilian", "scout", "CODE-SCOU-0001");
const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001");
/* Die beiden fehlenden Rollen -- gebraucht fuer die Mittenpruefung ganz
unten. Filipe: "das soll bei jedem und jeder rolle verbessert
werden", und eine Pruefung an drei von fuenf Rollen haette genau
diese Aussage nicht belegen koennen. */
const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt);
const LANG = "Ein sehr langer Eintrag ohne Leerzeichenumbruch fuer die Handypruefung";
for (const [status, frist] of [["offen", gestern], ["offen", heute], ["arbeit", null],
["review", null], ["erledigt", null]]) {
d.prepare(`INSERT INTO aufgaben (titel, status, creator_id, verantwortlich_id, frist,
prioritaet, erstellt, erstellt_von) VALUES (?,?,?,?,?,'hoch',?,?)`)
.run(LANG, status, idTili, idTili, frist, jetzt, idDogi);
}
for (const [art, stunde] of [["call", "10:00"], ["termin", "14:30"], ["review", "18:00"]]) {
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id,
erledigt, erstellt, erstellt_von) VALUES (?,?,?,60,?,?,0,?,?)`)
.run(LANG, art, heute + "T" + stunde, idTili, idTili, jetzt, idDogi);
}
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status,
creator_id, erstellt, erstellt_von)
VALUES ('technik','problem',?,?,'hoch','offen',?,?,?)`)
.run(LANG, heute, idTili, jetzt, idDogi);
d.close();
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
/* ---------- WELCHE SEITEN? ALLE. -----------------------------------------
Hier stand bis zum 06.09.2026 eine Liste von Hand -- und sie war
veraltet, ohne dass es jemandem auffiel: chat.html, leistung.html und
steckbrief.html fehlten darin, also DREI von neunzehn Seiten waren nie
auf einem Handy gemessen worden. Der Chat ausgerechnet ist die Seite,
die man am ehesten unterwegs benutzt.
Das ist genau die Sorte Fehler, die eine Liste macht: Sie altert in
dem Moment, in dem jemand weiterarbeitet, und sie sagt nichts dazu.
Der Lauf blieb gruen, die Zahl der Pruefungen stimmte -- gemessen
wurde trotzdem zu wenig.
Deshalb wird die Liste jetzt aus dem VERZEICHNIS gelesen. Eine neue
Seite ist damit von selbst dabei, so wie sie durch die Schranke in
workspace.js von selbst geschuetzt ist -- derselbe Gedanke: Der
sichere Rueckfall muss der VORGABEwert sein, nicht der, an den man
denken muss.
Ausgenommen ist einzig index.html -- das ist die Anmeldeseite, sie
wird weiter unten eigens geprueft (vor der Anmeldung, denn danach
leitet sie weiter). */
const { readdirSync } = await import("node:fs");
const { dirname } = await import("node:path");
const { fileURLToPath } = await import("node:url");
/* NICHT process.cwd(): Diese Datei wird mal aus dem Wurzelverzeichnis
gestartet (tools/alles-pruefen.mjs) und mal aus server/ von Hand. Im
zweiten Fall zeigte cwd auf server/, dort gibt es kein workspace/,
und readdirSync wuerfe -- oder faende, schlimmer, ein anderes
Verzeichnis. Der Ort DIESER Datei steht dagegen fest. */
const SEITEN_ORDNER = join(dirname(fileURLToPath(import.meta.url)), "..", "workspace");
const ALLE_SEITEN = readdirSync(SEITEN_ORDNER)
.filter((n) => n.endsWith(".html") && n !== "index.html")
.sort();
/* Ein paar Seiten brauchen eine Abfrage, sonst zeigen sie nur ihre
leere Huelle. Fuer die steht hier, WOMIT sie aufgerufen werden. */
const MIT_ABFRAGE = { "bereich.html": "?b=live" };
const SEITEN = ALLE_SEITEN.map((n) => n + (MIT_ABFRAGE[n] || ""));
/* Und die Zahl gehoert in die BEDINGUNG, nicht nur in den Meldetext:
Faende readdirSync nichts (falsches Arbeitsverzeichnis), liefe die
Schleife unten null Mal -- und der Lauf waere gruen, ohne eine
einzige Seite angesehen zu haben. Genau der Fehler, den ich schon
zweimal hatte. */
if (SEITEN.length < 15) {
console.log(` FEHL nur ${SEITEN.length} Seiten in ${SEITEN_ORDNER} gefunden `
+ "-- die Handypruefung kann so nichts messen");
process.exit(1);
}
console.log(`${SEITEN.length} Workspace-Seiten werden gemessen: ${SEITEN.join(", ")}`);
/* Drei echte Geraete, nicht "irgendwas Schmales":
320 iPhone SE (1. Gen) -- das schmalste noch benutzte Geraet
390 iPhone 14/15, das haeufigste Mass
412 Pixel 8 / Galaxy S24, typisches Android */
const GERAETE = [[320, 568, "sehr schmal"], [390, 844, "iPhone"], [412, 915, "Android"]];
const messen = () => ({
/* 1. Waagerechtes Scrollen. Der schlimmste Fehler auf einem Handy. */
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
schuld: [...document.querySelectorAll("body *")]
.filter((e) => {
const r = e.getBoundingClientRect();
/* Nur was wirklich sichtbar ist. Ein Wasserzeichen hinter
overflow:hidden meldet ein Rechteck, das nie zu sehen war --
das hat frueher schon einmal falschen Alarm ausgeloest. */
if (r.width < 1 || r.height < 1) return false;
const st = getComputedStyle(e);
if (st.visibility === "hidden" || st.display === "none") return false;
/* Steckt es in einem Vorfahren mit overflow:hidden? Dann ist es
abgeschnitten und steht nicht wirklich ueber. */
for (let v = e.parentElement; v; v = v.parentElement) {
const vs = getComputedStyle(v);
if (vs.overflow !== "visible" || vs.overflowX !== "visible") return false;
}
return r.right > window.innerWidth + 1;
})
.map((e) => `${e.tagName}.${String(e.className).slice(0, 26)}(+${Math.round(e.getBoundingClientRect().right - window.innerWidth)}px)`)
.slice(0, 4),
/* 2. Abgeschnittener Text. Gemessen wird die tatsaechliche Breite
gegen die verfuegbare -- nicht "sieht ganz aus". Elemente, die
ausdruecklich kuerzen duerfen (ellipsis), sind ausgenommen: Dort
ist das Kuerzen die Absicht und der volle Text steht woanders. */
abgeschnitten: [...document.querySelectorAll("h1, h2, h3, p, span, a, label, button, li, td, th")]
.filter((e) => {
if (e.children.length) return false;
const t = e.textContent.trim();
if (t.length < 3) return false;
const st = getComputedStyle(e);
if (st.display === "none" || st.visibility === "hidden") return false;
if (st.textOverflow === "ellipsis") return false;
if (st.overflow === "hidden" && st.whiteSpace === "nowrap") return false;
return e.scrollWidth > e.clientWidth + 2;
})
.map((e) => `"${e.textContent.trim().slice(0, 22)}" (${e.scrollWidth}>${e.clientWidth})`)
.slice(0, 5),
/* 3. Beruehrziele. 24x24 CSS-Pixel ist das Mindestmass nach WCAG 2.2
(2.5.8). Gemessen wird das Ziel selbst ODER sein umschliessender
Bereich -- ein 16 px grosses Symbol in einem 44 px grossen Knopf
ist in Ordnung. */
klein: [...document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")]
.filter((e) => {
const r = e.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return false;
const st = getComputedStyle(e);
if (st.display === "none" || st.visibility === "hidden") return false;
return r.width < 24 || r.height < 24;
})
.map((e) => {
const r = e.getBoundingClientRect();
return `${e.tagName}"${(e.textContent || e.getAttribute("aria-label") || "").trim().slice(0, 14)}" `
+ `${Math.round(r.width)}x${Math.round(r.height)}`;
})
.slice(0, 5),
/* 4. Ueberlappende Bedienelemente. Zwei Knoepfe, die sich decken,
heissen: Man trifft den falschen. Geprueft wird der Mittelpunkt --
liegt dort ein ANDERES Bedienelement, ist das Ziel verdeckt. */
verdeckt: [...document.querySelectorAll("button, a[href], input, select")]
.filter((e) => {
const r = e.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return false;
if (r.top < 0 || r.bottom > window.innerHeight) return false;
const st = getComputedStyle(e);
if (st.display === "none" || st.visibility === "hidden" || st.pointerEvents === "none") return false;
/* NICHT ZAEHLEN, WAS IN EINEM ZUGEKLAPPTEN <details> STECKT.
Chromium versteckt dessen Inhalt ueber `content-visibility`,
nicht ueber `display` -- die Kaesten behalten deshalb eine
Groesse und sehen fuer jede Messung aus wie sichtbare
Bedienelemente. Am 06.09.2026 meldete das einen "verdeckten"
Knopf, den auf dem Bildschirm niemand sehen konnte. Eine
Warnung ueber etwas Unsichtbares gewoehnt man sich ab zu
lesen. */
if (e.closest("details:not([open])")) return false;
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return oben && oben !== e && !e.contains(oben) && !oben.contains(e);
})
/* WER verdeckt WEN, und wo. Vorher stand hier nur der Name des
verdeckten Knopfes -- eine Meldung wie `BUTTON"Meine Sicht"` sagt,
DASS etwas nicht stimmt, und laesst einen danach raten, WAS
darueberliegt. Ich habe daran am 06.09.2026 eine Stunde gesucht.
Ein Befund, den man nicht verorten kann, ist ein halber Befund. */
.map((e) => {
const r = e.getBoundingClientRect();
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
const kette = (x) => {
const a = [];
for (let v = x; v && a.length < 4; v = v.parentElement) {
a.push(v.tagName + (v.className && typeof v.className === "string"
? "." + v.className.split(" ")[0] : ""));
}
return a.join("<");
};
const q = oben ? oben.getBoundingClientRect() : null;
return `${kette(e)} "${(e.textContent || "").trim().slice(0, 16)}" `
+ `[${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}]`
+ ` <- verdeckt von ${oben ? kette(oben) : "?"}`
+ (q ? ` [${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}]` : "");
})
.slice(0, 3),
/* DIE GEOMETRIE DER KOPFLEISTE, aber nur wenn etwas nicht stimmt.
Am 06.09.2026 meldete diese Pruefung auf zwoelf Seiten "BUTTON
'Meine Sicht' verdeckt" -- und mehr nicht. Ich habe danach eine
Stunde gesucht, weil aus der Meldung nicht hervorging, WAS
darueberlag und bei WELCHER Breite. Erst dieser Auszug zeigte es in
einer Zeile: bei 412 px stand `wrap=nowrap`, der Umschalter war auf
50 px zusammengedrueckt, sein Knopf 104 px breit.
Bleibt deshalb drin. Ein Befund, den man nicht verorten kann, ist
ein halber Befund -- und er kostet beim naechsten Mal wieder eine
Stunde. */
kopfDump: (() => {
const k = document.querySelector(".kopfleiste__rechts");
const l = document.querySelector(".kopfleiste");
if (!k || !l) return null;
const box = (e) => { const q = e.getBoundingClientRect();
return `${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}`; };
const m = document.querySelector(".kopfleiste > .marke");
return {
w: window.innerWidth,
leiste: box(l) + " wrap=" + getComputedStyle(l).flexWrap,
rechts: box(k) + " wrap=" + getComputedStyle(k).flexWrap,
marke: m ? box(m) + " flex=" + getComputedStyle(m).flex : "keine",
kinder: [...k.children].map((e) => `${e.tagName}.${String(e.className||"").split(" ")[0]} ${box(e)}`),
};
})(),
/* 5. Die Kopfleiste muss vollstaendig im Bild sein -- sie ist die
einzige Stelle, ueber die man wieder wegkommt. */
kopfDrin: (() => {
const a = document.querySelector(".abmelden")?.getBoundingClientRect();
return a ? (a.right <= window.innerWidth + 1 && a.left >= -1) : null;
})(),
/* 6. Schriftgroesse: unter 12 px liest niemand auf einem Handy. */
/* NUR SICHTBARER TEXT. Ein ausgeblendetes Element hat weiterhin eine
Schriftgroesse -- die Pruefung meldete deshalb ein Schild, das auf
dem Handy per display:none gar nicht erscheint. Eine Pruefung, die
Unsichtbares anmahnt, gewoehnt man sich ab zu lesen. */
winzig: [...document.querySelectorAll("p, span, li, a, label, small")]
.filter((e) => {
if (e.children.length || e.textContent.trim().length <= 3) return false;
const r = e.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
return parseFloat(getComputedStyle(e).fontSize) < 11.5;
})
.map((e) => `"${e.textContent.trim().slice(0, 18)}" ${getComputedStyle(e).fontSize}`)
.slice(0, 3),
});
for (const [breite, hoehe, wie] of GERAETE) {
console.log(`\n=== ${wie} (${breite}x${hoehe})`);
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
});
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
/* Auch die Anmeldeseite selbst wird geprueft -- sie ist die erste,
die jemand auf dem Handy sieht. */
{
await seite.waitForTimeout(500);
const r = await seite.evaluate(messen);
const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length && !r.verdeckt.length;
ok(gut, `Anmeldung über=${r.ueber}px abgeschnitten=${r.abgeschnitten.length} `
+ `klein=${r.klein.length} verdeckt=${r.verdeckt.length}`
+ (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "")
+ (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "")
+ (r.klein.length ? `\n zu klein: ${r.klein.join(", ")}` : "")
+ (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : ""));
}
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
for (const s of SEITEN) {
const konsole = [];
const auf = (m) => { if (m.type() === "error") konsole.push(m.text()); };
const err = (e) => konsole.push("pageerror: " + e.message);
seite.on("console", auf);
seite.on("pageerror", err);
await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" });
await seite.waitForTimeout(700);
const r = await seite.evaluate(messen);
const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length
&& !r.verdeckt.length && !r.winzig.length
&& r.kopfDrin !== false && !konsole.length;
console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}`
+ `über=${String(r.ueber).padStart(3)}px`
+ (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "")
+ (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "")
+ (r.klein.length ? `\n Berührziel zu klein: ${r.klein.join(", ")}` : "")
+ (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "")
+ (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "")
+ (r.kopfDrin === false ? "\n der Abmelden-Knopf steht aus dem Bild" : "")
+ (konsole.length ? `\n KONSOLE: ${konsole.join(" | ")}` : "")
+ (!gut && r.kopfDump ? `\n KOPF: ${JSON.stringify(r.kopfDump)}` : ""));
if (!gut) fehler++;
seite.off("console", auf);
seite.off("pageerror", err);
}
if (breite === 390) {
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
await seite.screenshot({ path: "pruef-handy.png", fullPage: true });
}
await kontext.close();
}
/* ---------------------------------------------------------------------
Bedienbarkeit mit dem Finger: Auf einem Geraet ohne feinen Zeiger darf
das Licht gar nicht erst eingebaut werden -- es kostete Arbeit fuer
einen Effekt, den dort niemand sehen kann.
--------------------------------------------------------------------- */
console.log("\n=== Kein Licht ohne Zeiger");
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.waitForTimeout(800);
/* Eine Kachel antippen und wieder loslassen -- danach darf nichts
leuchten bleiben. */
await seite.tap(".kachel >> nth=1").catch(() => {});
await seite.waitForTimeout(400);
const r = await seite.evaluate(() => ({
lichter: document.querySelectorAll(".licht").length,
an: document.querySelectorAll('[data-licht="an"]').length,
}));
ok(r.lichter === 0, `auf dem Handy wird gar kein Licht eingebaut (${r.lichter})`);
ok(r.an === 0, `und keines bleibt an (${r.an})`);
await kontext.close();
}
/* ---------------------------------------------------------------------
RING UND UHR STEHEN IN DER MITTE -- bei jeder Rolle (09.09.2026)
Filipe, mit Bildschirmfoto: "ich will dass auf dem handy die uhr und
der kreis ganz oben mit den erledigten aufgaben, schoen mittig,
zentriert sind. die stehen sehr verzogen von der mitte. das soll bei
jedem und jeder rolle verbessert werden."
Gemessen war es bei allen fuenf Rollen und beiden Breiten dieselbe
Zahl: Ring 31 px nach links, Uhr 31 px nach rechts. Ursache war
`justify-content: center` auf einer Reihe, in der neben dem
Instrument noch ein Knopf steht -- zentriert wurde der Block aus
beiden. 62 px Knopfbreite (`--instrument-knoepfe`), halbiert, macht
genau die 31.
Warum das hier ALLE Rollen durchgeht, obwohl die Ursache eine einzige
CSS-Regel ist: Weil genau das die Behauptung war, die geprueft
gehoert. Eine Regel kann von einer rollenabhaengigen ueberschrieben
werden, und dann faellt es nur bei einer auf.
--------------------------------------------------------------------- */
/* EINE Messung, zweimal benutzt (eigene und fremde Sicht). Zwei Kopien
waeren zwei Kopien, die auseinanderlaufen -- und dann prueft die eine
etwas anderes als die andere, ohne dass es auffaellt.
Gemessen wird die ZEILENZAHL, nicht die Hoehe: Eine Hoehengrenze waere
wieder eine Zahl von gestern -- sie stimmt, bis jemand die Polsterung
anfasst. Ob zwei Dinge in derselben Zeile stehen, sieht man an ihrer
Oberkante, und das gilt immer. */
async function kopfMessen(seite) {
return await seite.evaluate(() => {
const k = document.querySelector(".kopfleiste");
const teile = [...k.querySelectorAll(".kopfleiste__rechts > *"), k.querySelector(".marke")]
.filter((e) => e && e.getBoundingClientRect().width > 0);
const kanten = teile.map((e) => Math.round(e.getBoundingClientRect().top));
return { zeilen: new Set(kanten).size, spanne: Math.max(...kanten) - Math.min(...kanten),
anzahl: teile.length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth };
});
}
let fremdGeprueft = 0;
const mitUmschalter = new Set();
const KOPF_GERAETE = [[360, 800], [390, 844], [412, 915]];
console.log("\n=== Ring und Uhr stehen mittig (alle Rollen)");
for (const [breite, hoehe] of KOPF_GERAETE) {
for (const [rolle, code] of [["admin", "CODE-DOGI-0001"], ["spicy", "CODE-SPIC-0001"],
["manager", "CODE-MANA-0001"], ["scout", "CODE-SCOU-0001"], ["creator", "CODE-ANNA-0001"]]) {
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.waitForTimeout(1500);
const m = await seite.evaluate(() => {
const ab = (w) => {
const e = document.querySelector(w);
if (!e) return null;
const r = e.getBoundingClientRect();
if (r.width < 4) return null;
return Math.round((r.left + r.right) / 2 - window.innerWidth / 2);
};
return { ring: ab("#zring"), uhr: ab(".uhr"),
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth };
});
/* 3 px Nachsicht: Ein ungerader Rest bei ungerader Breite ist kein
Befund. 31 px waren einer. */
const gut = m.ring !== null && m.uhr !== null
&& Math.abs(m.ring) <= 3 && Math.abs(m.uhr) <= 3 && m.ueber <= 1;
ok(gut, `${breite}px ${rolle.padEnd(8)} Ring ${m.ring} px, Uhr ${m.uhr} px von der Mitte`
+ (m.ueber > 1 ? `, Ueberstand ${m.ueber}` : ""));
/* ---- UND DIE KOPFLEISTE BLEIBT IN EINER REIHE (09.09.2026) ------
Filipe, mit Bildschirmfoto: "es soll alles in einer reihe sein."
Auf seinem Bild stand "Abmelden" allein in einer zweiten Zeile.
Gemessen wird die ZEILENZAHL, nicht die Hoehe: Eine Hoehengrenze
waere wieder eine Zahl von gestern -- sie stimmt, bis jemand die
Polsterung anfasst. Ob zwei Dinge in derselben Zeile stehen,
sieht man an ihrer Oberkante, und das gilt immer. */
const kopf = await kopfMessen(seite);
/* 12 px Nachsicht: Ein Knopf von 36 px und ein Bild von 28 px sind
mittig ausgerichtet und haben deshalb verschiedene Oberkanten --
das ist dieselbe Zeile. Eine zweite Zeile liegt 40 px tiefer. */
ok(kopf.anzahl >= 3 && kopf.spanne <= 12 && kopf.ueber <= 1,
`${breite}px ${rolle.padEnd(8)} Kopfleiste: ${kopf.anzahl} Teile in einer Reihe `
+ `(Spanne ${kopf.spanne} px)`);
/* ---- UND DASSELBE IN EINER FREMDEN SICHT (09.09.2026) ----------
DIESE PRUEFUNG FEHLTE, UND GENAU DAS HAT DER FEHLER AUSGENUTZT.
Filipe meldete aus der installierten App "es ist noch nicht alles
in einer zeile" -- waehrend hier alles gruen war. Der Grund: Alle
Messungen liefen in der EIGENEN Sicht. Dort klappt der Umschalter
auf 36 px zusammen; sobald man die Sicht einer anderen Person
uebernimmt, stand der Name darin und er war 152 px breit. Auf
320, 360, 390, 412 UND 430 px waren es dann zwei Zeilen.
Ein gruener Haken sagt nur, dass die Bedingung erfuellt war --
nicht, dass sie den Zustand geprueft hat, in dem der Nutzer ist.
Wer keinen Umschalter hat (Creator, Scout ...), wird nicht
uebersprungen, sondern gar nicht erst gezaehlt; dass ueberhaupt
jemand geprueft wurde, steht unten in einer eigenen Bedingung. */
const wenAnsehen = await seite.evaluate(() => {
const s = document.getElementById("sicht-feld");
if (!s) return null;
const o = [...s.options].find((x) => x.value);
return o ? { id: o.value, name: o.textContent } : null;
});
if (wenAnsehen) {
await seite.evaluate((x) => localStorage.setItem("dfw-sicht", x), wenAnsehen.id);
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
const fremd = await kopfMessen(seite);
const band = await seite.evaluate(() => {
const b = document.querySelector(".fremd-band");
if (!b) return { da: false };
const r = b.getBoundingClientRect();
return { da: r.width > 0 && r.height > 0, text: (b.textContent || "").trim().slice(0, 60) };
});
fremdGeprueft++;
mitUmschalter.add(rolle);
ok(fremd.anzahl >= 3 && fremd.spanne <= 12 && fremd.ueber <= 1,
`${breite}px ${rolle.padEnd(8)} fremde Sicht: ${fremd.anzahl} Teile in einer Reihe `
+ `(Spanne ${fremd.spanne} px)`);
/* Der Name darf nicht einfach verschwinden -- er ist aus der
Leiste ins Band gewandert, und dass er DORT steht, gehoert
geprueft. Sonst waere "eine Reihe" damit erkauft, dass niemand
mehr sieht, wessen Zahlen er ansieht. */
ok(band.da && band.text.includes(wenAnsehen.name.trim()),
`${breite}px ${rolle.padEnd(8)} das Band nennt den vollen Namen`);
await seite.evaluate(() => localStorage.removeItem("dfw-sicht"));
}
await kontext.close();
}
}
/* Die Zahl gehoert in die Bedingung, nicht nur in den Bericht: Findet
die Schleife keinen einzigen Umschalter (umbenannt, Rechte geaendert),
waere oben alles gruen, ohne dass die fremde Sicht je gemessen wurde.
Die Erwartung wird ABGELEITET, nicht hingeschrieben: so viele Rollen
mit Umschalter, wie es gibt, mal so viele Breiten, wie geprueft
werden. Heute hat nur DogFather einen -- bekommt Spicy morgen auch
einen, steigt die Zahl von selbst mit, statt still danebenzuliegen.
(Beim ersten Anlauf stand hier eine 6, geraten; gemessen waren es 3.
Genau die Sorte Zahl, die spaeter grundlos rot wird.) */
ok(mitUmschalter.size >= 1 && fremdGeprueft === KOPF_GERAETE.length * mitUmschalter.size,
`die fremde Sicht wurde ${fremdGeprueft}-mal gemessen `
+ `(${mitUmschalter.size} Rolle(n) mit Umschalter x ${KOPF_GERAETE.length} Breiten)`);
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
process.exit(fehler ? 1 : 0);