File size: 13,640 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
"""Contract của /api/recommend shape full rack — MỘT engine từ 31/07/2026.

Monkeypatch tầng engine (`recommend_v2` / `recommend`) — test kiểm SHAPE /
STATUS / MESSAGE, không kiểm vật lý. `state` của app được nhét env stub +
jit_ready=True.

LỊCH SỬ 31/07 (BRIEF việc B): shape full rack không còn bộ chọn engine —
route luôn chạy ZonePlanner V2 (`recommend_v2`). Trường `engine`/`topk` của
request CŨ được BỎ QUA CÓ CHỦ ĐÍCH (G5.5, phương án 2): client cũ gửi
"oracle"/"hybrid" vẫn 200 và nhận cú V2, không 4xx, không 500. Các test
routing oracle/hybrid của bản trước được THAY bằng bộ khoá hợp đồng mới này
— hợp đồng cũ mô tả code đã bị thay, không phải bị "sửa test cho qua".
"""

from __future__ import annotations

import numpy as np
import pytest
from fastapi.testclient import TestClient

from poolcoach_rl import recommend as rec_pkg
from poolcoach_rl.recommend.core import RecommendFullResult, ShotFull

from app import main as app_main

BALLS_FULL = {"cue": {"x": 0.45, "y": 0.30}, "1": {"x": 0.32, "y": 1.09},
              "2": {"x": 0.74, "y": 0.95}, "9": {"x": 0.50, "y": 1.29}}
BALLS_LAST = {"cue": {"x": 0.40, "y": 0.55}, "9": {"x": 0.62, "y": 1.35}}


@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)


def make_shot(rank=1, **kw):
    s = dict(rank=rank, pocket=2, phi=45.0, v0=2.0, side=0.0, vert=0.0,
             target="1", next="2", win=False, foul=False, scratch=False,
             potted=["1"], first_contact="1", q=0.6, ev=1.3,
             rank_by="roll", roll_len=0.42, d_land=0.97,
             balls_final={"cue": np.array([0.5, 1.0]),
                          "1": None,
                          "2": np.array([0.74, 0.95])})
    s.update(kw)
    shot = ShotFull(**s)
    shot.trajectories = {"cue": [[0.45, 0.30], [0.5, 1.0]]}
    return shot


def v2_result(shots, target="1", **kw):
    args = dict(engine="zone", target=target, shots=shots, n_results=99,
                n_legal_pot=len(shots), n_win=0, elapsed_s=1.46,
                fallback=None)
    args.update(kw)
    return RecommendFullResult(**args)


def patch_v2(monkeypatch, result):
    monkeypatch.setattr(rec_pkg, "recommend_v2", lambda *a, **k: result)


def spy_v2(monkeypatch, result):
    """Patch `recommend_v2` và GIỮ LẠI kwargs — bằng chứng route KHÔNG kéo
    engine/net/topk của request cũ xuống tầng dưới."""
    seen = {}

    def fake(balls, **kw):
        seen.update(kw)
        seen["balls"] = balls
        return result

    monkeypatch.setattr(rec_pkg, "recommend_v2", fake)
    return seen


# ------------------------------------------------------------------- (b)(e)

def test_b_thieu_ca_balls_lan_cue_b1_tra_422_detail_string(client):
    r = client.post("/api/recommend", json={})
    assert r.status_code == 422
    detail = r.json()["detail"]
    assert isinstance(detail, str)          # FE toast chỉ đọc được string
    assert detail.startswith("Cần balls")


def test_e_full_rack_tra_target_shots_search(client, monkeypatch):
    patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2, d_land=1.31)],
                                    n_results=99, n_legal_pot=12))

    r = client.post("/api/recommend",
                    json={"balls": BALLS_FULL, "alternatives": 3})
    assert r.status_code == 200
    data = r.json()

    assert data["target"] == "1"
    assert len(data["shots"]) == 2
    assert data["fallback"] is None
    assert data["message"] is None
    assert data["search"] == {"engine": "zone", "n_sim": 99, "n_pot": 12,
                              "elapsed_s": 1.46}

    shot = data["shots"][0]
    assert shot["target"] == "1" and shot["next"] == "2"
    assert shot["win"] is False and shot["foul"] is False
    assert shot["pocket"] == {"index": 2, "name": "góc trên-trái"}
    assert shot["outcome"]["potted"] == ["1"]
    assert shot["outcome"]["balls_final"]["1"] is None          # đã vào lỗ
    assert shot["outcome"]["balls_final"]["cue"] == {"x": 0.5, "y": 1.0}
    assert shot["describe"].startswith("ĐÁNH BI 1 VÀO LỖ")
    assert shot["trajectories"]["cue"][0] == [0.45, 0.30]


def test_e2_win_badge_len_response(client, monkeypatch):
    patch_v2(monkeypatch, v2_result(
        [make_shot(target="9", next=None, win=True, potted=["9"],
                   first_contact="9", d_land=None)], target="9"))
    data = client.post("/api/recommend", json={"balls": BALLS_FULL}).json()
    assert data["shots"][0]["win"] is True
    assert "⚑ Bi 9 vào lỗ" in data["shots"][0]["describe"]


# ------------------------------------------------- hết đường (B.2, G5.2)

def test_f_het_duong_message_trung_thuc_khong_fallback(client, monkeypatch):
    """Zone rỗng → shots=[] + message "hết đường" — KHÔNG fallback, KHÔNG
    thang nới, KHÔNG 500. Hết đường là thông tin cho người tập (Danh: "nằm
    trong dự tính"), wording phải nói tiêu chí + lộ trình, không đổ "bàn kẹt".
    """
    patch_v2(monkeypatch, v2_result([], n_legal_pot=0))

    r = client.post("/api/recommend", json={"balls": BALLS_FULL})
    assert r.status_code == 200
    data = r.json()
    assert data["shots"] == []
    assert data["fallback"] is None
    assert "không có đường điều bi" in data["message"]
    assert "5 tiêu chí" in data["message"]
    assert "cân bi" in data["message"]          # lộ trình kỹ năng bổ sung
    assert "bị chắn" not in data["message"]     # không mượn câu oracle cũ


def test_f2_het_duong_ke_ca_cu_cuoi_van(client, monkeypatch):
    """G6: bàn chỉ còn bi 9 mà zone rỗng → vẫn hết-đường đúng chuẩn."""
    patch_v2(monkeypatch, v2_result([], target="9", n_legal_pot=0))
    data = client.post("/api/recommend", json={"balls": BALLS_LAST}).json()
    assert data["shots"] == [] and data["fallback"] is None
    assert "bi 9" in data["message"]


# ---------------------------------------- request cũ: engine/topk bỏ qua

def test_c_engine_oracle_cu_van_200_va_chay_v2(client, monkeypatch):
    """G5.5 — chọn phương án "bị bỏ qua có chủ đích": request 30/07 còn gửi
    engine="oracle" phải 200, chạy V2, và KHÔNG một mảnh engine/net/topk nào
    lọt xuống `recommend_v2`."""
    seen = spy_v2(monkeypatch, v2_result([make_shot(1)]))

    r = client.post("/api/recommend",
                    json={"balls": BALLS_FULL, "engine": "oracle"})
    assert r.status_code == 200
    assert r.json()["search"]["engine"] == "zone"
    assert "engine" not in seen and "net" not in seen and "topk" not in seen
    assert set(seen["balls"]) == set(BALLS_FULL)


def test_c2_engine_hybrid_cu_van_200_khong_can_net(client, monkeypatch):
    """hybrid cũ cần net; V2 thì không — request hybrid phải chạy được trên
    server không còn khái niệm net (state.net đã gỡ 31/07)."""
    seen = spy_v2(monkeypatch, v2_result([make_shot(1)]))
    r = client.post("/api/recommend",
                    json={"balls": BALLS_FULL, "engine": "hybrid"})
    assert r.status_code == 200
    assert "net" not in seen


def test_c3_engine_rac_cung_bi_bo_qua_khong_422(client, monkeypatch):
    """Trước 31/07 pydantic Literal chặn "zzone" bằng 422. Giờ trường này
    là chuỗi tự do bị bỏ qua — giá trị rác cũng không đổi được đường chạy."""
    patch_v2(monkeypatch, v2_result([make_shot(1)]))
    r = client.post("/api/recommend",
                    json={"balls": BALLS_FULL, "engine": "zzone"})
    assert r.status_code == 200
    assert r.json()["search"]["engine"] == "zone"


def test_d_topk_cu_bi_bo_qua(client, monkeypatch):
    seen = spy_v2(monkeypatch, v2_result([make_shot(1)]))
    r = client.post("/api/recommend",
                    json={"balls": BALLS_FULL, "engine": "hybrid", "topk": 40})
    assert r.status_code == 200
    assert "topk" not in seen


def test_d2_khong_gui_engine_la_duong_mac_dinh(client, monkeypatch):
    """FE mới không gửi engine — thiếu trường phải là đường chuẩn, không phải
    ca đặc biệt."""
    seen = spy_v2(monkeypatch, v2_result([make_shot(1)]))
    r = client.post("/api/recommend", json={"balls": BALLS_FULL})
    assert r.status_code == 200
    assert seen["alternatives"] == 3            # mặc định schema giữ nguyên


# ------------------------------------------------- render lười (giữ từ 30/07)

def test_h_render_luoi_chi_dung_quy_dao_cho_cu_rank_1(client, monkeypatch):
    """`alternatives` GIỮ NGUYÊN (đủ 4 cú kèm tham số), chỉ `n_render` ghim 1.
    Cùng phép kiểm hai vế của bản 30/07 — đổi mỗi tầng nhận: `recommend_v2`."""
    seen = spy_v2(monkeypatch, v2_result([make_shot(1)]))
    client.post("/api/recommend", json={"balls": BALLS_FULL,
                                        "alternatives": 3})
    assert seen["alternatives"] == 3
    assert seen["n_render"] == 1


# ------------------------------------------------- trường V2 qua API (B.3)

def test_z_roll_len_d_land_rank_by_len_toi_response(client, monkeypatch):
    """FE hiện đường-lăn + d_đáp thay EV/Q — hai trường phải ĐI QUA API với
    đủ độ phân giải (4 chữ số, như dt cũ)."""
    patch_v2(monkeypatch, v2_result(
        [make_shot(1, roll_len=0.4237, d_land=0.9712),
         make_shot(2, roll_len=0.4237, d_land=1.3141)]))

    shots = client.post("/api/recommend",
                        json={"balls": BALLS_FULL}).json()["shots"]
    assert (shots[0]["roll_len"], shots[0]["d_land"]) == (0.4237, 0.9712)
    assert shots[1]["d_land"] == 1.3141
    assert all(s["rank_by"] == "roll" for s in shots)


def test_z2_cu_cuoi_van_d_land_null_khong_phai_0(client, monkeypatch):
    """Không có bi kế thì d_land là "không đo được" — null, đừng kẹp về 0 m."""
    patch_v2(monkeypatch, v2_result(
        [make_shot(target="9", next=None, win=True, potted=["9"],
                   first_contact="9", roll_len=0.31, d_land=None)],
        target="9"))
    s = client.post("/api/recommend",
                    json={"balls": BALLS_LAST}).json()["shots"][0]
    assert s["d_land"] is None and s["roll_len"] == 0.31
    assert s["rank_by"] == "roll"


def test_z3_dt_cua_V1_khong_con_trong_response(client, monkeypatch):
    """`dt` (thang pen zone V1) gỡ khỏi shape 31/07 cùng raster — response
    không được còn khoá này (client mới đừng viết code đọc nó)."""
    patch_v2(monkeypatch, v2_result([make_shot(1)]))
    s = client.post("/api/recommend",
                    json={"balls": BALLS_FULL}).json()["shots"][0]
    assert "dt" not in s


def test_z4_describe_v2_khong_in_Q(client, monkeypatch):
    """B.3: UI không hiện EV/Q/dt — describe của cú V2 nói đường lăn + d_đáp,
    không nói "Q = "."""
    patch_v2(monkeypatch, v2_result([make_shot(1)]))
    s = client.post("/api/recommend",
                    json={"balls": BALLS_FULL}).json()["shots"][0]
    assert "Q =" not in s["describe"]
    assert "lăn 0.42 m" in s["describe"]
    assert "cách bi 2 0.97 m" in s["describe"]


# ---------------------------------------------------------------------- (g)

def test_g_health_shape_sau_don_dep(client):
    """`workers`/`hybrid_available` gỡ 31/07 cùng pool + net; bàn giao 14
    (04/08) THÊM đúng ba trường queue (mode/redis_ok/worker_alive), bàn giao
    19 (05/08) THÊM cv_worker_alive — trường cũ FE đọc không đổi. Vẫn khoá
    bằng so TẬP để trường lạ lẻn vào là đỏ (tripwire này bắt đúng cả hai lần
    thêm trường, và lần nào cũng phải cập nhật CÓ Ý THỨC ở đây)."""
    data = client.get("/api/health").json()
    assert set(data) == {"status", "jit_ready", "error",
                         "mode", "redis_ok", "worker_alive",
                         "cv_worker_alive"}
    assert data["status"] == "ok" and data["jit_ready"] is True
    # Không REDIS_URL → inprocess; các trường queue là null "không áp dụng",
    # KHÔNG phải False "chết" (test_queue.py phủ các trạng thái queue).
    assert data["mode"] == "inprocess"
    assert data["redis_ok"] is None and data["worker_alive"] is None
    assert data["cv_worker_alive"] is None


def test_g2_request_v1_cu_bi_tu_choi_ro_rang(client, monkeypatch):
    """Shape v1 (cue/b1) gỡ cùng oracle/hybrid: client cũ nhận 422 message
    chỉ đường, KHÔNG phải 500 hay một response shape khác."""
    patch_v2(monkeypatch, v2_result([make_shot(1)]))
    r = client.post("/api/recommend", json={
        "cue": {"x": 0.30, "y": 0.50}, "b1": {"x": 0.50, "y": 1.00}})
    assert r.status_code == 422
    detail = r.json()["detail"]
    assert isinstance(detail, str)
    assert "v1-full" in detail          # nói rõ bản cũ sống ở đâu


# ------------------------------------------------------- validate → 422 str

def test_validate_error_thanh_422_string(client, monkeypatch):
    def boom(*a, **k):
        raise ValueError("cue và 1 chồng lên nhau (< 2R)")

    monkeypatch.setattr(rec_pkg, "recommend_v2", boom)
    r = client.post("/api/recommend", json={"balls": BALLS_FULL})
    assert r.status_code == 422
    assert r.json()["detail"] == "cue và 1 chồng lên nhau (< 2R)"