Incident: evening full snapshot could be recorded as ok with already_completed/elapsed=0 after mutual skip with hourly sync, then next_run jumped to the next day. - Full-site snapshot never skips because another task is running - 23:00-01:00 Asia/Shanghai: defer discovery / snapshot_recent / candidate_cleanup to after 01:00; fans never defers - Skip records ok=false + skipped=true (not success / not completed) - Skip/fail reschedule soon (~5m); daily success still next snapshot_at - TaskControl return value drives next_run; system UI shows skip fields
1060 lines
45 KiB
Python
1060 lines
45 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
|
||
from datetime import datetime
|
||
from reaction_board.timeutil import SHANGHAI_TZ, interval_next_run
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
# 午后完成:间隔后仍在静默窗外,应严格 finished+3600
|
||
finished = int(datetime(2026, 10, 31, 15, 0, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||
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
|
||
assert system["next_runs"]["snapshot_recent"] == int(
|
||
interval_next_run(float(finished), 3600)
|
||
)
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_snapshot_recent_next_run_defers_quiet_window_and_retry_on_skip():
|
||
import json
|
||
from datetime import datetime
|
||
from reaction_board.timeutil import SHANGHAI_TZ, next_after_snapshot_quiet_window
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
# 22:50 成功 → 下一档 23:50 落入静默窗 → 推到 01:00
|
||
finished = int(datetime(2026, 10, 31, 22, 50, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||
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()
|
||
expected = int(datetime(2026, 11, 1, 1, 0, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||
assert system["next_runs"]["snapshot_recent"] == expected
|
||
|
||
# skip 记录:补跑到窗后 01:00,不得当成功推进
|
||
quiet = int(datetime(2026, 10, 31, 23, 20, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||
repo.kv_set(
|
||
"task:last:snapshot_recent",
|
||
json.dumps({
|
||
"name": "snapshot_recent", "ok": False, "skipped": True,
|
||
"started_at": quiet, "finished_at": quiet,
|
||
"error": "snapshot_quiet_window",
|
||
"summary": {"skipped": True, "reason": "snapshot_quiet_window"},
|
||
}),
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert system["next_runs"]["snapshot_recent"] == int(
|
||
next_after_snapshot_quiet_window(float(quiet))
|
||
)
|
||
assert not any(e["task"] == "snapshot_recent" for e in system["errors"])
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def test_snapshot_failed_next_run_retries_soon_not_next_day():
|
||
import json
|
||
from datetime import datetime
|
||
from reaction_board.timeutil import SHANGHAI_TZ, next_daily_run
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
finished = int(datetime(2026, 10, 31, 23, 15, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||
repo.kv_set(
|
||
"task:last:snapshot",
|
||
json.dumps({
|
||
"name": "snapshot", "ok": False,
|
||
"started_at": finished - 1, "finished_at": finished,
|
||
"error": "boom", "summary": {"completed": False},
|
||
}),
|
||
)
|
||
DASHBOARD_CACHE.clear()
|
||
system = q.system()
|
||
assert system["next_runs"]["snapshot"] == finished + 300
|
||
assert system["next_runs"]["snapshot"] != int(
|
||
next_daily_run(finished, "23:15")
|
||
)
|
||
assert any(e["task"] == "snapshot" for e in system["errors"])
|
||
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_snapshot_recent_next_run_defers_quiet_window_and_retry_on_skip()
|
||
test_snapshot_failed_next_run_retries_soon_not_next_day()
|
||
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
|
||
from datetime import datetime
|
||
from reaction_board.timeutil import SHANGHAI_TZ
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
db, q = setup(tmp)
|
||
try:
|
||
repo = Repository(db)
|
||
# 午后完成:下次 = finished + 60min,不跳档、不误入静默窗推算
|
||
finished = int(datetime(2026, 10, 31, 15, 0, 0, tzinfo=SHANGHAI_TZ).timestamp())
|
||
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
|
||
assert system["next_runs"]["discover"] != finished + 7200
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_test()
|