File size: 24,867 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
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
"""Unit test tầng giải tích broadcast (bàn giao 24, Bước 1.3) — thuần numpy,
KHÔNG cv2/torch: chỉ đụng ``analyze_track`` + các hàm thuần của
``poolcoach_cv.broadcast`` (phần ``analyze_clip`` cần YOLO/cv2 nghiệm thu
tay ở gate G-24.3 end-to-end, cùng lý do G3 của scan).

Hai nguồn dữ liệu:
- Track TỔNG HỢP tự dựng: biết trước V0/φ/va chạm nên assert được số đúng,
  và tiêm frame trùng kiểu 25fps-upconvert để khoá luật dedup (HANDOFF 23
  Bất ngờ 1 — dip giả về 0 mỗi 6 bước nếu quên).
- Track THẬT rút gọn từ shot_01 P0 (fixtures/bb9_shot01_track_cut.csv —
  61 dòng CSV, KHÔNG commit frame): còn đủ still → đánh (~2.6 m/s, hướng
  ~200°) → chạm bi lúc ~2.0s → lăn. Số kỳ vọng đọc từ chính track đã được
  mắt kiểm ở bàn giao 23 (overlay shot_01, Danh xác nhận).
"""

from __future__ import annotations

import csv
import math
from pathlib import Path

import pytest

from poolcoach_cv import broadcast as bc

FIXTURE = Path(__file__).parent / "fixtures" / "bb9_shot01_track_cut.csv"

DT = 1 / 30.0


def make_rows(legs, *, still_s=0.5, dup_every=None, start=(0.6, 0.5)):
    """Dựng track tổng hợp: đứng yên ``still_s`` rồi đi từng ``leg``
    (heading_deg, speed_mps, dur_s). ``dup_every``: chèn frame TRÙNG kiểu
    25fps-upconvert — lặp vị trí + img_diff ~0, PTS vẫn tiến đều (đúng bẫy
    thật: PTS frame trùng hợp lệ, chỉ img_diff lộ)."""
    rows = []
    t = 0.0
    x, y = start
    moving = False

    def push(px, py, diff):
        nonlocal t
        rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t, 6),
                     "covered": 1, "table_x_m": px, "table_y_m": py,
                     "img_diff": diff})
        t += DT

    for _ in range(int(round(still_s / DT))):
        push(x, y, -1.0 if not rows else 0.2)
    hd = 0.0
    sp = 0.0
    for hd, sp, dur in legs:
        moving = True
        for _ in range(int(round(dur / DT))):
            x += sp * DT * math.cos(math.radians(hd))
            y += sp * DT * math.sin(math.radians(hd))
            push(x, y, 0.8)
    # lăn chậm dần 2 m/s² (vật lý thật — measure_p0 đo 0.1–1.5) rồi mới
    # dừng: dừng ĐỘT NGỘT là một cú full-hit và tầng speed-drop SẼ bắt nó
    while moving and sp > 0.05:
        sp = max(sp - 2.0 * DT, 0.0)
        x += sp * DT * math.cos(math.radians(hd))
        y += sp * DT * math.sin(math.radians(hd))
        push(x, y, 0.6)
    for _ in range(6):
        push(x, y, 0.2 if moving else 0.15)

    if dup_every:
        out, k = [], 0
        for r in rows:
            out.append(dict(r))
            k += 1
            if k % dup_every == 0:
                out.append({**r, "img_diff": 0.01})
        for i, r in enumerate(out):     # PTS đều lại như stream CFR thật
            r["frame_file"] = f"{i:05d}"
            r["t_s"] = round(i * DT, 6)
        rows = out
    return rows


# ------------------------------------------------------------- tổng hợp

def test_kink_v0_phi_va_dedup_frame_trung():
    """Cú thẳng 2 m/s hướng +y, gãy 60° sang 150°: V0 ≈ 2, φ ≈ 90, đúng
    MỘT va chạm tại chỗ gãy — và kết quả Y HỆT khi track bị chèn frame
    trùng chu kỳ 6 (dedup phải nuốt trọn, không lệch số)."""
    legs = [(90.0, 2.0, 0.4), (150.0, 1.6, 0.4)]
    clean = bc.analyze_track(make_rows(legs))
    duped = bc.analyze_track(make_rows(legs, dup_every=6))

    for out in (clean, duped):
        m = out["metrics"]
        assert m["v0_mps"] == pytest.approx(2.0, abs=0.15)
        assert m["phi_deg"] == pytest.approx(90.0, abs=5.0)
        assert m["n_collisions"] == 1
        c = out["collisions"][0]
        assert "kink" in c["kind"]
        # gãy tại (0.6, 0.5 + 2.0×0.4) — cho phép lệch 1 bước frame
        assert c["x_m"] == pytest.approx(0.6, abs=0.08)
        assert c["y_m"] == pytest.approx(1.3, abs=0.08)
        assert c["t_s"] == pytest.approx(0.5 + 0.4, abs=0.1)

    assert clean["metrics"]["n_dup_frames"] == 0
    assert duped["metrics"]["n_dup_frames"] > 0
    # số giải tích không được lệch giữa hai biến thể
    assert duped["metrics"]["v0_mps"] == pytest.approx(
        clean["metrics"]["v0_mps"], abs=0.05)
    assert duped["metrics"]["phi_deg"] == pytest.approx(
        clean["metrics"]["phi_deg"], abs=2.0)


def test_speed_drop_khong_doi_huong_van_ra_va_cham():
    """Full-hit: đi thẳng +x 2.5 m/s rồi tụt còn 0.9 m/s (drop 64%) mà KHÔNG
    đổi hướng — tầng 2 (speed-drop) phải bắt được va chạm này."""
    out = bc.analyze_track(make_rows([(0.0, 2.5, 0.3), (0.0, 0.9, 0.3)]))
    assert out["metrics"]["n_collisions"] == 1
    c = out["collisions"][0]
    assert "speed_drop" in c["kind"]
    assert c["x_m"] == pytest.approx(0.6 + 2.5 * 0.3, abs=0.1)
    assert c["t_s"] == pytest.approx(0.8, abs=0.1)


def test_kink_gia_tai_motion_onset_bi_guard_2r_loai():
    """Tái hiện kink giả của cú 11 (HANDOFF 24 Bất ngờ 8, Cowork chốt guard
    phương án (a) BRIEF 25): bước chuyển động ĐẦU TIÊN bi mới nhích ~14mm
    (cỡ sàn nhiễu dọc hướng chạy ±13mm) theo hướng rác, rồi mới chạy thật
    ~1.05 m/s. Không guard thì Δθ ~180° bắn va chạm giả NGAY motion_start,
    cắt cụt cửa sổ V0 → V0 rác 0.5x, φ null. Có guard 2R: không va chạm,
    V0 ≈ 1.05, φ ≈ 90°."""
    rows = []
    t = [0.0]

    def push(x, y):
        rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
                     "covered": 1, "table_x_m": x, "table_y_m": y,
                     "img_diff": 0.5 if len(rows) else -1.0})
        t[0] += DT

    x, y = 0.6, 0.5
    for _ in range(15):                    # đứng yên 0.5s
        push(x, y)
    y -= 0.014                             # bước nhiễu: 14mm heading 270°
    push(x, y)
    sp = 1.05
    for _ in range(15):                    # chạy thật hướng +y
        y += sp * DT
        push(x, y)
    while sp > 0.05:                       # lăn chậm dần như make_rows
        sp = max(sp - 2.0 * DT, 0.0)
        y += sp * DT
        push(x, y)
    for _ in range(6):
        push(x, y)

    out = bc.analyze_track(rows)
    m = out["metrics"]
    assert m["n_collisions"] == 0, out["collisions"]
    assert m["v0_mps"] == pytest.approx(1.05, abs=0.1)
    assert m["phi_deg"] == pytest.approx(90.0, abs=8.0)
    assert not any("V0" in w for w in out["warnings"])


def test_speed_drop_duoi_san_1ms_khong_con_va_cham_gia():
    """Sàn speed-drop 0.5 → 1.0 m/s (Cowork chốt BRIEF 25): cú lăn chậm
    0.9 m/s tụt 55% giữa đường — kiểu 2 va chạm giả t=4.0/4.8 của cú 11 —
    không được ra va chạm nữa. Hệ quả khai trong bảng hằng: va chạm nhẹ
    dưới 1 m/s bỏ sót có chủ đích."""
    out = bc.analyze_track(make_rows([(0.0, 0.9, 0.4), (0.0, 0.4, 0.4)]))
    assert out["metrics"]["n_collisions"] == 0, out["collisions"]


def test_khong_co_cu_danh_khong_va_cham_va_canh_bao():
    rows = make_rows([], still_s=1.0)
    out = bc.analyze_track(rows)
    m = out["metrics"]
    assert m["v0_mps"] is None and m["phi_deg"] is None
    assert m["motion_start_s"] is None
    assert m["n_collisions"] == 0
    assert any("bắt đầu chạy" in w for w in out["warnings"])


def test_contact_cushion_khi_va_cham_sat_mep_bang():
    """Nhánh 1/3 (BRIEF 25 bước 2): bi chạy tới sát băng dài trên rồi bật
    ngược — điểm gãy cách mép ≤ 1.5R → contact "cushion"."""
    out = bc.analyze_track(
        make_rows([(90.0, 2.0, 0.45), (270.0, 1.5, 0.4)], start=(0.5, 1.6)))
    assert out["metrics"]["n_collisions"] == 1
    c = out["collisions"][0]
    assert c["y_m"] > bc.TABLE_L_M - 2 * bc.CONTACT_CUSHION_M
    assert c["contact"] == "cushion"


def test_contact_ball_khi_gan_bi_tinh_truoc_cu():
    """Nhánh 2/3: speed-drop đúng chỗ có bi tĩnh (median detection không-cue
    cửa sổ TRƯỚC motion_start) → contact "ball". Detection sau motion_start
    cố tình đặt lệch chỗ khác — không được tính vào bi tĩnh."""
    rows = make_rows([(90.0, 2.5, 0.4), (90.0, 1.2, 0.4)])
    others = [{"t_s": i * DT, "x_m": 0.601, "y_m": 1.499} for i in range(12)]
    others += [{"t_s": 2.0, "x_m": 0.2, "y_m": 0.2}] * 5   # sau motion — bỏ
    out = bc.analyze_track(rows, others=others)
    assert out["metrics"]["n_collisions"] == 1
    c = out["collisions"][0]
    assert "speed_drop" in c["kind"]
    assert c["contact"] == "ball"


def test_contact_cushion_neo_kink_lech_van_ra_cushion():
    """Cơ chế phương án (a) BRIEF 26 bước 1.1 (từ HANDOFF 25 Bất ngờ 1):
    neo kink tại điểm gãy heading nằm 1.56R khỏi mép (> ngưỡng 1.5R) nhưng
    điểm track NGAY SAU neo chạm tới 1.0R → MIN trên ±2 điểm quanh neo phải
    ra "cushion"; đo tại neo như cũ thì "unknown".

    LƯU Ý (đo được ở rerun BG26 — HANDOFF 26): track THẬT của cú 11 KHÔNG
    có điểm sát mép hơn neo (chạm rơi giữa hai frame) nên ca thật vẫn
    unknown sau fix — test này khoá CƠ CHẾ cửa sổ, không phải khoá kết quả
    cú 11 thật; ngưỡng cho ca thật chờ Cowork chốt."""
    R = bc.BALL_R_M
    rows = []
    t = [0.0]

    def push(x, y):
        rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
                     "covered": 1, "table_x_m": x, "table_y_m": y,
                     "img_diff": 0.8 if len(rows) else -1.0})
        t[0] += DT

    x = 0.5
    for _ in range(15):                     # đứng yên 0.5s
        push(x, 1.56 * R + 8 * 0.05)
    for k in range(8, 0, -1):               # lao xuống băng ngắn dưới ~1.5 m/s
        push(x, 1.56 * R + k * 0.05)
    push(x, 1.56 * R)                       # NEO: gãy heading tại đây, 1.56R
    push(x + 0.05, 1.00 * R)                # bước kế mới là điểm sát mép 1.0R
    push(x + 0.10, 1.08 * R)                # bật ra xiên dọc băng
    sp = 1.4
    hd = 35.0
    while sp > 0.05:                        # lăn chậm dần rồi dừng
        x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
        push(x2 + sp * DT * math.cos(math.radians(hd)),
             y2 + sp * DT * math.sin(math.radians(hd)))
        sp = max(sp - 2.0 * DT, 0.0)
    for _ in range(6):
        push(rows[-1]["table_x_m"], rows[-1]["table_y_m"])

    out = bc.analyze_track(rows)
    assert out["metrics"]["n_collisions"] >= 1
    c = out["collisions"][0]
    assert "kink" in c["kind"]
    # neo NẰM NGOÀI ngưỡng cushion — chứng tỏ MIN cửa sổ mới là thứ cứu ca này
    assert min(c["y_m"], bc.TABLE_L_M - c["y_m"]) > bc.CONTACT_CUSHION_M
    assert c["contact"] == "cushion"
    # hệ quả đúng của fix (kỳ vọng BRIEF 26 bước 1.4): side đọc được ở băng
    assert "side" in (out["spin_class"] or "")


def _bounce_rows(*, v_in, v_out, d_vertex_m, hd_in=270.0, hd_out=120.0,
                 x0=0.5):
    """Track bật băng ngắn dưới với đỉnh (vertex) CHÍNH LÀ điểm gần mép nhất
    — mô phỏng ca "chạm rơi giữa hai frame" của cú 11/cú 12-t2.6 (HANDOFF 26
    Bất ngờ 1): không có điểm track nào sát mép hơn neo, chỉ ngưỡng động
    (b′) mới với tới. Vào thẳng đứng xuống ``hd_in`` tốc độ ``v_in`` tới
    đỉnh cách mép ``d_vertex_m``, bật ra ``hd_out`` tốc độ ``v_out``."""
    rows = []
    t = [0.0]

    def push(x, y):
        rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
                     "covered": 1, "table_x_m": x, "table_y_m": y,
                     "img_diff": 0.8 if len(rows) else -1.0})
        t[0] += DT

    n_in = 14
    for _ in range(15):                     # đứng yên 0.5s
        push(x0, d_vertex_m + n_in * v_in * DT)
    for k in range(n_in - 1, 0, -1):        # lao xuống mép
        push(x0, d_vertex_m + k * v_in * DT)
    push(x0, d_vertex_m)                    # ĐỈNH = neo kink = điểm gần nhất
    sp = v_out
    while sp > 0.05:                        # bật ra rồi lăn chậm dần
        x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
        push(x2 + sp * DT * math.cos(math.radians(hd_out)),
             y2 + sp * DT * math.sin(math.radians(hd_out)))
        sp = max(sp - 2.0 * DT, 0.0)
    for _ in range(6):
        push(rows[-1]["table_x_m"], rows[-1]["table_y_m"])
    return rows


def test_contact_cushion_nguong_dong_cu11_cham_giua_frame():
    """(b′) BG26b ca cú 11: đỉnh track 1.56R khỏi mép, KHÔNG điểm nào sát
    hơn, chord vào ~0.9 m/s → ngưỡng động 1.5R + 0.9·dt/2 ≈ 2.0R phủ được →
    cushion (ngưỡng tĩnh 1.5R thì unknown — chính là điểm DỪNG HANDOFF 26).
    Side đọc được ở băng và bị ÉP low (chord vào < 1 m/s, fix 1.2)."""
    R = bc.BALL_R_M
    out = bc.analyze_track(_bounce_rows(v_in=0.9, v_out=0.8,
                                        d_vertex_m=1.56 * R))
    assert out["metrics"]["n_collisions"] >= 1
    c = out["collisions"][0]
    assert "kink" in c["kind"]
    assert min(c["y_m"], bc.TABLE_L_M - c["y_m"]) == pytest.approx(
        1.56 * R, abs=5e-4)      # JSON làm tròn 4dp
    assert min(c["y_m"], bc.TABLE_L_M - c["y_m"]) > bc.CONTACT_CUSHION_M
    assert c["contact"] == "cushion"
    assert "side" in (out["spin_class"] or "")
    assert out["spin_confidence"] == "low"
    assert any("ép low" in e["text"] for e in out["spin_evidence"])


def test_contact_cushion_nguong_dong_cu12_t26_nhanh():
    """(b′) ca cú 12-t2.6: đỉnh 1.53R, chord vào ~2 m/s → ngưỡng động
    ≈ 2.7R → cushion; tốc độ ≥ 1 m/s nên KHÔNG ép low."""
    R = bc.BALL_R_M
    out = bc.analyze_track(_bounce_rows(v_in=2.0, v_out=1.6,
                                        d_vertex_m=1.53 * R))
    assert out["metrics"]["n_collisions"] >= 1
    c = out["collisions"][0]
    assert "kink" in c["kind"]
    assert c["contact"] == "cushion"
    assert not any("ép low" in e["text"] for e in out["spin_evidence"])


def test_contact_kink_giua_ban_v_cao_khong_lat_thanh_cushion():
    """(b′) ca đối chứng: gãy khúc GIỮA BÀN (d_rail ~5R) ở tốc độ cao 3 m/s
    — slack động ~3.25R vẫn không với tới → unknown; có bi tĩnh tại chỗ →
    ball. Khoá caveat "cú nhanh sát băng" không tràn thành cào ngưỡng."""
    R = bc.BALL_R_M
    rows = []
    t = [0.0]

    def push(x, y):
        rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
                     "covered": 1, "table_x_m": x, "table_y_m": y,
                     "img_diff": 0.8 if len(rows) else -1.0})
        t[0] += DT

    x, y = 5 * R, 0.4                       # chạy dọc cách băng trái đúng 5R
    for _ in range(15):
        push(x, y)
    for _ in range(6):                      # chạy +y 3 m/s
        y += 3.0 * DT
        push(x, y)
    # gãy 50° tại đây (90° → 40°) — mọi điểm quanh neo đều cách mép ≥ 5R
    sp = 2.5
    while sp > 0.05:
        x += sp * DT * math.cos(math.radians(40.0))
        y += sp * DT * math.sin(math.radians(40.0))
        push(x, y)
        sp = max(sp - 2.0 * DT, 0.0)
    for _ in range(6):
        push(x, y)

    out = bc.analyze_track(rows)
    assert out["metrics"]["n_collisions"] >= 1
    c = out["collisions"][0]
    assert "kink" in c["kind"]
    assert c["contact"] == "unknown", c
    others = [{"t_s": i * DT, "x_m": c["x_m"] + 0.02, "y_m": c["y_m"] + 0.03}
              for i in range(12)]
    out2 = bc.analyze_track(rows, others=others)
    assert out2["collisions"][0]["contact"] == "ball"


def test_contact_speed_drop_giu_cach_tinh_tai_neo():
    """BRIEF 26 bước 1.1 vế sau: speed_drop GIỮ cách đo cũ (tại neo). Cú chạm
    dày vào bi tĩnh ở ~1.8R cạnh băng dài trái, chạy chéo RA XA băng nên điểm
    track 2 bước TRƯỚC neo còn ở ~1.4R (≤ 1.5R): nếu cửa sổ MIN ±2 bị áp
    nhầm cho speed_drop thì ra "cushion" — đúng phải là "ball"."""
    R = bc.BALL_R_M
    rows = []
    t = [0.0]

    def push(x, y, diff=0.8):
        rows.append({"frame_file": f"{len(rows):05d}", "t_s": round(t[0], 6),
                     "covered": 1, "table_x_m": x, "table_y_m": y,
                     "img_diff": diff})
        t[0] += DT

    x0, y0 = 1.0 * R, 0.30                  # xuất phát sát băng dài trái
    for _ in range(15):
        push(x0, y0, diff=-1.0 if not rows else 0.2)
    # 4 bước nhanh chéo ra xa băng: +6mm x, +50mm y mỗi bước (~1.51 m/s).
    # Neo speed-drop = điểm cuối chuỗi nhanh (1.0R + 4×6mm ≈ 1.84R); điểm
    # 2 bước trước neo ở 1.0R + 2×6mm ≈ 1.42R ≤ 1.5R — chính là bẫy.
    for k in range(1, 5):
        push(x0 + k * 0.006, y0 + k * 0.050)
    x_anchor = x0 + 4 * 0.006
    assert x0 + 2 * 0.006 <= bc.CONTACT_CUSHION_M < x_anchor
    # sau va chạm: cùng hướng, tốc độ tụt còn ~0.5 m/s (drop ~67%)
    sp = 0.5
    hd = math.degrees(math.atan2(0.050, 0.006))
    for _ in range(6):
        x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
        push(x2 + sp * DT * math.cos(math.radians(hd)),
             y2 + sp * DT * math.sin(math.radians(hd)))
    while sp > 0.05:
        sp = max(sp - 2.0 * DT, 0.0)
        x2, y2 = rows[-1]["table_x_m"], rows[-1]["table_y_m"]
        push(x2 + sp * DT * math.cos(math.radians(hd)),
             y2 + sp * DT * math.sin(math.radians(hd)))
    for _ in range(6):
        push(rows[-1]["table_x_m"], rows[-1]["table_y_m"])

    others = [{"t_s": i * DT, "x_m": x_anchor + 0.02, "y_m": rows[18]["table_y_m"] + 0.03}
              for i in range(12)]
    out = bc.analyze_track(rows, others=others)
    assert out["metrics"]["n_collisions"] >= 1
    c = out["collisions"][0]
    assert c["kind"] == "speed_drop", c
    assert c["x_m"] > bc.CONTACT_CUSHION_M      # neo ngoài ngưỡng cushion
    assert c["contact"] == "ball"


def test_contact_unknown_giua_ban_khong_bi_tinh():
    """Nhánh 3/3: gãy khúc giữa bàn, không có detection bi nào quanh đó
    (others rỗng/None) → contact "unknown" — kể cả đường CSV cũ không truyền
    others vẫn chạy nguyên."""
    legs = [(90.0, 2.0, 0.4), (150.0, 1.6, 0.4)]
    for others in (None, []):
        out = bc.analyze_track(make_rows(legs), others=others)
        assert out["metrics"]["n_collisions"] == 1
        assert out["collisions"][0]["contact"] == "unknown"


def test_coverage_thap_ra_canh_bao_khong_phai_loi():
    """Mất track (người che) là THÔNG TIN chứ không phải bug (BRIEF #5):
    coverage < 80% → cảnh báo hiện, số vẫn trả."""
    rows = make_rows([(90.0, 2.0, 0.4)])
    for r in rows[: int(0.35 * len(rows))]:
        r["covered"] = 0
    out = bc.analyze_track(rows)
    assert out["metrics"]["coverage"] < 0.80
    assert any("phủ" in w for w in out["warnings"])


def test_track_rong_nem_valueerror():
    with pytest.raises(ValueError):
        bc.analyze_track([])


# ------------------------------------------------- baseline spin (BG25)

STATIC_15 = [{"t_s": i * DT, "x_m": 0.601, "y_m": 1.499} for i in range(12)]


def test_spin_follow_cu_cham_day_tien_tiep():
    """Follow, cú chạm dày (speed_drop, không kink): sau chạm bi tĩnh cue
    tiếp tục TIẾN dọc hướng cũ → follow, confidence medium."""
    rows = make_rows([(90.0, 2.5, 0.4), (90.0, 1.2, 0.4)])
    out = bc.analyze_track(rows, others=STATIC_15)
    assert out["collisions"][0]["contact"] == "ball"
    assert out["spin_class"] == "follow"
    assert out["spin_confidence"] == "medium"
    assert any(e["axis"] == "vertical" and "follow" in e["text"]
               for e in out["spin_evidence"])


def test_spin_draw_duong_ve_sau_cham():
    """Draw: sau chạm bi đường đi quay ngược ~180° (tangent cú cắt tối đa
    90° — chỉ backspin kéo về được) → draw."""
    rows = make_rows([(90.0, 2.5, 0.4), (270.0, 1.0, 0.5)])
    out = bc.analyze_track(rows, others=STATIC_15)
    assert out["collisions"][0]["contact"] == "ball"
    assert out["spin_class"] == "draw"
    assert out["spin_confidence"] == "medium"


def test_spin_side_lech_phan_xa_guong():
    """Side: bật băng dài phải, hướng ra lệch +25° so với phản xạ gương —
    tiếp tuyến sau bật bị đẩy theo chiều ωz > 0 → side-R, confidence low
    (25° < 30°). Va chạm đầu là băng → trục dọc KHÔNG đọc."""
    rows = make_rows([(30.0, 2.0, 16 * DT), (175.0, 1.2, 0.35)],
                     start=(0.31, 0.8))
    out = bc.analyze_track(rows)
    assert out["metrics"]["n_collisions"] == 1
    assert out["collisions"][0]["contact"] == "cushion"
    assert out["spin_class"] == "side-R"
    assert out["spin_confidence"] == "low"
    assert any(e["axis"] == "vertical" and "không phải bi" in e["text"]
               for e in out["spin_evidence"])
    lat = [e for e in out["spin_evidence"] if e["axis"] == "lateral"]
    assert lat and lat[0]["deg"] == pytest.approx(25.0, abs=6.0)


def test_spin_ep_low_khi_toc_do_quanh_va_cham_duoi_san():
    """BG26 bước 1.2 (Cowork chốt từ câu hỏi HANDOFF 25 #4): CÙNG hình học
    bật băng lệch gương +35° (≥ 30° → medium theo luật cũ), chỉ khác tốc độ
    tới va chạm — 2.0 m/s giữ medium, 0.9 m/s (< sàn SPEED_DROP_MIN_MPS)
    bị ÉP low kèm evidence nói rõ, vì vùng chậm là vùng nhiễu dọc hướng
    chạy chi phối (bài học cú 11)."""
    fast = bc.analyze_track(
        make_rows([(30.0, 2.0, 16 * DT), (185.0, 1.6, 0.35)],
                  start=(0.31, 0.8)))
    assert fast["spin_class"] == "side-R"
    assert fast["spin_confidence"] == "medium"
    assert not any("ép low" in e["text"] for e in fast["spin_evidence"])

    slow = bc.analyze_track(
        make_rows([(30.0, 0.9, 36 * DT), (185.0, 0.8, 0.35)],
                  start=(0.31, 0.8)))
    assert slow["metrics"]["n_collisions"] >= 1
    assert slow["collisions"][0]["contact"] == "cushion"
    assert slow["spin_class"] == "side-R"
    assert slow["spin_confidence"] == "low"
    assert any("ép low" in e["text"] for e in slow["spin_evidence"])


def test_spin_null_cu_khong_va_cham():
    """Cú lăn không chạm gì: spin unidentifiable VỀ NGUYÊN TẮC (§3.2) —
    null + lý do, không đoán bừa (2/10 cú P0 thuộc loại này)."""
    out = bc.analyze_track(make_rows([(90.0, 1.8, 0.5)]))
    assert out["metrics"]["n_collisions"] == 0
    assert out["spin_class"] is None
    assert out["spin_confidence"] is None
    assert any("không có va chạm" in e["text"] for e in out["spin_evidence"])


# ------------------------------------------------------------ track thật

def _fixture_rows():
    with open(FIXTURE, encoding="utf-8") as f:
        return list(csv.DictReader(f))


def test_shot01_that_v0_phi_va_cham():
    """Track thật shot_01 (P0, mắt đã kiểm): đánh lúc ~0.77s (t0 fixture
    3173.0, motion 3173.77), V0 quãng 2–3.6 m/s hướng ~200°, chạm bi mục
    tiêu lúc ~1.0s sau đó (t≈3174.0) rồi lăn chậm hướng ~134°."""
    out = bc.analyze_track(_fixture_rows())
    m = out["metrics"]
    assert m["motion_start_s"] == pytest.approx(0.77, abs=0.1)
    assert 1.5 <= m["v0_mps"] <= 4.5
    assert 185 <= m["phi_deg"] <= 220
    assert m["n_collisions"] >= 1
    c = out["collisions"][0]
    assert c["t_s"] - 3173.0 == pytest.approx(1.0, abs=0.15)
    # fixture chỉ có track cue (không detection bi khác) → contact "unknown"
    assert c["contact"] == "unknown"
    # sau va chạm KHÔNG được mọc thêm va chạm giả giữa đoạn lăn chậm
    # (nhiễu dọc hướng chạy ±13mm làm tốc độ per-step lắc mạnh — HANDOFF 23)
    assert m["n_collisions"] <= 2
    # stream 25fps upconvert: fixture 61 frame phải dính ~1/6 frame trùng
    assert m["n_dup_frames"] >= 5
    assert out["warnings"] == []       # cú đẹp: không có gì để cảnh báo


def test_shot01_track_tra_toa_do_tho_khong_smooth():
    """Track trả ra = đúng điểm covered đã dedup, toạ độ THÔ từ CSV —
    smooth chỉ để vẽ là việc của FE (BRIEF bước 3.2)."""
    rows = _fixture_rows()
    out = bc.analyze_track(rows)
    raw = {(round(float(r["table_x_m"]), 4), round(float(r["table_y_m"]), 4))
           for r in rows if r["covered"] == "1"}
    for p in out["track"]:
        assert (p["x_m"], p["y_m"]) in raw