Files
trainingsplan/tests/test_ui.js
T
AI-Agent 6d57200032
Image bauen und in die Registry schieben / build (push) Successful in 6s
Wizard-Texte, Admin-Onboarding ueberspringen, kompakte Set-Zeile mobil
- Wizard: Sprach-Hinweis gekuerzt, Repo-Link nur beim Erst-Setup (nicht
  im Onboarding), Hinweis 'erster Benutzer wird Administrator'
- Erster Benutzer (Admin) ist automatisch onboarded und erbt die
  festgelegten Sprache/Design-Einstellungen (kein erneuter Wizard);
  Einstellungen werden nach Admin-Anlage sofort frisch gerendert
- Set-Zeile mobil-tauglich: nowrap, width 100%, kg/reps-Inputs flexibel
  (flex 1, min-width 0), Einheit kompakt im Feld, 'S1' statt 'Set 1',
  Reset als SVG-Icon, kleinere Buttons - kein horizontales Scrollen
- Tests: 169 Checks gruen (39+18+52+32+28)
2026-10-02 20:26:09 +00:00

269 lines
13 KiB
JavaScript

/* UI-Integrationstest (jsdom) gegen einen echten Server-Subprozess.
*
* Lauf: node tests/test_ui.js (jsdom muss installierbar sein, z.B.
* NODE_PATH=/pfad/zu/node_modules node tests/test_ui.js)
*
* Der Test laeuft im offenen Modus (frische DB ohne Benutzer).
*/
const path = require("path");
const BASE = path.join(__dirname, "..");
let jsdom;
try {
jsdom = require("jsdom");
} catch (_) {
console.error("jsdom fehlt. Installieren: npm install jsdom@22.1.0 (z.B. in /tmp/tp_test) und mit NODE_PATH=<dort>/node_modules starten.");
process.exit(2);
}
const { JSDOM } = jsdom;
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const STATE = "/tmp/tp_state_ui";
const PORT = 8233;
const BASE_URL = `http://127.0.0.1:${PORT}`;
let pass = 0, fail = 0;
let jsErrors = [];
function check(name, cond, detail = "") {
if (cond) { pass++; console.log(" ok " + name); }
else { fail++; console.log("FAIL " + name + " " + detail); }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function waitFor(dom, fn, timeout = 4000) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
if (fn(dom)) return true;
await sleep(80);
}
return false;
}
function apiGet(p) {
return new Promise((resolve, reject) => {
http.get(BASE_URL + p, res => {
let d = "";
res.on("data", c => d += c);
res.on("end", () => resolve(JSON.parse(d)));
}).on("error", reject);
});
}
function cleanState() {
fs.rmSync(STATE, { recursive: true, force: true });
fs.mkdirSync(STATE, { recursive: true });
}
async function main() {
cleanState();
const server = spawn("python3", [path.join(BASE, "server.py")], {
env: { ...process.env, TRAINING_STATE_DIR: STATE, PORT: String(PORT) },
cwd: BASE, stdio: "ignore",
});
let window;
try {
for (let i = 0; i < 60; i++) {
try { await apiGet("/api/meta"); break; }
catch (_) { await sleep(200); }
}
const html = fs.readFileSync(path.join(BASE, "index.html"), "utf8")
.replace(/<script src="\/app.js"><\/script>/, "");
const appJs = fs.readFileSync(path.join(BASE, "app.js"), "utf8");
const dom = new JSDOM(html, { runScripts: "dangerously", url: BASE_URL + "/" });
window = dom.window;
window.errors = [];
jsErrors = window.errors;
window.addEventListener("error", e => window.errors.push(e.message || String(e)));
window.fetch = (url, opts) => fetch(BASE_URL + url, opts);
window.eval(appJs);
check("init: 3 Aktivitaets-Karten", await waitFor(dom, d =>
d.window.document.querySelectorAll(".act-card").length === 3));
// --- Ersteinrichtungs-Wizard abschliessen (Einzelmodus) ----------------
const gate = () => window.document.querySelector("#setup-gate");
await waitFor(dom, d => !d.window.document.querySelector("#setup-gate").hidden);
check("Setup: Sprache kommt zuerst", await waitFor(dom, d =>
gate().querySelector(".wizard-title")
&& gate().querySelector(".wizard-title").textContent === "Sprache festlegen"));
check("Setup: Fortschrittsleiste oben",
gate().querySelectorAll(".wp-step").length === 3
&& gate().querySelectorAll(".wp-step.is-current").length === 1
&& gate().querySelectorAll(".wp-step.is-done").length === 0);
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click();
await waitFor(dom, d => gate().querySelector(".accent-row"));
check("Setup: Fortschritt aktualisiert",
gate().querySelectorAll(".wp-step.is-done").length === 1
&& gate().querySelectorAll(".wp-step.is-current").length === 1);
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click();
await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2);
const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option"));
check("Setup: zwei gleichwertige Optionen",
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
&& setupOptions().some(b => b.textContent.includes("Einzelmodus")));
setupOptions().find(b => b.textContent.includes("Einzelmodus")).click();
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Einrichtung abschließen").click();
check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d =>
d.window.document.querySelector("#setup-gate").hidden));
const gymCard = () => window.document.querySelectorAll(".act-card")[0];
gymCard().querySelector(".btn-primary").click();
check("gym: Session gestartet", await waitFor(dom, d =>
d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".set-row").length > 0));
const firstBlock = () => gymCard().querySelector(".ex-block");
const chk1 = () => firstBlock().querySelectorAll(".set-check")[0];
check("Set-Button mit Label 'Set' + Box",
chk1().querySelector(".set-check-label").textContent === "S"
&& !!chk1().querySelector(".set-check-box"));
check("Set-Button: Box leer (nicht abgehakt)", chk1().querySelector(".set-check-box").textContent === "");
firstBlock().querySelectorAll(".set-check")[2].click();
check("Set-Kette: 1..3 aktiv mit Haken", await waitFor(dom, d => {
const c = d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".set-check");
return c[0].classList.contains("is-done") && c[2].classList.contains("is-done")
&& !c[3].classList.contains("is-done")
&& c[0].querySelector(".set-check-box").textContent === "✓";
}));
const wIn = () => firstBlock().querySelectorAll(".set-row")[0].querySelector(".field-input");
wIn().value = "18,5";
wIn().dispatchEvent(new window.Event("input", { bubbles: true }));
const wBefore = wIn().value;
chk1().click();
check("Set-Klick veraendert Gewicht nicht", wIn().value === wBefore);
firstBlock().querySelectorAll(".set-check")[2].click();
const picker = () => gymCard().querySelector(".ex-picker");
const pullEx = "Pull/Latzug / Klimmzug-Maschine";
picker().value = pullEx;
picker().dispatchEvent(new window.Event("change", { bubbles: true }));
check("Uebung aus anderem Tag hinzugefuegt", await waitFor(dom, d => {
const blocks = d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".ex-block");
const last = blocks[blocks.length - 1];
return last.querySelector(".ex-name").textContent === "Latzug / Klimmzug-Maschine";
}));
const blocks = () => gymCard().querySelectorAll(".ex-block");
const addedBlock = blocks()[blocks().length - 1];
const rmBtn = addedBlock.querySelector(".ex-remove");
check("Entfernen-Knopf fuer hinzugefuegte Uebung", !!rmBtn);
rmBtn.click();
check("Uebung entfernt", await waitFor(dom, d =>
!Array.from(d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".ex-block"))
.some(b => b.querySelector(".ex-name").textContent === "Latzug / Klimmzug-Maschine")));
gymCard().querySelector(".session-head .btn-primary").click();
check("abschliessen: Karte zurueck auf Start", await waitFor(dom, d => {
const c = d.window.document.querySelectorAll(".act-card")[0];
return !!c.querySelector(".start-box");
}));
let done = await apiGet("/api/sessions?status=done");
check("Verlauf (API): 1 abgeschlossene Session", done.length === 1);
window.document.querySelector('.tab[data-view="history"]').click();
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length === 1);
const histCard = () => window.document.querySelector(".hist-card");
check("Verlauf: Haken-Box zeigt Abhak-Status",
!!histCard().querySelector(".set-done-box")
&& histCard().querySelector(".set-done-box").textContent === "✓");
check("Verlauf: Abgehaktes Set mit Gewicht", await waitFor(dom, d =>
d.window.document.querySelector(".hist-sets").textContent.includes("18,5")));
window.document.querySelector('.tab[data-view="settings"]').click();
await waitFor(dom, d => !!d.window.document.querySelector(".accent-row"));
check("Einstellungen: Theme-Buttons",
Array.from(window.document.querySelectorAll("#view-settings .day-picker .day-btn"))
.filter(b => ["Dunkel", "System", "Hell"].includes(b.textContent)).length === 3);
check("Einstellungen: Akzent-Swatches", window.document.querySelectorAll(".accent-swatch").length >= 5);
check("Einstellungen: Toggles + Selects",
window.document.querySelectorAll("#view-settings .toggle-input").length === 4
&& window.document.querySelectorAll("#view-settings .settings-select-row").length === 2);
const themeBtns = () => Array.from(window.document.querySelectorAll("#view-settings .day-picker .day-btn"));
// OLED-Toggle bei dunklem Theme -> reines Schwarz
Array.from(window.document.querySelectorAll("#view-settings .toggle-row"))
.find(r => r.querySelector("strong").textContent === "OLED-Dark")
.querySelector(".toggle-input").click();
await waitFor(dom, d => d.window.document.documentElement.dataset.theme === "oled");
check("OLED-Toggle: dunkles Theme wird pure black",
window.document.documentElement.dataset.theme === "oled");
themeBtns().find(b => b.textContent === "Hell").click();
await waitFor(dom, d => d.window.document.documentElement.dataset.theme === "light");
check("Theme 'Hell' gesetzt", window.document.documentElement.dataset.theme === "light");
const swatches = () => Array.from(window.document.querySelectorAll(".accent-swatch"));
swatches().find(s => s.getAttribute("aria-label") === "Akzent green").click();
await waitFor(dom, d => d.window.document.documentElement.dataset.accent === "green");
check("Akzent 'green' gesetzt", window.document.documentElement.dataset.accent === "green");
const toggles = () => Array.from(window.document.querySelectorAll(".toggle-row"));
const tipRow = toggles().find(r => r.querySelector("strong").textContent === "Übungs-Hinweise");
tipRow.querySelector(".toggle-input").click();
await waitFor(dom, d => d.window.document.querySelector("#view-today").classList.contains("no-tips"));
check("showTips aus: no-tips Klasse", window.document.querySelector("#view-today").classList.contains("no-tips"));
const resetRow = toggles().find(r => r.querySelector("strong").textContent === "Letztes Training vorbefüllen");
resetRow.querySelector(".toggle-input").click();
// Auf Server-Zustand warten (DOM-Check war falsch: [0] ist der OLED-Toggle)
let arOk = false;
for (let i = 0; i < 80; i++) {
if ((await apiGet("/api/settings")).autoReset === true) { arOk = true; break; }
await sleep(100);
}
check("Einstellung 'Letztes Training' serverseitig aktiv", arOk);
window.document.querySelector('.tab[data-view="today"]').click();
gymCard().querySelector(".btn-primary").click();
// Auto-Reset serverseitig pruefen (deterministisch statt DOM-Timing)
let autoOk = false;
for (let i = 0; i < 120; i++) {
const act = await apiGet("/api/sessions?status=active");
if (act.length && act[0].entries.length === 1
&& act[0].entries[0].weight === "18,5") { autoOk = true; break; }
await sleep(100);
}
check("Auto-Reset: 1 gefuellter Satz geladen", autoOk);
await waitFor(dom, d => d.window.document.querySelectorAll(".act-card")[0]
.querySelectorAll(".set-row").length > 0, 9000);
check("Auto-Reset: Satz nicht abgehakt",
gymCard().querySelector(".ex-block .set-check").querySelector(".set-check-box").textContent === "");
check("Auto-Reset: 'Alles zuruecksetzen'-Knopf da",
!!gymCard().querySelector(".session-head .btn[title*='letzte']"));
check("Hinweiszeile ausgeblendet",
!gymCard().querySelector(".ex-block .ex-hint"));
// --- Uebungen per Drag & Drop sortieren --------------------------------
gymCard().querySelectorAll(".ex-block")[0]
.dispatchEvent(new window.Event("dragstart", { bubbles: true }));
const target = gymCard().querySelectorAll(".ex-block")[1];
target.dispatchEvent(new window.Event("drop", { bubbles: true }));
check("Uebungen: Drag & Drop sortiert",
Array.from(gymCard().querySelectorAll(".ex-block"))[0]
.querySelector(".ex-name").textContent !== "Bankdrücken KH (je Hand)");
// --- Wochen-Uebersicht ---------------------------------------------------
window.document.querySelector('.tab[data-view="history"]').click();
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length >= 1);
Array.from(window.document.querySelectorAll(".day-picker .day-btn"))
.find(b => b.textContent === "Woche").click();
check("Wochen-Uebersicht: Raster mit 7 Tagen",
await waitFor(dom, d => d.window.document.querySelectorAll(".week-day").length === 7));
} finally {
server.kill("SIGTERM");
}
if (jsErrors && jsErrors.length) {
console.log("JS-Fehler im Browser:", jsErrors.slice(0, 5).join(" | "));
fail += jsErrors.length;
}
console.log(`\n${pass} ok, ${fail} fail`);
process.exit(fail ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });