Spaces:
Sleeping
Sleeping
File size: 9,058 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | """Contract mode "local" + video mẫu bundled (BRIEF 14/08/2026, việc B).
Mode "local" = MỘT tiến trình cho cả app lẫn CV (Space HF): transport chạy
fakeredis trong tiến trình, CV worker là một luồng (`app/localcv.py`), còn
`/api/recommend` GIỮ in-process như bản không queue. Test ở đây khoá đúng
ba mệnh đề đó + đường đọc kết quả từ ĐĨA (thứ làm video mẫu mở được kể cả
khi tầng CV chết).
KHÔNG test luồng CV thật (cần torch/YOLO/ffmpeg) — cái đó nghiệm thu bằng
lần chạy tay trên clip thật, ghi số trong HANDOFF.
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from app import analyzer_store as astore
from app import jobqueue
from app import main as app_main
from test_api_v2 import BALLS_FULL, make_shot, patch_v2, v2_result
@pytest.fixture(autouse=True)
def _reset_jobqueue():
yield
jobqueue.teardown()
@pytest.fixture
def client(monkeypatch, env_stub):
monkeypatch.setattr(app_main.state, "env_h", env_stub)
monkeypatch.setattr(app_main.state, "jit_ready", True)
monkeypatch.setattr(app_main.state, "boot_error", None)
return TestClient(app_main.app)
@pytest.fixture
def local_mode(monkeypatch):
monkeypatch.setenv("POOLCOACH_LOCAL_CV", "1")
monkeypatch.delenv("REDIS_URL", raising=False)
assert jobqueue.setup() == "local"
return jobqueue
# ------------------------------------------------------------ mode
def test_env_bat_thi_ra_mode_local(local_mode):
assert jobqueue.mode() == "local"
assert jobqueue.cv_enabled() is True
assert jobqueue.ping() is True # fakeredis trong tiến trình
@pytest.mark.parametrize("val", ["", "0", "false", "no"])
def test_env_tat_thi_van_inprocess(monkeypatch, val):
monkeypatch.setenv("POOLCOACH_LOCAL_CV", val)
monkeypatch.delenv("REDIS_URL", raising=False)
assert jobqueue.setup() == "inprocess"
assert jobqueue.cv_enabled() is False
def test_redis_url_van_thang_local(monkeypatch):
"""Có REDIS_URL thì vẫn là mode queue — local chỉ là đường dự phòng cho
container một tiến trình, không được cướp cấu hình thật."""
monkeypatch.setenv("POOLCOACH_LOCAL_CV", "1")
monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
assert jobqueue.setup() == "queue"
def test_health_khai_mode_local_va_khong_doi_engine_worker(client, local_mode):
h = client.get("/api/health").json()
assert h["mode"] == "local"
# engine chạy IN-PROCESS ở mode local → không có engine worker để chết:
# null nghĩa "không áp dụng", KHÁC False = "chết" (quy ước bàn giao 14)
assert h["worker_alive"] is None
assert h["redis_ok"] is True
assert h["cv_worker_alive"] is False # luồng nhúng chưa chạy trong test
def test_recommend_o_mode_local_van_chay_in_process(client, local_mode,
monkeypatch):
"""Bẫy của việc thêm mode: nếu route đọc "có hàng đợi ⇒ đẩy engine đi
worker" thì tab Gợi ý trên Space sẽ 503 vì chẳng có engine worker nào."""
patch_v2(monkeypatch, v2_result([make_shot(1)]))
called = {}
orig = app_main.jobqueue.submit
def boom(*a, **k): # gọi vào đây là đã đi nhầm đường
called["queue"] = True
return orig(*a, **k)
monkeypatch.setattr(app_main.jobqueue, "submit", boom)
r = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r.status_code == 200
assert "queue" not in called
# --------------------------------------------------- video mẫu bundled
def _canned_video(base, vid="a" * 32, n=2):
d = base / vid
d.mkdir(parents=True)
shots = [{"idx": i, "t_start_s": i, "t_end_s": i + 2.0,
"t_onset_s": i + 0.2, "t_settle_s": i + 1.5,
"status": "queued", "reason": None,
"analyze_id": astore.shot_analyze_id(vid, i),
"thumb": f"thumb_{i:02d}.jpg"} for i in range(1, n + 1)]
astore.write_json(astore.manifest_path(d),
{"status": "done", "video_id": vid, "shots": shots,
"duration_s": 60.0, "warnings": []})
for i in range(1, n + 1):
astore.write_json(astore.shot_json_path(d, i), {
"metrics": {"v0_mps": 2.0, "phi_deg": 12.3, "n_collisions": 1},
"spin_class": "stun", "warnings": []})
return vid, d
def test_seed_demo_chep_va_khong_de_ban_dang_xem(tmp_path, monkeypatch):
src = tmp_path / "src"
vid, _ = _canned_video(src)
monkeypatch.setattr(astore, "DEMO_SRC_DIR", src)
base = tmp_path / "out"
assert astore.seed_demo(base) == [vid]
assert astore.manifest_path(base / vid).exists()
# lần hai KHÔNG đè: người dùng có thể đang xem dở
mine = base / vid / "shot_01.json"
mine.write_text('{"metrics": {"v0_mps": 99.0}, "warnings": []}',
encoding="utf-8")
astore.seed_demo(base)
assert json.loads(mine.read_text(encoding="utf-8"))["metrics"]["v0_mps"] \
== 99.0
def test_seed_demo_thieu_thu_muc_khong_nem(tmp_path, monkeypatch):
monkeypatch.setattr(astore, "DEMO_SRC_DIR", tmp_path / "khong-co")
assert astore.seed_demo(tmp_path / "out") == []
def test_endpoint_demo_tra_id_bundled(client, tmp_path, monkeypatch):
src = tmp_path / "src"
vid, _ = _canned_video(src)
monkeypatch.setattr(astore, "DEMO_SRC_DIR", src)
assert client.get("/api/analyzer/demo").json()["id"] == vid
def test_endpoint_demo_tra_null_khi_khong_dong_goi(client, tmp_path,
monkeypatch):
monkeypatch.setattr(astore, "DEMO_SRC_DIR", tmp_path / "khong-co")
assert client.get("/api/analyzer/demo").json() == {"id": None}
def test_bo_thu_muc_demo_that_trong_repo_doc_duoc():
"""Bộ mẫu đóng gói phải THẬT SỰ đọc được (manifest + đủ JSON/overlay
per cú) — mất file lúc đóng gói thì Space mở ra là bảng trống, mà đó
lại đúng thứ người mở link thấy đầu tiên."""
ids = astore.demo_ids()
if not ids:
pytest.skip("bản này không đóng gói video mẫu")
for vid in ids:
d = astore.DEMO_SRC_DIR / vid
man = astore.read_json(astore.manifest_path(d))
assert man and man.get("status") == "done" and man.get("shots")
for sh in man["shots"]:
if sh.get("status") == "error":
continue
i = int(sh["idx"])
assert astore.shot_json_path(d, i).exists(), (vid, i)
assert astore.overlay_path(d, i).exists(), (vid, i)
assert astore.thumb_path(d, i).exists(), (vid, i)
# ------------------------------------- đọc kết quả từ ĐĨA khi không queue
def test_video_co_manifest_doc_duoc_khi_khong_co_hang_doi(client, tmp_path,
monkeypatch):
"""Đĩa là nguồn sự thật (nếp BG29b/BG31): video đã phân tích xong phải
mở được kể cả khi tầng CV tắt hẳn — đó là điều giữ cho video mẫu sống
trên một bản deploy không có worker."""
base = tmp_path / "an"
vid, _ = _canned_video(base, n=3)
monkeypatch.setattr(app_main, "_analyzer_video_dir", lambda v: base / v)
jobqueue.teardown() # mode inprocess
r = client.get("/api/analyzer/shots", params={"video": vid})
assert r.status_code == 200
body = r.json()
assert body["n_total"] == 3 and body["n_done"] == 3
assert body["shots"][0]["phi_deg"] == 12.3
# export dùng chung nguồn → cũng phải sống
assert client.get(f"/api/analyzer/videos/{vid}/export.csv").status_code \
== 200
def test_khong_manifest_va_khong_hang_doi_van_503(client, tmp_path,
monkeypatch):
"""Không nới lỏng ca cũ: chưa có gì trên đĩa thì phải có hàng đợi mới
trả lời được video đang ở đâu."""
base = tmp_path / "an"
monkeypatch.setattr(app_main, "_analyzer_video_dir", lambda v: base / v)
jobqueue.teardown()
r = client.get("/api/analyzer/shots", params={"video": "b" * 32})
assert r.status_code == 503
def test_fe_tu_nap_video_mau_luc_mo_app():
js = (app_main.STATIC_DIR / "app.js").read_text(encoding="utf-8")
assert "/api/analyzer/demo" in js
assert "loadAnvDemo()" in js
def test_fe_canh_bao_cham_chi_o_ban_cpu():
"""Cảnh báo chậm phải bám tín hiệu SERVER (health.mode == "local"), chứ
không hiện vô điều kiện — máy Danh chạy GPU qua mode queue."""
js = (app_main.STATIC_DIR / "app.js").read_text(encoding="utf-8")
html = (app_main.STATIC_DIR / "index.html").read_text(encoding="utf-8")
assert 'id="an-cpu-warn"' in html
assert 'h.mode === "local"' in js
assert 'S.cpuOnly' in js
|