Spaces:
Sleeping
Sleeping
| """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": [], | |
| } | |
| def _reset_jobqueue(): | |
| yield | |
| jobqueue.teardown() | |
| def _pin_ffprobe(monkeypatch): | |
| monkeypatch.setattr(app_main, "_probe_duration_s", lambda p: 300.0) | |
| 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 | |
| def client(): | |
| return TestClient(app_main.app) | |
| def fq(): | |
| from test_queue import FakeQueue | |
| q = FakeQueue() | |
| jobqueue.setup(client=q.api) | |
| return q | |
| 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 | |
| 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ú). | |
| 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 | |