File size: 5,253 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
"""Gate 1 (ImplPlan Bước 1): bảng EV v2 của `judge_shot` — anti lucky-9.

Gọi thẳng `judge_shot` với env stub: tầng luật thuần logic, không đụng vật lý.
Điểm mấu chốt phải khoá lại: cú làm rơi bi 9 nhưng KHÔNG ăn được target là
combo may mắn → ev = 0.0 (case 4), và combo ăn kèm cũng không được thưởng
(case 3) — chỉ bi 9 đúng là target mới đáng WIN_EV (case 1).
"""

from __future__ import annotations

import numpy as np
import pytest

from poolcoach_rl.recommend.rules import WIN_EV, judge_shot
from poolcoach_rl.recommend.simulate import POS_COEF

Q_STUB = 0.6                      # EnvStub._position_q trả cố định
EV_POT = 1.0 + POS_COEF * Q_STUB  # 1.3


def make_m(potted, first_contact, balls, scratch=False):
    """Dựng facts y như `simulate_shot_multi` trả (chỉ phần luật cần đọc)."""
    return {
        "potted": list(potted),
        "scratch": scratch,
        "first_contact": first_contact,
        # vị trí thật không quan trọng — _position_q của stub trả hằng số;
        # chỉ cần đủ key để judge_shot index không KeyError
        "balls_final": {bid: (None if bid in potted else np.array([0.5, 1.0]))
                        for bid in balls},
    }


# ---------------------------------------------------------------- case 1-8

def test_case1_target_9_truc_tiep(env_stub):
    """target = "9" vào lỗ sạch → WIN_EV (cú duy nhất còn được thưởng)."""
    balls = {"cue": None, "9": None}
    r = judge_shot(env_stub, make_m(["9"], "9", balls), balls, "9")
    assert r["foul"] is False
    assert r["win"] is True
    assert r["ev"] == pytest.approx(WIN_EV)
    assert r["next"] is None


def test_case2_pot_thuong(env_stub):
    """pot target sạch, còn bi kế → 1 + POS_COEF × Q."""
    balls = {"cue": None, "1": None, "2": None}
    r = judge_shot(env_stub, make_m(["1"], "1", balls), balls, "1")
    assert r["foul"] is False
    assert r["win"] is False
    assert r["next"] == "2"
    assert r["q"] == pytest.approx(Q_STUB)
    assert r["ev"] == pytest.approx(EV_POT)


def test_case3_combo_an_kem_khong_duoc_thuong(env_stub):
    """pot target VÀ bi 9 rơi kèm → vẫn chỉ 1 + POS_COEF × Q, KHÔNG phải WIN_EV.

    win=True được giữ (FE hiện badge + dừng ván), nhưng EV không đổi.
    """
    balls = {"cue": None, "1": None, "2": None, "9": None}
    r = judge_shot(env_stub, make_m(["1", "9"], "1", balls), balls, "1")
    assert r["win"] is True
    assert r["ev"] == pytest.approx(EV_POT)
    assert r["ev"] != pytest.approx(WIN_EV)
    assert r["next"] == "2"


def test_case4_lucky_9_bi_cham_nhu_miss(env_stub):
    """BI 9 RƠI mà target KHÔNG vào → ev = 0.0 (đây là lỗi 13/15 bàn 24/07)."""
    balls = {"cue": None, "1": None, "9": None}
    r = judge_shot(env_stub, make_m(["9"], "1", balls), balls, "1")
    assert r["foul"] is False        # chạm đúng bi 1 trước, không scratch
    assert r["win"] is True          # fact: bi 9 đã vào lỗ
    assert r["ev"] == pytest.approx(0.0)


def test_case5_foul_sai_first_contact(env_stub):
    balls = {"cue": None, "1": None, "2": None}
    r = judge_shot(env_stub, make_m(["1"], "2", balls), balls, "1")
    assert r["foul"] is True
    assert r["ev"] is None


def test_case6_foul_khong_cham_gi(env_stub):
    balls = {"cue": None, "1": None}
    r = judge_shot(env_stub, make_m([], None, balls), balls, "1")
    assert r["foul"] is True
    assert r["ev"] is None


def test_case7_foul_scratch_du_pot(env_stub):
    balls = {"cue": None, "1": None, "2": None}
    m = make_m(["1"], "1", balls, scratch=True)
    r = judge_shot(env_stub, m, balls, "1")
    assert r["foul"] is True
    assert r["win"] is False         # scratch → không tính thắng
    assert r["ev"] is None


def test_case8_pot_bi_cuoi_khong_co_next(env_stub):
    """Bàn chỉ còn bi 1 (bi 9 bị tắt) → pot sạch = 1.0, không cần position."""
    balls = {"cue": None, "1": None}
    r = judge_shot(env_stub, make_m(["1"], "1", balls), balls, "1")
    assert r["next"] is None
    assert r["ev"] == pytest.approx(1.0)


# ------------------------------------------------------------------ case 9

def test_case9_filter_legal_loai_lucky_9(env_stub):
    """Biểu thức filter mới của `recommend_full` loại case 4 khỏi `legal`.

    Kiểm thẳng biểu thức (core.py) chứ KHÔNG gọi `recommend_full` thật — hàm
    đó đi xuyên pooltool (`pt.System` nằm NGOÀI try/except của
    simulate_shot_multi), ngoài phạm vi test contract.
    """
    target = "1"
    balls = {"cue": None, "1": None, "2": None, "9": None}

    lucky = make_m(["9"], "1", balls)                  # case 4: chỉ bi 9 rơi
    lucky.update(judge_shot(env_stub, lucky, balls, target))
    good = make_m(["1"], "1", balls)                   # case 2: pot target
    good.update(judge_shot(env_stub, good, balls, target))

    results = [lucky, good]
    legal = [r for r in results if not r["foul"] and target in r["potted"]]

    assert lucky not in legal        # điều cần chứng minh
    assert legal == [good]           # và filter không "rỗng hoá" mọi thứ