Files
dogfather-universe/server/pruef-wege-nach-draussen.mjs
T
DogFatherGitandClaude Opus 5 f62ade3573 Vertraulicher Meldeweg, klappbare Abschnitte, lila Stunden, Logos
Eine Nacht voller Auftraege von Filipe. Der Reihe nach:

1. VERTRAULICH MELDEN -- der groesste neue Teil.

"es soll auch eine kategorie also eine hauptkachel geben wo die
community leute sich anonym melden koennen wenn sie probleme haben ...
rechte hand und dogfather sollen zugriff auf diese anonyme nachrichten
haben. also anonym fuer die anderen."

WICHTIG, UND ES STEHT SO AUF DER SEITE: "anonym" heisst hier
VERTRAULICH. DogFather und die rechte Hand sehen den Namen -- so
bestellt und auch richtig, ohne Namen kann man niemandem helfen.
Anonym ist es gegenueber allen anderen: kein Modi, kein anderes
Mitglied. Wer glaubt, er schreibe unerkannt, schreibt anders und fuehlt
sich hinterher getaeuscht -- deshalb steht der Satz im Formular, bevor
jemand tippt.

Der strikte Wechsel (melden -> warten -> antworten -> warten) ist im
SERVER erzwungen, nicht nur im Knopf. Ein ausgegrauter Knopf ist keine
Regel. Geschlossen wird nur von der Leitung; abgeschlossene Faelle
werden nach 90 Tagen samt Nachrichten geloescht -- hier stehen die
empfindlichsten Texte des Hauses.

Modis sind ausdruecklich AUSSEN VOR: Sehr oft geht es in diesen
Meldungen um eine Moderationsentscheidung.

2. JEDE KATEGORIE LAESST SICH ZUKLAPPEN.

"man soll auf dieser seite jede kategorie auf und zu klappen koennen
mit einem button ... ueberall wo so eine liste entstehen kann."

Das Muster gab es auf der Startseite schon; es steht jetzt als
`abschnittKlappbar` in kopf.js und wird von den Brettern und dem
Zeitstrahl benutzt. Die Koepfe sind echte <button> (fuer die Tastatur),
der Zustand haelt in localStorage, und er haengt je Brett UND Art --
sonst waere "Regel zugeklappt" ueberall gleichzeitig zu.

3. DIE SPRUNGLEISTE SIEHT AUS WIE ETWAS ZUM ANFASSEN.

Vorher unterstrichene Woerter, die aussahen wie eine Fusszeile. Jetzt
Marken mit Rahmen in einem eigenen Block.

4. DIE STUNDEN SIND LILA.

UMGERECHNET, NICHT NEU GEWAEHLT: Jeder der fuenf Verlaufsstopps wurde
nach OKLCH zerlegt, der Farbton auf 303 Grad gedreht, zurueckgerechnet.
Helligkeit und Buntheit blieben gleich -- eine frei gegriffene Palette
waere heller oder bunter geworden, und die Uhr damit unruhiger.

5. DIE KARTEN "UNSERE SEITEN": babyblau und lila, mit schwebenden Logos.

Beide Logos wurden zu MASKEN gerechnet (tools/logo-maske.mjs): Die PNG
tragen nur Alpha, die Farbe kommt aus dem CSS. Dadurch passt dasselbe
Logo in jede Kachel, egal welchen Ton sie hat -- und VanVans 378-KB-
Logo wurde dabei zu 109 KB. Vier Stueck je Karte, verschieden gross,
zwei Bahnen, 26 bis 41 Sekunden Umlauf. Der erste Versuch war mit 5-9 %
Deckkraft praktisch unsichtbar; jetzt 10-16 %.

6. "ENTWICKLUNG" HEISST JETZT "EURE AUFGABEN".

Filipe hat recht: Die Kachel fuehrt seit dem 11.09. auf den Katalog --
eine Aufgabenliste. Der Name blieb stehen, weil beim Umhaengen des
Ziels niemand ihn angefasst hat. Der Name "Entwicklung" ist damit frei
fuer das, was er darunter versteht (Auswertung je Person mit Verlauf
und Text) -- das ist noch NICHT gebaut.

DREI PRUEFUNGEN, DIE ZUFAELLIG GRUEN WAREN, sind nebenbei aufgefallen:
- pruef-kachelraster wartete feste 500 ms auf eine gestaffelte
  Einblendung und mass vier Reihen, wo drei sind. Jetzt reducedMotion.
- pruef-start-ansicht scrollte 600 px und nahm an, danach liege keine
  Kachel mehr unter dem Zeiger.
- pruef-wege-nach-draussen zaehlte das Kachelraster ueber ALLE Gruppen
  statt je Gruppe.

GEMESSEN: pruef-hilfe (neu) 55/0, pruef-wege-nach-draussen 64/0,
pruef-jeder-hat-eine-seite 65/0, pruef-community-sicht 10/0,
pruef-bereiche-lesend, pruef-steckbrief, pruef-start-ansicht,
pruef-kachelraster alle gruen.

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

532 lines
25 KiB
JavaScript

/* =====================================================================
WEGE NACH DRAUSSEN -- 19.09.2026
Zwei Aufträge von Filipe, und beide haben dieselbe Wurzel:
„ich brauch im community bereich noch eine kachel, die wenn man
drauf drückt dan kacheln hat die auf die website von dogfather
universe geht ... und die shop site von vanvan."
„die community soll sofort auf das video geschickt werden sobald
sie auf die kachel drücken, also die ganze kacheln will ich egal
wo man drauf drückt, foto text.... egal wo."
Beides führt AUS dem Treff hinaus. Und beides ist die Sorte
Funktion, die man für erledigt hält, weil sie im Quelltext
dasteht -- bis jemand daraufklickt und nichts passiert.
DIE DREI FALLEN, DIE HIER ABGESICHERT WERDEN:
1. EIN LINK IM LINK. Die Video-Karte enthält bereits Links und
Knöpfe. Hätte ich die ganze Karte in ein <a> gepackt, würde der
Browser das still reparieren -- und ein Teil der Karte wäre tot,
ohne Fehlermeldung. Deshalb wird hier gemessen, dass BEIDES
funktioniert: die Fläche UND die Knöpfe darin.
2. EINE FLÄCHE, DIE ÜBERALL KLICKT. Genauso falsch wäre das
Gegenteil: Wer auf „löschen“ tippt, darf nicht bei TikTok
landen. Die Gegenprobe steht deshalb gleichberechtigt daneben.
3. EIN TOTER WEG. Eine Kachel, die ein Mitglied sieht und nicht
öffnen darf, ist schlimmer als keine. Genau diese Sorte hat die
Sichtprüfung am 18.09. zehnmal gefunden -- alle derselbe
Chat-Knopf.
Aufrufen mit: node server/pruef-wege-nach-draussen.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 { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4196, "pruef-wege-nach-draussen");
const ordner = mkdtempSync(join(tmpdir(), "ws-wege-"));
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";
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");
notbremse(150_000, "pruef-wege-nach-draussen");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
const WURZEL = join(import.meta.dirname, "..");
function roh(pfad, host, kopf = {}, koerper = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: koerper ? "POST" : "GET",
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
/* ---- Leute anlegen ------------------------------------------------- */
const LEUTE = [
["Filipe", "admin", "CODE-DOGI-0001"],
["Mara", "gast", "CODE-GAST-0001"],
["Nico", "creator", "CODE-CREA-0001"],
];
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
for (const [name, rolle, code] of LEUTE) {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 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, salz, 32768, new Date().toISOString());
}
d.close();
}
async function anmelden(rolle, code, host) {
let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true });
}
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
/* =======================================================================
1. DIE KACHEL -- GIBT ES SIE, UND FUEHRT SIE IRGENDWOHIN?
======================================================================= */
melde("");
melde("=== 1. Die Kachel „Unsere Seiten“ ===");
/* GEFRAGT WIRD, WAS EIN MITGLIED BEKOMMT -- nicht, was in einer
internen Liste steht. `TREFF_BEREICHE` ist absichtlich nicht
exportiert; `bereicheFuer` ist die Funktion, die auch den Browser
beliefert. Damit misst dieser Abschnitt denselben Weg, den die
Community geht, statt einen daneben. */
const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js");
{
const TREFF_BEREICHE = bereicheFuer({ rolle: "gast" });
ok(Array.isArray(TREFF_BEREICHE) && TREFF_BEREICHE.length > 0,
`ein Mitglied bekommt ${TREFF_BEREICHE?.length} Kacheln`);
const kachel = TREFF_BEREICHE.find((k) => k.ziel === "unsere-seiten.html");
ok(!!kachel, "sie steht in der Liste der Treff-Kacheln");
ok(kachel?.name === "Unsere Seiten", `sie heisst „${kachel?.name}“`);
ok(!!kachel?.unter && kachel.unter.length > 5,
`und sagt, was dahinter steckt („${kachel?.unter}“)`);
/* SIE DARF KEIN BRETT SEIN. Waere sie eins, wuerde der Server ein
Brett „unsere-seiten“ erwarten, das es nicht gibt -- und die
Ableitung unten nimmt sie nur deshalb nicht mit, weil im Ziel kein
„?b=“ steht. Das ist eine stille Abhaengigkeit, also wird sie
gemessen. */
ok(!TREFF_BRETTER.includes("unsere-seiten"),
`sie ist kein Brett (${TREFF_BRETTER.length} Bretter abgeleitet)`);
ok(TREFF_BRETTER.length === 7,
`es bleiben genau die sieben Bretter (${TREFF_BRETTER.join(", ")})`);
/* DAS RASTER -- JE GRUPPE, NICHT UEBER ALLES.
Der erste Entwurf zaehlte alle Kacheln zusammen und meldete ein
Loch, sobald die Community eine zweite Gruppe bekam (Fuer dich).
Das war ein Fehler der Pruefung, nicht der Oberflaeche: Jede
Gruppe hat ihr EIGENES Raster und faengt in einer neuen Zeile an.
Eine freie Zelle am ENDE einer Gruppe ist erlaubt -- ein Loch in
der MITTE nicht. Geprueft wird deshalb die Gruppe mit den sieben
Brettern: Dort haette eine falsch einsortierte breite Kachel eine
Luecke mitten hinein geschoben. */
const jeGruppe = new Map();
for (const x of TREFF_BEREICHE) {
if (!jeGruppe.has(x.gruppe)) jeGruppe.set(x.gruppe, []);
jeGruppe.get(x.gruppe).push(x);
}
const treffGruppe = jeGruppe.get("Community") || [];
const plaetze = treffGruppe.reduce((n, k) => n + (k.gross ? 2 : 1), 0);
ok(treffGruppe.length > 0 && plaetze % 3 === 0,
`die Community-Gruppe geht auf: ${treffGruppe.length} Kacheln = ${plaetze} Plaetze,`
+ ` ${plaetze / 3} volle Reihen`);
ok(treffGruppe.findIndex((k) => k.gross) === 0,
"und die doppelt breite Kachel steht an erster Stelle");
/* EIGENER TON. Zwei Kacheln mit derselben Farbe nebeneinander sind
auf einem Bildschirmfoto nicht zu unterscheiden. */
const toene = TREFF_BEREICHE.map((k) => k.ton);
ok(new Set(toene).size === toene.length,
`jede Kachel hat einen eigenen Ton (${toene.join(", ")})`);
/* UND DEN TON MUSS ES IM CSS GEBEN. Fehlt er, faellt die Kachel auf
die Vorgabefarbe zurueck -- und sieht aus wie eine andere. */
const css = readFileSync(join(WURZEL, "workspace/assets/css/start.css"), "utf8");
ok(css.includes(`[data-ton="${kachel?.ton}"]`),
`Ton ${kachel?.ton} ist im CSS hinterlegt`);
/* UND DAS ZEICHEN. Ein unbekannter Name gibt ein leeres Feld -- ohne
Fehlermeldung. */
const zeichenDatei = readFileSync(join(WURZEL, "workspace/assets/js/bereiche.js"), "utf8");
ok(new RegExp(`^\\s{4}${kachel?.zeichen}: \\{`, "m").test(zeichenDatei),
`das Zeichen „${kachel?.zeichen}“ gibt es wirklich`);
}
/* =======================================================================
2. DARF SIE AUCH JEMAND OEFFNEN? -- der tote Weg
======================================================================= */
melde("");
melde("=== 2. Wer darf hinein ===");
const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
const kCreator = await anmelden("creator", "CODE-CREA-0001", CREW);
{
ok(!!kGast && !!kDogi, "Mitglied und DogFather sind angemeldet");
const a = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kGast });
ok(a.code === 200, `ein Mitglied bekommt die Seite (${a.code})`);
ok(a.text.includes("DOGI10"), "der Rabattcode steht darin");
ok(a.text.includes("vans-diy-bastelbedarf.com"), "die Adresse des Shops steht darin");
ok(a.text.includes("dogfather-universe.com"), "die Adresse der Website steht darin");
/* KEIN PERSOENLICHER CODE AUF EINER SEITE FUER ALLE. In derselben
Datei des Shops steht ein zweiter, der ausdruecklich „Nur fuer
Filipe persoenlich" ist. Stuende er hier, waere er binnen eines
Tages in jedem Gruppenchat. */
ok(!/FILIPE\d/i.test(a.text),
"der persoenliche Partnercode steht NICHT darin");
const b = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kDogi });
ok(b.code === 200, `DogFather auch (${b.code})`);
/* GEGENPROBE -- ohne sie beweist „200“ nichts. Die Agentur hat mit
dem Treff nichts zu tun. */
const c = await roh("/workspace/unsere-seiten.html", CREW, { cookie: kCreator });
ok(c.code === 403 || c.code === 302,
`ein Creator kommt NICHT hinein (${c.code})`);
const d = await roh("/workspace/unsere-seiten.html", CREW);
ok(d.code === 302 || d.code === 401 || d.code === 403,
`ohne Anmeldung auch nicht (${d.code})`);
}
/* =======================================================================
3. EIN HIGHLIGHT MIT VIDEO -- die Daten dafuer
======================================================================= */
melde("");
melde("=== 3. Drei Karten zum Vergleich ===");
const VIDEO = "https://www.tiktok.com/@dogfather0804/video/7500000000000000000";
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const wer = d.prepare("SELECT id FROM personen WHERE name = 'Filipe'").get().id;
const jetzt = new Date().toISOString();
/* (a) mit Video -- muss klickbar sein */
d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von,"
+ " quelle_url, quelle_kanal) VALUES (?,?,?,?,?,?,?,?,?,?)")
.run("highlight", "clip", "Wer ist euer Komet", "Schreibt es in die Kommentare",
jetzt.slice(0, 10), "offen", jetzt, wer, VIDEO, "tiktok");
/* (b) ohne Video -- darf NICHT klickbar sein */
d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von)"
+ " VALUES (?,?,?,?,?,?,?,?)")
.run("highlight", "clip", "Nur ein Bild", "Ohne Quelle",
jetzt.slice(0, 10), "offen", jetzt, wer);
/* (c) Video bei TikTok geloescht -- darf NICHT klickbar sein, sonst
fuehrt der Klick auf eine Fehlerseite und WIR sehen kaputt aus */
d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, status, erstellt, erstellt_von,"
+ " quelle_url, quelle_kanal, quelle_fehlt_seit) VALUES (?,?,?,?,?,?,?,?,?,?,?)")
.run("highlight", "clip", "Nicht mehr da", "Wurde geloescht",
jetzt.slice(0, 10), "offen", jetzt, wer, VIDEO, "tiktok", jetzt);
/* EIN ECHTES BILD AN DIE ERSTE KARTE.
Filipe hat „foto" ausdrücklich genannt -- es ist die größte Fläche
der Karte und das, worauf ein Daumen zuerst landet. Ohne
angehängtes Bild meldete dieser Abschnitt beim ersten Lauf
„Bildweg nicht gemessen", und damit wäre genau der Teil ungeprüft
geblieben, um den es geht.
ES MUSS EIN ECHTES PNG SEIN. Die Bildroute entscheidet an den
ersten Bytes der Datei, nicht am behaupteten Typ -- und schlägt
sie fehl, entfernt der Fehlerbehandler im Browser den Rahmen
wieder. Eine leere Datei hätte also dasselbe Loch hinterlassen,
nur unauffälliger. */
const EIN_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64");
{
const { mkdirSync, writeFileSync } = await import("node:fs");
const bildOrdner = join(ordner, "dateien");
mkdirSync(bildOrdner, { recursive: true });
writeFileSync(join(bildOrdner, "probe-standbild.png"), EIN_PNG);
const mitVideoId = d.prepare(
"SELECT id FROM eintraege WHERE bereich = 'highlight' AND quelle_url IS NOT NULL"
+ " AND quelle_fehlt_seit IS NULL").get().id;
d.prepare("INSERT INTO dateien (name_original, name_datei, groesse, typ, status,"
+ " hochgeladen_von, erstellt, eintrag_id) VALUES (?,?,?,?,?,?,?,?)")
.run("standbild.png", "probe-standbild.png", EIN_PNG.length, "image/png",
"freigegeben", wer, jetzt, mitVideoId);
}
/* FREIGEBEN -- sonst sieht ein Mitglied gar nichts.
Im Treff ist ein Beitrag erst sichtbar, wenn jemand aus dem Team
ihn freigegeben hat (`treff_freigaben`). Beim ersten Lauf hat
genau das gefehlt: Die drei Karten standen in der Datenbank, das
Brett blieb leer, und vier Prüfungen meldeten „0 Karten" -- was wie
ein Fehler am Klickverhalten aussah und keiner war. Wer eine
Oberfläche prüft, muss den Zustand herstellen, in dem sie im
Alltag steht. */
const wann = new Date().toISOString();
for (const z of d.prepare("SELECT id FROM eintraege WHERE bereich = 'highlight'").all()) {
d.prepare("INSERT INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)")
.run(z.id, wann, wer);
}
const n = d.prepare("SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'highlight'").get().n;
const f = d.prepare("SELECT COUNT(*) AS n FROM treff_freigaben").get().n;
d.close();
ok(n === 3, `drei Karten angelegt: mit Video, ohne Video, geloescht (${n})`);
ok(f === 3, `und alle drei freigegeben, sonst sieht ein Mitglied nichts (${f})`);
}
/* =======================================================================
4. IM BROWSER -- klickt die Flaeche wirklich?
ALLES DAVOR IST VORARBEIT. Ob eine Karte anklickbar ist, entscheidet
sich nicht im Quelltext, sondern unter einem Finger.
======================================================================= */
melde("");
melde("=== 4. Im Browser ===");
{
let pw = null;
try {
pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch { /* dritter Ausgang unten */ }
if (!pw) {
/* DRITTER AUSGANG: nicht „bestanden“, sondern „konnte nicht
nachsehen" -- und die Zaehlung sagt das auch. */
melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
melde(" Das ist kein Befund. Die Abschnitte 1-3 gelten weiter.");
} else {
const browser = await pw.chromium.launch();
try {
for (const breite of [1400, 412]) {
melde("");
melde(` --- Breite ${breite} px ---`);
const ctx = await browser.newContext({
viewport: { width: breite, height: 950 },
isMobile: breite < 700, hasTouch: breite < 700 });
/* ANMELDUNG ALS KEKS, NICHT ÜBER DAS FORMULAR. Der Weg über die
Oberfläche prüft hier nichts, was nicht anderswo schon
geprüft wird -- er bricht nur bei jeder Änderung an der
Anmeldeseite, und dann sieht diese Prüfung kaputt aus,
obwohl an den Wegen nach draußen nichts fehlt. */
await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => {
const [name, ...rest] = z.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const kaputt = [];
seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/start.html`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(900);
/* ---- 4a. Steht die Kachel wirklich auf dem Bildschirm? ---- */
const kachel = await seite.evaluate(() => {
const a = [...document.querySelectorAll('a[href*="unsere-seiten"]')][0];
if (!a) return null;
const r = a.getBoundingClientRect();
return { text: a.textContent.trim().slice(0, 40), breit: Math.round(r.width),
hoch: Math.round(r.height), sichtbar: r.width > 0 && r.height > 0 };
});
ok(!!kachel && kachel.sichtbar,
`die Kachel ist da und sichtbar (${kachel?.breit}x${kachel?.hoch} px)`);
/* GROSS GENUG FUER EINEN DAUMEN. 44 px ist das Mass, unter dem
ein Ziel auf dem Handy regelmaessig verfehlt wird. */
ok((kachel?.hoch || 0) >= 44,
`und hoch genug zum Antippen (${kachel?.hoch} px, mindestens 44)`);
/* ---- 4b. Die Seite selbst ---- */
await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(500);
/* window.open abfangen, damit keine echten Fenster aufgehen --
und damit messbar wird, WOHIN es gegangen waere. */
await seite.evaluate(() => {
window.__wohin = [];
window.open = (u) => { window.__wohin.push(String(u)); return null; };
});
const karten = await seite.evaluate(() =>
[...document.querySelectorAll(".aussen[data-weg]")].map((k) => k.dataset.weg));
ok(karten.length === 2, `zwei Karten nach draussen (${karten.length})`);
ok(karten.some((w) => w.includes("dogfather-universe.com")), "eine zur Website");
ok(karten.some((w) => w.includes("vans-diy-bastelbedarf.com")), "eine zum Shop");
/* KLICK AUF DEN TEXT -- nicht auf den Knopf. Genau das war
Filipes Wunsch: „egal wo man drauf drückt“. */
await seite.click(".aussen--shop .aussen__satz");
await seite.waitForTimeout(250);
let wohin = await seite.evaluate(() => window.__wohin);
ok(wohin.length === 1 && wohin[0].includes("vans-diy-bastelbedarf"),
`ein Klick auf den TEXT fuehrt zum Shop (${wohin.join(", ") || "nichts"})`);
/* GEGENPROBE: Der Kopierknopf darf NICHT zusaetzlich die Karte
ausloesen. Ohne diese Zeile waere „die Flaeche klickt“ auch
dann gruen, wenn sie ueberall klickt -- auch dort, wo es
stoert. */
await seite.evaluate(() => { window.__wohin = []; });
await seite.click("#code-knopf");
await seite.waitForTimeout(250);
wohin = await seite.evaluate(() => window.__wohin);
ok(wohin.length === 0,
`der Kopierknopf oeffnet NICHTS (${wohin.length} Fenster)`);
const hinweis = await seite.textContent("#code-hinweis");
ok(/kopiert|markiert|abschreiben/.test(hinweis || ""),
`und sagt, was er getan hat: ${(hinweis || "").trim()}`);
/* GEGENPROBE 2: Der eigene Knopf oeffnet genau EINMAL -- nicht
zweimal, weil die Karte mitklickt. */
await seite.evaluate(() => {
window.__wohin = [];
/* Der Knopf ist ein echter Link; sein Standardverhalten
unterbinden, sonst navigiert die Prueferin weg. */
for (const a of document.querySelectorAll(".aussen__knopf")) {
a.addEventListener("click", (e) => {
e.preventDefault();
window.__wohin.push(a.href);
});
}
});
await seite.click(".aussen--dogi .aussen__knopf");
await seite.waitForTimeout(250);
wohin = await seite.evaluate(() => window.__wohin);
ok(wohin.length === 1,
`der Knopf „Seite oeffnen“ wirkt genau einmal (${wohin.length}x)`);
/* ---- 4c. Die Highlight-Karten ---- */
await seite.goto(`http://127.0.0.1:${PORT}/workspace/bereich.html?b=highlight`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
await seite.evaluate(() => {
window.__wohin = [];
window.open = (u) => { window.__wohin.push(String(u)); return null; };
});
const stand = await seite.evaluate(() =>
[...document.querySelectorAll(".eintrag-karte")].map((k) => ({
titel: k.querySelector(".eintrag-titel")?.textContent?.trim() || "",
klickbar: k.dataset.klickbar === "ja",
})));
ok(stand.length === 3, `drei Karten stehen da (${stand.length})`);
const mitVideo = stand.find((s) => s.titel.includes("Komet"));
const ohneVideo = stand.find((s) => s.titel.includes("Nur ein Bild"));
const fort = stand.find((s) => s.titel.includes("Nicht mehr da"));
ok(mitVideo?.klickbar === true, "die Karte MIT Video ist klickbar");
/* DIE BEIDEN GEGENPROBEN. Ohne sie waere „klickbar“ auch dann
gruen, wenn ALLE Karten klicken -- und ein Klick auf ein
geloeschtes Video fuehrt auf eine Fehlerseite. */
ok(ohneVideo?.klickbar === false, "die Karte OHNE Video ist es nicht");
ok(fort?.klickbar === false,
"und die mit geloeschtem Video auch nicht (sonst Fehlerseite)");
/* Klick auf die Ueberschrift -- „egal wo“. */
const titelWahl = ".eintrag-karte[data-klickbar='ja'] .eintrag-titel";
await seite.click(titelWahl);
await seite.waitForTimeout(250);
wohin = await seite.evaluate(() => window.__wohin);
ok(wohin.length === 1 && wohin[0] === VIDEO,
`ein Klick auf die UEBERSCHRIFT fuehrt zum Video (${wohin[0] || "nichts"})`);
/* Und auf den Fliesstext. */
await seite.evaluate(() => { window.__wohin = []; });
const textWahl = ".eintrag-karte[data-klickbar='ja'] .eintrag-text";
if (await seite.$(textWahl)) {
await seite.click(textWahl);
await seite.waitForTimeout(250);
wohin = await seite.evaluate(() => window.__wohin);
ok(wohin.length === 1 && wohin[0] === VIDEO,
`und ein Klick auf den TEXT ebenso (${wohin[0] || "nichts"})`);
} else {
ok(false, "der Fliesstext der Karte liess sich nicht finden");
}
/* GEGENPROBE: Auf der NICHT klickbaren Karte passiert nichts. */
await seite.evaluate(() => { window.__wohin = []; });
const stiller = ".eintrag-karte:not([data-klickbar='ja']) .eintrag-titel";
await seite.click(stiller);
await seite.waitForTimeout(250);
wohin = await seite.evaluate(() => window.__wohin);
ok(wohin.length === 0,
`auf einer Karte ohne Video passiert NICHTS (${wohin.length})`);
/* DER TIKTOK-KNOPF MUSS WEITER EIN LINK SEIN. Waere er von
einem <a> umschlossen worden, haette der Browser ihn still
aufgeloest -- die haeufigste Art, eine Karte kaputtzumachen. */
const knopf = await seite.evaluate(() => {
const a = document.querySelector(".eintrag-karte[data-klickbar='ja'] .videozeile__weg");
return a ? { tag: a.tagName, href: a.getAttribute("href"),
imLink: !!a.closest("a a") } : null;
});
ok(knopf?.tag === "A" && knopf.href === VIDEO,
`der TikTok-Knopf ist noch ein echter Link (${knopf?.tag})`);
ok(knopf?.imLink === false, "und steckt in keinem zweiten Link");
/* DAS BILD FUEHRT ZUM VIDEO, nicht mehr ins Vollbild -- das war
Filipes „foto“. Ohne Bilddatei gibt es keinen Bildstreifen;
dann ist hier nichts zu messen, und das muss man sehen. */
const bild = await seite.evaluate(() => {
const a = document.querySelector(".eintrag-karte[data-klickbar='ja'] .bildstreifen__feld");
return a ? { href: a.getAttribute("href"), label: a.getAttribute("aria-label") } : null;
});
if (bild) {
ok(bild.href === VIDEO, `das Bild fuehrt zum Video (${bild.href})`);
ok(/Video ansehen/.test(bild.label || ""),
`und sagt das auch vorlesbar („${bild.label}“)`);
} else {
/* KEIN STILLES UEBERSPRINGEN. Das Bild ist angelegt; fehlt der
Streifen trotzdem, ist etwas kaputt -- entweder lud das
Bild nicht (dann entfernt der Fehlerbehandler den Rahmen)
oder die Karte zeigt gar keine Anhaenge mehr. Beides ist
ein Befund, kein Grund zu schweigen. */
ok(false, "der Bildstreifen fehlt, obwohl ein Bild angehaengt ist");
}
ok(kaputt.length === 0,
`kein Skriptfehler auf dem Weg${kaputt.length ? ": " + kaputt.join(" | ") : ""}`);
await ctx.close();
}
} finally {
await browser.close();
}
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
process.exit(fehler ? 1 : 0);