This commit is contained in:
2026-09-01 16:27:43 +02:00
parent f374553668
commit 43d14552f1
11 changed files with 640 additions and 5332 deletions
+97 -524
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import importlib.util
import json
import os
import time
import uuid
from pathlib import Path
@@ -27,554 +26,128 @@ def load_module(module_path: Path, data_dir: Path, plans_dir: Path):
def sample_plan() -> dict:
return {
"name": "Testplan",
"revision": 4,
"published_revision": 4,
"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",
},
"schema_version": 5,
"contract_version": 4,
"plan_id": "testplan",
"meta": {"title": "Testplan"},
"exercises": [
{
"id": "exercise-push",
"name": "Push-up-Progression",
"progressions": [
{"id": "progression-high-plank", "name": "High Plank"},
{"id": "progression-negative", "name": "Negative"},
],
"current_progression_id": "progression-high-plank",
}
],
"days": [
{
"id": "day-1",
"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"},
],
}],
},
"name": "Tag 1",
"warmup": {"items": ["Mobilisieren"]},
"cooldown": {"items": ["Atmen"]},
"exercises": [{"id": "item-1", "exercise_id": "exercise-push"}],
}
],
},
"draft": {
"schema_version": 5,
"contract_version": 4,
"plan_id": "testplan",
"meta": {"title": "Testplan"},
"exercises": [
{
"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"},
],
}],
},
"id": "exercise-push",
"name": "Push-up-Progression",
"progressions": [
{"id": "progression-high-plank", "name": "High Plank"},
{"id": "progression-negative", "name": "Negative"},
],
"current_progression_id": "progression-high-plank",
}
],
"days": [
{
"id": "day-1",
"num": 1,
"name": "Tag 1",
"warmup": {"items": ["Mobilisieren"]},
"cooldown": {"items": ["Atmen"]},
"exercises": [{"id": "item-1", "exercise_id": "exercise-push"}],
}
],
"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):
def test_lists_only_training_plans_and_returns_slim_detail(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
data_dir = 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 / "training.json").write_text(json.dumps(sample_plan()), encoding="utf-8")
(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)
module = load_module(Path(__file__).parents[1] / "app.py", data_dir, plans)
client = module.app.test_client()
result = client.get("/api/plans").get_json()
assert [item["id"] for item in result["plans"]] == ["training.json"]
listed = client.get("/api/plans").get_json()
assert [item["id"] for item in listed["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
cfg = detail["plan"]["config"]
assert cfg["schema_version"] == 5
assert cfg["contract_version"] == 4
assert cfg["days"][0]["exercises"][0]["exercise"]["current_progression"]["name"] == "High Plank"
def test_week_archive_contains_prompt_dataset_response_and_cache_uses_index(tmp_path: Path, monkeypatch):
def test_current_progression_update_writes_config_and_draft(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
data_dir = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
training.write_text(json.dumps(sample_plan()), encoding="utf-8")
plan_file = plans / "training.json"
plan_file.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)
module = load_module(Path(__file__).parents[1] / "app.py", data_dir, 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"))
plan = module._normalize_plan(training)
stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"]
assert "d1-r0-e0" not in raw["sessions"]["w01-d01"]["items"]
item = raw["sessions"]["w01-d01"]["items"][stable_id]
assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps"
assert item["result_data"]["version"] == 2
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()
def test_legacy_results_are_migrated_server_side_without_hidden_values(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
tracker_data = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
training = plans / "training.json"
payload = sample_plan()
payload["config"]["days"][0]["rotations"][0]["exercises"][0]["result_schema"] = {
"mode": "reps", "weight_mode": "required", "laterality": "unilateral",
"sides_mode": "separate", "sets": 3,
}
training.write_text(json.dumps(payload), encoding="utf-8")
sessions = tracker_data / "sessions"
sessions.mkdir(parents=True)
(sessions / "training.json").write_text(json.dumps({
"version": 5,
"revision": 4,
"sessions": {
"w01-d01": {
"status": "completed",
"items": {
"d1-r0-e0": {
"done": True,
"progression": "assistiert",
"result": "4kg, 6/6/5 je Seite",
"note": "Altwert",
}
},
}
},
}), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans)
client = module.app.test_client()
detail = client.get("/api/plans/training.json").get_json()
plan = detail["plan"]
tracker = detail["tracker"]
stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"]
item = tracker["sessions"]["w01-d01"]["items"][stable_id]
assert "d1-r0-e0" not in tracker["sessions"]["w01-d01"]["items"]
assert item["result_data"] == {
"version": 2,
"mode": "reps",
"laterality": "unilateral",
"sides_mode": "same",
"sets": 3,
"weight_kg": 4.0,
"values": [6.0, 6.0, 5.0],
"left_values": [None, None, None],
"right_values": [None, None, None],
}
assert item["result_format"]["sides_mode"] == "same"
assert item["result"] == "4 kg · 6/6/5 Reps je Seite"
assert tracker["revision"] == 5
persisted = json.loads((sessions / "training.json").read_text(encoding="utf-8"))
assert persisted["sessions"]["w01-d01"]["items"][stable_id]["result_data"]["version"] == 2
backup = tracker_data / "sessions" / ".migration-backups" / "training.json.pre-result-data-v2.json"
assert json.loads(backup.read_text(encoding="utf-8"))["version"] == 5
assert client.get("/api/plans/training.json").get_json()["tracker"]["revision"] == 5
def test_legacy_mode_override_and_zero_weight_are_preserved(tmp_path: Path):
plans = tmp_path / "plans"
tracker_data = tmp_path / "tracker"
plans.mkdir()
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)
seconds, override = module._parse_legacy_result_data("6x20 s", {
"mode": "reps", "weight_mode": "none", "laterality": "bilateral",
"sides_mode": "same", "default_sets": 6,
})
assert seconds["mode"] == "seconds"
assert seconds["values"] == [20.0] * 6
assert override["mode"] == "seconds"
weighted, override = module._parse_legacy_result_data("0kg, 8/7", {
"mode": "reps", "weight_mode": "required", "laterality": "bilateral",
"sides_mode": "same", "default_sets": 2,
})
assert weighted["weight_kg"] == 0.0
assert weighted["values"] == [8.0, 7.0]
assert override is None
def test_empty_stable_placeholder_does_not_erase_completed_legacy_item(tmp_path: Path):
plans = tmp_path / "plans"
tracker_data = tmp_path / "tracker"
plans.mkdir()
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)
merged = module._merge_migrated_item(
{"done": True, "completion_status": "completed", "result": "8/7", "note": "wichtig"},
{"done": False, "completion_status": "planned", "result": "", "note": "", "exercise_name": "Squat"},
response = client.patch(
"/api/plans/training.json/exercises/exercise-push/current",
json={"current_progression_id": "progression-negative"},
)
assert merged["done"] is True
assert merged["completion_status"] == "completed"
assert merged["result"] == "8/7"
assert merged["note"] == "wichtig"
assert merged["exercise_name"] == "Squat"
assert response.status_code == 200
payload = response.get_json()
exercise = payload["plan"]["config"]["exercises"][0]
assert exercise["current_progression_id"] == "progression-negative"
saved = json.loads(plan_file.read_text(encoding="utf-8"))
assert saved["config"]["exercises"][0]["current_progression_id"] == "progression-negative"
assert saved["draft"]["exercises"][0]["current_progression_id"] == "progression-negative"
assert saved["revision"] == 5
assert saved["published_revision"] == 5
def test_frontend_hotfix_uses_result_data_v2_and_non_overlapping_status_layout():
def test_frontend_no_longer_contains_session_or_analysis_surfaces():
source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8")
assert "const RESULT_DATA_VERSION = 2" in source
assert "grid-template-columns: minmax(112px,132px) minmax(0,1fr)" in source
assert "@media (max-width: 420px)" in source
assert ".exercise-title { grid-template-columns: minmax(0,1fr); }" in source
assert ".app { width: min(1180px, 100%)" in source
assert "formatOverride" in source
assert "data-current-progression" in source
assert "analysis/status" not in source
assert "sessionView" not in source
assert "week" not in source.lower()
def test_frontend_embeds_plugin_mount_path(tmp_path: Path):
plans = tmp_path / "data" / "trainingsplan" / "plans"
data_dir = tmp_path / "data" / "trainingstracker"
plans.mkdir(parents=True)
(plans / "training.json").write_text(json.dumps(sample_plan()), encoding="utf-8")
module = load_module(Path(__file__).parents[1] / "app.py", data_dir, plans)
client = module.app.test_client()
html = client.get("/", environ_overrides={"SCRIPT_NAME": "/plugins/trainingstracker"}).get_data(as_text=True)
assert 'data-base="/plugins/trainingstracker"' in html
@@ -6,49 +6,15 @@ 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
from contract import CONTRACT_VERSION, PLAN_SCHEMA_VERSION
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"}},
def test_manifest_matches_slim_training_contract():
manifest = json.loads((Path(__file__).parents[1] / "plugin.json").read_text(encoding="utf-8"))
assert manifest["contract"] == {
"plan_schema": PLAN_SCHEMA_VERSION,
"training_contract": CONTRACT_VERSION,
}
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
assert "tracker_schema" not in manifest["contract"]
assert "KI" not in manifest["description"]