"""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("", 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('
' in html assert '