Files
re-xxm/tests/test_discovery.py
T
kairee aa9aa7c589 feat(classify): gate loose re token, non-linear negatives, drop 纯享/转场
reaction 词表用 re|reaction|反应 但须与咻咻满或充电同现,兼容 reactio/reation 等拼写又避免通用词加分;负向改为每词 -2 且命中多条额外叠加(k*(k-1)),日期 -4;纯享(误伤纯享受)移出负向,转场移出正向。库内 796 条有效视频复测:线以下由 14 降至 8。
2026-11-01 19:18:20 +08:00

231 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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,
**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):
path = write_config(tmp)
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(1 分)与 aid 2(2 分)
assert summary.notified == 0
assert repo.get_candidate(6)["score"] == 1
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 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()
print("OK: 发现过滤、打分、候选池与通知批次测试全部通过")
if __name__ == "__main__":
main()