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