Spaces:
Sleeping
Sleeping
File size: 22,148 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 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 | """ZonePlanner V2 — gate G3 (lọc) + G4 (xếp hạng) phần LOGIC (31/07/2026).
Chạy dưới stub ``pooltool`` RỖNG của ``conftest.py``: không sim cú nào. Cái
được khoá ở đây là MÁY LỌC và KHOÁ XẾP HẠNG trên facts dựng tay; phần "sim
thật có cho ra đúng như thế không" (G3/G4 trên thế dựng tay THẬT) nằm ở
``scripts/check_zone_v2_gates.py``.
Ba chỗ đắt nhất:
* **(iii) đọc từ events, không phải từ vị trí bi.** ``touched_others`` V1 so
vị trí trước/sau nên MÙ với double-kiss (bi mục tiêu bị đụng thì nó đang
bay vào lỗ, vị trí "sau" là None). V2 phải bắt được đúng ca đó (G3.1).
* **(v) so bằng GIÁ TRỊ GRID** — ``v0_grid[1]`` = 0.8888... ≠ 0.889; một
lần làm tròn là lọc lực im lặng loại nhầm cả nấc (G3.3).
* **Bất biến ``sorted(pool, key)[0] is shot``** — cùng bất biến V1: caller
xếp top-4 phải cho đúng top-1 mà ``plan_shot_v2`` chọn bằng ``min``.
"""
from __future__ import annotations
import numpy as np
import pytest
from poolcoach_rl.recommend import core
from poolcoach_rl.recommend import zone_v2 as zv
from poolcoach_rl.recommend.simulate import path_len_after
from poolcoach_rl.recommend.zone_v2 import (CandidateV2, cue_hits_after_contact,
force_ok, rank_key_v2,
target_path_dirty, v2_specs)
BALLS_MID = {"cue": np.array([0.45, 0.30]), "1": np.array([0.32, 1.09]),
"2": np.array([0.74, 0.95]), "9": np.array([0.50, 1.29])}
BALLS_LAST = {"cue": np.array([0.40, 0.55]), "9": np.array([0.62, 1.35])}
# ------------------------------------------------------------ tập ứng viên
def test_v2_specs_99_ung_vien_khong_co_nac_1():
specs = v2_specs()
assert len(specs) == 99 # 11 kỹ thuật × 9 nấc
v0s = zv.default_v0_grid()
assert all(v0 > float(v0s[0]) for _s, _v, v0 in specs)
# giá trị lực là PHẦN TỬ GRID nguyên xi, không phải literal làm tròn
assert {v0 for _s, _v, v0 in specs} == {float(x) for x in v0s[1:]}
def test_v2_specs_spin_da_snap_ve_luoi():
"""0.2 literal phải thành 0.20000000000000007 của linspace — sim ở literal
là sim ở một ô lưới chưa từng quét (bài học docstring zone_planner)."""
grid = zv.default_spin_grid()
specs = v2_specs()
vals = {s for s, _v, _v0 in specs} | {v for _s, v, _v0 in specs}
assert vals <= {float(x) for x in grid}
# ---------------------------------------------------------------- G3.3 (v)
def test_g3_3_luc_so_bang_gia_tri_grid():
v0s = zv.default_v0_grid()
assert force_ok(float(v0s[1])) # 0.888... — nấc 2, QUA
assert not force_ok(float(v0s[0])) # 0.5 — nấc 1, LOẠI
# literal 0.889 KHÔNG phải phần tử grid → không được nhận
assert not force_ok(0.889)
# ----------------------------------------------------- (i)/(iii) từ events
BB_CLEAN = ((0.22, ("cue", "1")),) # một chạm duy nhất
BB_DOUBLE_KISS = ((0.22, ("cue", "1")), (0.35, ("cue", "1")))
BB_CUE_HITS_9 = ((0.22, ("cue", "1")), (0.90, ("cue", "9")))
BB_TARGET_HITS_2 = ((0.22, ("cue", "1")), (0.60, ("1", "2")))
def test_g3_1_iii_bat_double_kiss_va_cham_bi_sau():
assert not cue_hits_after_contact(BB_CLEAN)
assert cue_hits_after_contact(BB_DOUBLE_KISS) # kiss lại CHÍNH bi mục tiêu
assert cue_hits_after_contact(BB_CUE_HITS_9)
assert not cue_hits_after_contact(()) # không chạm gì
assert not cue_hits_after_contact(None) # facts cũ không có key
def test_i_duong_bi_muc_tieu_khong_duoc_cham_bi_khac():
assert not target_path_dirty(BB_CLEAN, "1")
assert target_path_dirty(BB_TARGET_HITS_2, "1") # combo/đẩy bi 2
# double-kiss dính CẢ (i) lẫn (iii) — event thứ hai có target
assert target_path_dirty(BB_DOUBLE_KISS, "1")
# bi khác chạm nhau mà không dính target thì (i) không quan tâm
assert not target_path_dirty(((0.2, ("cue", "1")), (0.5, ("2", "9"))), "1")
# ------------------------------------------------------------ plan_shot_v2
def fake_m(bb=BB_CLEAN, roll=0.5, cue_xy=(0.5, 1.0), potted=("1",),
scratch=False, first="1"):
bf = {"cue": None if scratch else np.array(cue_xy), "1": None,
"2": np.array([0.74, 0.95]), "9": np.array([0.50, 1.29])}
for b in potted:
bf[b] = None
return {"system": None, "potted": list(potted), "scratch": scratch,
"first_contact": first, "balls_final": bf,
"bb_events": bb, "cue_roll_after": roll}
def run_plan(monkeypatch, env_stub, sim_results, balls=None, target="1",
pockets=None, **plan_kw):
"""plan_shot_v2 với sim trả theo kịch bản: hàm nhận (side, vert, v0) →
facts dict | None. Mặc định MỘT lỗ để kịch bản tất định; ``plan_kw``
truyền thẳng xuống (04/08: cho ``debug_candidates=True``)."""
from poolcoach_rl.recommend import rules, simulate
def fake_sim(env_h, b, phi, v0, side, vert, render=False):
return sim_results(side, vert, v0)
def fake_judge(env_h, m, b, tgt):
potted = m["potted"]
foul = m["scratch"] or m["first_contact"] != tgt
remaining = [bid for bid in b if bid != "cue" and bid not in potted]
nxt = str(min(int(x) for x in remaining)) if remaining else None
return {"foul": foul, "win": "9" in potted and not foul, "next": nxt,
"q": 0.6, "ev": None if foul else 1.3}
monkeypatch.setattr(simulate, "simulate_shot_multi", fake_sim)
monkeypatch.setattr(rules, "judge_shot", fake_judge)
return zv.plan_shot_v2(env_stub, balls or BALLS_MID, target,
pockets=pockets or [(2, 30.0, 0.95, False)],
**plan_kw)
def test_g3_1_double_kiss_bi_loai_qua_ca_may_loc(monkeypatch, env_stub):
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(bb=BB_DOUBLE_KISS))
assert plan.shot is None and plan.pool == []
rep = plan.pockets[0]
assert rep.n_pass == 0
assert rep.fail_counts["iii"] == 99 # mọi ứng viên chết vì (iii)
def test_g3_2_dai_d_dap_015_150(monkeypatch, env_stub):
"""d ≈ 0.20 qua; ≈ 0.10 loại; ≈ 1.6 loại. Bi kế là bi 2 ở (0.74, 0.95)."""
def sim(side, vert, v0):
return fake_m(cue_xy=(0.74, 0.75)) # d = 0.20 tới bi 2
plan = run_plan(monkeypatch, env_stub, sim)
assert plan.shot is not None
assert plan.shot.d_land == pytest.approx(0.20)
for cue_xy, expect_fail in (((0.74, 0.85), "iv"), # d = 0.10 < 0.15
((0.74, 2.55), "iv")): # d = 1.60 > 1.50
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0, xy=cue_xy: fake_m(cue_xy=xy))
assert plan.shot is None
assert plan.pockets[0].fail_counts[expect_fail] == 99
def test_ii_nem_45_do_tren_diem_dap(monkeypatch, env_stub):
"""Điểm đáp cho góc cắt vào bi kế > 45° với MỌI lỗ → (ii) loại.
Hình học quyết định thế dựng: bi kế GIỮA BÀN thì 6 hướng lỗ phủ gần kín
vòng tròn — khe hở lớn nhất ~84° nên góc tốt nhất không vượt nổi ~42°,
tức (ii) gần như KHÔNG ràng buộc ở đó (đúng tiên đoán §7 design cho
θ_max=45°). Muốn có vùng chết phải đưa bi kế VỀ GÓC — hướng lỗ co cụm,
khe hở nở ra. Test dò điểm chết bằng chính ``best_cut_deg`` thay vì tin
trực giác một toạ độ, và ràng ``d`` trong [0.15, 1.5] để ứng viên chỉ có
thể chết vì (ii)."""
from poolcoach_rl.recommend.zone_v2 import best_cut_deg
balls = dict(BALLS_MID, **{"2": np.array([0.10, 0.10])}) # bi kế sát góc
# điểm đáp thẳng hướng bi2 → lỗ góc dưới-trái: cut 0°, d = 0.5 — PHẢI QUA
ok_plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(cue_xy=(0.454, 0.454)),
balls=balls)
assert ok_plan.shot is not None
assert ok_plan.shot.cut_next_deg <= 45.0
bad_xy = None
for cand in ((0.04, 0.44), (0.06, 0.52), (0.10, 0.60), (0.03, 0.38)):
cut = float(best_cut_deg(env_stub._pockets,
balls["2"], [np.array(cand)])[0])
d = float(np.linalg.norm(np.array(cand) - balls["2"]))
if cut > 45.0 and 0.15 <= d <= 1.50:
bad_xy = cand
break
assert bad_xy is not None, "không dựng nổi điểm đáp > 45° — sửa test"
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(cue_xy=bad_xy), balls=balls)
assert plan.shot is None
assert plan.pockets[0].fail_counts["ii"] == 99
def test_i_scratch_va_sai_bi_dau_va_combo_deu_chet_o_i(monkeypatch, env_stub):
for m in (fake_m(scratch=True),
fake_m(first="2"),
fake_m(potted=("1", "9"))): # bi 9 rơi kèm = không trực tiếp
plan = run_plan(monkeypatch, env_stub, lambda s, v, v0, mm=m: mm)
assert plan.shot is None
assert plan.pockets[0].fail_counts["i"] == 99
# --------------------------- G2 04/08: debug_candidates (mở rộng thuần)
def snap_plan(plan):
"""Ảnh chụp MỌI trường CŨ của PlanV2Result, so được bằng ``==`` — thiếu
trường nào ở đây là test khoá G2 mù trường đó, đừng rút gọn."""
def snap_c(c):
return (c.idx, c.side, c.vert, c.v0, c.pocket, c.phi, c.ok, c.fails,
c.roll_len, c.d_land, c.cut_next_deg)
return {
"shot": None if plan.shot is None else snap_c(plan.shot),
"pool": [snap_c(c) for c in plan.pool],
"pocket_rank": plan.pocket_rank,
"n_tie_top1": plan.n_tie_top1,
"picks": [(p.idx, p.phi, p.theta_deg, p.dist) for p in plan.picks],
"n_cand": plan.n_cand,
"pockets": [(r.rank, r.n_cand, r.n_sim_none, r.n_pass,
dict(r.fail_counts)) for r in plan.pockets],
}
# ≥2 thế cố định (G2): một thế CÓ cú, một thế hết đường, một thế cú cuối ván
G2_SCENARIOS = {
"co-cu": (lambda s, v, v0: fake_m(cue_xy=(0.74, 0.55)), BALLS_MID, "1"),
"het-duong-iii": (lambda s, v, v0: fake_m(bb=BB_DOUBLE_KISS),
BALLS_MID, "1"),
"cu-cuoi-van": (lambda s, v, v0: fake_m(bb=((0.22, ("cue", "9")),),
potted=("9",), first="9"),
BALLS_LAST, "9"),
}
@pytest.mark.parametrize("name", list(G2_SCENARIOS))
def test_g2_flag_tat_va_bat_truong_cu_y_het(monkeypatch, env_stub, name):
"""Flag tắt (mặc định) → không có debug + mọi trường cũ y hệt; flag bật →
top-1 và mọi trường cũ VẪN y hệt (debug chỉ THÊM thông tin)."""
sim, balls, target = G2_SCENARIOS[name]
off = run_plan(monkeypatch, env_stub, sim, balls=balls, target=target)
on = run_plan(monkeypatch, env_stub, sim, balls=balls, target=target,
debug_candidates=True)
assert off.debug_candidates == []
assert snap_plan(off) == snap_plan(on)
def test_g2_mask_du_5_phan_tu_moi_ung_vien_pass_all_trung_ok(monkeypatch,
env_stub):
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(cue_xy=(0.74, 0.55)),
debug_candidates=True)
assert len(plan.debug_candidates) == 99 # đủ MỌI ô đã enumerate
for e in plan.debug_candidates:
assert set(e.mask) == set(zv.MASK_KEYS) # đủ 5 phần tử/ứng viên
# pass_all tính từ mask phải trùng máy lọc thật (ok) — cùng một code path
assert (sum(e.pass_all for e in plan.debug_candidates)
== plan.pockets[0].n_pass)
assert [e.idx for e in plan.debug_candidates] == list(range(99))
def test_mask_khong_short_circuit_ii_iv_khi_i_chet(monkeypatch, env_stub):
"""Ứng viên chết (i) (sai bi đầu) VÀ đáp d = 0.10: engine cũ chỉ đếm "i"
(short-circuit — giữ nguyên), mask phải thấy CẢ "iv" trượt."""
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(first="2", cue_xy=(0.74, 0.85)),
debug_candidates=True)
assert plan.shot is None
rep = plan.pockets[0]
assert rep.fail_counts["i"] == 99
assert rep.fail_counts["iv"] == 0 # đường cũ vẫn short-circuit
for e in plan.debug_candidates:
assert e.mask["i"] is False
assert e.mask["iv"] is False # mask thì chấm đủ
assert e.mask["iii"] is True
assert not e.pass_all
def test_mask_cu_cuoi_van_ii_iv_tu_qua(monkeypatch, env_stub):
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(bb=((0.22, ("cue", "9")),),
potted=("9",), first="9"),
balls=BALLS_LAST, target="9", debug_candidates=True)
assert plan.shot is not None
for e in plan.debug_candidates:
assert e.mask["ii"] is True and e.mask["iv"] is True
assert e.pass_all
def test_mask_scratch_khong_diem_dap_ii_iv_none(monkeypatch, env_stub):
"""Cue vào lỗ → không có điểm đáp: (ii)/(iv) là "không đo được" (None),
không phải False — script đo phải phân biệt được hai chuyện đó."""
plan = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(scratch=True),
debug_candidates=True)
assert plan.shot is None
for e in plan.debug_candidates:
assert e.mask["i"] is False # scratch = foul
assert e.mask["ii"] is None and e.mask["iv"] is None
assert not e.pass_all
def test_mask_sim_none_van_co_entry(monkeypatch, env_stub):
v0s = zv.default_v0_grid()
dead = float(v0s[5])
def sim(s, v, v0):
return None if v0 == dead else fake_m(cue_xy=(0.74, 0.55))
plan = run_plan(monkeypatch, env_stub, sim, debug_candidates=True)
assert len(plan.debug_candidates) == 99 # sim chết vẫn có entry
nones = [e for e in plan.debug_candidates if e.sim_none]
assert len(nones) == 11 # 11 kỹ thuật × 1 nấc chết
for e in nones:
assert e.mask["i"] is None and e.mask["iv"] is None
assert e.mask["v"] is True # (v) thuần tham số, vẫn chấm
assert not e.pass_all
assert plan.pockets[0].n_sim_none == 11
assert (sum(e.pass_all for e in plan.debug_candidates)
== plan.pockets[0].n_pass == 88)
def test_debug_khong_sim_them_o_nao_lo_2_van_theo_nep_cu(monkeypatch,
env_stub):
"""Flag bật không được đổi tập ô đã sim: lỗ 2 CHỈ thử khi lỗ 1 trắng tay
(nếp cũ), nên thế có cú → 99 entry, thế hết đường 2 lỗ → 198 entry."""
two_pockets = [(2, 30.0, 0.95, False), (4, 210.0, 0.90, False)]
ok = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(cue_xy=(0.74, 0.55)),
pockets=two_pockets, debug_candidates=True)
assert ok.shot is not None
assert len(ok.debug_candidates) == 99
assert {e.pocket_rank for e in ok.debug_candidates} == {1}
dry = run_plan(monkeypatch, env_stub,
lambda s, v, v0: fake_m(bb=BB_DOUBLE_KISS),
pockets=two_pockets, debug_candidates=True)
assert dry.shot is None
assert len(dry.debug_candidates) == 198
assert {e.pocket_rank for e in dry.debug_candidates} == {1, 2}
# ------------------------------------------------------------ G4: xếp hạng
def cand(roll, d_land=None, idx=0):
return CandidateV2(side=0.0, vert=0.0, v0=2.0, pocket=2, phi=45.0,
idx=idx, facts={}, ok=True, roll_len=roll,
d_land=d_land)
def test_g4_1_duong_lan_ngan_hon_thang():
key = rank_key_v2()
a, b = cand(0.40, 1.0, idx=0), cand(0.90, 1.0, idx=1)
assert min([b, a], key=key) is a
def test_g4_2_hoa_duong_lan_thi_gan_1m_thang():
key = rank_key_v2()
a, b = cand(0.50, 1.72, idx=0), cand(0.50, 0.95, idx=1)
assert min([a, b], key=key) is b # |0.95−1| = 0.05 < 0.72
def test_g4_3_van_hoa_thi_thu_tu_enumerate(monkeypatch, env_stub):
key = rank_key_v2()
a, b = cand(0.50, 0.95, idx=3), cand(0.50, 0.95, idx=7)
assert min([b, a], key=key) is a # idx nhỏ hơn
def test_cu_cuoi_van_khong_co_d_dap_xep_theo_duong_lan():
key = rank_key_v2()
a, b = cand(0.80, None, idx=0), cand(0.30, None, idx=1)
assert min([a, b], key=key) is b
def test_path_len_after_duong_gay_khuc_dai_hon_euclid():
"""Vế logic của G4.3: polyline có đỉnh dội băng phải DÀI HƠN đoạn thẳng
đầu→cuối. Vế sim thật nằm ở scripts/check_zone_v2_gates.py."""
xy = np.array([[0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
[1.0, 0.5], [0.5, 0.5]]) # gấp khúc 2 lần
ts = np.array([0.0, 0.2, 0.5, 0.8, 1.0])
ln = path_len_after(xy, ts, 0.2) # từ điểm chạm (0.5, 0)
euclid = float(np.linalg.norm(xy[-1] - xy[1]))
assert ln == pytest.approx(1.5)
assert ln > euclid
def test_path_len_after_khong_rot_snapshot_va_cham_vi_float():
xy = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]])
ts = np.array([0.0, 0.3000000000000001, 0.6])
assert path_len_after(xy, ts, 0.30000000000000016) == pytest.approx(1.0)
# ----------------------------------------------- bất biến pool → top-4
def test_sorted_pool_dau_bang_min_ke_ca_khi_hoa(monkeypatch, env_stub):
rolls = {2.0: 0.50, 2.5: 0.30, 3.0: 0.30} # hoà 0.30 giữa hai nấc lực
def sim(side, vert, v0):
r = rolls.get(round(v0, 1), 0.9)
return fake_m(roll=r, cue_xy=(0.74, 0.55)) # d = 0.4 — qua (iv)
plan = run_plan(monkeypatch, env_stub, sim)
key = rank_key_v2()
assert sorted(plan.pool, key=key)[0] is plan.shot
assert plan.shot is min(plan.pool, key=key)
# ------------------------------------------------------------ recommend_v2
def make_v2_plan(pool, shot=None):
rep = zv.PocketV2Report(pick=None, rank=1, n_cand=99, n_sim_none=0,
n_pass=len(pool), fail_counts={})
return zv.PlanV2Result(shot=shot or (min(pool, key=rank_key_v2())
if pool else None),
pool=list(pool), pocket_rank=1 if pool else None,
n_cand=99, pockets=[rep])
def v2c(roll, d_land, idx, v0=2.0, target="1", nxt="2", win=False):
facts = {"pocket": 2, "phi": 45.0, "v0": v0, "side": 0.0, "vert": 0.0,
"potted": [target], "scratch": False, "first_contact": target,
"foul": False, "win": win, "next": nxt, "q": 0.6, "ev": 1.3,
"balls_final": {"cue": np.array([0.5, 1.0]), target: None,
"9": np.array([0.50, 1.29])}}
return CandidateV2(side=0.0, vert=0.0, v0=v0, pocket=2, phi=45.0, idx=idx,
facts=facts, ok=True, roll_len=roll, d_land=d_land)
def test_recommend_v2_top4_theo_khoa_v2_va_khai_zone(monkeypatch, env_stub):
pool = [v2c(0.50, 0.9, 0), v2c(0.30, 1.7, 1), v2c(0.30, 1.1, 2),
v2c(0.90, 1.0, 3), v2c(0.70, 1.0, 4)]
monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan(pool))
res = core.recommend_v2(BALLS_MID, env_h=env_stub, n_render=0)
assert res.engine == "zone" and res.target == "1"
assert len(res.shots) == 4
# thứ tự: roll 0.30 (|d−1| 0.1) → 0.30 (0.7) → 0.50 → 0.70
assert [s.roll_len for s in res.shots] == [0.30, 0.30, 0.50, 0.70]
assert [s.d_land for s in res.shots] == [1.1, 1.7, 0.9, 1.0]
assert all(s.rank_by == "roll" for s in res.shots)
# `dt` của V1 đã gỡ khỏi ShotFull (việc D) — không được mọc lại
assert all(not hasattr(s, "dt") for s in res.shots)
assert [s.rank for s in res.shots] == [1, 2, 3, 4]
def test_recommend_v2_het_duong_shots_rong_khong_fallback(monkeypatch,
env_stub):
monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan([]))
res = core.recommend_v2(BALLS_MID, env_h=env_stub, n_render=0)
assert res.shots == [] and res.fallback is None
assert res.engine == "zone"
def test_recommend_v2_cu_cuoi_van_d_land_none(monkeypatch, env_stub):
pool = [v2c(0.4, None, 0, target="9", nxt=None, win=True)]
monkeypatch.setattr(zv, "plan_shot_v2", lambda *a, **k: make_v2_plan(pool))
res = core.recommend_v2(BALLS_LAST, env_h=env_stub, n_render=0)
s = res.shots[0]
assert s.d_land is None and s.roll_len == 0.4
assert s.rank_by == "roll" and s.win is True
def test_recommend_v2_khong_con_duong_cu_de_lac_sang(monkeypatch, env_stub):
"""Sau việc D core không còn pick_engine/load_qfield_net — khoá luôn điều
đó: đường cũ mà mọc lại trong core là test này đỏ, và recommend_v2 vẫn
phải tự chạy trọn không cần chúng."""
assert not hasattr(core, "pick_engine")
assert not hasattr(core, "load_qfield_net")
assert not hasattr(core, "recommend_full")
monkeypatch.setattr(zv, "plan_shot_v2",
lambda *a, **k: make_v2_plan([v2c(0.5, 1.0, 0)]))
res = core.recommend_v2(BALLS_MID, env_h=env_stub, n_render=0)
assert res.engine == "zone"
def test_recommend_v2_validate_van_chay(monkeypatch, env_stub):
with pytest.raises(ValueError, match="chồng lên nhau"):
core.recommend_v2({"cue": np.array([0.5, 0.5]),
"1": np.array([0.5, 0.5])}, env_h=env_stub)
|