"""Contract của `POST /api/trajectory` — render lười (30/07/2026). Cùng nguyên tắc mọi test app khác: kiểm SHAPE / STATUS / MESSAGE, KHÔNG kiểm vật lý. `simulate_shot_multi` bị monkeypatch (conftest cố ý nhét stub pooltool rỗng — chạm tầng sim thật sẽ ném AttributeError ngay). Phép kiểm "quỹ đạo endpoint này trả ĐÚNG BẰNG quỹ đạo `n_render = 1 + alternatives` trả cho cùng cú" (gate G6.2) KHÔNG nằm ở đây được: nó cần sim thật. Nó chạy bằng `scripts/check_lazy_render.py`, cùng lý do `check_api_ drift.py` tồn tại ngoài `tests/`. """ from __future__ import annotations import numpy as np import pytest from fastapi.testclient import TestClient from poolcoach_rl import recommend as rec_pkg from app import main as app_main BALLS = {"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}} REQ = {"balls": BALLS, "phi": 45.0, "v0": 2.0, "side": 0.0, "vert": 0.0} @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 patch_sim(monkeypatch, result, seen=None): """Thay `simulate_shot_multi` — `_trajectory_sync` import LAZY từ `poolcoach_rl.recommend`, nên patch ở package là đúng chỗ (cùng cách `test_api_v2.patch_full` làm với `recommend_full`).""" def fake(env_h, balls, phi, v0, side, vert, render=False): if seen is not None: seen.update(phi=phi, v0=v0, side=side, vert=vert, render=render, balls=balls) return result monkeypatch.setattr(rec_pkg, "simulate_shot_multi", fake) monkeypatch.setattr(rec_pkg, "extract_trajectories", lambda system, min_disp=None: { "cue": [[0.45, 0.30], [0.5, 1.0]], "1": [[0.32, 1.09], [0.33, 1.10]]}) def sim_ok(): return {"system": object(), "potted": ["1"], "scratch": False, "first_contact": "1", "balls_final": {"cue": np.array([0.5, 1.0]), "1": None, "2": np.array([0.74, 0.95]), "9": np.array([0.50, 1.29])}} # ------------------------------------------------------------------- shape def test_tra_dung_hai_khoa_trajectories_va_balls_final(client, monkeypatch): seen = {} patch_sim(monkeypatch, sim_ok(), seen) r = client.post("/api/trajectory", json=REQ) assert r.status_code == 200 data = r.json() assert set(data) == {"trajectories", "balls_final"} assert data["trajectories"]["cue"][0] == [0.45, 0.30] assert data["balls_final"]["1"] is None # bi đã vào lỗ assert data["balls_final"]["cue"] == {"x": 0.5, "y": 1.0} def test_render_true_va_tham_so_cu_di_nguyen_xuong_sim(client, monkeypatch): """Endpoint là MỘT lần sim, không phải một lần search: 4 tham số cú phải tới `simulate_shot_multi` nguyên xi, và `render=True` (không có nó thì `system is None` và quỹ đạo rỗng).""" seen = {} patch_sim(monkeypatch, sim_ok(), seen) client.post("/api/trajectory", json={"balls": BALLS, "phi": 132.5, "v0": 3.25, "side": -0.4, "vert": 0.2}) assert seen["render"] is True assert (seen["phi"], seen["v0"], seen["side"], seen["vert"]) == ( 132.5, 3.25, -0.4, 0.2) assert set(seen["balls"]) == set(BALLS) def test_spin_mac_dinh_0_khi_request_khong_gui(client, monkeypatch): seen = {} patch_sim(monkeypatch, sim_ok(), seen) r = client.post("/api/trajectory", json={"balls": BALLS, "phi": 45.0, "v0": 2.0}) assert r.status_code == 200 assert (seen["side"], seen["vert"]) == (0.0, 0.0) # ------------------------------------------------------------------- lỗi def test_the_ban_hong_tra_422_string_y_nhu_api_recommend(client, monkeypatch): """Cùng `validate_full` với `/api/recommend` → cùng message, không phải một bộ message thứ hai. `detail` phải là STRING (FE toast chỉ đọc string). """ patch_sim(monkeypatch, sim_ok()) bad = dict(BALLS, **{"2": {"x": 0.32, "y": 1.09}}) # trùng chỗ bi 1 r = client.post("/api/trajectory", json={**REQ, "balls": bad}) assert r.status_code == 422 detail = r.json()["detail"] assert isinstance(detail, str) assert "chồng lên nhau" in detail def test_sim_tra_none_thi_422_chu_khong_phai_200_quy_dao_rong(client, monkeypatch): """pooltool ném → `simulate_shot_multi` trả None. Trả 200 kèm quỹ đạo rỗng thì FE vẽ bàn trống và không ai biết vì sao — phải nói thẳng là sim hỏng.""" patch_sim(monkeypatch, None) r = client.post("/api/trajectory", json=REQ) assert r.status_code == 422 assert "Không mô phỏng được" in r.json()["detail"] def test_chua_jit_xong_tra_503(client, monkeypatch): monkeypatch.setattr(app_main.state, "jit_ready", False) r = client.post("/api/trajectory", json=REQ) assert r.status_code == 503 def test_boot_error_tra_500(client, monkeypatch): monkeypatch.setattr(app_main.state, "boot_error", "RuntimeError: hong") r = client.post("/api/trajectory", json=REQ) assert r.status_code == 500