UI
This commit is contained in:
@@ -1,85 +1,48 @@
|
||||
# Trainings-Session-Tracker 2.0
|
||||
# Training 3.0
|
||||
|
||||
Mobiler Session-Tracker für veröffentlichte Pläne des boehmitools-Plugins `trainingsplan`.
|
||||
Schlanke mobile Ansicht fuer Plaene aus dem Plugin `trainingsplan`.
|
||||
|
||||
## Datenaufteilung
|
||||
Das Plugin verwaltet keine Sessions mehr. Es gibt keine Wochen, kein Tracking,
|
||||
keine Ergebnisfelder, keine KI-Analyse und keine Vorschlaege. Angezeigt werden
|
||||
nur:
|
||||
|
||||
- Trainingstage
|
||||
- Warm-up
|
||||
- Cool-down
|
||||
- Uebungen des Tages
|
||||
- aktuelle Progression pro Uebung
|
||||
|
||||
## Datenzugriff
|
||||
|
||||
Gelesen und geschrieben werden die Plan-JSON-Dateien aus:
|
||||
|
||||
```text
|
||||
data/trainingstracker/
|
||||
├── sessions/<Plan-Dateiname>.json
|
||||
├── analyses/<Plan-Dateiname>/
|
||||
│ ├── index.json
|
||||
│ ├── state.json
|
||||
│ ├── week-01.json
|
||||
│ ├── week-02.json
|
||||
│ └── overall.json
|
||||
└── proposals/<Plan-Dateiname>.json
|
||||
data/trainingsplan/plans/
|
||||
```
|
||||
|
||||
Die Sessiondatei enthält ausschließlich Profil-, Wochenstatus- und Sessiondaten. Prompts, KI-Antworten und Jobstatus liegen vollständig getrennt.
|
||||
|
||||
## Genau eine Analyse pro Bereich
|
||||
|
||||
Für jede Woche gibt es höchstens eine Datei `week-NN.json`, für den Gesamtplan genau eine `overall.json`. Eine neue Auswertung überschreibt die vorherige Datei. Es gibt keine Analysehistorie und keine Versionierung von KI-Auswertungen. Ältere Zeitstempeldateien werden beim ersten Lesen auf dieses Modell reduziert; erhalten bleibt nur die zuvor als aktuell markierte Auswertung.
|
||||
|
||||
Unveränderte Daten werden über einen Hash erkannt und lösen keinen neuen OpenAI-Aufruf aus. Der Hash berücksichtigt unter anderem:
|
||||
|
||||
- veröffentlichte Planrevision,
|
||||
- Plan- und Vertragsversion,
|
||||
- Prompt- und Antwortschemaversion,
|
||||
- Modell,
|
||||
- Normalisierungsversion,
|
||||
- Sessiondaten und Wochenstatus.
|
||||
|
||||
## Sessiondaten und Konfliktschutz
|
||||
|
||||
Der Browser speichert nur die aktuell bearbeitete Session als Patch. Eine Revisionsnummer verhindert, dass ein älterer Browser-Tab neuere Daten überschreibt.
|
||||
|
||||
Jede Session merkt `plan_id` und `plan_revision`. Alte positionsbasierte Einträge wie `d1-r0-e0` werden beim Öffnen automatisch den neuen stabilen Übungs-IDs zugeordnet und beim nächsten Speichern migriert.
|
||||
|
||||
Sessionstatus:
|
||||
Die einzige fachliche Schreiboperation ist:
|
||||
|
||||
```text
|
||||
planned → in_progress → stopped/completed
|
||||
stopped → in_progress
|
||||
completed → in_progress
|
||||
PATCH /api/plans/<plan>/exercises/<exercise>/current
|
||||
```
|
||||
|
||||
Übungsstatus:
|
||||
Body:
|
||||
|
||||
- offen,
|
||||
- erledigt,
|
||||
- teilweise,
|
||||
- übersprungen mit optionalem Grund.
|
||||
```json
|
||||
{
|
||||
"current_progression_id": "progression-high-plank"
|
||||
}
|
||||
```
|
||||
|
||||
Nicht enthalten sind RIR/RPE, Technikbewertung oder unterschiedliche Gewichte pro Satz.
|
||||
Die Aenderung wird direkt in `config` und `draft` des Plans geschrieben und ist
|
||||
damit die neue Wahrheit fuer App und Editor.
|
||||
|
||||
## Plan- und progressionsabhängige Ergebnisfelder
|
||||
## Vertrag
|
||||
|
||||
Der Tracker liest das Ergebnisformat zunächst aus der gewählten Progressionsstufe, danach aus der Übung. So kann eine Squat-Progression zunächst Sekunden mit optionalem Gewicht und später Wiederholungen oder Gewicht plus Wiederholungen verlangen.
|
||||
```text
|
||||
plan_schema: 5
|
||||
training_contract: 4
|
||||
```
|
||||
|
||||
Alte Freitextergebnisse bleiben lesbar. Eindeutige Werte werden in strukturierte Felder übernommen, unklare Angaben nicht erfunden.
|
||||
|
||||
## Wochenabschluss
|
||||
|
||||
Eine Woche kann ausdrücklich als laufend oder abgeschlossen markiert werden. Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse. Wird der Wochenstatus oder eine Session geändert, gilt die bestehende Analyse als veraltet. Die nächste manuelle Auswertung überschreibt sie.
|
||||
|
||||
## Progressionsanalyse
|
||||
|
||||
Wochen werden einzeln analysiert. Die Gesamtanalyse verwendet nur aktuelle Wochenzusammenfassungen und lokale Aggregate, nicht erneut sämtliche Rohsessions.
|
||||
|
||||
Der veröffentlichte Plan ist bindend. Bei Tabata bleiben Arbeitszeit, Pause, Rundenzahl und Satzlogik unverändert. Die KI erhält lokal berechnete Variantencluster und soll deren Faktoren nicht selbst neu erfinden.
|
||||
|
||||
Planvorschläge werden strukturiert mit `exercise_id`, `progression_id` und `step_id` nach `data/trainingstracker/proposals/` geschrieben. Der Planeditor zeigt sie als Prüfpostfach an und verändert den Plan niemals automatisch.
|
||||
|
||||
## Robuste Analysejobs
|
||||
|
||||
Der Jobstatus wird vor dem API-Aufruf gespeichert. Eine Prozess- und Dateisperre verhindert parallele Jobs für denselben Plan. Ein Heartbeat verlängert die Job-Lease. Nach einem Prozessabbruch läuft die Sperre zeitnah ab, auch nach einem Browser-Reload oder Containerneustart.
|
||||
|
||||
## Übungsbibliothek und FAQ
|
||||
|
||||
Die planbezogene `exercise_catalog` ist die primäre Quelle für Bewegungscluster, Varianten und Faktoren. Die FAQ zeigt diese Planbibliothek sowie die transparenten Fallback-Tabellen. Dynamische Übungen werden als Referenz-Reps, Holds als Referenzsekunden und externe Lasten als kg·Reps beziehungsweise kg·s ausgewertet.
|
||||
|
||||
## Oberflächenzustand
|
||||
|
||||
Der letzte Tab, die Woche, der Tag und die ausgewählte Analyse bleiben pro Trainingsplan im Browser erhalten.
|
||||
Uebungen enthalten `progressions` und `current_progression_id`. Tagesuebungen
|
||||
referenzieren nur noch `exercise_id`.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def current_filename(record: dict[str, Any]) -> str:
|
||||
if record.get("type") == "week":
|
||||
return f"week-{int(record.get('week') or 0):02d}.json"
|
||||
return "overall.json"
|
||||
|
||||
|
||||
def cleanup_legacy_files(directory: Path, record: dict[str, Any], keep: str) -> None:
|
||||
if record.get("type") == "week":
|
||||
week = int(record.get("week") or 0)
|
||||
pattern = re.compile(rf"^week[-_]?0?{week}(?:[-_].*)?\.json$", re.I)
|
||||
else:
|
||||
pattern = re.compile(r"^overall(?:[-_].*)?\.json$", re.I)
|
||||
for path in directory.glob("*.json"):
|
||||
if path.name in {"index.json", "state.json", keep}: continue
|
||||
if pattern.match(path.name):
|
||||
try: path.unlink()
|
||||
except OSError: pass
|
||||
+188
-3198
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Plugin-Adapter für den Trainings-Session-Tracker."""
|
||||
"""Plugin-Adapter fuer die schlanke mobile Trainingsansicht."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -10,13 +10,7 @@ from core.loader import load_module
|
||||
|
||||
|
||||
def create_app(ctx):
|
||||
"""Hängt die Flask-App unter /plugins/trainingstracker ein.
|
||||
|
||||
Es werden ausschließlich Pfade relativ zum boehmitools-Datenordner benutzt:
|
||||
data/trainingsplan/plans (nur lesen)
|
||||
data/trainingstracker/sessions (Sessiondaten schreiben)
|
||||
data/trainingstracker/analyses (Prompts, Antworten, Cache und Jobstatus)
|
||||
"""
|
||||
"""Haengt die Flask-App unter /plugins/trainingstracker ein."""
|
||||
tracker_data_dir = Path(ctx.data_dir)
|
||||
plans_dir = tracker_data_dir.parent / "trainingsplan" / "plans"
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared constants for the versioned plan/tracker contract."""
|
||||
PLAN_SCHEMA_VERSION = 3
|
||||
CONTRACT_VERSION = 2
|
||||
TRACKER_SCHEMA_VERSION = 7
|
||||
RESULT_DATA_VERSION = 2
|
||||
PROMPT_VERSION = 5
|
||||
ANALYSIS_SCHEMA_VERSION = 3
|
||||
PROGRESSION_NORMALIZATION_VERSION = 5
|
||||
"""Shared constants for the slim training plan contract."""
|
||||
PLAN_SCHEMA_VERSION = 5
|
||||
CONTRACT_VERSION = 4
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
{
|
||||
"id": "trainingstracker",
|
||||
"name": "Trainings-Session-Tracker",
|
||||
"summary": "Sessions mobil erfassen und planbewusst über Wochen analysieren",
|
||||
"description": "Mobiler Tracker für veröffentlichte Trainingspläne mit stabilen Übungs-IDs, progressionsabhängigen Ergebnisfeldern, Session-Patches, Wochenabschluss, Variantenclustern und genau einer überschreibbaren KI-Analyse pro Woche beziehungsweise Gesamtplan.",
|
||||
"name": "Training",
|
||||
"summary": "Schlanke mobile Ansicht für Trainingstage und aktuelle Progressionen",
|
||||
"description": "Mobile Planansicht für den aktuellen Trainingsplan. Zeigt Tage, Warm-up, Cool-down und Übungen und schreibt ausschließlich die aktuelle Progression einer Übung zurück in den Plan.",
|
||||
"icon": "📱",
|
||||
"category": "Planung",
|
||||
"version": "2.0.0",
|
||||
"version": "3.0.0",
|
||||
"entrypoint": "backend:create_app",
|
||||
"order": 11,
|
||||
"requires": [],
|
||||
"plugin_dependencies": {
|
||||
"trainingsplan": ">=2.0.0"
|
||||
"trainingsplan": ">=3.0.0"
|
||||
},
|
||||
"contract": {
|
||||
"plan_schema": 3,
|
||||
"training_contract": 2,
|
||||
"tracker_schema": 7,
|
||||
"result_data": 2
|
||||
"plan_schema": 5,
|
||||
"training_contract": 4
|
||||
},
|
||||
"features": [
|
||||
"Stabile Plan-, Übungs-, Progressions- und Stufen-IDs",
|
||||
"Alte Positionsschlüssel werden automatisch migriert",
|
||||
"Session-Patches mit Revisionskonfliktschutz",
|
||||
"Planrevision wird pro Session gespeichert",
|
||||
"Erledigt, teilweise und übersprungen mit Grund",
|
||||
"Expliziter Wochenabschluss mit Zwischen- oder Abschlussanalyse",
|
||||
"Genau eine Wochenanalyse je Woche und eine Gesamtanalyse, jeweils überschreibbar",
|
||||
"Keine Analysehistorie",
|
||||
"Persistente Analysejobs mit Dateisperre, Heartbeat und Lease",
|
||||
"Planbezogene Übungsbibliothek und Variantencluster",
|
||||
"Strukturierte KI-Vorschläge für den Planeditor",
|
||||
"Letzter Tab, Woche, Tag und Auswahl bleiben erhalten",
|
||||
"Trainingsplan bleibt read-only"
|
||||
"Trainingstage mobil anzeigen",
|
||||
"Warm-up und Cool-down anzeigen",
|
||||
"Übungen eines Tages anzeigen",
|
||||
"Aktuelle Progression je Übung ändern",
|
||||
"Planwechsel merken"
|
||||
],
|
||||
"docs": "README.md"
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
VALID_SESSION_STATUSES = {"planned", "in_progress", "stopped", "completed"}
|
||||
VALID_ITEM_STATUSES = {"planned", "completed", "partial", "skipped"}
|
||||
|
||||
|
||||
def merge_session_patch(existing: dict[str, Any], payload: dict[str, Any], now: str) -> dict[str, Any]:
|
||||
current_revision = int(existing.get("revision") or 1)
|
||||
expected = payload.get("expected_revision")
|
||||
if expected is not None and int(expected) != current_revision:
|
||||
raise RuntimeError(f"revision_conflict:{current_revision}")
|
||||
result = deepcopy(existing)
|
||||
profile = payload.get("profile")
|
||||
if isinstance(profile, dict):
|
||||
result["profile"] = deepcopy(profile)
|
||||
week_statuses = payload.get("week_statuses")
|
||||
if isinstance(week_statuses, dict):
|
||||
result["week_statuses"] = deepcopy(week_statuses)
|
||||
key = str(payload.get("session_key") or "")
|
||||
session = payload.get("session")
|
||||
if key and isinstance(session, dict):
|
||||
result.setdefault("sessions", {})[key] = deepcopy(session)
|
||||
result["revision"] = current_revision + 1
|
||||
result["updated_at"] = now
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 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"))
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user