from __future__ import annotations import importlib.util import json import os import time import uuid from pathlib import Path def load_module(module_path: Path, data_dir: Path, plans_dir: Path): try: import flask # noqa: F401 except ModuleNotFoundError: import pytest pytest.skip("Flask ist in dieser isolierten Testumgebung nicht installiert") os.environ["TRAININGSTRACKER_DATA_DIR"] = str(data_dir) os.environ["TRAININGSTRACKER_PLANS_DIR"] = str(plans_dir) name = f"tracker_test_app_{uuid.uuid4().hex}" spec = importlib.util.spec_from_file_location(name, module_path) module = importlib.util.module_from_spec(spec) assert spec.loader spec.loader.exec_module(module) return module def sample_plan() -> dict: return { "name": "Testplan", "config": { "meta": {"title": "Test", "weeks": 2}, "front": { "session_how_body": "Tabata: 8× (20 Sek Arbeit / 10 Sek Pause).", "timer_note": "20/10 unverändert", "goals_note": "Stufen sauber steigern", }, "days": [ { "num": 1, "focus": "Ganzkörper", "rotations": [{ "label": "Rotation A — 3 Sätze", "exercises": [ {"name": "Squat", "key": "sq", "cue": "20 s sauber"}, {"name": "Push-up", "key": "push", "cue": "20 s stabil"}, ], }], }, { "num": 3, "focus": "Ganzkörper B", "rotations": [{ "label": "Rotation A — 3 Sätze", "exercises": [ {"name": "Squat", "key": "sq", "cue": "20 s sauber"}, {"name": "Push-up", "key": "push", "cue": "20 s stabil"}, ], }], }, ], "prepost": { "1": {"warmup": "Kreisen · Squat", "cooldown": "Gehen", "stretch": "Wade"}, "3": {"warmup": "Kreisen", "cooldown": "Gehen", "stretch": "Brust"}, }, "phases": {"items": [{"name": "Phase & Technik", "params": "Sauber & kontrolliert", "weeks": 2}]}, "stages": { "sq": {"name": "Squat", "steps": ["assistiert", "voll", "Goblet schwerer"]}, "push": {"name": "Push-up", "steps": ["erhöht", "voll"]}, }, }, } def wait_for_analysis(client, plan_id: str, timeout: float = 3.0): deadline = time.time() + timeout while time.time() < deadline: payload = client.get(f"/api/plans/{plan_id}/analysis/status").get_json() if payload["analysis_state"]["status"] != "running": return payload time.sleep(0.02) raise AssertionError("Analyse blieb im Test zu lange aktiv") def test_source_is_read_only_and_session_file_strips_all_analysis_fields(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) training = plans / "training.json" training.write_text(json.dumps(sample_plan()), encoding="utf-8") original = training.read_bytes() (plans / "food.json").write_text(json.dumps({"name": "Essen", "config": {"type": "recipe", "days": []}}), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) client = module.app.test_client() result = client.get("/api/plans").get_json() assert [item["id"] for item in result["plans"]] == ["training.json"] detail = client.get("/api/plans/training.json").get_json() assert detail["plan"]["phases"][0]["name"] == "Phase & Technik" assert detail["plan"]["training_format"]["is_tabata"] is True tracker = detail["tracker"] tracker["sessions"] = {"w01-d01": {"status": "completed", "note": "lief gut", "items": {}}} tracker["analysis_cache"] = {"weeks": {"1": {"bad": True}}} tracker["analysis_state"] = {"status": "running"} tracker["analyses"] = [{"legacy": True}] assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200 raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8")) assert raw["version"] == 7 assert "analysis_cache" not in raw assert "analysis_state" not in raw assert "analyses" not in raw assert raw["sessions"]["w01-d01"]["note"] == "lief gut" assert training.read_bytes() == original def test_week_archive_contains_prompt_dataset_response_and_cache_uses_index(tmp_path: Path, monkeypatch): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) training = plans / "training.json" training.write_text(json.dumps(sample_plan()), encoding="utf-8") monkeypatch.setenv("OPENAI_API_KEY", "test-key") monkeypatch.setenv("OPENAI_MODEL", "test-model") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) client = module.app.test_client() tracker = client.get("/api/plans/training.json").get_json()["tracker"] tracker["profile"]["start_date"] = "2026-07-27" tracker["sessions"] = { "w01-d01": {"status": "completed", "note": "Woche eins", "items": { "d1-r0-e0": {"done": True, "progression_id": "sq", "exercise_name": "Squat", "progression": "assistiert", "result": "8 / 7", "note": "sauber"} }}, "w02-d01": {"status": "completed", "note": "Woche zwei", "items": { "d1-r0-e0": {"done": True, "progression_id": "sq", "exercise_name": "Squat", "progression": "voll", "result": "8 / 8", "note": "gut"} }}, } assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200 datasets = [] def fake_call(messages, model): dataset = json.loads(messages[1]["content"].split("Analyse-Datensatz:\n", 1)[1]) datasets.append(dataset) return { "headline": "Kompakt", "summary": "Kurze Analyse.", "data_quality": "Ausreichend.", "metrics": [{"label": "Sessions", "value": "1", "detail": "Woche"}], "exercise_updates": [{ "name": "Squat", "trend": "up", "current_level": "voll", "evidence": "8 / 8", "next_action": "Haltezeit auf 30 Sekunden verlängern", "criterion": "sauber", }], "plan_adjustments": [{ "target": "Squat", "suggested_change": "Arbeitsintervall auf 30 Sekunden erhöhen", "reason": "mehr Belastung", "manual_step": "Timer ändern", "condition": "wenn sauber", }], "warnings": [], "conclusion": "Weiter.", } module._call_ai = fake_call started = client.post("/api/plans/training.json/analysis", json={"scope": "week", "week": 1}) assert started.status_code == 202 status = wait_for_analysis(client, "training.json") assert status["analysis_state"]["status"] == "done" assert len(datasets) == 1 week_record = status["analysis_cache"]["weeks"]["1"] assert "unverändert" in week_record["result"]["exercise_updates"][0]["next_action"] plan_dir = tracker_data / "analyses" / "training.json" archive_files = list(plan_dir.glob("week-01.json")) assert len(archive_files) == 1 archive = json.loads(archive_files[0].read_text(encoding="utf-8")) assert archive["request"]["scope"] == "week" assert archive["request"]["dataset"]["analysis_type"] == "week" assert archive["request"]["messages"] assert archive["response"]["headline"] == "Kompakt" index = json.loads((plan_dir / "index.json").read_text(encoding="utf-8")) assert index["latest"]["weeks"]["1"] == archive_files[0].name assert "history" not in index assert (plan_dir / "state.json").exists() session_raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8")) assert "analysis_cache" not in session_raw and "analysis_state" not in session_raw cached = client.post("/api/plans/training.json/analysis", json={"scope": "week", "week": 1}) assert cached.status_code == 200 assert cached.get_json()["cached"] is True assert len(datasets) == 1 overall_start = client.post("/api/plans/training.json/analysis", json={"scope": "overall"}) assert overall_start.status_code == 202 final = wait_for_analysis(client, "training.json") assert final["analysis_state"]["status"] == "done" assert len(datasets) == 3 overall_dataset = datasets[-1] assert overall_dataset["analysis_type"] == "overall" assert "weekly_analyses" in overall_dataset assert "sessions" not in overall_dataset assert len(list(plan_dir.glob("overall.json"))) == 1 def test_frontend_persists_navigation_and_polls_running_job(): source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") assert "localStorage.setItem" in source assert "analysisSelection" in source assert "analysis/status" in source assert "state.tracker.analysis_state.status === 'running'" in source def test_stopped_session_is_valid_and_included_in_analysis_records(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) training = plans / "training.json" training.write_text(json.dumps(sample_plan()), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) client = module.app.test_client() tracker = client.get("/api/plans/training.json").get_json()["tracker"] tracker["sessions"] = { "w01-d01": { "status": "stopped", "started_at": "2026-07-27T08:00:00+00:00", "stopped_at": "2026-07-27T08:20:00+00:00", "completed_at": "", "items": {}, "note": "Vorzeitig beendet", } } assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200 plan = module._normalize_plan(training) saved = module._load_tracker(plan) records = module._session_records(plan, saved) assert records[0]["status"] == "stopped" assert records[0]["stopped_at"] == "2026-07-27T08:20:00+00:00" assert module._local_metrics(plan, records, week=1)["completed_sessions"] == 0 def test_frontend_supports_stop_continue_and_confirmed_reset(): source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") assert 'id="stopSession"' in source assert 'id="resetSession"' in source assert "session.status = 'stopped'" in source assert "Session fortsetzen" in source assert "window.confirm" in source assert "state.tracker.sessions[sessionKey(state.week, state.dayNum)] = blankSession()" in source def test_variant_clusters_create_reference_reps_and_reference_seconds(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) training = plans / "training.json" training.write_text(json.dumps(sample_plan()), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) plan = module._normalize_plan(training) records = [ {"week": 1, "day": 1, "session_key": "w01-d01", "items": [ {"exercise": "Push-up", "progression_id": "push", "progression": "Inkline (hoch)", "result": "10"}, {"exercise": "Plank", "progression_id": "plank", "progression": "auf Knien 15–20 s", "result": "6x20 s"}, ]}, {"week": 1, "day": 3, "session_key": "w01-d03", "items": [ {"exercise": "Push-up", "progression_id": "push", "progression": "Inkline mittel", "result": "5"}, {"exercise": "Plank", "progression_id": "plank", "progression": "voll 20–30 s", "result": "3x20 s"}, ]}, {"week": 2, "day": 1, "session_key": "w02-d01", "items": [ {"exercise": "Push-up", "progression_id": "push", "progression": "volle Push-ups", "result": "1"}, ]}, ] series = module._cluster_progression_series(plan, records, limit=20) push = next(row for row in series if row["id"] == "push_up") assert [point["value"] for point in push["points"]] == [1.0, 1.0, 1.0] plank = next(row for row in series if row["id"] == "plank") assert plank["unit"] == "Referenzsekunden" assert plank["points"][0]["value"] == 78.0 # 6×20 s × 0,65 assert plank["points"][1]["value"] == 60.0 # 3×20 s × 1,00 def test_equivalence_guide_and_mobile_faq_are_exposed(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) training = plans / "training.json" training.write_text(json.dumps(sample_plan()), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) plan = module._normalize_plan(training) guide = module._public_equivalence_guide(plan) push = next(table for table in guide["tables"] if table["id"] == "push_up") examples = {row["label"]: row["example"] for row in push["variants"]} assert examples["Incline hoch, ca. 60 cm"].startswith("10 Reps") assert examples["Incline mittel"].startswith("5 Reps") assert examples["Voller Push-up"].startswith("1 Rep") source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") assert 'data-view="faq"' in source assert 'id="faqView"' in source assert "renderFAQ()" in source assert "grid-template-columns: minmax(0,1fr) auto" in source assert ".history-item button { width: 100%; min-width: 0;" in source assert "overflow-x: hidden" in source def test_plan_infers_structured_result_schemas(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) plan_payload = sample_plan() plan_payload["config"]["days"][0]["rotations"][0]["exercises"] = [ {"name": "KB Floor Press", "key": "press", "cue": "sauber drücken"}, {"name": "Side Plank (Wechsel)", "key": "side", "cue": "links / rechts halten"}, {"name": "Einarm-Rudern (KB)", "key": "row", "cue": "DG1 links / DG2 rechts"}, {"name": "Regeneration prüfen", "key": "", "cue": "Schlaf und Gelenke prüfen"}, ] plan_payload["config"]["stages"].update({ "press": {"name": "Press", "steps": ["leicht", "schwerer"]}, "side": {"name": "Side Plank", "steps": ["auf Knien", "voll, länger"]}, "row": {"name": "Row", "steps": ["leicht", "mehr"]}, }) training = plans / "training.json" training.write_text(json.dumps(plan_payload), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) plan = module._normalize_plan(training) exercises = {exercise["name"]: exercise for exercise in plan["days"][0]["rotations"][0]["exercises"]} press = exercises["KB Floor Press"]["result_schema"] assert press["mode"] == "reps" and press["weight_mode"] == "required" assert press["laterality"] == "bilateral" and press["locked_sets"] is True side = exercises["Side Plank (Wechsel)"]["result_schema"] assert side["mode"] == "seconds" and side["laterality"] == "unilateral" assert side["sides_mode"] == "separate" row = exercises["Einarm-Rudern (KB)"]["result_schema"] assert row["mode"] == "reps" and row["weight_mode"] == "required" assert row["laterality"] == "unilateral" and row["sides_mode"] == "separate" recovery = exercises["Regeneration prüfen"]["result_schema"] assert recovery["mode"] == "none" def test_structured_result_is_saved_canonically_and_used_by_analysis(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) training = plans / "training.json" training.write_text(json.dumps(sample_plan()), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) client = module.app.test_client() tracker = client.get("/api/plans/training.json").get_json()["tracker"] tracker["sessions"] = { "w01-d01": { "status": "completed", "items": { "d1-r0-e0": { "done": True, "exercise_name": "Einarm-Rudern (KB)", "progression_id": "row", "progression": "leicht", "result": "dieser Alttext wird ersetzt", "result_data": { "version": 1, "mode": "reps", "laterality": "unilateral", "sides_mode": "separate", "sets": 3, "weight_kg": "4,0", "values": [], "left_values": [9, 8, 8], "right_values": [9, 8, 9], }, } }, } } assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200 raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8")) item = raw["sessions"]["w01-d01"]["items"]["d1-r0-e0"] assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps" assert item["result_data"]["weight_kg"] == 4.0 assert item["result_data"]["left_values"] == [9.0, 8.0, 8.0] measure = module._extract_training_measure(item) assert measure["mode"] == "reps" assert measure["total"] == 51.0 assert measure["weight_kg"] == 4.0 assert measure["structured"] is True def test_frontend_uses_exercise_specific_result_editor_and_legacy_fallback(): source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") assert "Ergebnis heute" in source assert "data-result-value" in source assert "data-result-weight" in source assert "data-result-sides" in source assert "data-result-weight-mode" in source assert "parseLegacyResultData" in source assert "result_data" in source assert "Bitte mindestens einen Satz bzw. ein Intervall eintragen" in source assert "data-item-result" not in source def test_explicit_training_format_and_stage_result_schemas(tmp_path: Path): plans = tmp_path / "data" / "trainingsplan" / "plans" tracker_data = tmp_path / "data" / "trainingstracker" plans.mkdir(parents=True) payload = sample_plan() payload["config"]["training_format"] = { "mode": "tabata", "fixed_interval": True, "work_seconds": 20, "rest_seconds": 10, "rounds": 8, } payload["config"]["days"][0]["rotations"][0]["exercises"][0]["result_schema"] = { "mode": "auto", "weight_mode": "optional", "laterality": "bilateral" } payload["config"]["stages"]["sq"] = { "name": "Squat-Progression", "steps": ["Deep Squat Hold", "Negative Squats", "Goblet Squat"], "result_schemas": [ {"mode": "seconds", "weight_mode": "optional", "laterality": "bilateral"}, {"mode": "reps", "weight_mode": "none", "laterality": "bilateral"}, {"mode": "reps", "weight_mode": "required", "laterality": "bilateral"}, ], } training = plans / "training.json" training.write_text(json.dumps(payload), encoding="utf-8") module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) plan = module._normalize_plan(training) assert plan["training_format"]["source"] == "plan" assert plan["training_format"]["mode"] == "tabata" assert plan["training_format"]["rounds"] == 8 assert plan["training_format"]["work_seconds"] == 20 assert plan["stages"]["sq"]["result_schemas"][0]["mode"] == "seconds" assert plan["stages"]["sq"]["result_schemas"][1]["mode"] == "reps" assert plan["stages"]["sq"]["result_schemas"][2]["weight_mode"] == "required" source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") assert "progressionResultSchema" in source assert "durch Progressionsstufe vorgegeben" in source def test_analysis_store_uses_one_deterministic_file_per_scope(tmp_path: Path): import sys sys.path.insert(0, str(Path(__file__).parents[1])) from analysis_store import current_filename assert current_filename({"type": "week", "week": 3}) == "week-03.json" assert current_filename({"type": "overall"}) == "overall.json" def test_frontend_contains_explicit_week_status_and_item_skip_states(): source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") assert "Woche abschließen" in source assert "Woche wieder öffnen" in source assert "Übersprungen" in source assert "Teilweise" in source assert "eine neue analyse überschreibt die vorherige" in source.lower()