poolcoach / tests /test_api_v2.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
13.6 kB
"""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)"