Spaces:
Sleeping
Sleeping
| """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 | |
| def data(): | |
| assert DRILLS_FILE.exists(), "thiếu app/drills.json" | |
| return json.loads(DRILLS_FILE.read_text(encoding="utf-8")) | |
| 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) | |
| 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() | |
| 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] | |