"""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