Spaces:
Sleeping
Sleeping
| """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}} | |
| 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)" | |