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}{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]