Keep small-base videos on the delta board only, and raise the smoothing prior to 1000 so videos that just clear the floor do not dominate the ranking.
977 lines
41 KiB
Python
977 lines
41 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""只读查询层测试(离线): uv run python tests/test_web_queries.py"""
|
||
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
|
||
from reaction_board.config import ConfigStore # noqa: E402
|
||
from reaction_board.database import Database # noqa: E402
|
||
from reaction_board.repository import Repository # noqa: E402
|
||
from reaction_board.timeutil import day_bounds # noqa: E402
|
||
from reaction_board.web.cache import DASHBOARD_CACHE # noqa: E402
|
||
from reaction_board.web.queries import DashboardQueries # noqa: E402
|
||
from tests.web_fixtures import DATES, FOLDER, build_db, resource, write_config # noqa: E402
|
||
|
||
FIXED_NOW = 1_793_459_000 # 2026-10-31 前后
|
||
|
||
|
||
def setup(tmp: str):
|
||
build_db(tmp)
|
||
config = write_config(tmp)
|
||
db = Database(Path(tmp) / "app.db")
|
||
db.initialize()
|
||
store = ConfigStore(config)
|
||
queries = DashboardQueries(db, store, clock=lambda: FIXED_NOW)
|
||
return db, queries
|
||
|
||
|
||
def test_overview():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
overview = q.overview()
|
||
assert overview["data_date"] == DATES[-1]
|
||
assert overview["snapshot_days"] == 3
|
||
assert overview["is_first_day"] is False
|
||
assert overview["initial_import_count"] == 2
|
||
assert overview["counts"]["active"] == 2
|
||
assert overview["counts"]["unfaved"] == 1
|
||
assert overview["counts"]["removed"] == 1
|
||
assert overview["counts"]["active_ups"] == 2
|
||
assert overview["counts"]["candidates_pending"] == 1
|
||
assert overview["totals"]["view"] == 1200 + 5200 + 120
|
||
assert overview["interactions"] > 0
|
||
assert overview["view_delta"] == (100 + 100 + 120) # 101/102 跨日 + 103 当日发布
|
||
assert overview["snapshot_health"]["total"] == 3
|
||
assert overview["folders"][0]["media_count"] == 3
|
||
assert overview["folders"][0]["ratio"] < 1
|
||
assert overview["fans"]["follower"] == 1020
|
||
assert overview["fans"]["delta_1d"] == 10
|
||
assert overview["preview"] is False
|
||
assert overview["years"] == [2026]
|
||
assert "snapshot" in overview["tasks"]
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_video_listing_filters_and_sorting():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
heat = q.list_videos(sort="heat")
|
||
aids = [item["aid"] for item in heat["items"]]
|
||
assert 104 not in aids, "removed 视频不应进入榜单"
|
||
# 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除
|
||
assert aids == [102], aids
|
||
assert heat["items"][0]["heat_percentile"] == 100.0
|
||
|
||
views = q.list_videos(sort="view")
|
||
assert [item["aid"] for item in views["items"]] == [102, 101, 103]
|
||
|
||
interactions = q.list_videos(sort="interaction")
|
||
assert interactions["items"][0]["aid"] == 101
|
||
|
||
added = q.list_videos(sort="added", within_days=1)
|
||
assert [item["aid"] for item in added["items"]] == [103]
|
||
|
||
published = q.list_videos(sort="published", within_days=2)
|
||
assert {item["aid"] for item in published["items"]} == {101, 103}
|
||
|
||
searched = q.list_videos(sort="view", q="UP B")
|
||
assert [item["aid"] for item in searched["items"]] == [103]
|
||
searched = q.list_videos(sort="view", q="视频 102")
|
||
assert [item["aid"] for item in searched["items"]] == [102]
|
||
|
||
folder = q.list_videos(sort="view", folder_id=3700023351)
|
||
assert folder["total"] == 3
|
||
folder = q.list_videos(sort="view", folder_id=999)
|
||
assert folder["total"] == 0
|
||
|
||
page = q.list_videos(sort="view", page=2, page_size=1)
|
||
assert page["total"] == 3
|
||
assert page["pages"] == 3
|
||
assert [item["aid"] for item in page["items"]] == [101]
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_growth_and_cold_start():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
growth = q.list_videos(sort="growth", growth_days=2)
|
||
assert growth["available"] is True
|
||
assert growth["days"] == 2
|
||
assert growth["growth_days"] == 2
|
||
assert {item["aid"] for item in growth["items"]} == {101, 102}
|
||
first = next(item for item in growth["items"] if item["aid"] == 101)
|
||
assert first["growth"]["available"] is True
|
||
assert first["growth"]["delta"] == 200
|
||
assert first["growth"]["gap_days"] == 2
|
||
|
||
rate = q.list_videos(sort="growth_rate", growth_days=2)
|
||
assert {item["aid"] for item in rate["items"]} == {101, 102}
|
||
|
||
# 只有一个快照日时增长不可用,不能返回假 0
|
||
for day in DATES[:-1]:
|
||
db.write(lambda conn, day=day: conn.execute(
|
||
"DELETE FROM snapshot WHERE stat_date = ?", (day,)
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
cold = q.list_videos(sort="growth", growth_days=2)
|
||
assert cold["available"] is False
|
||
assert cold["items"] == []
|
||
assert "至少 3 天快照" in cold["reason"]
|
||
assert cold["growth_days"] == 2
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_video_detail_and_trend():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
detail = q.video_detail(101)
|
||
assert detail["aid"] == 101
|
||
assert detail["metrics"]["view"] == 1200
|
||
assert detail["growth"]["delta"] == 100
|
||
assert detail["snapshot_count"] == 3
|
||
assert detail["folder_title"] == "2026"
|
||
assert detail["url"].endswith("BV101")
|
||
assert q.video_detail(999999) is None
|
||
|
||
trend = q.video_trend(101, days=3)
|
||
assert trend["dates"] == list(DATES)
|
||
assert trend["series"]["view"] == [1000, 1100, 1200]
|
||
assert trend["available"] is True
|
||
assert len(trend["points"]) == 3
|
||
|
||
# 请求区间早于首次快照时,从首次快照开始,不制造空白历史
|
||
wide = q.video_trend(101, days=30)
|
||
assert wide["dates"] == list(DATES)
|
||
|
||
# 删除中间一天,折线在缺失日期应为 None,而不是插值
|
||
db.write(lambda conn: conn.execute(
|
||
"DELETE FROM snapshot WHERE aid=101 AND stat_date=?",
|
||
("2026-10-30",),
|
||
))
|
||
trend = q.video_trend(101, days=3)
|
||
assert trend["series"]["view"] == [1000, None, 1200]
|
||
|
||
# 失败快照也视为缺失
|
||
db.write(lambda conn: conn.execute(
|
||
"UPDATE snapshot SET ok=0, view=NULL WHERE aid=101 AND stat_date=?",
|
||
(DATES[-1],),
|
||
))
|
||
trend = q.video_trend(101, days=3)
|
||
assert trend["series"]["view"][-1] is None
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_up_queries():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
ups = q.list_ups()
|
||
assert ups["sort"] == "avg_views"
|
||
assert ups["total"] == 2
|
||
assert ups["items"][0]["up_mid"] == 1
|
||
assert ups["items"][0]["video_count"] == 2
|
||
assert ups["items"][0]["total_views"] == 6400
|
||
assert ups["items"][0]["avg_views"] == 3200.0
|
||
assert ups["items"][0]["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg"
|
||
assert ups["items"][0]["follower_latest"] == 12345
|
||
|
||
one = q.list_ups(min_videos=2)
|
||
assert one["total"] == 1
|
||
|
||
detail = q.up_detail(1)
|
||
assert detail["video_count"] == 2
|
||
assert detail["total_views"] == 6400
|
||
assert detail["blacklisted"] is False
|
||
assert detail["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg"
|
||
assert detail["series"]["dates"] == list(DATES)
|
||
assert detail["series"]["views"] == [6000, 6200, 6400]
|
||
assert detail["space_url"].endswith("/1")
|
||
assert "folders" not in detail
|
||
assert detail["first_pubdate"] > 0
|
||
|
||
blacklisted = q.up_detail(2)
|
||
assert blacklisted["blacklisted"] is True
|
||
assert blacklisted["video_count"] == 1
|
||
assert q.up_detail(999999) is None
|
||
|
||
# UP 搜索只匹配 UP 名,不匹配视频标题
|
||
title_only = q.list_ups(q="视频")
|
||
assert title_only["total"] == 0
|
||
name_match = q.list_ups(q="UP A")
|
||
assert name_match["total"] == 1
|
||
assert name_match["items"][0]["up_mid"] == 1
|
||
assert name_match["items"][0]["video_count"] == 2
|
||
# 大小写不敏感
|
||
lower = q.list_ups(q="up b")
|
||
assert lower["total"] == 1 and lower["items"][0]["up_mid"] == 2
|
||
# 命中多个 UP
|
||
assert q.list_ups(q="UP")["total"] == 2
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_query_cache_invalidation():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
DASHBOARD_CACHE.clear()
|
||
first = q.overview()
|
||
db.write(lambda conn: conn.execute(
|
||
"UPDATE snapshot SET \"view\" = 999999 WHERE aid=101 AND stat_date=?",
|
||
(DATES[-1],),
|
||
))
|
||
cached = q.overview()
|
||
assert cached["totals"]["view"] == first["totals"]["view"]
|
||
|
||
repo = Repository(db)
|
||
repo.bump_cache_version("videos")
|
||
refreshed = q.overview()
|
||
assert refreshed["totals"]["view"] > first["totals"]["view"]
|
||
|
||
fans_first = q.fans(days=3)
|
||
db.write(lambda conn: conn.execute(
|
||
"UPDATE up_stat SET follower=999999 WHERE mid=37754047 AND stat_date=?",
|
||
(DATES[-1],),
|
||
))
|
||
fans_cached = q.fans(days=3)
|
||
assert fans_cached["follower"] == fans_first["follower"]
|
||
|
||
repo.bump_cache_version("fans")
|
||
fans_refreshed = q.fans(days=3)
|
||
assert fans_refreshed["follower"] == 999999
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_fans_custom_range_modes():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
daily = q.fans(start="2026-10-29", end="2026-10-31")
|
||
assert daily["mode"] == "daily"
|
||
assert daily["range_start"] == "2026-10-29"
|
||
assert daily["range_end"] == "2026-10-31"
|
||
assert daily["dates"] == list(DATES)
|
||
assert daily["series"] == [1000, 1010, 1020]
|
||
|
||
# 超过一年触发按月峰值模式
|
||
db.write(lambda conn: conn.execute(
|
||
"INSERT OR REPLACE INTO up_stat"
|
||
"(stat_date, mid, follower, following, captured_at, ok) "
|
||
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
||
assert monthly["mode"] == "monthly"
|
||
assert monthly["span_days"] == 669
|
||
assert monthly["dates"][0] == "2025-01-01"
|
||
assert monthly["series"][0] == 500
|
||
assert monthly["dates"][-1] == "2026-10-31"
|
||
assert monthly["series"][-1] == 1020
|
||
|
||
# 同月多条时取峰值日期,并优先较晚日期
|
||
db.write(lambda conn: conn.execute(
|
||
"INSERT OR REPLACE INTO up_stat"
|
||
"(stat_date, mid, follower, following, captured_at, ok) "
|
||
"VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), "
|
||
"('2026-10-16', 37754047, 5000, NULL, 1, 1)"
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
||
assert monthly["dates"][-1] == "2026-10-16"
|
||
assert monthly["series"][-1] == 5000
|
||
|
||
try:
|
||
q.fans(start="2026-11-01", end="2026-10-01")
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
raise AssertionError("start 晚于 end 应报错")
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_fans_candidates_system_and_export():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
fans = q.fans(days=3)
|
||
assert fans["mid"] == 37754047
|
||
assert fans["name"] == "咻咻满"
|
||
assert fans["dates"] == list(DATES)
|
||
assert fans["series"] == [1000, 1010, 1020]
|
||
assert fans["delta_1d"] == 10
|
||
assert fans["delta_7d"] is None
|
||
assert fans["delta_cum"] == 20
|
||
assert fans["options"][0]["mid"] == 37754047
|
||
wide = q.fans(days=30)
|
||
assert wide["dates"] == list(DATES)
|
||
|
||
# “最近记录”只返回最近 14 天,早于窗口的记录不进入表格
|
||
db.write(lambda conn: conn.execute(
|
||
"INSERT OR REPLACE INTO up_stat"
|
||
"(stat_date, mid, follower, following, captured_at, ok) "
|
||
"VALUES (?, ?, ?, NULL, 1, 1)",
|
||
("2026-10-01", 37754047, 900),
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
recent = q.fans(days=90)
|
||
assert recent["recent_days"] == 14
|
||
assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES)
|
||
deltas = {point["stat_date"]: point["delta"] for point in recent["recent_points"]}
|
||
assert deltas["2026-10-29"] == 100 # 对比 2026-10-01 的 900
|
||
assert deltas["2026-10-30"] == 10
|
||
assert deltas["2026-10-31"] == 10
|
||
one_day = q.fans(days=90, recent_days=1)
|
||
assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]]
|
||
|
||
# 缺失前一天时不能把跨多天的变化标成“日增”
|
||
db.write(lambda conn: conn.execute(
|
||
"DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?",
|
||
("2026-10-30",),
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
gapped = q.fans(days=3)
|
||
assert gapped["delta_1d"] is None
|
||
assert gapped["delta_7d"] is None
|
||
|
||
candidates = q.candidates()
|
||
assert candidates["total"] == 2
|
||
assert candidates["pending"] == 1
|
||
assert candidates["items"][0]["aid"] == 201
|
||
assert 1 <= candidates["items"][0]["days_left"] <= 7
|
||
assert candidates["items"][1]["decision"] == "rejected"
|
||
|
||
system = q.system()
|
||
assert system["data_date"] == DATES[-1]
|
||
assert system["snapshot_days"] == 3
|
||
assert system["blacklist"][0]["video_count"] == 1
|
||
assert system["tasks"]["snapshot"]["ok"] is True
|
||
assert system["tasks"]["fans"]["ok"] is True
|
||
assert system["errors"] == []
|
||
assert system["preview"] is False
|
||
assert system["running"] == {}
|
||
assert set(system["next_runs"]) >= {
|
||
"fans", "snapshot", "discover", "snapshot_recent",
|
||
}
|
||
assert system["next_runs"]["snapshot"] > 0
|
||
assert system["next_runs"]["fans"] > 0
|
||
assert system["next_runs"]["discover"] > 0
|
||
assert system["next_runs"]["snapshot_recent"] > 0
|
||
assert system["schedule"]["recent_snapshot_minutes"] == 60
|
||
assert system["schedule"]["recent_video_days"] == 2
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_duration_efficiency_queries():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
db.write(lambda conn: conn.executemany(
|
||
"UPDATE video SET duration = ?, pubdate = ? WHERE aid = ?",
|
||
[
|
||
(9000, 1_756_684_800, 101), # 2025-09-01 前后,2.5 小时
|
||
(7200, 1_756_771_200, 102), # 2 小时
|
||
(300, 1_756_857_600, 103), # 5 分钟,应被排除
|
||
],
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
|
||
efficiency = q.list_videos(sort="efficiency")
|
||
assert [item["aid"] for item in efficiency["items"]] == [102]
|
||
first = efficiency["items"][0]
|
||
assert first["duration"] == 7200
|
||
assert first["content_hours"] == 2.0
|
||
assert first["efficiency"] == 2600.0 # 原始播放/小时,详情页口径
|
||
assert abs(first["efficiency_score"] - 2207.5) < 0.1
|
||
assert efficiency["duration_filter"] == {
|
||
"min_duration_seconds": 600, "maturity_days": 2,
|
||
"min_view": 2000, "shrink_minutes": 60,
|
||
}
|
||
|
||
# 播放不足 2000 的视频不进入效率榜(101 只有 1200)
|
||
assert all(item["aid"] != 101 for item in efficiency["items"])
|
||
|
||
interaction = q.list_videos(sort="interaction_efficiency")
|
||
assert [item["aid"] for item in interaction["items"]] == [102]
|
||
assert abs(interaction["items"][0]["interaction_efficiency_score"] - 159.36) < 0.1
|
||
|
||
# 综合热度榜也应用最小播放门槛
|
||
heat = q.list_videos(sort="heat")
|
||
assert [item["aid"] for item in heat["items"]] == [102]
|
||
|
||
# 时长缺失不计入时长效率榜
|
||
ups = q.list_ups(sort="efficiency")
|
||
assert ups["sort"] == "efficiency"
|
||
up_a = next(item for item in ups["items"] if item["up_mid"] == 1)
|
||
assert up_a["total_duration"] == int(9000 + 7200)
|
||
assert up_a["content_hours"] == 4.5
|
||
assert up_a["views_per_hour"] == round(6400 / 4.5, 1)
|
||
assert up_a["efficiency"] is not None
|
||
assert up_a["interaction_efficiency"] is not None
|
||
|
||
by_duration = q.list_ups(sort="duration")
|
||
assert [item["up_mid"] for item in by_duration["items"]][0] == 1
|
||
|
||
db.write(lambda conn: conn.execute(
|
||
"UPDATE video SET duration = NULL WHERE aid = 101"
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
missing = q.list_videos(sort="efficiency")
|
||
assert [item["aid"] for item in missing["items"]] == [102]
|
||
|
||
# 发布未满 2 天的视频不进入效率榜
|
||
db.write(lambda conn: conn.execute(
|
||
"UPDATE video SET duration = 7200, pubdate = ? WHERE aid = 102",
|
||
(int(day_bounds("2026-10-30")[0]),),
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
fresh = q.list_videos(sort="efficiency")
|
||
assert fresh["items"] == []
|
||
assert fresh["available"] is False
|
||
assert "发布满 2 天" in fresh["reason"]
|
||
assert "播放 ≥ 2000" in fresh["reason"]
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_year_filter_and_published_sort():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
by_year = q.list_videos(sort="view", year=2026)
|
||
assert [item["aid"] for item in by_year["items"]] == [102, 101, 103]
|
||
assert by_year["year"] == 2026
|
||
|
||
none = q.list_videos(sort="view", year=2025)
|
||
assert none["items"] == []
|
||
assert none["total"] == 0
|
||
|
||
published = q.list_videos(sort="published")
|
||
assert [item["aid"] for item in published["items"]] == [103, 101, 102]
|
||
|
||
added = q.list_videos(sort="added")
|
||
assert added["total"] == 3
|
||
|
||
ups = q.list_ups(sort="efficiency", year=2026)
|
||
assert ups["year"] == 2026 and ups["total"] == 2
|
||
ups_none = q.list_ups(sort="views", year=2025)
|
||
assert ups_none["total"] == 0
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_about_payload():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
about = q.about()
|
||
assert about["data_date"] == DATES[-1]
|
||
assert about["snapshot_days"] == 3
|
||
assert about["snapshot_first_date"] == DATES[0]
|
||
assert about["fans_start_date"] == DATES[0]
|
||
assert about["schema_version"] == 8
|
||
assert about["analytics"] == {
|
||
"min_duration_seconds": 600,
|
||
"maturity_days": 2,
|
||
"min_view_for_ranking": 2000,
|
||
"video_shrink_minutes": 60,
|
||
"shrink_minutes": None,
|
||
"home_growth_days": "auto",
|
||
"growth_rate_prior": 1000,
|
||
}
|
||
assert about["heat_weights"]["view"] == 0.30
|
||
assert about["followers"]["videos_total"] == 3
|
||
assert about["followers"]["videos_covered"] == 3
|
||
assert about["followers"]["videos_missing"] == 0
|
||
assert about["folders"][0]["media_id"] == 3700023351
|
||
assert about["preview"] is False
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_stats_query():
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
empty = q.stats(days=7)
|
||
assert empty["today"] == "2026-10-31"
|
||
assert empty["today_pv"] == 0 and empty["today_uv"] == 0
|
||
assert len(empty["series"]["dates"]) == 7
|
||
assert empty["series"]["dates"][-1] == "2026-10-31"
|
||
assert empty["total_pv"] == 0 and empty["total_uv"] == 0
|
||
assert empty["top_pages"] == []
|
||
assert empty["enabled"] is True
|
||
assert empty["pending"] is False
|
||
|
||
def insert(conn, day, ip, path, hits, first, last):
|
||
conn.execute(
|
||
"INSERT INTO visit(stat_date, ip, path, hits, first_at, last_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||
(day, ip, path, hits, first, last),
|
||
)
|
||
|
||
db.write(lambda conn: [
|
||
insert(conn, "2026-10-31", "1.1.1.1", "/home", 3, 1, 2),
|
||
insert(conn, "2026-10-31", "2.2.2.2", "/home", 1, 1, 1),
|
||
insert(conn, "2026-10-31", "1.1.1.1", "/videos", 2, 1, 1),
|
||
insert(conn, "2026-10-30", "1.1.1.1", "/home", 5, 1, 1),
|
||
insert(conn, "2026-09-01", "9.9.9.9", "/home", 100, 1, 1),
|
||
])
|
||
stats = q.stats(days=7)
|
||
assert stats["today_pv"] == 6
|
||
assert stats["today_uv"] == 2
|
||
assert stats["total_pv"] == 11 # 今天 6 + 昨天 5,区间外不计
|
||
assert stats["total_uv"] == 2
|
||
assert stats["series"]["pv"][-1] == 6
|
||
assert stats["series"]["pv"][-2] == 5
|
||
assert stats["series"]["uv"][-1] == 2
|
||
pages = {page["path"]: page for page in stats["top_pages"]}
|
||
assert pages["/home"]["pv"] == 9 and pages["/home"]["uv"] == 2
|
||
assert pages["/videos"]["pv"] == 2 and pages["/videos"]["uv"] == 1
|
||
assert stats["top_pages"][0]["path"] == "/home"
|
||
|
||
# 内存缓冲合并:不同键相加,重叠键去重合并
|
||
pending = [
|
||
{"stat_date": "2026-10-31", "ip": "3.3.3.3",
|
||
"path": "/about", "hits": 4, "first_at": 1, "last_at": 1},
|
||
{"stat_date": "2026-10-31", "ip": "1.1.1.1",
|
||
"path": "/home", "hits": 1, "first_at": 3, "last_at": 3},
|
||
]
|
||
merged = q.stats(days=7, pending=pending)
|
||
assert merged["today_pv"] == 11
|
||
assert merged["today_uv"] == 3
|
||
assert merged["pending"] is True
|
||
pages = {page["path"]: page for page in merged["top_pages"]}
|
||
assert pages["/about"]["pv"] == 4
|
||
assert pages["/home"]["pv"] == 10
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
|
||
def test_growth_auto_falls_back_to_one_day():
|
||
"""3 天快照撑不起 7 日增长;auto 应降到 1 日。"""
|
||
import json
|
||
import os
|
||
import time
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
# 省略 growth_days 与显式 auto 等价:后端决定窗口并回传 days
|
||
auto = q.list_videos(sort="growth", page_size=6)
|
||
assert auto["available"] is True
|
||
assert auto["growth_days"] == 1
|
||
assert auto["days"] == 1
|
||
assert {item["aid"] for item in auto["items"]} == {101, 102, 103}
|
||
first = next(item for item in auto["items"] if item["aid"] == 101)
|
||
assert first["growth"]["delta"] == 100 # 相邻日
|
||
assert first["growth"]["baseline"] == "snapshot"
|
||
today = next(item for item in auto["items"] if item["aid"] == 103)
|
||
assert today["growth"]["baseline"] == "zero"
|
||
assert today["growth"]["delta"] == 120
|
||
|
||
forced7 = q.list_videos(sort="growth", growth_days=7)
|
||
assert forced7["available"] is False
|
||
assert forced7["days"] == 7
|
||
assert forced7["growth_days"] == 7
|
||
assert "至少 8 天快照" in forced7["reason"]
|
||
|
||
# 配置钉死 7 天:auto 不再降级
|
||
path = Path(tmp) / "config.json"
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
data.setdefault("analytics", {})["home_growth_days"] = 7
|
||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||
target = max(path.stat().st_mtime_ns, time.time_ns()) + 1_000_000
|
||
os.utime(path, ns=(target, target))
|
||
DASHBOARD_CACHE.clear()
|
||
pinned = q.list_videos(sort="growth") # omit → auto → 配置钉死 7
|
||
assert pinned["growth_days"] == 7
|
||
assert pinned["days"] == 7
|
||
assert pinned["available"] is False
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_snapshot_recent_next_run_follows_completion():
|
||
import json
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
finished = FIXED_NOW - 10
|
||
repo.kv_set(
|
||
"task:last:snapshot_recent",
|
||
json.dumps({
|
||
"name": "snapshot_recent", "ok": True,
|
||
"started_at": finished - 5, "finished_at": finished,
|
||
"error": None, "summary": {},
|
||
}),
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert system["next_runs"]["snapshot_recent"] == finished + 3600
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
|
||
def test_overview_adjacent_previous_with_seed_and_dual_fields():
|
||
"""seed 当日行存在时,播放增量按片相邻成功日;overview 暴露更新时刻与总数。"""
|
||
import json
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
today = "2026-11-01"
|
||
# 克隆上一完成日到今天(不推进 last_completed)
|
||
for aid in (101, 102):
|
||
prev = repo.get_snapshot(DATES[-1], aid)
|
||
metrics = {
|
||
k: int(prev[k]) for k in (
|
||
"view", "like", "coin", "favorite", "share", "reply", "danmaku"
|
||
)
|
||
}
|
||
repo.upsert_snapshot(
|
||
today, aid, metrics=metrics, ok=True, err=None,
|
||
captured_at=FIXED_NOW, src="seed",
|
||
)
|
||
repo.kv_set("snapshot:seeded:" + today, "1")
|
||
# 小时短更新任务记录
|
||
repo.kv_set(
|
||
"task:last:snapshot_recent",
|
||
json.dumps({
|
||
"name": "snapshot_recent", "ok": True,
|
||
"started_at": FIXED_NOW - 60, "finished_at": FIXED_NOW - 30,
|
||
"error": None, "summary": {},
|
||
}),
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
overview = q.overview()
|
||
assert overview["data_date"] == DATES[-1]
|
||
assert overview["updated_at"] == FIXED_NOW - 30
|
||
assert overview["counts"]["total"] == (
|
||
overview["counts"]["active"]
|
||
+ overview["counts"]["unfaved"]
|
||
+ overview["counts"]["removed"]
|
||
)
|
||
# 101/102:当前=seed(=完成日数值),相邻 previous=完成日本身 → 增量 0
|
||
# 103:当日发布,近 1 日相对 0,计入当前播放
|
||
assert overview["view_delta_available"] is True
|
||
assert overview["view_delta"] == 120
|
||
|
||
growth = q.list_videos(sort="growth", growth_days=1)
|
||
assert growth["available"] is True
|
||
for item in growth["items"]:
|
||
if item["aid"] in (101, 102):
|
||
assert item["growth"]["available"] is True
|
||
assert item["growth"]["delta"] == 0
|
||
assert item["growth"]["gap_days"] == 1
|
||
assert item["growth"]["baseline"] == "snapshot"
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _view_metrics(view: int) -> dict[str, int]:
|
||
return {
|
||
"view": view, "like": 0, "coin": 0, "favorite": 0,
|
||
"share": 0, "reply": 0, "danmaku": 0,
|
||
}
|
||
|
||
|
||
def test_same_day_growth_baselines():
|
||
"""近 1 日:当日发布相对 0;更早发布用 view_open;7 日不用这两条退路。"""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
today = DATES[-1]
|
||
start = int(day_bounds(today)[0])
|
||
old_pub = int(day_bounds("2026-09-01")[0])
|
||
|
||
repo.upsert_snapshot(
|
||
today, 103, metrics=_view_metrics(200),
|
||
ok=True, err=None, captured_at=start + 3600,
|
||
)
|
||
repo.upsert_from_fav(
|
||
resource(105, up_mid=1, up_name="UP A", pubdate=old_pub),
|
||
FOLDER.media_id, was_added=True, now=start + 10,
|
||
)
|
||
repo.upsert_snapshot(
|
||
today, 105, metrics=_view_metrics(500),
|
||
ok=True, err=None, captured_at=start + 10,
|
||
)
|
||
repo.upsert_snapshot(
|
||
today, 105, metrics=_view_metrics(800),
|
||
ok=True, err=None, captured_at=start + 20,
|
||
)
|
||
repo.upsert_from_fav(
|
||
resource(106, up_mid=1, up_name="UP A", pubdate=old_pub),
|
||
FOLDER.media_id, was_added=True, now=start + 30,
|
||
)
|
||
repo.upsert_snapshot(
|
||
today, 106, metrics=_view_metrics(900),
|
||
ok=True, err=None, captured_at=start + 30,
|
||
)
|
||
repo.upsert_from_fav(
|
||
resource(107, up_mid=1, up_name="UP A", pubdate=old_pub),
|
||
FOLDER.media_id, was_added=True, now=start + 40,
|
||
)
|
||
repo.upsert_snapshot(
|
||
today, 107, metrics=_view_metrics(50),
|
||
ok=True, err=None, captured_at=start + 40,
|
||
)
|
||
db.write(lambda conn: conn.execute(
|
||
"UPDATE video SET pubdate = 0 WHERE aid = 107"
|
||
))
|
||
DASHBOARD_CACHE.clear()
|
||
|
||
one = q.list_videos(sort="growth", growth_days=1, page_size=20)
|
||
by_aid = {item["aid"]: item for item in one["items"]}
|
||
assert 103 in by_aid and 105 in by_aid
|
||
assert 106 not in by_aid and 107 not in by_aid
|
||
assert by_aid[103]["growth"]["baseline"] == "zero"
|
||
assert by_aid[103]["growth"]["delta"] == 200
|
||
assert by_aid[103]["growth"]["rate"] is None
|
||
assert by_aid[103]["growth"]["gap_days"] == 0
|
||
assert by_aid[105]["growth"]["baseline"] == "open"
|
||
assert by_aid[105]["growth"]["delta"] == 300
|
||
assert by_aid[101]["growth"]["baseline"] == "snapshot"
|
||
assert by_aid[101]["growth"]["delta"] == 100
|
||
|
||
rate = q.list_videos(sort="growth_rate", growth_days=1, page_size=20)
|
||
rate_aids = {item["aid"] for item in rate["items"]}
|
||
assert 103 not in rate_aids and 105 not in rate_aids
|
||
assert 101 in rate_aids
|
||
|
||
seven = q.list_videos(sort="growth", growth_days=7)
|
||
seven_aids = {item["aid"] for item in seven["items"]}
|
||
assert 103 not in seven_aids
|
||
assert 105 not in seven_aids
|
||
|
||
detail103 = q.video_detail(103)
|
||
assert detail103["growth"]["baseline"] == "zero"
|
||
assert detail103["growth"]["delta"] == 200
|
||
detail105 = q.video_detail(105)
|
||
assert detail105["growth"]["baseline"] == "open"
|
||
assert detail105["growth"]["delta"] == 300
|
||
assert q.video_detail(106)["growth"]["available"] is False
|
||
assert q.video_detail(107)["growth"]["available"] is False
|
||
|
||
overview = q.overview()
|
||
assert overview["view_delta"] == 100 + 100 + 200 + 300
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_growth_rate_sort_uses_prior():
|
||
"""基线不足 1000 不进增速榜;达标后按基线 + prior 排序,不按 raw rate。"""
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
latest = DATES[-1]
|
||
previous_day = DATES[-2]
|
||
start = int(day_bounds(latest)[0])
|
||
pub = int(day_bounds("2026-09-01")[0])
|
||
# 301:80→512,真实 +540%,基线低于 1000,只留在增量榜。
|
||
# 304:1000→2500,真实增速高于 302,平滑后应排在 302 后面。
|
||
for aid, views in ((301, (80, 512)), (302, (5000, 10000)), (304, (1000, 2500))):
|
||
repo.upsert_from_fav(
|
||
resource(aid, up_mid=1, up_name="UP A", pubdate=pub),
|
||
FOLDER.media_id, was_added=True, now=start,
|
||
)
|
||
repo.upsert_snapshot(
|
||
previous_day, aid, metrics=_view_metrics(views[0]),
|
||
ok=True, err=None, captured_at=start - 86400,
|
||
)
|
||
repo.upsert_snapshot(
|
||
latest, aid, metrics=_view_metrics(views[1]),
|
||
ok=True, err=None, captured_at=start + 60,
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
ranked = q.list_videos(sort="growth_rate", growth_days=1, page_size=20)
|
||
order = [item["aid"] for item in ranked["items"]]
|
||
assert ranked["growth_rate_prior"] == 1000
|
||
assert 301 not in order
|
||
assert order.index(302) < order.index(304)
|
||
modest = next(item for item in ranked["items"] if item["aid"] == 304)
|
||
assert abs(modest["growth"]["rate"] - 1500 / 1000) < 1e-12
|
||
|
||
growth = q.list_videos(sort="growth", growth_days=1, page_size=20)
|
||
grown = next(item for item in growth["items"] if item["aid"] == 301)
|
||
assert grown["growth"]["delta"] == 432
|
||
assert abs(grown["growth"]["rate"] - 432 / 80) < 1e-12
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_system_tasks_sorted_by_started_at_desc():
|
||
"""最近任务按 started_at 倒序;缺开始时间的排末尾(含进行中无 finished)。"""
|
||
import json
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
# 故意打乱写入/键名字母序;finished_at 与 started_at 错开,避免误用完成时间
|
||
samples = (
|
||
# name, started_at, finished_at
|
||
("zzz_old", FIXED_NOW - 300, FIXED_NOW - 10), # 开始最早,完成却很晚
|
||
("aaa_mid", FIXED_NOW - 100, FIXED_NOW - 90),
|
||
("mmm_new", FIXED_NOW - 10, None), # 进行中:只有 started
|
||
("no_time", None, FIXED_NOW), # 缺 started,应末尾
|
||
)
|
||
for name, started, finished in samples:
|
||
payload = {
|
||
"name": name,
|
||
"ok": True if finished is not None else True,
|
||
"error": None,
|
||
"summary": {},
|
||
}
|
||
if started is not None:
|
||
payload["started_at"] = started
|
||
if finished is not None:
|
||
payload["finished_at"] = finished
|
||
repo.kv_set(f"task:last:{name}", json.dumps(payload))
|
||
for legacy in ("snapshot", "fans", "discovery"):
|
||
repo.kv_delete(f"task:last:{legacy}")
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert list(system["tasks"].keys()) == [
|
||
"mmm_new", "aaa_mid", "zzz_old", "no_time",
|
||
]
|
||
overview = q.overview()
|
||
assert list(overview["tasks"].keys()) == list(system["tasks"].keys())
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def main_test():
|
||
test_overview()
|
||
test_video_listing_filters_and_sorting()
|
||
test_growth_and_cold_start()
|
||
test_video_detail_and_trend()
|
||
test_up_queries()
|
||
test_query_cache_invalidation()
|
||
test_duration_efficiency_queries()
|
||
test_year_filter_and_published_sort()
|
||
test_about_payload()
|
||
test_stats_query()
|
||
test_fans_custom_range_modes()
|
||
test_fans_candidates_system_and_export()
|
||
test_system_status_uses_completed_date_and_running_flags()
|
||
test_system_next_runs_follow_last_completion_not_wall_clock()
|
||
test_system_tasks_sorted_by_started_at_desc()
|
||
test_growth_auto_falls_back_to_one_day()
|
||
test_snapshot_recent_next_run_follows_completion()
|
||
test_overview_adjacent_previous_with_seed_and_dual_fields()
|
||
test_same_day_growth_baselines()
|
||
test_growth_rate_sort_uses_prior()
|
||
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过")
|
||
|
||
|
||
|
||
def test_system_status_uses_completed_date_and_running_flags():
|
||
"""采集中途的 ok=1 不得抢跑数据日期;running 显示三任务。"""
|
||
import json
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
# 模拟今日部分写入成功,但完成日仍停在前一天
|
||
today = "2026-11-01"
|
||
repo.upsert_snapshot(
|
||
today, 101,
|
||
metrics={"view": 1, "like": 0, "coin": 0, "favorite": 0,
|
||
"share": 0, "reply": 0, "danmaku": 0},
|
||
ok=True, err=None, captured_at=FIXED_NOW,
|
||
)
|
||
assert repo.kv_get("snapshot:last_completed") == DATES[-1]
|
||
|
||
system = q.system()
|
||
overview = q.overview()
|
||
assert system["data_date"] == DATES[-1]
|
||
assert overview["data_date"] == DATES[-1]
|
||
assert system["snapshot_days"] == 3
|
||
|
||
# 三个下次运行字段;标记 snapshot 运行中
|
||
repo.kv_set(
|
||
"task:running:snapshot",
|
||
json.dumps({"name": "snapshot", "started_at": FIXED_NOW}),
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert "snapshot" in system["running"]
|
||
assert system["running"]["snapshot"]["running"] is True
|
||
assert set(system["next_runs"]) >= {
|
||
"fans", "snapshot", "discover", "snapshot_recent",
|
||
}
|
||
|
||
# 失败记录保留,完成日不变
|
||
repo.kv_set(
|
||
"task:last:snapshot",
|
||
json.dumps({
|
||
"name": "snapshot", "ok": False,
|
||
"started_at": FIXED_NOW - 10, "finished_at": FIXED_NOW,
|
||
"error": "boom", "summary": None,
|
||
}),
|
||
)
|
||
repo.kv_delete("task:running:snapshot")
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert system["data_date"] == DATES[-1]
|
||
assert overview["data_date"] == DATES[-1]
|
||
assert any(err["task"] == "snapshot" for err in system["errors"])
|
||
assert system["tasks"]["snapshot"]["ok"] is False
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_system_next_runs_follow_last_completion_not_wall_clock():
|
||
import json
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
# 发现任务刚完成不久:下次 = finished + 60min,即使 now 已超过也不跳档
|
||
finished = FIXED_NOW - 30
|
||
repo.kv_set(
|
||
"task:last:discovery",
|
||
json.dumps({
|
||
"name": "discovery", "ok": True,
|
||
"started_at": finished - 5, "finished_at": finished,
|
||
"error": None, "summary": {},
|
||
}),
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert system["next_runs"]["discover"] == finished + 3600
|
||
# 仍小于/等于 now 也可以(表示本轮后的下一档已到期或未到),但绝不能跳到 +7200
|
||
assert system["next_runs"]["discover"] != finished + 7200
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_test()
|