# -*- 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_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_up_follower_snapshot_and_backfill() print("OK: 收藏夹/视频状态机与黑名单测试全部通过") if __name__ == "__main__": main()