scripts/test_plans_wp15.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
This commit is contained in:
parent
4d67cd9d66
commit
3806f4ac47
|
|
@ -1,16 +1,19 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Unit-, Integrations- und Mini-E2E-Tests für WP-15."""
|
"""Unit-, Integrations- und E2E-Tests für WP-15 (v1.2.0)."""
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
import json
|
import json
|
||||||
|
import requests
|
||||||
|
import pytest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
BASE = os.getenv("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
|
BASE = os.getenv("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
QDRANT = os.getenv("QDRANT_BASE", "http://127.0.0.1:6333").rstrip("/")
|
||||||
|
TPL_COLL = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates")
|
||||||
|
|
||||||
# ---------- Unit ----------
|
# ---------- Helpers ----------
|
||||||
|
|
||||||
def _fingerprint_local(plan_payload: dict) -> str:
|
def _fp_local(plan_payload: dict) -> str:
|
||||||
import hashlib
|
import hashlib
|
||||||
core = {
|
core = {
|
||||||
"title": plan_payload["title"],
|
"title": plan_payload["title"],
|
||||||
|
|
@ -25,9 +28,21 @@ def _fingerprint_local(plan_payload: dict) -> str:
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def test_fingerprint_unit():
|
def _unique_len_lower(xs):
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for x in xs:
|
||||||
|
k = x.casefold()
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
out.append(x)
|
||||||
|
return len(out)
|
||||||
|
|
||||||
|
# ---------- Unit ----------
|
||||||
|
|
||||||
|
def test_fingerprint_unit_v12():
|
||||||
p = {
|
p = {
|
||||||
"title": "Montag - Reaktion",
|
"title": "Montag – Reaktion",
|
||||||
"total_minutes": 90,
|
"total_minutes": 90,
|
||||||
"sections": [
|
"sections": [
|
||||||
{"name": "Warmup", "minutes": 15, "items": [
|
{"name": "Warmup", "minutes": 15, "items": [
|
||||||
|
|
@ -35,30 +50,119 @@ def test_fingerprint_unit():
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
fp1 = _fingerprint_local(p)
|
fp1 = _fp_local(p)
|
||||||
p2 = json.loads(json.dumps(p, ensure_ascii=False))
|
p2 = json.loads(json.dumps(p, ensure_ascii=False))
|
||||||
fp2 = _fingerprint_local(p2)
|
fp2 = _fp_local(p2)
|
||||||
assert fp1 == fp2
|
assert fp1 == fp2
|
||||||
|
|
||||||
# ---------- Integration / E2E ----------
|
# ---------- Integration: Templates mit mehreren Sections + ideal/supplement ----------
|
||||||
|
|
||||||
def test_template_plan_e2e():
|
def test_template_sections_ideal_supplement_roundtrip():
|
||||||
# 1) Template anlegen
|
|
||||||
tpl = {
|
tpl = {
|
||||||
"name": "Standard 90min", "discipline": "Karate", "age_group": "Teenager",
|
"name": "Std 90 v1.1",
|
||||||
"target_group": "Breitensport", "total_minutes": 90,
|
"discipline": "Karate",
|
||||||
|
"age_group": "Teenager",
|
||||||
|
"target_group": "Breitensport",
|
||||||
|
"total_minutes": 90,
|
||||||
"sections": [
|
"sections": [
|
||||||
{"name": "Warmup", "target_minutes": 15, "must_keywords": ["Reaktion"], "forbid_keywords": [], "capability_targets": {"Reaktionsfähigkeit": 2}}
|
{
|
||||||
|
"name": "Warmup",
|
||||||
|
"target_minutes": 15,
|
||||||
|
"must_keywords": ["Reaktion", "reaktion"], # Duplikat in anderer Schreibweise
|
||||||
|
"ideal_keywords": ["Koordination", "koordination"], # Duplikat in anderer Schreibweise
|
||||||
|
"supplement_keywords": ["Teamspiel", " Teamspiel "], # Duplikat mit Whitespace
|
||||||
|
"forbid_keywords": [],
|
||||||
|
"capability_targets": {"Reaktionsfähigkeit": 2, "Mobilität": 1}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Technikblock",
|
||||||
|
"target_minutes": 30,
|
||||||
|
"must_keywords": ["Mae-Geri"],
|
||||||
|
"ideal_keywords": ["Timing"],
|
||||||
|
"supplement_keywords": ["Partnerarbeit"],
|
||||||
|
"forbid_keywords": ["Bodenarbeit"],
|
||||||
|
"capability_targets": {"Technikpräzision": 2, "Schnelligkeit": 1}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"goals": ["Technik", "Kondition"], "equipment_allowed": ["Bälle"],
|
"goals": ["Technik", "Kondition"],
|
||||||
"created_by": "tester", "version": "1.0"
|
"equipment_allowed": ["Bälle"],
|
||||||
|
"created_by": "tester",
|
||||||
|
"version": "1.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
r = requests.post(f"{BASE}/plan_templates", json=tpl)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
tpl_id = r.json()["id"]
|
||||||
|
|
||||||
|
r2 = requests.get(f"{BASE}/plan_templates/{tpl_id}")
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
got = r2.json()
|
||||||
|
|
||||||
|
# Prüfen: Beide Sections vorhanden
|
||||||
|
assert len(got["sections"]) == 2
|
||||||
|
|
||||||
|
s1 = got["sections"][0]
|
||||||
|
# Normalisierung: Duplikate entfernt (case-insensitive), Whitespace getrimmt
|
||||||
|
assert _unique_len_lower(s1["must_keywords"]) == len(s1["must_keywords"]) == 1
|
||||||
|
assert _unique_len_lower(s1["ideal_keywords"]) == len(s1["ideal_keywords"]) == 1
|
||||||
|
assert _unique_len_lower(s1["supplement_keywords"]) == len(s1["supplement_keywords"]) == 1
|
||||||
|
|
||||||
|
# ---------- Optional: Qdrant-Payload-Check (materialisierte Felder) ----------
|
||||||
|
|
||||||
|
def test_qdrant_materialized_fields_template():
|
||||||
|
# Wenn Qdrant nicht erreichbar ist, Test überspringen
|
||||||
|
try:
|
||||||
|
rq = requests.post(
|
||||||
|
f"{QDRANT}/collections/{TPL_COLL}/points/scroll",
|
||||||
|
json={"with_payload": True, "limit": 1},
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pytest.skip("Qdrant nicht erreichbar – überspringe materialisierte Feldprüfung")
|
||||||
|
|
||||||
|
if rq.status_code != 200:
|
||||||
|
pytest.skip(f"Qdrant-Scroll liefert {rq.status_code}")
|
||||||
|
|
||||||
|
js = rq.json()
|
||||||
|
pts = (js.get("result") or {}).get("points") or []
|
||||||
|
if not pts:
|
||||||
|
pytest.skip("Keine Punkte in plan_templates – überspringe")
|
||||||
|
|
||||||
|
payload = pts[0].get("payload") or {}
|
||||||
|
# Erwartet: materialisierte Listen vorhanden
|
||||||
|
for key in [
|
||||||
|
"section_names",
|
||||||
|
"section_must_keywords",
|
||||||
|
"section_ideal_keywords",
|
||||||
|
"section_supplement_keywords",
|
||||||
|
"section_forbid_keywords",
|
||||||
|
]:
|
||||||
|
assert key in payload
|
||||||
|
assert isinstance(payload[key], list)
|
||||||
|
|
||||||
|
# ---------- E2E: Plan anlegen + Idempotenz trotz variierender Nebenfelder ----------
|
||||||
|
|
||||||
|
def test_plan_e2e_idempotence_same_fingerprint():
|
||||||
|
# Template minimal für Bezug
|
||||||
|
tpl = {
|
||||||
|
"name": "Std 90 for plan",
|
||||||
|
"discipline": "Karate",
|
||||||
|
"age_group": "Teenager",
|
||||||
|
"target_group": "Breitensport",
|
||||||
|
"total_minutes": 90,
|
||||||
|
"sections": [
|
||||||
|
{"name": "Warmup", "target_minutes": 15, "must_keywords": [], "forbid_keywords": [], "capability_targets": {}}
|
||||||
|
],
|
||||||
|
"goals": [],
|
||||||
|
"equipment_allowed": [],
|
||||||
|
"created_by": "tester",
|
||||||
|
"version": "1.0"
|
||||||
}
|
}
|
||||||
r1 = requests.post(f"{BASE}/plan_templates", json=tpl)
|
r1 = requests.post(f"{BASE}/plan_templates", json=tpl)
|
||||||
assert r1.status_code == 200, r1.text
|
assert r1.status_code == 200
|
||||||
tpl_id = r1.json()["id"]
|
tpl_id = r1.json()["id"]
|
||||||
|
|
||||||
# 2) Plan anlegen (aus Template, minimal)
|
plan_base = {
|
||||||
plan = {
|
|
||||||
"template_id": tpl_id,
|
"template_id": tpl_id,
|
||||||
"title": "KW32 – Montag",
|
"title": "KW32 – Montag",
|
||||||
"discipline": "Karate",
|
"discipline": "Karate",
|
||||||
|
|
@ -70,23 +174,33 @@ def test_template_plan_e2e():
|
||||||
{"exercise_external_id": "ex:001", "duration": 10, "why": "Aufwärmen"}
|
{"exercise_external_id": "ex:001", "duration": 10, "why": "Aufwärmen"}
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
"goals": ["Technik", "Kondition"],
|
|
||||||
"capability_summary": {"Reaktionsfähigkeit": 2},
|
|
||||||
"created_by": "tester",
|
"created_by": "tester",
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"source": "test"
|
"source": "test"
|
||||||
}
|
}
|
||||||
r2 = requests.post(f"{BASE}/plan", json=plan)
|
|
||||||
|
# Plan A mit goals/capability_summary
|
||||||
|
plan_a = dict(plan_base)
|
||||||
|
plan_a.update({
|
||||||
|
"goals": ["Technik"],
|
||||||
|
"capability_summary": {"Reaktionsfähigkeit": 2}
|
||||||
|
})
|
||||||
|
r2 = requests.post(f"{BASE}/plan", json=plan_a)
|
||||||
assert r2.status_code == 200, r2.text
|
assert r2.status_code == 200, r2.text
|
||||||
plan_id = r2.json()["id"]
|
plan_id = r2.json()["id"]
|
||||||
|
|
||||||
# 3) Idempotenz – gleicher Plan → gleiche ID
|
# Plan B – gleicher Fingerprint (gleiche items), aber andere Nebenfelder
|
||||||
r3 = requests.post(f"{BASE}/plan", json=plan)
|
plan_b = dict(plan_base)
|
||||||
|
plan_b.update({
|
||||||
|
"goals": ["Kondition"],
|
||||||
|
"capability_summary": {"Reaktionsfähigkeit": 3}
|
||||||
|
})
|
||||||
|
r3 = requests.post(f"{BASE}/plan", json=plan_b)
|
||||||
assert r3.status_code == 200
|
assert r3.status_code == 200
|
||||||
assert r3.json()["id"] == plan_id
|
assert r3.json()["id"] == plan_id, "Idempotenz verletzt: gleicher Fingerprint muss gleiche ID liefern"
|
||||||
|
|
||||||
# 4) GET /plan/{id}
|
# GET prüfen
|
||||||
r4 = requests.get(f"{BASE}/plan/{plan_id}")
|
r4 = requests.get(f"{BASE}/plan/{plan_id}")
|
||||||
assert r4.status_code == 200
|
assert r4.status_code == 200
|
||||||
js = r4.json()
|
js = r4.json()
|
||||||
assert js["title"] == "KW32 – Montag" # EN-Dash (–)
|
assert js["title"] == "KW32 – Montag"
|
||||||
Loading…
Reference in New Issue
Block a user