Let 1-day view growth use a zero baseline for videos published today, and the first successful sample for older videos ingested today, without mixing those into 7/30-day rankings.
430 lines
17 KiB
Python
430 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""收藏夹/视频状态机测试(不联网): uv run python tests/test_repository.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.bilibili.models import FavResource # noqa: E402
|
|
from reaction_board.config import FolderConfig # noqa: E402
|
|
from reaction_board.database import Database # noqa: E402
|
|
from reaction_board.repository import Repository # noqa: E402
|
|
|
|
F1 = FolderConfig(year=2025, media_id=4451053851, title="2025")
|
|
F2 = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
|
|
|
|
|
def resource(aid, *, attr=0, up_mid=100, title="视频", bvid=None, fav_time=None,
|
|
pages=2):
|
|
return FavResource(
|
|
aid=aid,
|
|
bvid=bvid or f"BV{aid}",
|
|
title=title,
|
|
up_mid=up_mid,
|
|
up_name="UP",
|
|
pubdate=1_700_000_000,
|
|
duration=600,
|
|
cover="http://example/cover.jpg",
|
|
attr=attr,
|
|
fav_time=fav_time,
|
|
pages=pages,
|
|
)
|
|
|
|
|
|
def setup(tmp):
|
|
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
|
db.initialize()
|
|
repo = Repository(db, clock=lambda: 1_800_000_000)
|
|
repo.ensure_folder(F1)
|
|
repo.ensure_folder(F2)
|
|
return db, repo
|
|
|
|
|
|
def test_upsert_and_folder_resource():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
outcome = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
assert outcome.status == "active"
|
|
assert outcome.previous_folder_id is None
|
|
video = repo.get_video(1)
|
|
assert video["bvid"] == "BV1"
|
|
assert video["folder_id"] == F1.media_id
|
|
assert video["source"] == f"fav:{F1.media_id}"
|
|
assert video["first_seen"] == 1_800_000_000
|
|
assert video["pages"] == 2
|
|
assert repo.folder_active_aids(F1.media_id) == {1}
|
|
|
|
# 幂等:重复导入不新增行、不改 first_seen
|
|
repo.upsert_from_fav(resource(1, title="新标题"), F1.media_id, was_added=False)
|
|
assert len(repo.list_videos()) == 1
|
|
video = repo.get_video(1)
|
|
assert video["title"] == "新标题"
|
|
assert video["first_seen"] == 1_800_000_000
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_invalid_attr_and_missing():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
out = repo.upsert_from_fav(resource(2, attr=9), F1.media_id, was_added=True)
|
|
assert out.status == "removed"
|
|
assert repo.get_video(2)["attr"] == 9
|
|
|
|
# 原本有效的视频后来失效:保留原历史标题
|
|
repo.upsert_from_fav(
|
|
resource(4, title="原始标题"), F1.media_id, was_added=True
|
|
)
|
|
repo.upsert_from_fav(
|
|
resource(4, title="已失效视频", attr=9), F1.media_id, was_added=False
|
|
)
|
|
video4 = repo.get_video(4)
|
|
assert video4["status"] == "removed"
|
|
assert video4["title"] == "原始标题"
|
|
|
|
status = repo.mark_missing_resource(
|
|
3, folder_id=F1.media_id, bvid="BV3"
|
|
)
|
|
assert status == "removed"
|
|
video = repo.get_video(3)
|
|
assert video["status"] == "removed"
|
|
assert video["bvid"] == "BV3"
|
|
assert video["title"] == "(资源已失效)"
|
|
assert video["attr"] == 9
|
|
assert 3 in repo.folder_active_aids(F1.media_id)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_remove_from_folder_transitions():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
for aid in (1, 2):
|
|
repo.upsert_from_fav(resource(aid), F1.media_id, was_added=True)
|
|
# 2 同时出现在两个夹:从 F1 移除后仍 active
|
|
repo.upsert_from_fav(resource(2), F2.media_id, was_added=True)
|
|
status = repo.mark_resource_removed(F1.media_id, 2)
|
|
assert status == "active"
|
|
assert repo.get_video(2)["status"] == "active"
|
|
|
|
# 1 只在 F1:移除 -> unfaved,但数据保留
|
|
status = repo.mark_resource_removed(F1.media_id, 1)
|
|
assert status == "unfaved"
|
|
assert repo.get_video(1)["status"] == "unfaved"
|
|
assert repo.get_video(1)["bvid"] == "BV1"
|
|
assert 1 not in repo.folder_active_aids(F1.media_id)
|
|
|
|
# 再次出现在收藏夹 -> active
|
|
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
assert out.status == "active"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_dropped_only_revives_on_reappearing_in_folder():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
repo.set_video_status(1, "dropped")
|
|
# 全量刷新(未重新加入)保持 dropped
|
|
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=False)
|
|
assert out.status == "dropped"
|
|
# 从收藏夹移除也不改变 dropped
|
|
repo.mark_resource_removed(F1.media_id, 1)
|
|
assert repo.get_video(1)["status"] == "dropped"
|
|
# 重新出现在收藏夹 -> 自动恢复 active
|
|
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
assert out.status == "active"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_removed_stays_removed_when_folder_removed():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1, attr=9), F1.media_id, was_added=True)
|
|
assert repo.get_video(1)["status"] == "removed"
|
|
status = repo.mark_resource_removed(F1.media_id, 1)
|
|
assert status == "removed"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_multi_folder_moves_primary_and_warns():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
out = repo.upsert_from_fav(resource(1), F2.media_id, was_added=True)
|
|
assert out.moved_folder is True
|
|
assert out.previous_folder_id == F1.media_id
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
# 两个夹的资源关系都保留
|
|
assert repo.folder_active_aids(F1.media_id) == {1}
|
|
assert repo.folder_active_aids(F2.media_id) == {1}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_blacklist_seed_idempotent():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
assert repo.seed_blacklist([1, 2, 3], now=100) == 3
|
|
assert repo.seed_blacklist([2, 3, 4], now=200) == 1
|
|
rows = repo.blacklist()
|
|
assert [r["mid"] for r in rows] == [1, 2, 3, 4]
|
|
assert all(r["reason"] == "seed" for r in rows)
|
|
assert repo.is_blacklisted(2) and not repo.is_blacklisted(99)
|
|
repo.add_blacklist(9, name="切片号", reason="manual")
|
|
assert repo.is_blacklisted(9)
|
|
repo.add_blacklist(9, reason="updated")
|
|
row = next(r for r in repo.blacklist() if r["mid"] == 9)
|
|
assert row["name"] == "切片号"
|
|
assert row["reason"] == "updated"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_folder_sync_metadata_and_counts():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
repo.finish_folder_sync(
|
|
F1, media_count=1, full_refresh=True, now=1_800_000_000
|
|
)
|
|
row = repo.get_folder(F1.media_id)
|
|
assert row.media_count == 1
|
|
assert row.last_ids_at == 1_800_000_000
|
|
assert row.last_full_at == 1_800_000_000
|
|
|
|
repo.finish_folder_sync(
|
|
F1, media_count=2, full_refresh=False, now=1_800_000_100
|
|
)
|
|
row = repo.get_folder(F1.media_id)
|
|
assert row.media_count == 2
|
|
assert row.last_ids_at == 1_800_000_100
|
|
assert row.last_full_at == 1_800_000_000
|
|
|
|
# 配置中移除的年度夹停用,重新加入时恢复
|
|
repo.disable_folders_except([F1.media_id])
|
|
assert repo.get_folder(F2.media_id).enabled is False
|
|
repo.ensure_folder(F2)
|
|
assert repo.get_folder(F2.media_id).enabled is True
|
|
|
|
counts = repo.counts()
|
|
assert counts["video_total"] == 1
|
|
assert counts["video_by_status"] == {"active": 1}
|
|
assert counts["folders"] == 2
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_primary_folder_is_order_independent():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1, fav_time=100), F1.media_id, was_added=True)
|
|
assert repo.get_video(1)["folder_id"] == F1.media_id
|
|
repo.upsert_from_fav(resource(1, fav_time=200), F2.media_id, was_added=True)
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
|
|
# 全量刷新按相反顺序遍历,主归属仍应指向 fav_time 更新的夹
|
|
repo.upsert_from_fav(resource(1, fav_time=100), F1.media_id, was_added=False)
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
repo.upsert_from_fav(resource(1, fav_time=200), F2.media_id, was_added=False)
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
|
|
# 从主夹移除、另一个夹仍持有时,主归属切换
|
|
status = repo.mark_resource_removed(F2.media_id, 1)
|
|
assert status == "active"
|
|
assert repo.get_video(1)["folder_id"] == F1.media_id
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_dropped_invalid_preserves_original_title():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(
|
|
resource(1, title="原始标题"), F1.media_id, was_added=True
|
|
)
|
|
repo.set_video_status(1, "dropped")
|
|
repo.upsert_from_fav(
|
|
resource(1, title="已失效视频", attr=9),
|
|
F1.media_id,
|
|
was_added=False,
|
|
)
|
|
video = repo.get_video(1)
|
|
assert video["status"] == "dropped"
|
|
assert video["title"] == "原始标题"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_pending_snapshot_aids():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
repo.upsert_from_fav(resource(2), F1.media_id, was_added=True)
|
|
repo.upsert_from_fav(resource(3), F1.media_id, was_added=True)
|
|
repo.set_video_status(3, "unfaved")
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 1,
|
|
metrics={"view": 1, "like": 1, "favorite": 1, "coin": 1,
|
|
"share": 1, "reply": 1, "danmaku": 1},
|
|
ok=True, err=None, captured_at=1,
|
|
)
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 2,
|
|
metrics=None, ok=False, err="x", captured_at=1,
|
|
)
|
|
assert repo.pending_snapshot_aids("2026-10-31") == [2, 3]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_upsert_snapshot_preserves_view_open():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
metrics = {
|
|
"view": 10, "like": 1, "favorite": 0, "coin": 0,
|
|
"share": 0, "reply": 0, "danmaku": 0,
|
|
}
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 1, metrics=metrics, ok=True, err=None, captured_at=10,
|
|
)
|
|
first = repo.get_snapshot("2026-10-31", 1)
|
|
assert first["view"] == 10
|
|
assert first["view_open"] == 10
|
|
assert first["open_captured_at"] == 10
|
|
|
|
metrics["view"] = 40
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 1, metrics=metrics, ok=True, err=None, captured_at=20,
|
|
)
|
|
second = repo.get_snapshot("2026-10-31", 1)
|
|
assert second["view"] == 40
|
|
assert second["view_open"] == 10
|
|
assert second["open_captured_at"] == 10
|
|
assert second["captured_at"] == 20
|
|
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 2, metrics=None, ok=False, err="x", captured_at=1,
|
|
)
|
|
failed = repo.get_snapshot("2026-10-31", 2)
|
|
assert failed["view_open"] is None
|
|
metrics["view"] = 7
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 2, metrics=metrics, ok=True, err=None, captured_at=5,
|
|
)
|
|
recovered = repo.get_snapshot("2026-10-31", 2)
|
|
assert recovered["view"] == 7
|
|
assert recovered["view_open"] == 7
|
|
assert recovered["open_captured_at"] == 5
|
|
|
|
copied = repo.copy_ok_snapshots(
|
|
"2026-10-31", "2026-11-01", captured_at=30, src="seed",
|
|
)
|
|
assert copied == 2
|
|
seeded = repo.get_snapshot("2026-11-01", 1)
|
|
assert seeded["view"] == 40
|
|
assert seeded["view_open"] == 40
|
|
assert seeded["open_captured_at"] == 30
|
|
assert seeded["src"] == "seed"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_up_follower_snapshot_and_backfill():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1, up_mid=100), F1.media_id, was_added=True)
|
|
repo.upsert_from_fav(resource(2, up_mid=100), F1.media_id, was_added=True)
|
|
repo.upsert_from_fav(resource(3, up_mid=200), F1.media_id, was_added=True)
|
|
missing = {row["up_mid"] for row in repo.up_mids_missing_follower()}
|
|
assert missing == {100, 200}
|
|
|
|
now = 1_800_000_000
|
|
assert repo.apply_up_follower(
|
|
mid=100, name="UP", follower=1234, following=5, now=now
|
|
) == 2
|
|
first = repo.get_video(1)
|
|
assert first["up_follower"] == 1234
|
|
assert first["up_follower_at"] == now
|
|
assert first["up_follower_backfilled"] == 0
|
|
profile = repo.get_up_profile(100)
|
|
assert profile["follower_first"] == 1234
|
|
assert profile["follower_latest"] == 1234
|
|
assert {
|
|
row["up_mid"] for row in repo.up_mids_missing_follower()
|
|
} == {200}
|
|
|
|
# 同一 UP 后来新增的视频记录当下的粉丝快照,不覆盖旧视频
|
|
repo.upsert_from_fav(resource(4, up_mid=100), F1.media_id, was_added=True)
|
|
repo.apply_up_follower(
|
|
mid=100, name="UP", follower=2000, following=6, now=now + 60
|
|
)
|
|
assert repo.get_video(4)["up_follower"] == 2000
|
|
assert repo.get_video(1)["up_follower"] == 1234
|
|
profile = repo.get_up_profile(100)
|
|
assert profile["follower_first"] == 1234
|
|
assert profile["follower_latest"] == 2000
|
|
|
|
# 回填只补空值,并标记 backfilled
|
|
assert repo.apply_up_follower(
|
|
mid=200, name="UP2", follower=88, now=now + 120, backfilled=True
|
|
) == 1
|
|
assert repo.get_video(3)["up_follower"] == 88
|
|
assert repo.get_video(3)["up_follower_backfilled"] == 1
|
|
|
|
# live_since 之前的视频标为回填,之后的视为实时采集
|
|
repo.upsert_from_fav(resource(5, up_mid=300), F1.media_id, was_added=True)
|
|
repo.apply_up_follower(
|
|
mid=300, name="UP3", follower=50, now=now + 180,
|
|
live_since=now + 1,
|
|
)
|
|
assert repo.get_video(5)["up_follower_backfilled"] == 1
|
|
|
|
coverage = repo.up_follower_coverage()
|
|
assert coverage == {
|
|
"total": 5, "covered": 5, "missing": 0, "ups_covered": 3,
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def main():
|
|
test_upsert_and_folder_resource()
|
|
test_invalid_attr_and_missing()
|
|
test_remove_from_folder_transitions()
|
|
test_dropped_only_revives_on_reappearing_in_folder()
|
|
test_removed_stays_removed_when_folder_removed()
|
|
test_multi_folder_moves_primary_and_warns()
|
|
test_blacklist_seed_idempotent()
|
|
test_folder_sync_metadata_and_counts()
|
|
test_primary_folder_is_order_independent()
|
|
test_dropped_invalid_preserves_original_title()
|
|
test_pending_snapshot_aids()
|
|
test_upsert_snapshot_preserves_view_open()
|
|
test_up_follower_snapshot_and_backfill()
|
|
print("OK: 收藏夹/视频状态机与黑名单测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|