Auftrag: "einen grossen Check machen, ob alles klappt, auf dem Handy und
PC." Dafuer ein neuer Rundgang (pruef-grosscheck.mjs), der stumpf ueber
alles geht: 19 Workspace-Seiten mal 4 Rollen mal 2 Bildschirmgroessen
plus 33 oeffentliche Seiten, zweimal. 208 Seiten, 50 774 Elemente.
Solche Rundgaenge finden andere Fehler als gezielte Pruefungen: nicht
den falsch gerechneten Wert, sondern die Seite, die bei genau einer
Rolle ueberlaeuft.
=== DER WICHTIGSTE FUND: "heute" war in UTC gerechnet ===
Der Check lief um 01:10 Uhr. Ortszeit war der 2. September, in UTC noch
der 1. -- und in diesem Fenster rechnete die Anwendung an ZWOELF Stellen
"heute" als toISOString(), also in UTC. Server und Benutzer stehen beide
auf Europe/Berlin.
Was das im Alltag bedeutete, jede Nacht zwischen 0 und 2 Uhr:
* eine heute faellige Aufgabe galt noch nicht als faellig
* eine um Mitternacht ueberfaellig gewordene erschien erst um 2 Uhr
* der Filter "Heute faellig" zeigte den Vortag
* Datumsfelder schlugen gestern vor
* der Kalender begann seine Vorgabe einen Tag zu frueh
Also genau dann, wenn nach einem Stream gearbeitet wird.
kalender.js machte es die ganze Zeit RICHTIG -- samt Begruendung, warum
die ARITHMETIK trotzdem in UTC laufen muss (UTC-Mittag ueberlebt die
Zeitumstellung; wer lokal rechnet, verliert am 27. Oktober einen Tag).
Diese Trennung gilt jetzt ueberall, aus je einer Quelle:
RECHNEN mit Datumsangaben -> UTC-Mittag, unveraendert
WELCHER TAG IST HEUTE -> Ortszeit (heuteLokal/tagLokal im Server,
window.heuteLokal in kopf.js)
WIE ES AUFFIEL, und das ist die eigentliche Lehre: Zuerst schlugen zwei
Pruefungen fehl -- und die Ursache lag in IHNEN, sie rechneten selbst in
UTC (36 Stellen in 17 Dateien). Nach deren Reparatur schlugen sie WIEDER
fehl, und erst da zeigten sie auf die Anwendung. Wer beim ersten Mal
aufgehoert haette ("ist ja nur die Pruefung"), haette den echten Fehler
nie gesehen.
Nachtrag desselben Musters: pruef-uebersicht legte den Termin weiterhin
in UTC an, waehrend die Erwartung schon auf Ortszeit stand. Wer eine
Datumsrechnung umstellt, muss BEIDE Seiten umstellen -- die, die
schreibt, und die, die prueft.
=== VIER FEHLER AUF HANDY UND PC ===
1. Ein langer Creator-Name ("SpongBobSchwammKopf") schob die Startseite
auf dem Handy um 48 Pixel aus dem Bild -- ein Wort ohne Trennstelle,
und die Seite liess sich seitlich wegschieben. Trifft echte Namen:
Creator heissen selten "Tim".
2. Die klebende Speicherleiste verdeckte auf dem Handy ein Textfeld.
Beim Tippen sieht man die eigene Zeile nicht. Behoben mit
scroll-margin-bottom (WCAG 2.2, 2.4.11 "Focus Not Obscured").
3./4. Zwei Beschriftungen waren mit 9,6 px (Uebersicht: "ueberfaellig",
"dringend", "offen") und 9,9 px (Kalender: "heute") zu klein. Fuers
Handy gab es laengst eine Ausnahme -- nur der Rechner war vergessen
worden. Ausgerechnet die Woerter, die den Zahlen ihre Bedeutung geben.
=== WAS KEINE FEHLER WAREN ===
Der erste Durchgang meldete 19 Maengel, die keine waren. Alle einzeln im
Quelltext nachgeprueft und dem Rundgang beigebracht:
* Kacheln und Kopfzeilen "abgeschnitten" -- das Wasserzeichen ragt
ABSICHTLICH ueber den Rand (steht so im Quelltext)
* "verdeckt: wahl2__echt" -- das echte <select> liegt absichtlich
unsichtbar unter seinem Knopf
* "zurueck-knopf__text abgeschnitten" -- das uebliche Muster fuer
"nur fuer Vorleseprogramme"
* drei "zu kleine" Verweise -- WCAG 2.5.8 nimmt Verweise im Fliesstext
AUSDRUECKLICH aus. Eine Pruefung, die ihre eigene Messlatte nicht
kennt, misst nichts.
Beim vierten Punkt haette ich fast an der falschen Stelle repariert.
Und statt die Sticky-Meldung abzuschalten (dann faende sie auch echte
Ueberdeckungen nie mehr), wurde sie GENAUER: Ueberdeckt etwas Klebendes
ein Eingabefeld, ist das nur in Ordnung, wenn das Feld genug
scroll-margin-bottom hat, um darunter hervorzukommen. Aus einer vagen
Meldung wird eine pruefbare Zusage.
Vier Gegenproben belegen, dass der Rundgang ueberhaupt etwas finden
kann: ein zu breites Element, ein winziger Knopf, ein winziger Verweis
AUSSERHALB eines Satzes und ein wirklich abgeschnittenes Wort werden
alle gemeldet. Ohne diesen Nachweis waere "alles in Ordnung" wertlos.
Co-Authored-By: Claude Opus 5 <[email protected]>
234 lines
13 KiB
JavaScript
234 lines
13 KiB
JavaScript
/* Browserpruefung der Content-Planung: Strecke, Kennzahlen, Balance,
|
||
Ueberfuehren -- und was ein Creator davon zu sehen bekommt. */
|
||
import { mkdtempSync, rmSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-content-b-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
process.env.PORT = "4185";
|
||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
||
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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 }); };
|
||
await import("./index.js");
|
||
await new Promise((r) => setTimeout(r, 700));
|
||
const BASIS = "http://127.0.0.1:4185";
|
||
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_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");
|
||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
const jetzt = new Date().toISOString();
|
||
const heute = tagText();
|
||
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("Dogfather", "admin", "CODE-DOGI-0001");
|
||
const idAnna = anlegen("Anna Sturm", "creator", "CODE-ANNA-0001");
|
||
|
||
/* Vier Saeulen -- damit vier der fuenf Farbplaetze im Bild sind. */
|
||
const saeulen = {};
|
||
for (const [i, [name, ziel]] of [["Tutorials", 55], ["Community", 25], ["Hinter den Kulissen", 10], ["Eigenwerbung", 10]].entries()) {
|
||
d.prepare("INSERT INTO content_saeulen (creator_id, name, ziel, slot, erstellt) VALUES (?,?,?,?,?)")
|
||
.run(idAnna, name, ziel, i + 1, jetzt);
|
||
saeulen[name] = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
}
|
||
|
||
const video = (art, titel, hook, saeule, format, geplant) =>
|
||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, hook, format, saeule_id, geplant, datum, status, creator_id, erstellt, erstellt_von)
|
||
VALUES ('content',?,?,?,?,?,?,?,'offen',?,?,?)`)
|
||
.run(art, titel, hook, format, saeule ? saeulen[saeule] : null, geplant, heute, idAnna, jetzt, idDogi);
|
||
|
||
video("idee", "3 Fehler beim ersten LIVE", null, "Tutorials", null, null);
|
||
video("idee", "Warum ich fast aufgehört hätte", null, "Hinter den Kulissen", null, null);
|
||
video("skript", "Setup für unter 100 €", "Das hier kostet weniger als dein Handy.", "Tutorials", "Tutorial", null);
|
||
video("skript", "Antwort auf eure Fragen", null, "Community", "Talking Head", null);
|
||
video("gedreht", "Mein Tag als Creator", "Um 6 klingelt der Wecker – freiwillig.", "Hinter den Kulissen", "Story", null);
|
||
video("gedreht", "Die 3 häufigsten Fragen", "Nummer 2 fragt mir wirklich jeder.", "Community", "Talking Head", null);
|
||
video("fertig", "Licht richtig setzen", "Ein Fenster reicht. Ehrlich.", "Tutorials", "Tutorial",
|
||
tagText(new Date(Date.now() + 2 * 86400_000)));
|
||
video("fertig", "Verpasst und überfällig", "Guck bis zum Schluss.", "Tutorials", "Tutorial", "2026-01-05");
|
||
for (let i = 0; i < 9; i++) video("veroeffentlicht", "Draußen " + i, "x", "Tutorials", "Tutorial", null);
|
||
for (let i = 0; i < 4; i++) video("veroeffentlicht", "Community " + i, "x", "Community", "Talking Head", null);
|
||
for (let i = 0; i < 2; i++) video("veroeffentlicht", "Kulissen " + i, "x", "Hinter den Kulissen", "Story", null);
|
||
video("veroeffentlicht", "Ohne Zuordnung", "x", null, null, null);
|
||
d.close();
|
||
|
||
const { chromium } = await import(
|
||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
const browser = await chromium.launch();
|
||
|
||
async function seiteFuer(kontext, rolle, code) {
|
||
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.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(700);
|
||
return seite;
|
||
}
|
||
|
||
const lesen = (seite) => seite.evaluate(() => ({
|
||
spalten: [...document.querySelectorAll(".spalte")].map((s) => ({
|
||
name: s.querySelector(".spalte__name")?.textContent,
|
||
zahl: s.querySelector(".spalte__zahl")?.textContent,
|
||
karten: s.querySelectorAll(".video").length,
|
||
})),
|
||
kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({
|
||
wert: k.querySelector(".kachel__wert")?.textContent,
|
||
name: k.querySelector(".kachel__name")?.textContent,
|
||
zusatz: k.querySelector(".kachel__zusatz")?.textContent,
|
||
stufe: k.dataset.stufe,
|
||
})),
|
||
stuecke: [...document.querySelectorAll(".balance__stueck")].map((s) => ({
|
||
slot: s.dataset.slot, breite: s.style.width,
|
||
farbe: getComputedStyle(s).backgroundColor,
|
||
})),
|
||
legende: [...document.querySelectorAll(".legende__punkt")].map((l) => l.textContent),
|
||
hooks: [...document.querySelectorAll(".video__hook")].map((h) => h.textContent).slice(0, 3),
|
||
fehlendeHooks: document.querySelectorAll(".video__hook--fehlt").length,
|
||
anlegenDa: !document.getElementById("neu-oeffnen")?.hidden,
|
||
saeulenKnopfDa: !document.getElementById("saeulen-oeffnen")?.hidden,
|
||
/* Nur SICHTBARE zaehlen -- versteckte Knoepfe waeren zwar unschoen,
|
||
aber die Frage ist, was der Creator vor sich hat. Gezaehlt wird
|
||
zusaetzlich, was ueberhaupt im Dokument steht (siehe knoepfeGesamt):
|
||
Ein Knopf, den nur CSS versteckt, ist mit der Tastatur erreichbar. */
|
||
knoepfe: [...document.querySelectorAll(".tu-knopf")].filter((b) => b.offsetParent !== null).length,
|
||
knoepfeGesamt: document.querySelectorAll(".tu-knopf").length,
|
||
mehrKnopf: document.querySelectorAll(".spalte__mehr").length,
|
||
unterzeile: document.getElementById("unterzeile")?.textContent,
|
||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
ueberstehend: [...document.querySelectorAll("main *")]
|
||
.filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1)
|
||
.map((e) => String(e.className).slice(0, 40)).slice(0, 4),
|
||
}));
|
||
|
||
for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
|
||
console.log(`\n=== DogFather · ${wie}`);
|
||
const kontext = await browser.newContext({ viewport: { width: breite, height: 1100 } });
|
||
const konsole = [];
|
||
const seite = await seiteFuer(kontext, "admin", "CODE-DOGI-0001");
|
||
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
||
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
|
||
await seite.reload({ waitUntil: "networkidle" });
|
||
await seite.waitForTimeout(700);
|
||
|
||
const r = await lesen(seite);
|
||
console.log(" Spalten:", r.spalten.map((s) => `${s.name}=${s.zahl}`).join(" · "));
|
||
|
||
ok(r.spalten.length === 5, `fünf Stufen (${r.spalten.length})`);
|
||
ok(r.spalten[0].zahl === "2" && r.spalten[4].zahl === "16",
|
||
`Ideen 2, Veröffentlicht 16 (${r.spalten[0].zahl}/${r.spalten[4].zahl})`);
|
||
/* DIE KENNZAHLEN-REIHE IST WEG (01.09.2026, auf Wunsch: "diese Kisten
|
||
sind jetzt ueberall zu viel, die sollen weg").
|
||
|
||
Hier stand vorher, dass es genau fuenf davon gibt. Die Pruefung wird
|
||
nicht geloescht, sondern UMGEDREHT: Sie sichert jetzt, dass sie
|
||
nicht zurueckkommen. Eine geloeschte Pruefung merkt niemand, wenn
|
||
jemand die Reihe spaeter versehentlich wieder einbaut.
|
||
|
||
Die Zahlen selbst (Vorrat, ohne Hook, ueberfaellig) sind dadurch
|
||
NICHT ungeprueft: pruef-content.mjs prueft sie weiterhin an der
|
||
Schnittstelle, Abschnitt 5 und 6. Nur ihre Anzeige ist entfallen. */
|
||
ok(r.kacheln.length === 0, `keine Kennzahlen-Kisten mehr (${r.kacheln.length})`);
|
||
ok(r.spalten.length === 5,
|
||
"die Strecke zeigt die Zahlen weiterhin an jeder Spalte – dort gehoeren sie hin");
|
||
|
||
/* Die Balance -- vier Farben, jede eine andere, keine zwei gleich. */
|
||
ok(r.stuecke.length === 4, `vier Stücke im Balken (${r.stuecke.length})`);
|
||
const farben = new Set(r.stuecke.map((s) => s.farbe));
|
||
ok(farben.size === r.stuecke.length, `jede Farbe kommt genau einmal vor (${farben.size})`);
|
||
console.log(" Farben:", [...farben].join(" "));
|
||
ok(r.legende.some((l) => /Ziel 55/.test(l)), "die Legende nennt das Ziel");
|
||
ok(r.legende.some((l) => /über|unter/.test(l)), "und die Abweichung davon");
|
||
|
||
ok(r.fehlendeHooks === 1, `genau eine Karte sagt „kein Hook" (${r.fehlendeHooks})`);
|
||
ok(r.hooks.some((h) => /„/.test(h)), "Hooks stehen in Anführungszeichen");
|
||
ok(r.anlegenDa && r.saeulenKnopfDa, "DogFather kann anlegen und Säulen verwalten");
|
||
ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`);
|
||
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
|
||
|
||
/* Die lange Spalte darf nicht alles erschlagen -- und der Aufklapper
|
||
muss sie wirklich vollstaendig zeigen. */
|
||
ok(r.mehrKnopf === 1, `ein Aufklapper an der zu langen Spalte (${r.mehrKnopf})`);
|
||
ok(r.spalten[4].karten === 6, `Veröffentlicht zeigt 6 von 16 (${r.spalten[4].karten})`);
|
||
await seite.click(".spalte__mehr");
|
||
await seite.waitForTimeout(300);
|
||
const offen = await lesen(seite);
|
||
ok(offen.spalten[4].karten === 16, `aufgeklappt sind es alle 16 (${offen.spalten[4].karten})`);
|
||
await seite.click(".spalte__mehr");
|
||
await seite.waitForTimeout(300);
|
||
ok((await lesen(seite)).spalten[4].karten === 6, "und wieder zu");
|
||
|
||
if (breite === 1440) {
|
||
await seite.screenshot({ path: "pruef-content-computer.png", fullPage: true });
|
||
|
||
console.log("\n Überführen im Browser");
|
||
/* Die Karte mit dem geplanten Termin -- dort gibt es alle drei Wege. */
|
||
const knopf = seite.locator('.video:has-text("Licht richtig setzen") .tu-knopf', { hasText: "→ Aufgabe" });
|
||
await knopf.first().click();
|
||
await seite.waitForTimeout(1200);
|
||
const meldung = await seite.evaluate(() => document.getElementById("fehler")?.textContent);
|
||
ok(/Aufgabe angelegt/.test(meldung || ""), `Rückmeldung erscheint ("${meldung}")`);
|
||
const link = await seite.evaluate(() => document.querySelector("#fehler a")?.getAttribute("href"));
|
||
ok(link === "aufgaben.html", `mit Weg dorthin (${link})`);
|
||
} else {
|
||
await seite.screenshot({ path: "pruef-content-handy.png", fullPage: true });
|
||
}
|
||
await kontext.close();
|
||
}
|
||
|
||
console.log("\n=== Creator sieht, ändert aber nichts");
|
||
{
|
||
const kontext = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
|
||
const seite = await seiteFuer(kontext, "creator", "CODE-ANNA-0001");
|
||
const r = await lesen(seite);
|
||
ok(r.spalten.length === 5, `sieht die ganze Strecke (${r.spalten.length} Spalten)`);
|
||
/* 2+2+2+2 plus die gekuerzten 6 der letzten Spalte = 14. Die Kuerzung
|
||
gilt fuer JEDE Rolle -- sie ist eine Frage der Lesbarkeit, nicht der
|
||
Rechte. Der Rest ist einen Klick entfernt. */
|
||
ok(r.spalten.reduce((n, s) => n + s.karten, 0) === 14,
|
||
`und seine Videos, letzte Spalte gekürzt (${r.spalten.reduce((n, s) => n + s.karten, 0)})`);
|
||
ok(r.mehrKnopf === 1, `mit einem Aufklapper an der langen Spalte (${r.mehrKnopf})`);
|
||
ok(r.kacheln.length === 0, "sieht keine Kennzahlen-Kisten mehr");
|
||
ok(r.stuecke.length === 4, "sieht die Verteilung");
|
||
ok(!r.anlegenDa, "aber keinen Anlegen-Knopf");
|
||
ok(!r.saeulenKnopfDa, "und keine Säulenverwaltung");
|
||
ok(r.knoepfe === 0, `keinen einzigen sichtbaren Knopf (${r.knoepfe})`);
|
||
ok(r.knoepfeGesamt === 0, `und auch keinen versteckten im Dokument (${r.knoepfeGesamt})`);
|
||
ok(/deine Betreuung/.test(r.unterzeile || ""), `und die Unterzeile erklärt es ("${r.unterzeile}")`);
|
||
await seite.screenshot({ path: "pruef-content-creator.png", fullPage: true });
|
||
await kontext.close();
|
||
}
|
||
|
||
await browser.close();
|
||
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
process.exit(fehler === 0 ? 0 : 1);
|