File size: 5,625 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
"""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