reaction 词表用 re|reaction|反应 但须与咻咻满或充电同现,兼容 reactio/reation 等拼写又避免通用词加分;负向改为每词 -2 且命中多条额外叠加(k*(k-1)),日期 -4;纯享(误伤纯享受)移出负向,转场移出正向。库内 796 条有效视频复测:线以下由 14 降至 8。
231 lines
7.8 KiB
Python
231 lines
7.8 KiB
Python
# -*- 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()
|