chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 6b4ed9bc36
111 changed files with 95131 additions and 0 deletions
+460
View File
@@ -0,0 +1,460 @@
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 1520 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 2030 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()
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
from analysis_store import cleanup_legacy_files, current_filename
from session_store import merge_session_patch
def test_session_patch_preserves_other_sessions_and_detects_conflicts():
existing = {
"revision": 4,
"profile": {"start_date": "2026-01-01"},
"sessions": {"w01-d01": {"status": "completed"}, "w01-d02": {"status": "planned"}},
}
merged = merge_session_patch(existing, {
"expected_revision": 4,
"session_key": "w01-d02",
"session": {"status": "stopped"},
}, "2026-01-02T00:00:00Z")
assert merged["revision"] == 5
assert merged["sessions"]["w01-d01"]["status"] == "completed"
assert merged["sessions"]["w01-d02"]["status"] == "stopped"
try:
merge_session_patch(merged, {"expected_revision": 4}, "2026-01-02T00:00:01Z")
except RuntimeError as exc:
assert str(exc) == "revision_conflict:5"
else:
raise AssertionError("Revisionskonflikt wurde nicht erkannt")
def test_analysis_cleanup_keeps_exactly_current_scope(tmp_path: Path):
records = [
"week-01_20260101T100000Z.json",
"week-01_20260102T100000Z.json",
"week-02_20260101T100000Z.json",
"overall_20260101T100000Z.json",
"index.json",
"state.json",
]
for name in records:
(tmp_path / name).write_text(json.dumps({"name": name}), encoding="utf-8")
keep = current_filename({"type": "week", "week": 1})
(tmp_path / keep).write_text("{}", encoding="utf-8")
cleanup_legacy_files(tmp_path, {"type": "week", "week": 1}, keep)
names = {path.name for path in tmp_path.glob("*.json")}
assert keep in names
assert "week-02_20260101T100000Z.json" in names
assert "overall_20260101T100000Z.json" in names
assert "week-01_20260101T100000Z.json" not in names
assert "week-01_20260102T100000Z.json" not in names