461 lines
21 KiB
Python
461 lines
21 KiB
Python
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()
|