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)