poolcoach / tests /test_analyzer_shots.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
53.1 kB
"""Contract Analyzer đa cú (lát A1, 13/08/2026) — fakeredis + file tạm,
KHÔNG cần Docker/Redis/torch/ffmpeg (pipeline CV thật nghiệm thu ở gate
A1.2 trên VOD thật).
Nếp test_analyze: worker giả chạy ĐÚNG code path transport của worker thật
(`serve_one` trên SEGMENT_JOBS_KEY), handler ghi manifest canned. Hợp đồng
khoá ở đây:
1. POST /api/analyzer/videos: validate + lưu video + status TRƯỚC enqueue.
2. GET /api/analyzer/shots: ghép FILE (nguồn sự thật) → Redis (tiến độ) →
"unknown" — danh sách hiện DẦN, cú lỗi NẰM TRONG danh sách kèm lý do.
3. Transport worker: kết quả per cú ghi FILE ngay khi có (nếp BG29b — kết
quả từng MẤT THẬT khi chỉ nằm Redis TTL 1h), lỗi cũng ra file.
4. DB ghi danh sách best-effort, không chặn luồng khi DB tắt.
"""
from __future__ import annotations
import json
import threading
import pytest
from fastapi.testclient import TestClient
from app import analyzer_store as astore
from app import db, jobqueue
from app import main as app_main
VIDEO_BYTES = b"\x00\x00\x00 ftypisomfake-mp4-bytes"
CORNERS = "[[100, 80], [1800, 90], [1700, 900], [200, 880]]"
RESULT_SAMPLE = {
"metrics": {"v0_mps": 2.64, "phi_deg": 199.3, "motion_start_s": 0.77,
"n_collisions": 1, "coverage": 0.97, "max_gap_s": 0.067,
"n_frames": 61, "n_covered": 61, "n_dup_frames": 9,
"duration_s": 2.03, "table_w_m": 1.27, "table_l_m": 2.54,
"elapsed_s": 41.7},
"collisions": [{"t_s": 1.0, "x_m": 0.62, "y_m": 1.31,
"kind": "kink+speed_drop", "dtheta_deg": 54.5,
"drop_frac": 0.77, "contact": "ball"}],
"spin_class": "follow",
"spin_confidence": "low",
"shotnet": {"model": "shotnet_20260812_c4b", "v0_cue_mps": 2.31,
"phi_deg": 200.8, "a": -0.12, "b": 0.05,
"spin_vert": "follow", "spin_side": "side-R",
"identifiable_prob": 0.91, "confidence": "medium",
"confidence_raw": "high", "det_density": 1.8,
"inference_ms": 18.4, "n_target_slots": 7},
"track": [{"t_s": 0.0, "x_m": 0.99, "y_m": 0.586}],
"warnings": ["Track chỉ phủ 75% thời lượng."],
"balls_init": [],
}
@pytest.fixture(autouse=True)
def _reset_jobqueue():
yield
jobqueue.teardown()
@pytest.fixture(autouse=True)
def _pin_ffprobe(monkeypatch):
monkeypatch.setattr(app_main, "_probe_duration_s", lambda p: 300.0)
@pytest.fixture(autouse=True)
def _tmp_analyzer_dir(monkeypatch, tmp_path):
"""Thư mục kết quả analyzer rơi vào tmp_path — route lẫn helper store
cùng trỏ một chỗ (app + worker cùng máy là giao ước của tính năng)."""
base = tmp_path / "analyzer"
monkeypatch.setattr(app_main, "_analyzer_video_dir",
lambda vid: base / vid)
return base
@pytest.fixture
def client():
return TestClient(app_main.app)
@pytest.fixture
def fq():
from test_queue import FakeQueue
q = FakeQueue()
jobqueue.setup(client=q.api)
return q
@pytest.fixture
def db_mem():
db.setup("sqlite:///:memory:")
db.Base.metadata.create_all(db.get_engine())
yield db
db.teardown()
def beat_cv(fq):
jobqueue.beat(client=fq.worker, key=jobqueue.SCAN_HEARTBEAT_KEY)
def post_video(client, video=VIDEO_BYTES, corners=CORNERS, name="rack.mp4"):
return client.post("/api/analyzer/videos",
files={"video": (name, video, "video/mp4")},
data={"corners": corners})
def canned_manifest(video_id, base, n_ok=2, n_err=1):
"""Manifest như segment_once viết: n_ok cú lành (queued) + n_err cú
segmentation kết án (mất cảnh)."""
shots = []
for i in range(1, n_ok + 1):
shots.append({"idx": i, "t_start_s": 10.0 * i, "t_end_s": 10.0 * i + 5,
"t_onset_s": 10.0 * i + 0.5,
"t_settle_s": 10.0 * i + 4.5,
"status": "queued", "reason": None,
"analyze_id": astore.shot_analyze_id(video_id, i),
"clip": f"clips/shot_{i:02d}.mp4",
"thumb": f"thumb_{i:02d}.jpg"})
for j in range(n_ok + 1, n_ok + n_err + 1):
shots.append({"idx": j, "t_start_s": 10.0 * j, "t_end_s": 10.0 * j + 3,
"t_onset_s": 10.0 * j + 0.5, "t_settle_s": None,
"status": "error",
"reason": "mất cảnh giữa cú — không thấy bi nào trên "
"bàn quá 1s (video đổi góc/replay)",
"analyze_id": astore.shot_analyze_id(video_id, j),
"thumb": f"thumb_{j:02d}.jpg"})
return {"status": "done", "video_id": video_id, "filename": "rack.mp4",
"fps_nominal": 30.0, "t_first_s": 0.0, "t_last_s": 300.0,
"duration_s": 300.0, "n_frames": 9000, "n_dup_frames": 1500,
"elapsed_scan_s": 111.0, "warnings": [], "shots": shots}
def start_segment_worker(fq, base, n_ok=2, n_err=1, seen=None):
"""Worker segment giả — serve_one trên SEGMENT_JOBS_KEY như worker
thật; handler làm đúng các bước TRANSPORT của segment_once: manifest ra
FILE trước, rồi status queued per cú, rồi enqueue N job analyze."""
beat_cv(fq)
def handler(payload):
if seen is not None:
seen.update(payload)
vid = payload["video_id"]
man = canned_manifest(vid, base, n_ok=n_ok, n_err=n_err)
vdir = base / vid
astore.write_json(astore.manifest_path(vdir), man)
for s in man["shots"]:
if s["status"] != "queued":
continue
jobqueue.set_analyze_status(
s["analyze_id"], {"status": "queued", "progress": 0.0},
client=fq.worker)
c = fq.worker
c.lpush(jobqueue.ANALYZE_JOBS_KEY, json.dumps(
{"job_id": s["analyze_id"],
"payload": {"analyze_id": s["analyze_id"]}}))
jobqueue.set_video_status(vid, {"status": "done", "progress": 1.0,
"n_shots": len(man["shots"])},
client=fq.worker)
return {"ok": True, "result": {"video_id": vid}}
def run():
tries = 0
while tries < 50:
tries += 1
if jobqueue.serve_one(handler, timeout_s=0.2, client=fq.worker,
jobs_key=jobqueue.SEGMENT_JOBS_KEY):
return
t = threading.Thread(target=run, daemon=True)
t.start()
return t
def wait_manifest(base, vid, timeout=5.0):
import time
deadline = time.perf_counter() + timeout
p = astore.manifest_path(base / vid)
while time.perf_counter() < deadline:
if p.exists():
return
time.sleep(0.05)
raise AssertionError("worker giả không ghi manifest")
# ------------------------------------------------------------ degraded
def test_khong_redis_url_503(client):
jobqueue.teardown()
assert post_video(client).status_code == 503
r = client.get("/api/analyzer/shots", params={"video": "a" * 32})
assert r.status_code == 503
def test_redis_chet_503_khong_dong_file(client, fq, _tmp_analyzer_dir):
fq.server.connected = False
r = post_video(client)
assert r.status_code == 503
assert "Redis" in r.json()["detail"]
# video bản quyền không được đọng lại sau request thất bại
assert not _tmp_analyzer_dir.exists() \
or not any(_tmp_analyzer_dir.rglob("*"))
def test_vang_heartbeat_503_khong_enqueue(client, fq, _tmp_analyzer_dir):
r = post_video(client)
assert r.status_code == 503
assert "CV worker không chạy" in r.json()["detail"]
assert fq.api.llen(jobqueue.SEGMENT_JOBS_KEY) == 0
assert not _tmp_analyzer_dir.exists() \
or not any(_tmp_analyzer_dir.rglob("*"))
# ------------------------------------------------------- validate input
@pytest.mark.parametrize("bad", [
"khong-json", "[[1,2],[3,4]]", '[[1,2],[3,4],[5,6],["x",8]]'])
def test_corners_hong_422(client, fq, bad):
r = post_video(client, corners=bad)
assert r.status_code == 422
assert "corners" in r.json()["detail"]
def test_duoi_la_422(client, fq):
r = post_video(client, name="tran.avi")
assert r.status_code == 422
def test_video_rong_422(client, fq, _tmp_analyzer_dir):
r = post_video(client, video=b"")
assert r.status_code == 422
assert "rỗng" in r.json()["detail"]
def test_video_qua_dai_422(client, fq, monkeypatch, _tmp_analyzer_dir):
monkeypatch.setattr(app_main, "_probe_duration_s", lambda p: 25 * 60.0)
r = post_video(client)
assert r.status_code == 422
assert "phút" in r.json()["detail"]
assert not any(_tmp_analyzer_dir.rglob("*.mp4"))
def test_video_id_rac_422_khong_cham_file(client, fq):
"""Chặn id lạ trước khi ghép đường dẫn — '../' không được đi lạc."""
for vid in ["../../etc", "..%2F..", "A" * 32, "xyz"]:
r = client.get("/api/analyzer/shots", params={"video": vid})
assert r.status_code == 422
r = client.get("/api/analyzer/videos/../shots/1/result")
assert r.status_code in (404, 422)
# ------------------------------------------------------------ happy path
def test_post_luu_video_status_truoc_enqueue(client, fq, _tmp_analyzer_dir):
beat_cv(fq)
r = post_video(client)
assert r.status_code == 200
data = r.json()
assert data["status"] == "queued"
vid = data["id"]
assert len(vid) == 32
assert fq.api.llen(jobqueue.SEGMENT_JOBS_KEY) == 1
job = json.loads(fq.api.lindex(jobqueue.SEGMENT_JOBS_KEY, 0))
assert job["job_id"] == vid
p = job["payload"]
assert p["video_id"] == vid
assert p["corners"] == [[100, 80], [1800, 90], [1700, 900], [200, 880]]
assert p["out_dir"] == str(_tmp_analyzer_dir / vid)
saved = _tmp_analyzer_dir / vid / "video.mp4"
assert p["video_path"] == str(saved)
assert saved.read_bytes() == VIDEO_BYTES
# status queued đã nằm sẵn TRƯỚC khi worker kịp đụng job (nếp BG24)
st = jobqueue.get_video_status(vid)
assert st == {"status": "queued", "progress": 0.0}
g = client.get("/api/analyzer/shots", params={"video": vid})
assert g.status_code == 200
body = g.json()
assert body["video"]["status"] == "queued"
assert body["shots"] == [] and body["n_total"] == 0
def test_dang_scan_tra_tien_do(client, fq):
beat_cv(fq)
vid = post_video(client).json()["id"]
jobqueue.set_video_status(vid, {"status": "running", "progress": 0.37,
"stage": "detect"}, client=fq.worker)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert body["video"] == {"id": vid, "status": "running",
"progress": 0.37, "stage": "detect"}
def test_video_la_404(client, fq):
r = client.get("/api/analyzer/shots", params={"video": "b" * 32})
assert r.status_code == 404
def test_worker_gia_ra_danh_sach_cu_loi_nam_trong_danh_sach(
client, fq, _tmp_analyzer_dir):
seen = {}
start_segment_worker(fq, _tmp_analyzer_dir, seen=seen)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert seen["video_id"] == vid
assert body["video"]["status"] == "done"
assert body["video"]["n_shots"] == 3
assert body["n_total"] == 3 and body["n_done"] == 0
s1, s2, s3 = body["shots"]
# cú lành: đang xếp hàng phân tích, có link kết quả để FE click sau
assert s1["status"] == "queued"
assert s1["result_url"].endswith(f"/videos/{vid}/shots/1/result")
assert s1["thumb_url"].endswith(f"/videos/{vid}/shots/1/thumb")
# cú segmentation kết án: NẰM TRONG danh sách kèm lý do (gate A1.4)
assert s3["status"] == "error"
assert "mất cảnh" in s3["reason"]
assert "result_url" not in s3
def test_tien_do_per_cu_di_qua(client, fq, _tmp_analyzer_dir):
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
jobqueue.set_analyze_status(astore.shot_analyze_id(vid, 1),
{"status": "running", "progress": 0.55,
"stage": "detect"}, client=fq.worker)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
s1 = body["shots"][0]
assert s1["status"] == "running"
assert s1["progress"] == 0.55 and s1["stage"] == "detect"
def test_ket_qua_file_thanh_hang_done_hien_dan(client, fq,
_tmp_analyzer_dir):
"""JSON per cú trên ĐĨA là nguồn sự thật: có file → hàng done kèm tóm
tắt tham số; cú kia chưa có → vẫn queued. Danh sách hiện DẦN đúng nghĩa
(n_done tăng theo file, không theo Redis)."""
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert body["n_done"] == 1
s1 = body["shots"][0]
assert s1["status"] == "done"
assert s1["v0_mps"] == 2.64 and s1["phi_deg"] == 199.3
assert s1["spin_class"] == "follow"
assert s1["n_collisions"] == 1 and s1["n_warnings"] == 1
assert s1["shotnet"]["v0_cue_mps"] == 2.31
assert s1["shotnet"]["a"] == -0.12 and s1["shotnet"]["b"] == 0.05
assert s1["shotnet"]["confidence"] == "medium"
# rmse chừa chỗ cho resim A2 — A1 không được bịa số (exclude_none)
assert "rmse_mm" not in s1
assert body["shots"][1]["status"] == "queued"
def test_cu_loi_file_ra_hang_error(client, fq, _tmp_analyzer_dir):
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 2),
{"error": "validation",
"message": "Không thấy lúc cue ball bắt đầu chạy — "
"track đứt giữa cú."})
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
s2 = body["shots"][1]
assert s2["status"] == "error"
assert "track đứt" in s2["reason"]
def test_mat_dau_vet_ra_unknown_noi_thang(client, fq, _tmp_analyzer_dir):
"""Không file, hết TTL Redis (worker chết giữa chừng) — nói thẳng
unknown, không đoán mò thành queued vĩnh viễn."""
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
fq.worker.delete(jobqueue.ANALYZE_STATUS_KEY.format(
analyze_id=astore.shot_analyze_id(vid, 1)))
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
s1 = body["shots"][0]
assert s1["status"] == "unknown"
assert "dấu vết" in s1["reason"]
def test_segment_loi_manifest_error_ben_vung(client, fq, _tmp_analyzer_dir):
"""Manifest error trên ĐĨA sống lâu hơn TTL Redis — video hỏng vẫn
biết vì sao sau 1h."""
beat_cv(fq)
vid = post_video(client).json()["id"]
astore.write_json(astore.manifest_path(_tmp_analyzer_dir / vid),
{"status": "error", "video_id": vid,
"message": "Video dài 33.4 phút — giới hạn 20 phút."})
fq.worker.delete(jobqueue.VIDEO_STATUS_KEY.format(video_id=vid))
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert body["video"]["status"] == "error"
assert "giới hạn 20" in body["video"]["message"]
def test_result_endpoint_tra_json_tho(client, fq, _tmp_analyzer_dir):
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
r = client.get(f"/api/analyzer/videos/{vid}/shots/1/result")
assert r.status_code == 200
assert r.json() == RESULT_SAMPLE # thô nguyên vẹn, không gọt
assert client.get(
f"/api/analyzer/videos/{vid}/shots/2/result").status_code == 404
def test_thumb_endpoint(client, fq, _tmp_analyzer_dir):
beat_cv(fq)
vid = post_video(client).json()["id"]
p = astore.thumb_path(_tmp_analyzer_dir / vid, 1)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(b"\xff\xd8\xff\xe0fakejpg")
r = client.get(f"/api/analyzer/videos/{vid}/shots/1/thumb")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
assert client.get(
f"/api/analyzer/videos/{vid}/shots/9/thumb").status_code == 404
# ------------------------------------------------------- DB ghi danh sách
def test_db_ghi_video_va_sync_danh_sach(client, fq, db_mem,
_tmp_analyzer_dir):
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
with db.session() as s:
v = s.get(db.AnalyzerVideo, vid)
assert v is not None and v.filename == "rack.mp4"
client.get("/api/analyzer/shots", params={"video": vid})
with db.session() as s:
rows = (s.query(db.AnalyzerShot).filter_by(video_id=vid)
.order_by(db.AnalyzerShot.shot_idx).all())
assert [r.shot_idx for r in rows] == [1, 2, 3]
assert rows[2].status == "error"
v = s.get(db.AnalyzerVideo, vid)
assert v.status == "done" and v.n_shots == 3
# cú 1 xong → GET sync đổi status, KHÔNG đẻ row trùng
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
client.get("/api/analyzer/shots", params={"video": vid})
client.get("/api/analyzer/shots", params={"video": vid})
with db.session() as s:
rows = s.query(db.AnalyzerShot).filter_by(video_id=vid).all()
assert len(rows) == 3
r1 = next(r for r in rows if r.shot_idx == 1)
assert r1.status == "done"
assert r1.result["v0_mps"] == 2.64
def test_khong_db_van_chay_nguyen(client, fq, _tmp_analyzer_dir):
db.teardown()
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert body["video"]["status"] == "done"
# ---------------------------------------- probe nguồn FE (nếp #scan-go)
def _fe_src(name):
from pathlib import Path
return (Path(__file__).resolve().parents[1] / "app" / "static"
/ name).read_text(encoding="utf-8")
def test_fe_nut_video_da_cu_khong_muon_btn_go():
"""Nút luồng đa cú phải là .an-cta, KHÔNG .btn-go — .btn-go có binding
recommend() toàn cục + syncButtons ép label (bug 2 nút 'Đánh cú này'
giữa tab Analyzer, BG25/BG26)."""
html = _fe_src("index.html")
assert 'id="anv-pick"' in html
i = html.index('id="anv-pick"')
tag = html[html.rindex("<button", 0, i):html.index(">", i)]
assert "an-cta" in tag and "btn-go" not in tag
# bảng §3.1 đủ cột; khu kết quả + tbody để JS đổ dần
for header in [">V0<", ">φ<", ">Spin (a/b)<", ">Tin cậy<", ">RMSE<",
">Trạng thái<"]:
assert header in html
assert 'id="anv-result"' in html and 'id="anv-tbody"' in html
def test_fe_bang_hien_dan_va_hang_loi_co_ly_do():
"""app.js phải: poll GET /api/analyzer/shots, dòng tiến độ "Phát hiện N
cú — xong K/N", hàng lỗi .anv-err kèm lý do, click hàng done ra JSON
thô, móc smoke __anvRenderMock đi qua ĐÚNG render thật."""
js = _fe_src("app.js")
assert "/api/analyzer/videos" in js
assert "/api/analyzer/shots?video=" in js
assert "Phát hiện ${body.n_total} cú — xong " in js
assert "anv-err" in js and "anv-reason" in js
assert "không phân tích được" in js
assert "result_url" in js and "__anvRenderMock" in js
assert "renderAnvShots" in js
# RMSE resim là cột để dành A2 — không được bịa số
assert "RMSE" in _fe_src("index.html")
def test_fe_luong_1_cu_da_go_mot_duong_vao(client):
"""A2b feedback #1: luồng 1-cú GỠ HẲN — không còn hai đường vào Analyzer
trên UI (gate A2b.2), không còn code path POST /api/analyze trong FE,
và endpoint cũng đã gỡ (grep scripts/tests 13/08: không script nào gọi
HTTP — diag BG29b–BG32 chỉ đọc JSON đã lưu)."""
js = _fe_src("app.js")
assert '"/api/analyze"' not in js
assert "__anRenderMock =" not in js # mock luồng cũ đi theo
assert "__anvRenderMock" in js # mock đa cú còn nguyên
html = _fe_src("index.html")
assert 'id="an-pick"' not in html
assert 'id="an-result"' not in html and 'id="an-progress"' not in html
# đúng MỘT nút vào luồng phân tích ở khu đầu tab (trước #anv-result;
# #and-replay trong trang chi tiết cũng .an-cta — không tính đường vào)
i0 = html.index('<section id="analyzer"')
entry = html[i0:html.index('id="anv-result"', i0)]
assert entry.count('class="an-cta"') == 1 and 'id="anv-pick"' in entry
# endpoint: POST 404/405, GET 404 (route không tồn tại — khác 503 queue)
assert client.post("/api/analyze").status_code in (404, 405)
assert client.get("/api/analyze/deadbeef").status_code == 404
def test_video_1_cu_ra_dung_1_hang_du_thong_so(client, fq,
_tmp_analyzer_dir):
"""Gate A2b.2: video chứa ĐÚNG 1 cú đi luồng đa cú → bảng đúng 1 hàng
done đủ thông số (V0/φ/spin model + analytic) — trường hợp con thay
trọn vai luồng 1-cú cũ."""
start_segment_worker(fq, _tmp_analyzer_dir, n_ok=1, n_err=0)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert body["n_total"] == 1 and body["n_done"] == 1
s1 = body["shots"][0]
assert s1["status"] == "done"
assert s1["v0_mps"] == 2.64 and s1["phi_deg"] == 199.3
assert s1["shotnet"]["v0_cue_mps"] == 2.31
assert s1["spin_class"] == "follow"
assert s1["result_url"].endswith("/shots/1/result")
def test_fe_thoat_tab_khong_dung_poll_nep_cu():
"""Nếp BG24 giữ nguyên cho luồng mới: rời tab KHÔNG dừng poll — job
server cứ chạy, quay lại tab thấy kết quả."""
js = _fe_src("app.js")
i = js.index("function exitAnalyzer")
body = js[i:js.index("\nfunction ", i + 10)]
assert "stopAnvPoll" not in body
# ---------------------- van "nghi không phải cú đánh" (lát A2 phần 1)
# Cờ HIỂN THỊ từ 3 tín hiệu tự khai (V0 ngoài [1,8] / motion_start None /
# coverage < 0.8) — số đối chiếu là 3 cú ma + 11 cú thật rack_a1 ĐÃ CHỐT.
def _result_with_metrics(**over):
res = json.loads(json.dumps(RESULT_SAMPLE)) # deep copy
res["metrics"].update(over)
return res
def test_van_bat_dung_3_chu_ky_cu_ma_rack_a1():
"""Chữ ký tự khai của 3 cú ma idx 7/8/9 (BRIEF A2): v0 0.27 / None /
0.99 m/s, coverage 45.5% / 38.9% / 60% — van phải nổ cả 3."""
cw = _cv_worker()
ma7 = cw.not_shot_flag(_result_with_metrics(v0_mps=0.271,
coverage=0.455))
assert ma7["flagged"] is True
assert any("V0" in r for r in ma7["reasons"])
assert any("phủ" in r for r in ma7["reasons"])
ma8 = cw.not_shot_flag(_result_with_metrics(
v0_mps=None, motion_start_s=None, coverage=0.389))
assert ma8["flagged"] is True
assert any("motion_start" in r for r in ma8["reasons"])
ma9 = cw.not_shot_flag(_result_with_metrics(v0_mps=0.991, coverage=0.60))
assert ma9["flagged"] is True
def test_van_khong_bat_oan_cu_that():
"""Cú thật rack_a1: coverage thấp nhất 92.6%, V0 1.8–7.13 ∈ [1, 8] —
van im lặng (flagged False, reasons rỗng); V0 cao 7.9 sát trần vẫn qua."""
cw = _cv_worker()
ok = cw.not_shot_flag(_result_with_metrics(v0_mps=1.805, coverage=0.926))
assert ok == {"flagged": False, "reasons": []}
assert cw.not_shot_flag(
_result_with_metrics(v0_mps=7.9, coverage=1.0))["flagged"] is False
def test_van_v0_none_mot_minh_khong_no():
"""BRIEF chốt đúng 3 tín hiệu: V0 None mà motion_start CÓ ("không đủ
frame đo V0") thì tín hiệu V0 không nổ — không bịa tín hiệu thứ tư."""
cw = _cv_worker()
out = cw.not_shot_flag(_result_with_metrics(v0_mps=None, coverage=0.95))
assert out["flagged"] is False
def test_co_van_di_qua_api_hang_van_click_duoc(client, fq,
_tmp_analyzer_dir):
"""JSON có suspect_not_shot → hàng GET mang nguyên cờ + reasons, và
vẫn done + result_url (KHÔNG xoá/lọc — chỉ đổi cách hiển thị)."""
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
res = dict(RESULT_SAMPLE)
res["suspect_not_shot"] = {
"flagged": True,
"reasons": ["V0 giải tích 0.27 m/s ngoài khoảng tin [1, 8] m/s"]}
astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 1), res)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
s1 = body["shots"][0]
assert s1["status"] == "done"
assert s1["suspect_not_shot"]["flagged"] is True
assert "0.27" in s1["suspect_not_shot"]["reasons"][0]
assert s1["result_url"].endswith("/shots/1/result")
# kết quả trước A2 (không có key) — hàng không mọc cờ
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 2), RESULT_SAMPLE)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert "suspect_not_shot" not in body["shots"][1]
def test_fe_hang_van_xam_ly_do_van_click():
"""app.js: hàng cờ van dùng class RIÊNG .anv-suspect (không mượn
.anv-err — ngữ nghĩa khác), nhãn "nghi không phải cú đánh", lý do từ
server (reasons) nối thẳng; hàng done vẫn qua nhánh click chung."""
js = _fe_src("app.js")
assert "anv-suspect" in js
assert "nghi không phải cú đánh" in js
assert "suspect_not_shot" in js
css = _fe_src("style.css")
assert ".anv-suspect td" in css
html = _fe_src("index.html")
assert "nghi không phải cú đánh" in html
# ----------------------------- RMSE resim trong hàng bảng (lát A2 phần 2)
def _result_with_resim(sets):
res = json.loads(json.dumps(RESULT_SAMPLE))
res["resim"] = {"table_w_m": 1.27, "table_l_m": 2.54,
"ball_r_m": 0.028575, "sets": sets}
return res
def test_rmse_bo_shotnet_vao_cot_bang(client, fq, _tmp_analyzer_dir):
"""rmse_mm của hàng = bộ shotnet (model đang chạy app), kèm nhãn
rmse_set — bảng và JSON phải cùng một số (gate A2.3)."""
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
res = _result_with_resim({
"shotnet": {"params": {"v0_mps": 2.31, "phi_deg": 200.8,
"a": -0.12, "b": 0.05},
"rmse_mm": 412.4, "n_points": 147, "t0_s": 0.45,
"t_align_s": 0.033,
"series": {"cue": [[0.45, 0.99, 0.586]]}},
"analytic": {"params": {"v0_mps": 1.72, "phi_deg": 199.3,
"a": 0.0, "b": 0.0},
"rmse_mm": 734.2, "n_points": 147, "t0_s": 0.48,
"t_align_s": 0.0,
"series": {"cue": [[0.48, 0.99, 0.586]]}}})
astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 1), res)
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
s1 = body["shots"][0]
assert s1["rmse_mm"] == 412.4 # bộ shotnet, KHÔNG trộn
assert s1["rmse_set"] == "shotnet"
# JSON thô giữ nguyên khối resim đầy đủ — hai nơi cùng nguồn
raw = client.get(f"/api/analyzer/videos/{vid}/shots/1/result").json()
assert raw["resim"]["sets"]["shotnet"]["rmse_mm"] == 412.4
assert raw["resim"]["sets"]["analytic"]["rmse_mm"] == 734.2
def test_rmse_fallback_analytic_dan_nhan(client, fq, _tmp_analyzer_dir):
"""Vắng bộ shotnet (worker không ckpt) → fallback analytic NHƯNG
rmse_set phải dán nhãn — "ghi rõ bộ nào, đừng trộn"."""
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
res = _result_with_resim({
"shotnet": {"error": "kết quả không có khối shotnet"},
"analytic": {"params": {"v0_mps": 1.72, "phi_deg": 199.3,
"a": 0.0, "b": 0.0},
"rmse_mm": 91.0, "n_points": 80, "t0_s": 0.5,
"t_align_s": 0.0, "series": {"cue": []}}})
astore.write_json(astore.shot_json_path(_tmp_analyzer_dir / vid, 1), res)
s1 = client.get("/api/analyzer/shots",
params={"video": vid}).json()["shots"][0]
assert s1["rmse_mm"] == 91.0 and s1["rmse_set"] == "analytic"
# kết quả trước A2 (không resim) — không bịa số
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 2), RESULT_SAMPLE)
s2 = client.get("/api/analyzer/shots",
params={"video": vid}).json()["shots"][1]
assert "rmse_mm" not in s2
def test_fe_cot_rmse_hien_so_va_nhan_bo():
js = _fe_src("app.js")
assert "rmse_mm" in js and "rmse_set" in js
html = _fe_src("index.html")
assert "resim pooltool" in html
# --------------------------------- overlay.mp4 per cú (lát A2 phần 3)
def test_overlay_url_theo_file_tren_dia(client, fq, _tmp_analyzer_dir):
"""File overlay trên đĩa là nguồn sự thật: có file → hàng mọc
overlay_url + endpoint trả video/mp4; không file → không url + 404
tử tế (kết quả trước A2 không render hồi tố được)."""
start_segment_worker(fq, _tmp_analyzer_dir)
vid = post_video(client).json()["id"]
wait_manifest(_tmp_analyzer_dir, vid)
astore.write_json(
astore.shot_json_path(_tmp_analyzer_dir / vid, 1), RESULT_SAMPLE)
p = astore.overlay_path(_tmp_analyzer_dir / vid, 1)
p.write_bytes(b"\x00\x00\x00 ftypisomfake-overlay")
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
s1, s2 = body["shots"][0], body["shots"][1]
assert s1["overlay_url"].endswith(f"/videos/{vid}/shots/1/overlay")
assert "overlay_url" not in s2
r = client.get(f"/api/analyzer/videos/{vid}/shots/1/overlay")
assert r.status_code == 200
assert r.headers["content-type"] == "video/mp4"
assert r.content.endswith(b"fake-overlay")
r = client.get(f"/api/analyzer/videos/{vid}/shots/2/overlay")
assert r.status_code == 404
assert "hồi tố" in r.json()["detail"]
def test_segment_payload_mang_overlay_path(client, fq, _tmp_analyzer_dir):
"""Job analyze per cú phải nhận overlay_path — worker render TRONG job,
trước khi clip bị xoá (bài học A1: clip mất là overlay mất)."""
cw = _cv_worker()
vdir = _tmp_analyzer_dir / ("d" * 32)
assert str(astore.overlay_path(vdir, 3)).endswith("overlay_03.mp4")
# nguồn enqueue trong segment_once: soi trực tiếp code path (worker
# thật cần cv2/ffmpeg — transport đã có test riêng ở dưới)
import inspect
src = inspect.getsource(cw.segment_once)
assert "overlay_path" in src
def test_fe_nut_phat_va_tai_overlay():
js = _fe_src("app.js")
assert "overlay_url" in js
assert "anv-overlay-links" in js
assert "stopPropagation" in js # link không kích click-hàng mở JSON
css = _fe_src("style.css")
assert ".anv-overlay-links a" in css
# ------------------------------ trang chi tiết cú (lát A2 phần 4, §3.2)
def test_fe_trang_chi_tiet_du_manh():
"""index.html có đủ mảnh §3.2: video overlay + nút tải, bàn resim chồng
(nét đứt vẽ canvas), bảng 2 cột (tái dụng #an-metrics builder), nút
"Đánh lại cú"; app.js mở chi tiết từ click hàng, móc smoke riêng."""
html = _fe_src("index.html")
for i in ['id="and-detail"', 'id="and-back"', 'id="and-video"',
'id="and-download"', 'id="and-replay"', 'id="and-metrics"',
'id="and-caveat"', 'id="and-suspect"', 'id="and-resim"',
'id="anv-list"', 'id="and-json"']:
assert i in html, i
js = _fe_src("app.js")
for s in ["openAnvDetail", "renderAnvDetail", "closeAnvDetail",
"drawAnvDetail", "anvChosenResim", "__andRenderMock"]:
assert s in js, s
def test_fe_danh_lai_cu_tai_dung_animateShot_khong_animation_moi():
"""BRIEF A2 phần 4: nút "Đánh lại cú" phải đi qua ĐÚNG animateShot()
sẵn có (không viết vòng requestAnimationFrame mới); thoát tab/đóng
trang chi tiết bị chặn trong lúc animate — state sạch (bẫy BG25/26)."""
js = _fe_src("app.js")
i = js.index("async function anvReplay")
body = js[i:js.index("\n}", i)]
assert "animateShot(" in body
assert "requestAnimationFrame" not in body # không animation mới
for fn in ["function exitAnalyzer", "function closeAnvDetail",
"async function openAnvDetail"]:
j = js.index(fn)
head = js[j:js.index("\n}", j)]
assert "S.animating" in head, fn
def test_fe_bang_2_cot_dung_chung_mot_builder():
"""Bảng thông số trang chi tiết đi qua fillAnMetrics duy nhất (một
nguồn sự thật — từ A2b luồng 1-cú đã gỡ, chỉ còn trang chi tiết dùng)
+ hàng cờ height_comp / identifiable chỉ mọc ở chế độ flags."""
js = _fe_src("app.js")
assert js.count("function fillAnMetrics") == 1
assert "fillAnMetrics(tbl, res, { flags: true })" in js # chi tiết
assert "Bù độ cao tâm bi" in js
assert "identifiable" in js
# ------------------------------ xuất CSV/JSON danh sách cú (A2 phần 5)
def _seed_video_for_export(client, fq, base):
start_segment_worker(fq, base)
vid = post_video(client).json()["id"]
wait_manifest(base, vid)
res = json.loads(json.dumps(RESULT_SAMPLE))
res["resim"] = {"table_w_m": 1.27, "table_l_m": 2.54,
"ball_r_m": 0.028575,
"sets": {"shotnet": {
"params": {"v0_mps": 2.31, "phi_deg": 200.8,
"a": -0.12, "b": 0.05},
"rmse_mm": 412.4, "n_points": 147, "t0_s": 0.45,
"t_align_s": 0.033, "series": {"cue": []}}}}
res["suspect_not_shot"] = {
"flagged": True,
"reasons": ["track chỉ phủ 45% thời lượng (< 80%)"]}
astore.write_json(astore.shot_json_path(base / vid, 1), res)
return vid
def test_export_csv_utf8_bom_du_hang_du_cot(client, fq, _tmp_analyzer_dir):
"""CSV: UTF-8 BOM (Excel không vỡ tiếng Việt), đủ 1 hàng/cú KỂ CẢ cú
lỗi, cột van + RMSE; số TRÙNG với bảng (cùng _collect_shots)."""
vid = _seed_video_for_export(client, fq, _tmp_analyzer_dir)
r = client.get(f"/api/analyzer/videos/{vid}/export.csv")
assert r.status_code == 200
assert r.content.startswith(b"\xef\xbb\xbf") # BOM utf-8-sig
assert "attachment" in r.headers["content-disposition"]
text = r.content.decode("utf-8-sig")
lines = [ln for ln in text.split("\r\n") if ln]
assert len(lines) == 1 + 3 # header + 3 cú
head = lines[0]
for col in ["φ model", "RMSE resim (mm)", "nghi không phải cú đánh",
"lý do van", "tin cậy model"]:
assert col in head, col
row1 = lines[1]
assert "412.4" in row1 and "shotnet" in row1
assert "có" in row1 and "45% thời lượng" in row1
assert "xong" in row1
# cú segmentation kết án vẫn CÓ HÀNG kèm lý do — không lọc
assert "không phân tích được" in lines[3]
assert "mất cảnh" in lines[3]
def test_export_json_trung_body_get_shots(client, fq, _tmp_analyzer_dir):
vid = _seed_video_for_export(client, fq, _tmp_analyzer_dir)
r = client.get(f"/api/analyzer/videos/{vid}/export.json")
assert r.status_code == 200
assert "attachment" in r.headers["content-disposition"]
body = client.get("/api/analyzer/shots", params={"video": vid}).json()
assert r.json() == body # một nguồn — không thể lệch
def test_export_id_rac_khong_cham_file(client, fq):
"""id lạ bị chặn trước khi ghép đường dẫn: id sai dạng → 422; '../'
bị starlette chuẩn hoá path nên rơi 404 — cả hai đều không đụng file
(cùng khẩu vị test_video_id_rac A1)."""
for vid in ["xyz", "A" * 32]:
assert client.get(
f"/api/analyzer/videos/{vid}/export.csv").status_code == 422
assert client.get(
f"/api/analyzer/videos/{vid}/export.json").status_code == 422
assert client.get(
"/api/analyzer/videos/../export.csv").status_code in (404, 422)
def test_fe_nut_xuat_csv_json():
html = _fe_src("index.html")
assert 'id="anv-csv"' in html and 'id="anv-json"' in html
js = _fe_src("app.js")
assert "/export.csv" in js and "/export.json" in js
# ------------------------------- transport worker (file-first, nếp BG29b)
def _cv_worker():
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import cv_worker
return cv_worker
def test_handle_analyze_ghi_file_ket_qua_ngay(fq, monkeypatch, tmp_path):
"""`result_path` trong payload → kết quả ra FILE ngay khi worker xong
(điểm chốt BG29b). Job /api/analyze cũ (không result_path) không mọc
file nào."""
cw = _cv_worker()
monkeypatch.setattr(cw, "analyze_once",
lambda *a, **k: dict(RESULT_SAMPLE))
clip = tmp_path / "shot_01.mp4"
clip.write_bytes(b"x")
rp = tmp_path / "shot_01.json"
reply = cw.handle_analyze(
{"analyze_id": "vid00000000000000000000000000000as01",
"clip_path": str(clip), "result_path": str(rp)},
model=None, conf=0.3, predict_kw={})
assert reply["ok"] is True
assert json.loads(rp.read_text(encoding="utf-8")) == RESULT_SAMPLE
assert not clip.exists() # clip per cú không đọng
def test_handle_analyze_loi_cung_ra_file(fq, monkeypatch, tmp_path):
cw = _cv_worker()
def boom(*a, **k):
raise ValueError("Clip không còn trong thư mục tạm — upload lại.")
monkeypatch.setattr(cw, "analyze_once", boom)
rp = tmp_path / "shot_02.json"
reply = cw.handle_analyze({"analyze_id": "a" * 35,
"result_path": str(rp)},
model=None, conf=0.3, predict_kw={})
assert reply["ok"] is False
data = json.loads(rp.read_text(encoding="utf-8"))
assert data["error"] == "validation"
assert "upload lại" in data["message"]
def test_handle_analyze_khong_result_path_khong_doi_hanh_vi(
fq, monkeypatch, tmp_path):
cw = _cv_worker()
monkeypatch.setattr(cw, "analyze_once",
lambda *a, **k: dict(RESULT_SAMPLE))
reply = cw.handle_analyze({"analyze_id": "b" * 32}, model=None,
conf=0.3, predict_kw={})
assert reply["ok"] is True
assert list(tmp_path.iterdir()) == []
def test_handle_segment_loi_ghi_manifest_error(fq, monkeypatch, tmp_path):
"""Segment hỏng vì input → manifest error ra ĐĨA (sống lâu hơn TTL) +
video bị dọn."""
cw = _cv_worker()
def boom(*a, **k):
raise ValueError("Không mở được video — cần MP4/MOV đọc được.")
monkeypatch.setattr(cw, "segment_once", boom)
vp = tmp_path / "video.mp4"
vp.write_bytes(b"x")
reply = cw.handle_segment(
{"video_id": "c" * 32, "video_path": str(vp),
"out_dir": str(tmp_path)}, model=None, conf=0.3, predict_kw={})
assert reply["ok"] is False
man = json.loads((tmp_path / "shots.json").read_text(encoding="utf-8"))
assert man["status"] == "error"
assert "Không mở được video" in man["message"]
assert not vp.exists()
st = jobqueue.get_video_status("c" * 32)
assert st["status"] == "error"
# --------------- các test giữ lại từ test_analyze.py (endpoint gỡ ở A2b)
# Endpoint 1-cú POST/GET /api/analyze đã gỡ; nhóm dưới đây KHÔNG thuộc
# hợp đồng endpoint đó nên dời về đây thay vì xoá: van mật độ (hàm thuần
# cv_worker, BG29), probe FE (fillAnMetrics/caveat/chuẩn click sống trong
# trang chi tiết), và strictness của schema Analyze* (giờ là schema tài
# liệu của JSON per cú).
@pytest.mark.parametrize("density,raw,mong_doi", [
(5.28, "high", "high"), # trung bình train synth — không hạ
(3.09, "high", "high"), # cú 12 thật — trên ngưỡng, không hạ
(2.00, "high", "high"), # đúng p10 train (biên: chưa hạ)
(1.99, "high", "medium"), # dưới p10 → hạ 1 bậc
(1.99, "medium", "low"),
(1.01, "high", "low"), # cú 11 thật — hạ 2 bậc
(1.01, "medium", "low"), # đã thấp thì không hạ dưới sàn
(0.0, "low", "low"),
])
def test_van_mat_do_ha_cap_dung_bac(density, raw, mong_doi):
assert _cv_worker().density_confidence(raw, density) == mong_doi
def test_van_mat_do_soan_dong_canh_bao_khi_va_chi_khi_ha():
cw = _cv_worker()
blk = {"confidence_raw": "high", "confidence": "low", "det_density": 1.01}
note = cw.density_warning(blk)
assert note is not None
assert "1.01/frame" in note and "cao" in note and "thấp" in note
assert "số model giữ nguyên" in note
# không hạ → không có dòng nào (van im lặng khi mật độ đủ)
assert cw.density_warning(
{"confidence_raw": "high", "confidence": "high",
"det_density": 3.09}) is None
# worker cũ (không có confidence_raw) không được đẻ cảnh báo ma
assert cw.density_warning({"confidence": "high"}) is None
def test_fe_hien_viec_ha_cap_tin_cay():
"""Probe nguồn FE: ô tin cậy phải NÓI RA là đã hạ và hạ vì cái gì —
hạng tin tụt mà không giải thích thì người xem đọc thành 'model tệ'."""
js = _fe_src("app.js")
assert "confidence_raw" in js
assert "det_density" in js
assert "hạ từ" in js
assert "/frame" in js
def test_fe_chuan_click_mep_nose_vai():
"""Probe nguồn FE (nếp #scan-go BG26): hướng dẫn chuẩn click BG31 phải
nằm TĨNH trong index.html (một câu + hình minh hoạ), và bước chấm ①
trong app.js nói cùng một chuẩn — click cao hơn mặt vải làm bù quá tay
(BG30 đã đo)."""
html = _fe_src("index.html")
assert 'id="an-note"' in html
assert "mép nose vải" in html
assert "chấm miệng lỗ trên mặt gỗ" in html
js = _fe_src("app.js")
assert "mép nose vải" in js
def test_fe_hai_cot_va_caveat():
"""Probe nguồn FE (nếp #scan-go BG26): bảng 2 cột phải có đúng hai nhãn
cột BRIEF 28, nhãn V0 phân biệt HAI THƯỚC (gậy vs bi đo), caveat model
nằm TĨNH trong index.html (JS chỉ bật/tắt — không ai quên soạn nó; từ
A2b caveat sống ở trang chi tiết #and-caveat) và app.js toggle theo
`shotnet`."""
js = _fe_src("app.js")
assert "Model (ShotNet)" in js
assert "Đo giải tích (đối chứng)" in js
assert "V0 gậy (model)" in js
assert "tốc độ bi đo được" in js
assert "res.shotnet" in js
assert "and-caveat" in js
# ngưỡng bất đồng nằm ở bảng hằng, có cả hai trục φ + V0
assert "AN_DIFF_PHI_DEG" in js and "AN_DIFF_V0_RATIO" in js
html = _fe_src("index.html")
assert 'id="and-caveat"' in html
assert "chưa tinh chỉnh trên video thật" in html
def test_khoi_shotnet_gia_tri_la_khong_lot_schema():
"""Giá trị lạ trong khối shotnet (spin_vert ngoài 3 lớp) không được
lọt qua schema Analyze* — schema là hợp đồng shape của JSON per cú."""
import pydantic
from app.schemas import AnalyzeShotnetOut
with pytest.raises(pydantic.ValidationError):
AnalyzeShotnetOut(model="x", v0_cue_mps=1.0, phi_deg=0.0, a=0.0,
b=0.0, spin_vert="topspin", spin_side="side-L",
identifiable_prob=0.5, confidence="high",
inference_ms=1.0, n_target_slots=1)
def test_height_comp_gia_tri_la_khong_lot_schema():
import pydantic
from app.schemas import AnalyzeHeightComp
with pytest.raises(pydantic.ValidationError):
AnalyzeHeightComp(on="maybe-la-gi-do", h_m="cao")
# --------------------- Analyzer là tab đầu + mặc định (A2b phần 3)
def test_fe_analyzer_tab_dau_va_mac_dinh():
"""A2b feedback #2: Analyzer là tab ĐẦU (trước Gợi ý, trước Bài tập) và
active khi load; #analyzer hiện sẵn còn .controls/.legend của tab Gợi ý
ẩn lúc boot — HTML tĩnh phải khớp S.anMode=true trong app.js (một nguồn
sự thật trạng thái đầu, vùng bẫy init/state BG25/BG26)."""
html = _fe_src("index.html")
ia = html.index('id="tab-analyzer"')
isg = html.index('id="tab-suggest"')
idr = html.index('id="tab-drills"')
assert ia < isg < idr
assert '<button id="tab-analyzer" class="tab active">' in html
assert '<button id="tab-suggest" class="tab">' in html
assert '<button id="tab-drills" class="tab">' in html
assert '<section id="analyzer">' in html # KHÔNG hidden lúc boot
assert '<section class="controls hidden">' in html
assert '<section class="legend hidden">' in html
js = _fe_src("app.js")
assert "anMode: true" in js
def test_fe_deep_link_qr_vao_thang_tab_goi_y():
"""Deep-link CŨ ?qr=<token> (QR dán ở bàn) là lối vào luồng gợi ý —
phải đáp xuống ĐÚNG tab Gợi ý dù Analyzer giờ là mặc định."""
js = _fe_src("app.js")
assert ('if (new URLSearchParams(location.search).get("qr")) '
"exitAnalyzer();") in js
# và exitAnalyzer vẫn chặn khi đang animate (A2 gate 4 giữ xanh)
i = js.index("function exitAnalyzer")
body = js[i:js.index("\nfunction ", i + 10)]
assert "S.animating" in body
# ---------------- trang chi tiết A2b phần 4: replay quan sát + widget +
# toggle resim + số bi BallID trên bàn metric
def test_fe_danh_lai_cu_theo_quy_dao_quan_sat():
"""A2b (Danh chốt 13/08 tối): "Đánh lại cú" animate theo quỹ đạo QUAN
SÁT (smoothTrack trên res.track), KHÔNG còn chạy series resim; vẫn
animateShot() nguyên trạng."""
js = _fe_src("app.js")
i = js.index("async function anvReplay")
body = js[i:js.index("\n}", i)]
assert "smoothTrack(res.track)" in body
assert "anvChosenResim" not in body # không còn chạy resim
assert "animateShot(" in body
assert "requestAnimationFrame" not in body
html = _fe_src("index.html")
assert "Đánh lại cú (quỹ đạo quan sát)" in html
def test_fe_widget_thong_tin_cu_so_shotnet():
"""Widget khi đánh lại: chấm điểm đầu cơ (a/b) + thanh lực V0, số từ bộ
SHOTNET có dán nhãn bộ; id/class RIÊNG (.and-spin-dot/.and-power-bar)
vì .spin-dot/.power-bar bị initPowerBar + updateShotViz quét toàn cục
(bài học .btn-play). Cùng quy ước chấm spin tab gợi ý (a=+1 mép TRÁI:
cx = 50 − a·46)."""
html = _fe_src("index.html")
for i in ['id="and-shotviz"', 'id="and-spin-ball"', 'id="and-power-bar"',
'id="and-power-val"', 'id="and-spin-cap"',
'id="and-viz-label"', 'class="and-spin-dot"']:
assert i in html, i
js = _fe_src("app.js")
assert "function fillAndShotviz" in js
assert "sn.v0_cue_mps" in js and "50 - sn.a * 46" in js \
and "50 - sn.b * 46" in js
assert "số bộ shotnet" in js and "analytic không có a/b" in js
# widget KHÔNG mượn class bị quét toàn cục
assert 'class="and-power-bar"' in html
i0 = html.index('id="and-shotviz"')
widget = html[i0:html.index('id="and-suspect"', i0)]
assert 'class="power-bar"' not in widget
assert 'class="spin-dot"' not in widget
def test_fe_toggle_resim_mac_dinh_tat_mot_hang_config():
"""Lớp resim = toggle "So với mô phỏng" MẶC ĐỊNH TẮT (path quan sát ↔
resim đang lệch V0-thang); bật-mặc-định-lại là MỘT hằng
AND_RESIM_DEFAULT_ON. Bật mới vẽ nét đứt + RMSE (drawAnvDetail gate
theo showResim; dòng RMSE sync theo toggle)."""
js = _fe_src("app.js")
assert "const AND_RESIM_DEFAULT_ON = false;" in js
assert "showResim: AND_RESIM_DEFAULT_ON" in js
i = js.index("function drawAnvDetail")
body = js[i:js.index("\nfunction ", i + 10)]
assert "ANV.detail.showResim ? anvChosenResim(res) : null" in body
assert "function syncAndResim" in js
html = _fe_src("index.html")
assert 'id="and-resim-toggle"' in html
assert "So với mô phỏng" in html
# checkbox không được checked sẵn trong HTML — trạng thái từ JS
i0 = html.index('id="and-resim-toggle"')
tag = html[html.rindex("<input", 0, i0):html.index(">", i0)]
assert "checked" not in tag
def test_fe_bi_ban_metric_mau_so_theo_ballid():
"""Bi trên bàn metric tô màu + số theo `number` (BallID) qua drawBall
của app — kết quả cũ không có trường thì bi xám như trước (không bịa
số); bi cái tĩnh không vẽ đè lên bi đang animate."""
js = _fe_src("app.js")
i = js.index("function drawAnObserved")
body = js[i:js.index("\nfunction ", i + 10)]
assert "b.number" in body
assert "drawBall(" in body
assert "#8d9aa8" in body # fallback bi xám còn đó
assert 'ANIM && b.type === "cue"' in body # không bi ma đôi khi replay
def test_worker_match_ballid_vao_balls_init():
"""Hàm matching thuần: gắn số vào entry gần nhất trong tol, conf cao
ưu tiên, không đè entry đã có số, ngoài tol/None thì bỏ."""
cw = _cv_worker()
balls = [
{"x_m": 0.50, "y_m": 0.30, "type": "cue", "conf": 0.9},
{"x_m": 1.00, "y_m": 1.50, "type": "ball", "conf": 0.8},
{"x_m": 0.20, "y_m": 0.40, "type": "ball", "conf": 0.7},
{"x_m": 0.60, "y_m": 2.00, "type": "ball", "conf": 0.6},
]
numbered = [
(1.001, 1.501, 9, 0.91), # khớp entry 2 (lệch ~1mm)
(0.201, 0.399, 3, 0.85), # khớp entry 3
(0.202, 0.401, 5, 0.30), # tranh entry 3 — conf thấp hơn, thua
# (entry đã có số, nearest khác quá xa)
(0.60, 2.20, 7, 0.99), # lệch 20cm > tol — bỏ
(1.10, 0.58, None, 0.0), # màu không nhận ra — bỏ
]
n = cw.match_ballid_to_init(balls, numbered, 0.03)
assert n == 2
assert balls[1]["number"] == 9 and balls[1]["number_conf"] == 0.91
assert balls[2]["number"] == 3
assert "number" not in balls[3]
assert "number" not in balls[0] # cue không bao giờ gắn
def test_schema_balls_init_number_optional():
"""AnalyzeBallInit: number/number_conf optional — kết quả cũ (không
trường) lẫn mới (có số) đều qua; exclude_none không mọc key ma."""
from app.schemas import AnalyzeBallInit
old = AnalyzeBallInit(x_m=1.0, y_m=2.0, type="ball", conf=0.9)
assert "number" not in old.model_dump(exclude_none=True)
new = AnalyzeBallInit(x_m=1.0, y_m=2.0, type="ball", conf=0.9,
number=9, number_conf=0.8)
assert new.model_dump(exclude_none=True)["number"] == 9