Spaces:
Running
Running
Download backend/tests/test_analytics.py from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 18.3 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/tests/test_analytics.py
- Command line
-
hf download hf://spaces/tntgroup/website_content/backend/tests/test_analytics.py
-
curl -L -o test_analytics.py https://huggingface.co/spaces/tntgroup/website_content/resolve/main/backend/tests/test_analytics.py
18.3 kB
| """Test theo dõi người dùng: ghi log thô -> suy ra 9 chỉ số (A1-A5, B1-B4). | |
| Cô lập bằng cách trỏ STORAGE_DIR vào thư mục tạm TRƯỚC khi import storage, và | |
| ép DATABASE_URL rỗng để luôn chạy nhánh SQLite (không đụng Postgres thật). | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import tempfile | |
| import time | |
| import uuid | |
| from datetime import datetime, timedelta, timezone | |
| _TEST_STORAGE = tempfile.mkdtemp(prefix="tnt_an_") | |
| os.environ["STORAGE_DIR"] = _TEST_STORAGE | |
| from app.config import settings # noqa: E402 | |
| settings.STORAGE_DIR = _TEST_STORAGE # .env của máy load override -> ép lại | |
| settings.DATABASE_URL = "" # ép SQLite | |
| os.makedirs(_TEST_STORAGE, exist_ok=True) | |
| from app.services.analytics import etl, storage as store # noqa: E402 | |
| TZ = timezone(timedelta(hours=7)) | |
| T0 = time.time() - 3600 | |
| def _ev(off, name, step, *, feature="dub", task="task_1", sess="sess_1", | |
| user="an", lat=None, value="", **props): | |
| """Một dòng log đã chuẩn hoá, đặt tại mốc T0+off giây.""" | |
| ts = T0 + off | |
| row = store.normalize({ | |
| "event_id": uuid.uuid4().hex, | |
| "event_time": datetime.fromtimestamp(ts, TZ).isoformat(timespec="milliseconds"), | |
| "user_id": user, "session_id": sess, "task_id": task, "feature": feature, | |
| "step": step, "event_name": name, "event_value": value, | |
| "latency_ms": lat, "properties": props, | |
| }, ts) | |
| row["event_ts"] = ts # giữ nguyên trục thời gian giả lập | |
| # storage.normalize() trả dạng GHI (properties_json). Khi đọc lại từ DB, | |
| # _row_to_dict() bung thành `properties` dict — ETL dùng dạng đó. Cho dòng | |
| # mang cả hai để test được cả nhánh ghi lẫn nhánh tính chỉ số. | |
| row["properties"] = dict(props) | |
| return row | |
| def _one_full_task(): | |
| """Một task dub hoàn chỉnh: 2 lần máy, người sửa ở giữa, tải về, 👍.""" | |
| return [ | |
| _ev(0, "session_start", "open", feature="", task=""), | |
| _ev(2, "feature_open", "open"), | |
| _ev(6, "upload_done", "prepare"), | |
| _ev(9, "job_queued", "run", job_id="j1"), | |
| _ev(11, "job_started", "run", job_id="j1"), | |
| _ev(41, "job_done", "run", lat=30000, job_id="j1", phase="asr_translate"), | |
| _ev(120, "edit_translation", "review"), | |
| _ev(200, "job_queued", "run", job_id="j2"), | |
| _ev(202, "job_started", "run", job_id="j2"), | |
| _ev(260, "job_done", "run", lat=59000, job_id="j2", phase="tts"), | |
| _ev(275, "output_download", "output", value="mp4", count=2, | |
| success_type="download"), | |
| _ev(277, "thumbs_feedback", "output", value="up"), | |
| _ev(280, "feature_complete", "done", success_type="download"), | |
| ] | |
| # ── Chuẩn hoá dòng log ─────────────────────────────────────────── | |
| def test_normalize_drops_zero_latency(): | |
| """latency_ms chỉ dành cho dòng MÁY chạy: 0/rỗng phải thành None. | |
| Nhét 0 vào là kéo tụt trung bình tốc độ máy (spec §2 "quy tắc vàng"). | |
| """ | |
| assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": 0})["latency_ms"] is None | |
| assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": ""})["latency_ms"] is None | |
| assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": -5})["latency_ms"] is None | |
| assert store.normalize({"event_name": "x", "user_id": "a", "latency_ms": 1200})["latency_ms"] == 1200 | |
| def test_normalize_parses_time_and_props(): | |
| r = store.normalize({ | |
| "event_name": "feature_open", "user_id": " AN ", | |
| "event_time": "2026-08-24T09:25:09.300+07:00", | |
| "properties": {"entry": "sidebar"}, | |
| }) | |
| assert r["user_id"] == "an" # chuẩn hoá để gom đúng người | |
| assert abs(r["event_ts"] - 1787538309.3) < 1.0 # 09:25:09.300 +07:00 | |
| assert '"entry"' in r["properties_json"] | |
| # thời gian sai định dạng -> lùi về giờ máy chủ, không làm vỡ luồng | |
| bad = store.normalize({"event_name": "x", "user_id": "a", "event_time": "hôm qua"}) | |
| assert bad["event_ts"] > 0 | |
| # ── Kho lưu ────────────────────────────────────────────────────── | |
| def test_insert_query_and_dedup(): | |
| rows = _one_full_task() | |
| n = store.insert(rows) | |
| assert n == len(rows) | |
| assert store.insert(rows) == 0 # cùng event_id -> không nhân đôi | |
| got = store.query(user_id="an") | |
| assert len(got) >= len(rows) | |
| assert all(g["user_id"] == "an" for g in got) | |
| # dòng thiếu user_id thì bỏ, không rác vào bảng | |
| assert store.insert([store.normalize({"event_name": "x"})]) == 0 | |
| def test_query_newest_first_respects_limit(): | |
| """Cắt `limit` mà xếp tăng dần sẽ lấy nhầm N dòng CŨ NHẤT — màn log thô cần MỚI nhất.""" | |
| old = store.query(limit=3) | |
| new = store.query(limit=3, newest_first=True) | |
| assert len(old) == len(new) == 3 | |
| assert new[0]["server_ts"] >= old[-1]["server_ts"] | |
| assert [r["server_ts"] for r in new] == sorted([r["server_ts"] for r in new], reverse=True) | |
| def test_purge_old_keeps_recent(): | |
| before = store.stats()["rows"] | |
| assert store.purge_old(days=3650) == 0 # chưa có gì cũ tới vậy | |
| assert store.stats()["rows"] == before | |
| # ── ETL: dựng task & chỉ số ────────────────────────────────────── | |
| def test_task_metrics_from_one_session(): | |
| evs = _one_full_task() | |
| tasks = etl.build_tasks(evs) | |
| assert len(tasks) == 1 | |
| t = tasks[0] | |
| assert t["has_output"] and t["success_type"] == "download" | |
| # A1 = từ lúc mở tool tới ĐẦU RA đầu tiên (275 - 2 giây) | |
| assert abs(t["duration_ms"] - 273000) < 50 | |
| # B4 = tổng thời gian máy chạy thật, KHÔNG gồm chờ hàng đợi | |
| assert t["machine_ms"] == 89000 | |
| assert abs(t["wait_ms"] - 4000) < 50 # (11-9) + (202-200) | |
| assert t["attempts"] == 2 # A4: hai lần job_done | |
| assert t["produced"] == 2 # B1: count=2 | |
| assert t["thumbs_up"] == 1 and t["thumbs_down"] == 0 | |
| def test_step_durations_stop_at_done(): | |
| """Bấm 👍 sau khi xong mang step=output; dòng thời gian không được hiện thêm bước sau 'done'.""" | |
| steps = [d["step"] for d in etl.step_durations(etl.build_tasks(_one_full_task())[0])] | |
| assert steps[-1] == "done" | |
| assert steps.count("done") == 1 | |
| def test_abandon_and_completion_rate(): | |
| """A3 = task có đầu ra / tổng task; bỏ dở ghi rõ bước cuối chạm tới.""" | |
| evs = _one_full_task() + [ | |
| _ev(300, "feature_open", "open", feature="shuffle", task="task_2"), | |
| _ev(340, "upload_done", "prepare", feature="shuffle", task="task_2"), | |
| _ev(900, "feature_abandon", "prepare", feature="shuffle", task="task_2", | |
| last_step="prepare", reason="qua_nhieu_clip"), | |
| ] | |
| s = etl.summarize(evs) | |
| assert s["overall"]["tasks"] == 2 | |
| assert s["overall"]["tasks_done"] == 1 | |
| assert abs(s["overall"]["completion_rate"] - 0.5) < 1e-9 | |
| sh = next(f for f in s["features"] if f["feature"] == "shuffle") | |
| assert sh["abandon_by_step"] == {"prepare": 1} | |
| assert sh["label"] == "Xào video" | |
| def test_forward_counts_as_success(): | |
| """Chuyển tiếp sang tool khác cũng là ĐẦU RA hợp lệ (spec §6.2).""" | |
| evs = [ | |
| _ev(0, "feature_open", "open", feature="down_list", task="t_fw"), | |
| _ev(5, "output_forward", "output", feature="down_list", task="t_fw", | |
| value="cut", success_type="forward", to="cut", count=3), | |
| ] | |
| t = etl.build_tasks(evs)[0] | |
| assert t["has_output"] and t["success_type"] == "forward" | |
| prods = etl.summarize(evs)["products"] | |
| assert prods[0]["kind"].startswith("→") # đọc ra "chuyển sang", không lẫn đuôi file | |
| assert prods[0]["count"] == 3 | |
| def test_job_speed_split_by_phase(): | |
| """B2 phải tách tốc độ theo TỪNG BƯỚC máy, không gộp một cục.""" | |
| jobs = etl.summarize(_one_full_task())["jobs"] | |
| phases = {j["phase"]: j["median_ms"] for j in jobs} | |
| assert phases["asr_translate"] == 30000 | |
| assert phases["tts"] == 59000 | |
| def test_contribution_score_ranks_tools(): | |
| """Điểm đóng góp = 40% nhu cầu + 20% ít bấm chạy lại + 40% sản phẩm đầu ra. | |
| Tool A: 2 việc, 4 sản phẩm, mỗi việc bấm 1 lần -> phải đứng trên | |
| Tool B: 1 việc, 1 sản phẩm, bấm 2 lần/việc. | |
| """ | |
| evs = [] | |
| for i in (1, 2): # cut: 2 việc, mỗi việc 1 lần bấm, 2 sản phẩm | |
| evs += [ | |
| _ev(i * 10, "feature_open", "open", feature="cut", task=f"c{i}"), | |
| _ev(i * 10 + 2, "job_done", "run", feature="cut", task=f"c{i}", lat=1000), | |
| _ev(i * 10 + 4, "output_download", "output", feature="cut", task=f"c{i}", | |
| value="mp4", count=2), | |
| ] | |
| evs += [ # shuffle: 1 việc, 2 lần bấm, 1 sản phẩm | |
| _ev(100, "feature_open", "open", feature="shuffle", task="s1"), | |
| _ev(102, "job_done", "run", feature="shuffle", task="s1", lat=1000), | |
| _ev(104, "regenerate", "run", feature="shuffle", task="s1"), | |
| _ev(106, "output_download", "output", feature="shuffle", task="s1", | |
| value="mp4", count=1), | |
| ] | |
| feats = {f["feature"]: f for f in etl.summarize(evs)["features"]} | |
| cut, sh = feats["cut"], feats["shuffle"] | |
| # cut cao nhất ở cả 3 thành phần -> tròn 100 điểm | |
| assert cut["score_demand"] == 100.0 and cut["score_output"] == 100.0 | |
| assert cut["score_rerun"] == 100.0 and cut["score_total"] == 100.0 | |
| # shuffle: nhu cầu 1/2, sản phẩm 1/4, bấm gấp đôi -> 0.4*50 + 0.2*50 + 0.4*25 | |
| assert sh["score_demand"] == 50.0 | |
| assert sh["score_output"] == 25.0 | |
| assert sh["score_rerun"] == 50.0 | |
| assert abs(sh["score_total"] - 40.0) < 0.05 | |
| assert cut["score_total"] > sh["score_total"] | |
| def test_contribution_rerun_zero_when_never_ran(): | |
| """Tool mở ra rồi bỏ (chưa bấm chạy lần nào) KHÔNG được 100đ "ít chạy lại". | |
| Kẹp sàn 1.0 như bản đầu làm cả bảng cùng 100 -> thành phần 20% mất tác dụng. | |
| Chưa chạy = chưa đóng góp -> 0 điểm. | |
| """ | |
| evs = [ | |
| _ev(0, "feature_open", "open", feature="cut", task="c1"), | |
| _ev(2, "job_done", "run", feature="cut", task="c1", lat=1000), | |
| _ev(4, "output_download", "output", feature="cut", task="c1", value="mp4", count=1), | |
| _ev(50, "feature_open", "open", feature="text_video", task="v1"), # mở rồi bỏ | |
| ] | |
| feats = {f["feature"]: f for f in etl.summarize(evs)["features"]} | |
| assert feats["cut"]["score_rerun"] == 100.0 | |
| assert feats["text_video"]["attempts_avg"] == 0 | |
| assert feats["text_video"]["score_rerun"] == 0.0 | |
| assert feats["cut"]["score_total"] > feats["text_video"]["score_total"] | |
| def test_contribution_score_handles_single_tool(): | |
| """Chỉ có 1 tool -> không vỡ, tự là tool cao nhất (100 điểm).""" | |
| f = etl.summarize(_one_full_task())["features"] | |
| assert len(f) == 1 and f[0]["score_total"] == 100.0 | |
| def test_passive_pages_are_not_tasks(): | |
| """page_view (trang chủ/hướng dẫn) không có task_id -> không tính vào A3.""" | |
| evs = _one_full_task() + [ | |
| _ev(400, "page_view", "open", feature="home", task=""), | |
| _ev(410, "page_view", "open", feature="guide", task=""), | |
| ] | |
| s = etl.summarize(evs) | |
| assert s["overall"]["tasks"] == 1 | |
| assert all(f["feature"] not in ("home", "guide") for f in s["features"]) | |
| # ── ETL: phiên làm việc ────────────────────────────────────────── | |
| def test_session_splits_on_long_silence(): | |
| """Im lặng quá ngưỡng -> tách thành hai phiên (spec §6: session_end là mốc mềm).""" | |
| gap = (settings.SESSION_IDLE_MINUTES + 10) * 60 | |
| evs = [ | |
| _ev(0, "session_start", "open", feature="", task="", sess="s"), | |
| _ev(60, "feature_open", "open", task="t1", sess="s"), | |
| _ev(60 + gap, "feature_open", "open", task="t2", sess="s"), | |
| _ev(120 + gap, "upload_done", "prepare", task="t2", sess="s"), | |
| ] | |
| sessions = etl.build_sessions(evs) | |
| assert len(sessions) == 2 | |
| # thời lượng DÙNG THẬT không được cộng cả quãng im lặng | |
| assert sum(s["active_ms"] for s in sessions) < gap * 1000 | |
| assert abs(sessions[0]["active_ms"] - 60000) < 50 | |
| def test_returning_user_needs_two_days(): | |
| """A5 = một user xuất hiện ở >= 2 ngày khác nhau.""" | |
| day = 86400 | |
| evs = [ | |
| _ev(0, "feature_open", "open", task="t1", user="an"), | |
| _ev(-2 * day, "feature_open", "open", task="t0", user="an", sess="s0"), | |
| _ev(0, "feature_open", "open", task="t2", user="binh", sess="sb"), | |
| ] | |
| users = {u["user_id"]: u for u in etl.summarize(evs)["users"]} | |
| assert users["an"]["returning"] is True | |
| assert users["binh"]["returning"] is False | |
| def test_user_detail_lists_sessions_and_tasks(): | |
| d = etl.user_detail(_one_full_task(), "an") | |
| assert d["user_id"] == "an" and len(d["sessions"]) == 1 | |
| task = d["sessions"][0]["tasks"][0] | |
| assert task["label"] == "Dịch / đổi giọng" | |
| assert task["has_output"] and task["machine_ms"] == 89000 | |
| assert [j["latency_ms"] for j in task["jobs"]] == [30000, 59000] | |
| # ── Dịch log sang tiếng Việt + xuất CSV ────────────────────────── | |
| def test_labels_translate_every_column(): | |
| """Mỗi cột log thô phải ra được một câu tiếng Việt, không lọt khoá máy.""" | |
| from app.services.analytics import labels as lb | |
| assert lb.feature_label("dub") == "Dịch / đổi giọng" | |
| assert lb.step_label("run") == "3. Máy chạy" | |
| assert lb.event_label("feature_abandon") == "Bỏ dở" | |
| assert lb.value_label("up").startswith("Hài lòng") | |
| assert lb.value_label("mp4") == "Video MP4" | |
| assert lb.duration_label(53043) == "53,0 giây" | |
| assert lb.duration_label(None) == "" # thao tác NGƯỜI -> để trống, không in "0" | |
| def test_labels_keep_unknown_keys(): | |
| """Tool/thuộc tính mới thêm mà quên cập nhật bảng thì giữ nguyên khoá gốc, | |
| tuyệt đối không được nuốt mất dữ liệu.""" | |
| from app.services.analytics import labels as lb | |
| assert lb.feature_label("tool_moi") == "tool_moi" | |
| assert lb.event_label("su_kien_la") == "su_kien_la" | |
| assert "thuoc_tinh_la: 7" in lb.describe_props({"thuoc_tinh_la": 7}) | |
| def test_describe_props_unpacks_json_blob(): | |
| from app.services.analytics import labels as lb | |
| txt = lb.describe_props({"job_id": "f09c", "kind": "download", "attempt": 1, | |
| "phase": "asr_translate", "dur_ms": 63290}) | |
| # thuộc tính quan trọng lên trước, mili-giây in ra kiểu người đọc | |
| assert txt.startswith("Lần bấm chạy thứ: 1") | |
| assert "Bước máy: Bóc lời & dịch" in txt | |
| assert "Loại job: Tải video" in txt | |
| assert "Kéo dài: 1 phút 3 giây" in txt | |
| def test_export_csv_is_readable_and_raw_keeps_schema(): | |
| """CSV mặc định phải đọc được; `?raw=1` giữ nguyên 11 cột schema v2.""" | |
| import csv as _csv | |
| import io as _io | |
| from app.api import analytics as api | |
| store.insert(_one_full_task()) | |
| api.settings.VIDEO_ADMIN_TOKEN = "tok" | |
| def _read(**kw): | |
| # StreamingResponse bọc iterator đồng bộ thành async generator -> phải | |
| # rút bằng vòng lặp asyncio chứ không list() thẳng được. | |
| import asyncio | |
| resp = api.admin_export_csv(token="tok", days=2, **kw) | |
| async def drain(): | |
| return [c async for c in resp.body_iterator] | |
| text = "".join(c.decode("utf-8") if isinstance(c, bytes) else c | |
| for c in asyncio.run(drain())) | |
| return list(_csv.reader(_io.StringIO(text.lstrip("")))) | |
| rows = _read() | |
| head = rows[0] | |
| assert head[:8] == ["STT", "Ngày", "Giờ", "Người dùng", "Mã người dùng", | |
| "Tool", "Bước", "Hành động"] | |
| body = [r for r in rows[1:] if r] | |
| assert any(r[5] == "Dịch / đổi giọng" and r[7] == "Job chạy xong" | |
| and r[9] == "30,0 giây" for r in body) | |
| assert any(r[7] == "Đánh giá kết quả" and r[8].startswith("Hài lòng") for r in body) | |
| raw = _read(raw=1) | |
| assert raw[0] == ["event_id", "event_time", "user_id", "session_id", "task_id", | |
| "feature", "step", "event_name", "event_value", "latency_ms", | |
| "properties_json"] | |
| # ── Chỉ số mức độ bám app (nhóm "Tình trạng người dùng") ───────── | |
| def test_adoption_counts_ignore_lookers(): | |
| """`used_users` chỉ đếm người có thao tác THẬT; hàng ngày/hàng tuần tính | |
| theo mốc mới nhất TRONG KHOẢNG LỌC, không phải theo giờ chạy báo cáo.""" | |
| day = 86400 | |
| evs = [ | |
| _ev(0, "feature_open", "open", user="an", task="t1"), # làm thật | |
| _ev(-3 * day, "run_click", "run", user="binh", task="t2", sess="sb"), | |
| _ev(-10 * day, "page_view", "open", user="cuong", task="", sess="sc"), | |
| ] | |
| o = etl.summarize(evs)["overall"] | |
| assert o["used_users"] == 2 # cuong chỉ ngó trang -> không tính | |
| assert o["daily_users"] == 1 # trong 24h cuối: an | |
| assert o["weekly_users"] == 2 # trong 7 ngày cuối: an + binh | |
| assert o["session_max_ms"] >= 0 | |
| def test_job_rows_carry_attempt_and_vietnamese_phase(): | |
| evs = _one_full_task() | |
| jobs = {j["phase"]: j for j in etl.summarize(evs)["jobs"]} | |
| assert jobs["tts"]["phase_label"] == "Đọc thành tiếng" | |
| assert jobs["asr_translate"]["runs"] == 1 | |
| assert jobs["asr_translate"]["attempt_avg"] == 1.0 | |