Spaces:
Sleeping
Sleeping
File size: 13,696 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 | """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]
|