/* Prueft die Aufgaben-Vorlagen: vier Bereiche, vier Stufen, und den Weg von der Vorlage zur echten Aufgabe. WARUM DAS EINE EIGENE PRUEFUNG IST. Die Vorlagen sind Daten -- dass sie vollstaendig sind, laesst sich in Sekunden zaehlen. Interessant ist der Weg DANACH: Aus einer Vorlage muss eine Zeile in `aufgaben` werden, mit Creator, Frist und Status, und sie muss in der Aufgabenliste des Creators wirklich auftauchen. Zwischen "die Vorlage gibt es" und "der Creator sieht die Aufgabe" liegen vier Schichten, und jede kann still danebengehen. DREI SORTEN GEGENPROBE, alle drei noetig: - eine unbekannte Stufe MUSS abgelehnt werden, - eine Uebernahme ohne Creator MUSS abgelehnt werden (eine Aufgabe ohne Adressaten ist ein Zettel), - ein Betreuer darf sie NICHT fuer einen fremden Creator anlegen. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-aufv-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4211"; 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"); notbremse(240_000, "pruef-aufgaben-vorlagen"); await new Promise((r) => setTimeout(r, 700)); const BASIS = "http://127.0.0.1:4211"; let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; 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)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); 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; } anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); d.close(); const keks = async (rolle, code) => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }) }); return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); }; const kScout = await keks("scout", "CODE-SCOU-0001"); const kTili = await keks("creator", "CODE-ANNA-0001"); const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); const senden = (pfad, k, art, koerper) => ruf(pfad, k, { method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); /* ---------- 1. Die Vorlagen sind vollstaendig ---------------------------- */ console.log("\n=== Vier Bereiche, vier Stufen, drei Aufgaben"); const alles = await (await ruf("/workspace/api/vorlagen", kScout)).json(); const V = alles.aufgaben; ok(!!V, "die Aufgabenvorlagen werden ausgeliefert"); const BEREICHE = ["live", "content", "technik", "community"]; const STUFEN = ["anfaenger", "fort", "profi", "meister"]; let gesamt = 0; for (const b of BEREICHE) { const fehlend = STUFEN.filter((st) => !(V[b] || {})[st] || !V[b][st].length); const n = STUFEN.reduce((s, st) => s + ((V[b] || {})[st] || []).length, 0); gesamt += n; ok(fehlend.length === 0 && n >= 8, `${b}: ${n} Aufgaben über ${STUFEN.length - fehlend.length} von 4 Stufen` + (fehlend.length ? ` — fehlt: ${fehlend.join(", ")}` : "")); } ok(gesamt === 48, `zusammen ${gesamt} Aufgaben (erwartet 48)`); /* Jede Aufgabe braucht Titel, Text, Frist und einen eindeutigen Schluessel -- eine Aufgabe ohne Frist ist ein Wunsch. */ { const flach = BEREICHE.flatMap((b) => STUFEN.flatMap((st) => (V[b][st] || []))); const unvollstaendig = flach.filter((v) => !v.titel || !v.text || !v.tage || !v.schluessel); ok(unvollstaendig.length === 0, `jede Aufgabe hat Titel, Text, Frist und Schlüssel (${flach.length - unvollstaendig.length} von ${flach.length})`); const schluessel = flach.map((v) => v.schluessel); const doppelt = schluessel.filter((x, i) => schluessel.indexOf(x) !== i); ok(doppelt.length === 0, `alle Schlüssel eindeutig${doppelt.length ? ": " + doppelt.join(", ") : ""}`); /* DIE FRIST WAECHST NICHT DURCHGEHEND MIT DER STUFE -- und das ist kein Mangel. Der erste Anlauf verlangte anfaenger < fort < profi < meister und schlug fehl: Profi kam auf 6,7 Tage, Fortgeschritten auf 7,4. Beim Nachsehen war die Erwartung falsch, nicht die Zahl. Eine Profi-Aufgabe ist meist eine MESSUNG -- "Upload messen und Bitrate setzen", "Lautheit auf minus 20 bis minus 24 bringen": anspruchsvoll, aber an einem Abend erledigt. Eine Fortgeschrittenen-Aufgabe wie "Content-Saeulen anlegen und drei Wochen halten" braucht zwangslaeufig Wochen, weil sie aus Wiederholung besteht. Die Stufe sagt, was man KOENNEN muss, nicht wie lange es dauert. Was dagegen wirklich gelten muss: Eine Anfaengeraufgabe ist an einem Abend zu schaffen (sonst faengt niemand an), und eine Meisteraufgabe ist es nie (sonst ist sie keine). */ const schnitt = (st) => { const l = BEREICHE.flatMap((b) => V[b][st] || []); return l.reduce((s, v) => s + v.tage, 0) / l.length; }; const [sa, sf, sp, sm] = STUFEN.map(schnitt); const anfListe = BEREICHE.flatMap((b) => V[b].anfaenger || []); const meiListe = BEREICHE.flatMap((b) => V[b].meister || []); const anfZuLang = anfListe.filter((v) => v.tage > 5); const meiZuKurz = meiListe.filter((v) => v.tage < 7); ok(anfZuLang.length === 0, `jede Anfängeraufgabe ist in höchstens fünf Tagen zu schaffen` + (anfZuLang.length ? ` — zu lang: ${anfZuLang.map((v) => v.schluessel).join(", ")}` : "")); ok(meiZuKurz.length === 0, `keine Meisteraufgabe unter einer Woche` + (meiZuKurz.length ? ` — zu kurz: ${meiZuKurz.map((v) => v.schluessel).join(", ")}` : "")); ok(sa < sm, `Anfänger deutlich kürzer als Meister (${sa.toFixed(1)} gegen ${sm.toFixed(1)} Tage;` + ` fort ${sf.toFixed(1)}, profi ${sp.toFixed(1)} — dazwischen gilt keine Reihenfolge)`); /* GEGENPROBE: Eine erfundene Anfaengeraufgabe mit 30 Tagen MUSS auffallen -- sonst prueft die Regel oben nichts. */ ok([...anfListe, { schluessel: "test", tage: 30 }].filter((v) => v.tage > 5).length === 1, `Gegenprobe: eine Anfängeraufgabe mit 30 Tagen fällt auf`); } /* ---------- 2. Aus der Vorlage wird eine echte Aufgabe -------------------- */ console.log("\n=== Von der Vorlage zur Aufgabe"); { const vorher = (await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben?.length ?? 0; const a = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 0, creator_id: idTili }); ok(a.ok, `der Scout kann eine Aufgabe für seinen Creator übernehmen (${a.status})`); const liste = (await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []; ok(liste.length === vorher + 1, `sie steht in der Liste des Creators (${vorher} → ${liste.length})`); const neu = liste.find((x) => x.titel === V.technik.anfaenger[0].titel); ok(!!neu, `mit dem richtigen Titel ("${V.technik.anfaenger[0].titel}")`); ok(!!neu && neu.status === "offen", `als offen angelegt (${neu?.status})`); ok(!!neu && !!neu.frist, `und mit einer Frist (${neu?.frist})`); /* Die Frist muss in der Zukunft liegen -- und zwar nach ORTSZEIT gerechnet, nicht nach UTC. */ if (neu?.frist) { const heute = new Date(); const h = `${heute.getFullYear()}-${String(heute.getMonth() + 1).padStart(2, "0")}` + `-${String(heute.getDate()).padStart(2, "0")}`; ok(neu.frist > h, `die Frist liegt in der Zukunft (heute ${h}, Frist ${neu.frist})`); } } /* Alle drei einer Stufe auf einmal. */ { const vorher = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []).length; const a = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgaben_alle", bereich: "community", stufe: "meister", creator_id: idTili }); const nachher = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []).length; ok(a.ok && nachher === vorher + 3, `alle drei einer Stufe auf einmal (${vorher} → ${nachher})`); } /* ---------- 3. Gegenproben ------------------------------------------------ */ console.log("\n=== Gegenproben"); { const a = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgabe", bereich: "technik", stufe: "gibt-es-nicht", nr: 0, creator_id: idTili }); ok(a.status === 400, `eine unbekannte Stufe wird abgelehnt (${a.status})`); const b = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgabe", bereich: "erfunden", stufe: "anfaenger", nr: 0, creator_id: idTili }); ok(b.status === 400, `ein unbekannter Bereich wird abgelehnt (${b.status})`); const c = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 99, creator_id: idTili }); ok(c.status === 400, `eine Nummer, die es nicht gibt, wird abgelehnt (${c.status})`); const e = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 0 }); ok(e.status === 400, `ohne Creator wird abgelehnt — eine Aufgabe ohne Adressaten ist ein Zettel (${e.status})`); const f = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 0, creator_id: idFremd }); ok(f.status === 404, `für einen fremden Creator wird abgelehnt (${f.status})`); /* Und der Creator selbst legt sie fuer sich an -- ohne creator_id, der Server setzt sie. Sonst koennte er sie jemand anderem geben. */ const g = await senden("/workspace/api/vorlagen/uebernehmen", kTili, "POST", { art: "aufgabe", bereich: "live", stufe: "anfaenger", nr: 0, creator_id: idFremd }); const beiFremd = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []) .filter((x) => x.titel === V.live.anfaenger[0].titel).length; ok(g.ok && beiFremd === 1, `ein Creator legt sie für SICH an, auch wenn er jemand anderen angibt (${g.status})`); } /* ---------- 4. Im Browser: sieht man es, und geht es? -------------------- */ console.log("\n=== Auf der Aufgabenseite"); { const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const kontext = await browser.newContext({ viewport: { width: 1200, height: 1400 } }); const seite = await kontext.newPage(); const konsole = []; seite.on("pageerror", (f) => konsole.push(f.message)); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="creator"]'); await seite.fill("#code", "CODE-ANNA-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 20000 }); await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "domcontentloaded" }); await seite.waitForTimeout(2500); const d = await seite.evaluate(() => { const b = document.getElementById("vorlagenbrett"); return { sichtbar: !!b && !b.hidden, reihen: document.querySelectorAll("#vorlagenbrett .stufenleiter").length, karten: document.querySelectorAll("#vorlagenbrett .vorlagenbrett__karte").length, knoepfe: document.querySelectorAll("#vorlagenbrett .vorlagenbrett__fuss .knopf").length, /* Gestaltet oder nackt? Eine Karte ohne Kante ist keine Karte -- derselbe Fehler wie damals bei der Checkliste, wo das CSS in der falschen Datei lag und die Pruefung trotzdem gruen war, weil sie nur Elemente gezaehlt hat. */ gestaltet: (() => { const k = document.querySelector("#vorlagenbrett .vorlagenbrett__karte"); if (!k) return null; const st = getComputedStyle(k); return parseFloat(st.borderTopWidth) >= 1 && parseFloat(st.paddingLeft) >= 8; })(), ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, }; }); ok(d.sichtbar, "der Block ist da"); ok(d.reihen === 2, `zwei Knopfreihen: Bereich und Stufe (${d.reihen})`); ok(d.karten === 3, `drei Aufgaben je Feld (${d.karten})`); ok(d.knoepfe === 3, `jede hat einen Übernehmen-Knopf (${d.knoepfe})`); ok(d.gestaltet === true, "die Karten sind gestaltet, nicht nackt"); ok(d.ueber <= 0, `nichts steht seitlich über (${d.ueber}px)`); /* Der Bereich laesst sich wechseln, und die Karten wechseln mit. */ const vorher = await seite.textContent("#vorlagenbrett .vorlagenbrett__titel"); await seite.click("#vorlagenbrett .stufenleiter >> nth=0 >> .stufenleiter__knopf >> nth=2"); await seite.waitForTimeout(500); const nachher = await seite.textContent("#vorlagenbrett .vorlagenbrett__titel"); ok(vorher !== nachher, `ein anderer Bereich zeigt andere Aufgaben ("${vorher}" → "${nachher}")`); /* Und der Weg bis zum Brett. */ const zaehle = () => seite.evaluate(() => document.querySelectorAll("#brett .karte").length); const brettVorher = await zaehle(); await seite.click("#vorlagenbrett .vorlagenbrett__fuss .knopf"); await seite.waitForTimeout(2500); const brettNachher = await zaehle(); ok(brettNachher === brettVorher + 1, `Übernehmen legt eine Aufgabe auf dem Brett an (${brettVorher} → ${brettNachher})`); ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? ": " + konsole[0] : ""}`); await browser.close(); } console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(fehler ? 1 : 0);