# -*- coding: utf-8 -*- """发现任务测试(假搜索客户端,不联网): uv run python tests/test_discovery.py""" import json 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.client import BilibiliError # noqa: E402 from reaction_board.bilibili.search import SearchItem # noqa: E402 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.services.discovery import DiscoveryService # noqa: E402 NOW = 1_800_000_000 def write_config(tmp, **discover): data = { "bilibili": { "read": {"mid": 1, "csrf": "r", "cookie": "read-cookie"}, "write": {"mid": 2, "csrf": "w", "cookie": "write-cookie"}, }, "storage": { "database_path": str(Path(tmp) / "app.db"), "backup_dir": str(Path(tmp) / "backup"), }, "folders": [{"year": 2026, "media_id": 3700023351, "title": "2026"}], "discover": { "keywords": ["咻咻满 reaction"], "pages": 1, "window_days": 14, "candidate_min_score": 0, **discover, }, "classify": { "require_title": "咻咻满", "self_mid": 37754047, "reaction": ["reaction"], "charge": ["充电"], "weak_positive": [], "negative": ["切片"], "date_negative": True, "duration_boost": {"gt_30min": 1, "gt_60min": 2}, }, "notify": {"min_score": 4, "max_items_per_message": 10}, } path = Path(tmp) / "config.json" path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") return path class FakeSearch: def __init__(self, items, error=None): self.items = items self.error = error self.calls = [] def search(self, keyword, page): self.calls.append((keyword, page)) if self.error: raise self.error return list(self.items) class FakeNotifier: def __init__(self, ok=True): self.ok = ok self.batches = [] def send_candidates(self, candidates): self.batches.append(list(candidates)) return self.ok def item(aid, *, title="咻咻满 reaction", up_mid=100, pubdate=NOW - 3600, duration=600): return SearchItem( aid=aid, bvid=f"BV{aid}", title=title, up_mid=up_mid, up_name="UP", pubdate=pubdate, duration_seconds=duration, cover="https://example/cover.jpg", ) def make_service(tmp, items, *, notifier=None, **discover): path = write_config(tmp, **discover) store = ConfigStore(path, env={}) db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup") db.initialize() repo = Repository(db, clock=lambda: NOW) service = DiscoveryService( store, repo, FakeSearch(items), notifier=notifier, clock=lambda: NOW, sleeper=lambda seconds: None, logger=None, ) return db, repo, service def test_filters_and_scoring(): with tempfile.TemporaryDirectory() as tmp: notifier = FakeNotifier() db, repo, service = make_service(tmp, [ item(1), # 通知 item(2, title="咻咻满 reaction 切片"), # 负分丢弃 item(3, title="其他 UP reaction"), # 标题不含 item(4, pubdate=NOW - 20 * 86400), # 超时间窗 item(5, up_mid=999), # 黑名单 item(6, title="咻咻满 日常"), # 0 分入池不通知 ], notifier=notifier) try: repo.seed_blacklist([999], now=NOW) repo.record_seen(1, source="old", now=NOW) # aid=1 已 seen,改为验证已知跳过;另用 7 验证通知 summary = service.run() assert summary.items == 6 assert summary.known == 1 assert summary.title_miss == 1 assert summary.old == 1 assert summary.blacklisted == 1 assert summary.candidates == 2 # aid 6(0 分)与 aid 2(2 分) assert summary.notified == 0 assert repo.get_candidate(6)["score"] == 0 # 只过标题关:0 分 assert repo.get_candidate(2)["score"] == 2 # reaction 命中后单个「切片」不再清零 assert repo.get_candidate(4) is None assert repo.get_candidate(5) is None assert notifier.batches == [] finally: db.close() def test_notification_batch_and_failure(): with tempfile.TemporaryDirectory() as tmp: notifier = FakeNotifier() db, repo, service = make_service(tmp, [item(1), item(2)], notifier=notifier) try: summary = service.run() assert summary.candidates == 2 assert summary.notified == 2 assert len(notifier.batches) == 1 assert {c["aid"] for c in notifier.batches[0]} == {1, 2} assert repo.get_candidate(1)["notified_at"] == NOW finally: db.close() with tempfile.TemporaryDirectory() as tmp: notifier = FakeNotifier(ok=False) db, repo, service = make_service(tmp, [item(3)], notifier=notifier) try: summary = service.run() assert summary.notify_failed == 1 assert repo.get_candidate(3)["notified_at"] is None assert repo.get_candidate(3)["decision"] is None finally: db.close() def test_search_error_does_not_abort_other_keywords(): with tempfile.TemporaryDirectory() as tmp: path = write_config(tmp, keywords=["a", "b"]) store = ConfigStore(path, env={}) db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup") db.initialize() repo = Repository(db, clock=lambda: NOW) search = FakeSearch([item(1)], error=BilibiliError("network")) service = DiscoveryService( store, repo, search, clock=lambda: NOW, sleeper=lambda s: None ) try: summary = service.run() assert summary.searches == 0 assert len(summary.errors) == 2 finally: db.close() def test_pending_candidate_notified_on_later_run(): with tempfile.TemporaryDirectory() as tmp: db, repo, service = make_service(tmp, [item(1)], notifier=None) try: first = service.run() assert first.candidates == 1 assert repo.get_candidate(1)["notified_at"] is None notifier = FakeNotifier() service.notifier = notifier second = service.run() assert second.notified == 1 assert len(notifier.batches) == 1 assert repo.get_candidate(1)["notified_at"] == NOW finally: db.close() def test_duplicate_search_results_notified_once(): with tempfile.TemporaryDirectory() as tmp: notifier = FakeNotifier() db, repo, service = make_service( tmp, [item(1), item(1)], notifier=notifier ) try: summary = service.run() assert summary.notified == 1 assert len(notifier.batches) == 1 assert len(notifier.batches[0]) == 1 finally: db.close() def test_candidate_min_score_floor(): with tempfile.TemporaryDirectory() as tmp: db, repo, service = make_service( tmp, [ item(1, title="咻咻满 reaction 充电"), # 9 分 -> 入池 item(2, title="咻咻满 日常"), # 0 分 -> 只过标题关,不入池 item(3, title="咻咻满 reaction 切片"), # 2 分 -> 低于候选线,不入池 ], candidate_min_score=4, ) try: summary = service.run() assert summary.scored == 3 assert summary.candidates == 1 assert repo.get_candidate(1) is not None assert repo.get_candidate(2) is None assert repo.get_candidate(3) is None finally: db.close() def main(): test_filters_and_scoring() test_notification_batch_and_failure() test_search_error_does_not_abort_other_keywords() test_pending_candidate_notified_on_later_run() test_duplicate_search_results_notified_once() test_candidate_min_score_floor() print("OK: 发现过滤、打分、候选池与通知批次测试全部通过") if __name__ == "__main__": main()