"""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} và {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]