poolcoach / tests /test_drills.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
13.7 kB
"""Contract F3 thư viện bài tập (BRIEF 04/08/2026 bàn giao 11, Bước 4).
Ba lớp khoá:
1. DỮ LIỆU ``app/drills.json``: layout trong bàn + không chồng bi, pocket
đúng quy ước index, zone dương — file sai là tab Bài tập thành nút 422.
Số "cú đạt goal" KHÔNG khoá ở đây — đó là lần chạy
``scripts/verify_drills.py`` (engine thật, số nằm trong HANDOFF).
2. API drills list/detail: đọc từ file, sống không cần DB lẫn JIT.
3. API drill-attempts: best-effort persisted đúng sự thật, tổng hợp đúng,
drill rác 4xx. Chạy SQLite in-memory như test_db_qr — KHÔNG cần Docker.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pytest
import sqlalchemy as sa
from fastapi.testclient import TestClient
from poolcoach_rl.envs.position_env import BALL_R
from app import db
from app import main as app_main
DRILLS_FILE = Path(__file__).resolve().parents[1] / "app" / "drills.json"
W, L = 0.9906, 1.9812 # specs bàn mặc định pooltool (như EnvStub)
N_POCKETS = 6
@pytest.fixture(scope="module")
def data():
assert DRILLS_FILE.exists(), "thiếu app/drills.json"
return json.loads(DRILLS_FILE.read_text(encoding="utf-8"))
@pytest.fixture
def client(monkeypatch, env_stub):
monkeypatch.setattr(app_main.state, "env_h", env_stub)
monkeypatch.setattr(app_main.state, "jit_ready", True)
monkeypatch.setattr(app_main.state, "boot_error", None)
return TestClient(app_main.app)
@pytest.fixture
def db_mem():
"""SQLite in-memory sạch mỗi test; teardown trả module db về trạng thái
tắt để test degraded không thấy DB sót lại (cùng nếp test_db_qr)."""
db.setup("sqlite:///:memory:")
db.Base.metadata.create_all(db.get_engine())
yield db
db.teardown()
@pytest.fixture
def no_db():
db.teardown()
yield
# ------------------------------------------------- 1. dữ liệu drills.json
def test_file_du_8_den_10_drill_id_khong_trung(data):
ids = [d["id"] for d in data["drills"]]
assert 8 <= len(ids) <= 10
assert len(set(ids)) == len(ids)
def test_moi_drill_dung_schema_5_3(data):
"""Đúng shape §5.3 mà BRIEF chốt: layout/goal(pot+cue_zone)/scoring,
reps top-level; pocket = "any" hoặc index lỗ 0..5."""
for d in data["drills"]:
assert set(d) == {"id", "title", "tags", "reps", "layout", "goal",
"scoring"}, d["id"]
assert d["reps"] >= 1 and d["tags"], d["id"]
pot = d["goal"]["pot"]
assert pot["ball"] in d["layout"], d["id"]
assert pot["pocket"] == "any" or pot["pocket"] in range(N_POCKETS), \
d["id"]
z = d["goal"]["cue_zone"]
assert z["r"] > 0, d["id"]
# tâm zone nằm trong mặt bàn — vẽ được và bi cái dừng tới được
assert 0 <= z["cx"] <= W and 0 <= z["cy"] <= L, d["id"]
assert d["scoring"] == {"pass": "pot && cue_zone"}, d["id"]
def test_moi_layout_trong_ban_khong_chong_bi(data):
for d in data["drills"]:
pts = {bid: np.array([p["x"], p["y"]])
for bid, p in d["layout"].items()}
assert "cue" in pts, d["id"]
for bid, xy in pts.items():
assert BALL_R <= xy[0] <= W - BALL_R, f"{d['id']}: {bid} x"
assert BALL_R <= xy[1] <= L - BALL_R, f"{d['id']}: {bid} y"
ids = sorted(pts)
for i, a in enumerate(ids):
for c in ids[i + 1:]:
assert float(np.linalg.norm(pts[a] - pts[c])) >= 2 * BALL_R, \
f"{d['id']}: {a}{c} chồng nhau"
def test_tag_kho_theo_chot_cowork_0408(data):
"""Chốt Cowork 04/08 (bàn giao 12): draw_1 + position_1rail có grid đạt
goal hẹp (1/99) → dán tag "khó" cho người tập biết, KHÔNG nới zone.
FE hiện tag sẵn có — đây là chốt dữ liệu, khoá lại kẻo lần sửa
drills.json sau vô tình rơi mất."""
tags = {d["id"]: d["tags"] for d in data["drills"]}
assert "khó" in tags["draw_1"]
assert "khó" in tags["position_1rail"]
# --------------------------------------------------- 2. GET /api/drills
def test_drills_list_dung_shape_va_du_so(client, no_db, data):
r = client.get("/api/drills")
assert r.status_code == 200
lst = r.json()["drills"]
assert len(lst) == len(data["drills"])
for item in lst:
assert set(item) == {"id", "title", "tags", "reps"}
def test_drills_song_khi_chua_jit_va_khong_db(client, no_db, monkeypatch):
"""Drill đọc từ file — phải sống từ giây đầu boot, trước cả JIT."""
monkeypatch.setattr(app_main.state, "jit_ready", False)
monkeypatch.setattr(app_main.state, "env_h", None)
assert client.get("/api/drills").status_code == 200
assert client.get("/api/drills/stop_shot_short").status_code == 200
def test_drill_detail_du_layout_goal_scoring(client, no_db):
r = client.get("/api/drills/stop_shot_short")
assert r.status_code == 200
d = r.json()
assert set(d) == {"id", "title", "tags", "reps", "layout", "goal",
"scoring"}
assert d["layout"]["cue"] == {"x": 0.252, "y": 0.9906}
assert d["goal"]["pot"] == {"ball": "1", "pocket": "any"}
assert set(d["goal"]["cue_zone"]) == {"cx", "cy", "r"}
assert d["scoring"]["pass"] == "pot && cue_zone"
def test_drill_detail_khong_co_404_message_viet(client, no_db):
r = client.get("/api/drills/khong-ton-tai")
assert r.status_code == 404
detail = r.json()["detail"]
assert isinstance(detail, str) # FE toast chỉ đọc được string
assert "Không có drill" in detail
# ------------------------------------------- 3. POST /api/drill-attempts
def test_attempt_khong_db_200_persisted_false(client, no_db):
r = client.post("/api/drill-attempts",
json={"drill_id": "draw_1", "result": "pass"})
assert r.status_code == 200
assert r.json() == {"persisted": False, "id": None}
def test_attempt_khong_db_mang_session_van_200(client, no_db):
"""Degraded + client vẫn gửi session_id (bàn giao 12): lượt tập không
được lưu nhưng vẫn 200 persisted false — không nhánh nào chết vì phiên."""
r = client.post("/api/drill-attempts",
json={"drill_id": "draw_1", "result": "pass",
"session_id": "phien-nao-do"})
assert r.status_code == 200
assert r.json() == {"persisted": False, "id": None}
def test_attempt_co_db_persisted_true_ghi_dung_row(client, db_mem):
r = client.post("/api/drill-attempts",
json={"drill_id": "draw_1", "result": "fail",
"detail": {"rep": 2, "ghi_chu": "truot dai"}})
assert r.status_code == 200
body = r.json()
assert body["persisted"] is True
with db.session() as s:
row = s.get(db.DrillAttempt, body["id"])
assert row is not None
assert (row.drill_id, row.result) == ("draw_1", "fail")
assert row.detail == {"rep": 2, "ghi_chu": "truot dai"}
assert row.tenant_id is None and row.guest_session_id is None
def test_attempt_drill_rac_404_khong_ghi_row(client, db_mem):
r = client.post("/api/drill-attempts",
json={"drill_id": "khong-ton-tai", "result": "pass"})
assert r.status_code == 404
with db.session() as s:
n = s.scalar(sa.select(sa.func.count()).select_from(db.DrillAttempt))
assert n == 0
def test_attempt_result_rac_422(client, no_db):
r = client.post("/api/drill-attempts",
json={"drill_id": "draw_1", "result": "ok"})
assert r.status_code == 422
def _seed_session(hours=1.0):
"""Tenant + bàn + guest session còn ``hours`` giờ hạn (âm = đã hết hạn)
— cùng nếp helper của test_db_qr phần recommend."""
with db.session() as s:
tenant = db.Tenant(name="dev")
s.add(tenant)
s.flush()
tbl = db.Table(tenant_id=tenant.id, name="Bàn 1")
s.add(tbl)
s.flush()
gs = db.GuestSession(tenant_id=tenant.id, table_id=tbl.id,
expires_at=db.utcnow() + timedelta(hours=hours))
s.add(gs)
s.flush()
return tenant.id, gs.id
def test_attempt_guest_session_that_dien_tenant(client, db_mem):
# Bàn giao 13: session phải CÒN HẠN mới gắn phiên — bản cũ của test này
# seed `expires_at=db.utcnow()` (hết hạn ngay lúc tạo) và vẫn xanh vì
# endpoint chưa check; ngữ nghĩa "session thật → điền tenant" giữ nguyên,
# chỉ fixture đổi sang phiên còn sống.
tenant_id, session_id = _seed_session()
r = client.post("/api/drill-attempts",
json={"drill_id": "follow_1", "result": "pass",
"session_id": session_id})
assert r.json()["persisted"] is True
with db.session() as s:
row = s.get(db.DrillAttempt, r.json()["id"])
assert row.guest_session_id == session_id
assert row.tenant_id == tenant_id
def test_attempt_session_het_han_200_khong_gan_phien(client, db_mem, capfd):
"""G1 bàn giao 13: session THẬT nhưng hết hạn TTL → vẫn 200 + row vẫn
ghi (không bao giờ vứt lượt tập), nhưng row KHÔNG gắn phiên — tenant_id
lẫn guest_session_id đều NULL, đồng bộ check `expires_at` với log
recommend (khác session rác: rác giữ text làm dấu vết vì không FK)."""
_, session_id = _seed_session(hours=-1)
r = client.post("/api/drill-attempts",
json={"drill_id": "follow_1", "result": "pass",
"session_id": session_id})
assert r.status_code == 200
assert r.json()["persisted"] is True
with db.session() as s:
row = s.get(db.DrillAttempt, r.json()["id"])
assert row.tenant_id is None
assert row.guest_session_id is None
assert "session het han trong drill-attempt" in capfd.readouterr().out
def test_attempt_session_la_van_luu_tenant_null(client, db_mem):
"""Session không tồn tại KHÔNG chặn lượt tập (không FK, như scan_id):
row vẫn ghi, session giữ nguyên text, tenant NULL."""
r = client.post("/api/drill-attempts",
json={"drill_id": "follow_1", "result": "fail",
"session_id": "khong-phai-session"})
assert r.json()["persisted"] is True
with db.session() as s:
row = s.get(db.DrillAttempt, r.json()["id"])
assert row.guest_session_id == "khong-phai-session"
assert row.tenant_id is None
def test_attempt_db_hong_van_200_persisted_false(client, db_mem, monkeypatch,
capfd):
"""Best-effort đúng nghĩa: DB nổ giữa chừng chỉ được phép thành warning,
response vẫn 200 nhưng persisted phải nói THẬT là false."""
def boom():
raise RuntimeError("DB chet giua chung")
monkeypatch.setattr(db, "session", boom)
r = client.post("/api/drill-attempts",
json={"drill_id": "draw_1", "result": "pass"})
assert r.status_code == 200
assert r.json() == {"persisted": False, "id": None}
assert "khong ghi duoc drill attempt" in capfd.readouterr().out
# -------------------------------------------- 4. GET /api/drill-attempts
def test_attempts_khong_db_503_message_viet(client, no_db):
r = client.get("/api/drill-attempts")
assert r.status_code == 503
detail = r.json()["detail"]
assert isinstance(detail, str)
assert "chưa cấu hình cơ sở dữ liệu" in detail.lower()
def _post(client, drill_id, result, session_id=None):
body = {"drill_id": drill_id, "result": result}
if session_id:
body["session_id"] = session_id
r = client.post("/api/drill-attempts", json=body)
assert r.status_code == 200 and r.json()["persisted"] is True
return r.json()["id"]
def test_attempts_list_va_tong_hop(client, db_mem):
_post(client, "draw_1", "pass")
_post(client, "draw_1", "fail")
_post(client, "draw_1", "pass")
_post(client, "follow_1", "fail")
r = client.get("/api/drill-attempts")
assert r.status_code == 200
data = r.json()
assert [a["drill_id"] for a in data["attempts"]] == \
["draw_1", "draw_1", "draw_1", "follow_1"]
assert data["summary"] == {"draw_1": {"pass": 2, "fail": 1},
"follow_1": {"pass": 0, "fail": 1}}
# created_at trả AWARE UTC — FE không phải đoán múi giờ (nếp table-qr)
ts = datetime.fromisoformat(data["attempts"][0]["created_at"])
assert ts.tzinfo is not None and ts.utcoffset().total_seconds() == 0
def test_attempts_filter_theo_drill_va_session(client, db_mem):
_post(client, "draw_1", "pass", session_id="phien-a")
_post(client, "draw_1", "fail", session_id="phien-b")
_post(client, "follow_1", "pass", session_id="phien-a")
r = client.get("/api/drill-attempts", params={"drill_id": "draw_1"})
assert len(r.json()["attempts"]) == 2
r = client.get("/api/drill-attempts", params={"session_id": "phien-a"})
data = r.json()
assert len(data["attempts"]) == 2
assert data["summary"] == {"draw_1": {"pass": 1, "fail": 0},
"follow_1": {"pass": 1, "fail": 0}}
r = client.get("/api/drill-attempts",
params={"drill_id": "draw_1", "session_id": "phien-a"})
assert [a["id"] for a in r.json()["attempts"]] == [1]