Spaces:
Sleeping
Sleeping
| """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"), | |
| } | |
| 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) | |