私聊新增 /candidate [页码](待决策列表)、/candi_del(标题按钮逐条删除,删后原地重建键盘、可翻页连续删)、/candi_clear(全部/待决策/取消)。重构 handle_callback 为前缀分派(cdel/cpage/cclr,保留 add/ign/blk/fav);repository 加 list_pending_candidates/delete_all_candidates;LibraryService 加 pending_candidates/delete_candidate/clear_candidates 并刷看板缓存。
217 lines
7.5 KiB
Python
217 lines
7.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""候选池、去重、通知记录与 fav_action 状态机测试(不联网):
|
|
uv run python tests/test_candidates.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
|
|
|
|
FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
|
|
|
|
|
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(FOLDER)
|
|
return db, repo
|
|
|
|
|
|
def candidate(repo, aid=1, *, score=3, now=1_800_000_000):
|
|
return repo.upsert_candidate(
|
|
aid=aid,
|
|
bvid=f"BV{aid}",
|
|
title=f"咻咻满 reaction {aid}",
|
|
up_mid=100,
|
|
up_name="UP",
|
|
pubdate=1_799_000_000,
|
|
cover="https://example/cover.jpg",
|
|
score=score,
|
|
channel="search:test#p1",
|
|
now=now,
|
|
)
|
|
|
|
|
|
def test_seen_and_known_aid():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
assert repo.known_aid(1) is False
|
|
repo.record_seen(1, source="search:test#p1")
|
|
repo.record_seen(1, source="search:test#p2") # 幂等
|
|
assert repo.known_aid(1) is True
|
|
repo.upsert_from_fav(
|
|
FavResource(
|
|
aid=2, bvid="BV2", title="视频", up_mid=100, up_name="UP",
|
|
pubdate=1, duration=60, cover=None, attr=0, fav_time=None,
|
|
),
|
|
FOLDER.media_id,
|
|
was_added=True,
|
|
)
|
|
assert repo.known_aid(2) is True
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_candidate_upsert_preserves_decision_and_notified():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
first = candidate(repo, 1, score=2)
|
|
assert first["decision"] is None and first["notified_at"] is None
|
|
|
|
repo.mark_notified(
|
|
1, channel="telegram", batch_id="b1", ok=True, now=1_800_000_100
|
|
)
|
|
repo.mark_candidate_decision(1, "rejected", now=1_800_000_200)
|
|
|
|
second = candidate(repo, 1, score=4, now=1_800_000_300)
|
|
assert second["score"] == 4
|
|
assert second["decision"] == "rejected"
|
|
assert second["notified_at"] == 1_800_000_100
|
|
assert second["first_seen"] == 1_800_000_000
|
|
assert len(repo.list_candidates()) == 1
|
|
assert len(repo.list_candidates("rejected")) == 1
|
|
assert repo.list_candidates("accepted") == []
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_notify_and_reject_for_up():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
candidate(repo, 1)
|
|
candidate(repo, 2)
|
|
repo.mark_notified(
|
|
1, channel="telegram", batch_id="b1", ok=False, err="boom",
|
|
now=1_800_000_100,
|
|
)
|
|
assert repo.get_candidate(1)["notified_at"] is None
|
|
repo.mark_notified(
|
|
1, channel="telegram", batch_id="b2", ok=True, now=1_800_000_200
|
|
)
|
|
assert repo.get_candidate(1)["notified_at"] == 1_800_000_200
|
|
|
|
assert repo.reject_pending_for_up(100, now=1_800_000_300) == 2
|
|
assert repo.get_candidate(2)["decision"] == "rejected"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_delete_expired_candidates_only_pending():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
candidate(repo, 1, now=1_800_000_000)
|
|
candidate(repo, 2, now=1_800_000_000)
|
|
candidate(repo, 3, now=1_800_000_000)
|
|
repo.mark_candidate_decision(3, "rejected")
|
|
deleted = repo.delete_expired_candidates(
|
|
ttl_days=7, now=1_800_000_000 + 8 * 86400
|
|
)
|
|
assert deleted == 2
|
|
assert repo.get_candidate(1) is None
|
|
assert repo.get_candidate(3) is not None
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_folder_for_up_uses_earliest_video():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
for aid, pubdate in ((1, 1_700_000_500), (2, 1_700_000_100)):
|
|
repo.upsert_from_fav(
|
|
FavResource(
|
|
aid=aid, bvid=f"BV{aid}", title="视频", up_mid=100,
|
|
up_name="UP", pubdate=pubdate, duration=60,
|
|
cover=None, attr=0, fav_time=None,
|
|
),
|
|
FOLDER.media_id,
|
|
was_added=True,
|
|
)
|
|
assert repo.folder_for_up(100) == FOLDER.media_id
|
|
assert repo.folder_for_up(999) is None
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_fav_action_state_machine():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
token, state = repo.begin_fav_action(1, FOLDER.media_id)
|
|
assert token == "fav:1"
|
|
assert state == "processing"
|
|
|
|
token2, state2 = repo.begin_fav_action(1, FOLDER.media_id)
|
|
assert token2 == token and state2 == "busy"
|
|
|
|
repo.finish_fav_action(token, position=3)
|
|
assert repo.get_fav_action(token)["state"] == "done"
|
|
assert repo.get_fav_action(token)["position"] == 3
|
|
assert repo.begin_fav_action(1, FOLDER.media_id)[1] == "done"
|
|
|
|
token3, state3 = repo.begin_fav_action(2, FOLDER.media_id)
|
|
assert state3 == "processing"
|
|
repo.fail_fav_action(token3, error="夹满")
|
|
assert repo.get_fav_action(token3)["state"] == "failed"
|
|
assert repo.get_fav_action(token3)["err"] == "夹满"
|
|
|
|
# failed 可重试
|
|
_, retry = repo.begin_fav_action(2, FOLDER.media_id)
|
|
assert retry == "processing"
|
|
|
|
# 过期的 processing 自动失败
|
|
assert repo.expire_fav_actions(now=1_800_000_000 + 2 * 86400) >= 1
|
|
assert repo.get_fav_action("fav:2")["state"] == "failed"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_list_pending_and_delete_all():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
candidate(repo, 1, score=9)
|
|
candidate(repo, 2, score=4)
|
|
candidate(repo, 3, score=1)
|
|
repo.mark_candidate_decision(3, "rejected")
|
|
|
|
assert [c["aid"] for c in repo.list_pending_candidates()] == [1, 2]
|
|
assert repo.delete_all_candidates(pending_only=True) == 2
|
|
assert repo.get_candidate(3) is not None
|
|
assert repo.list_pending_candidates() == []
|
|
|
|
candidate(repo, 4)
|
|
assert repo.delete_all_candidates() == 2 # aid 3 + aid 4
|
|
assert repo.list_candidates() == []
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def main():
|
|
test_seen_and_known_aid()
|
|
test_candidate_upsert_preserves_decision_and_notified()
|
|
test_notify_and_reject_for_up()
|
|
test_delete_expired_candidates_only_pending()
|
|
test_list_pending_and_delete_all()
|
|
test_folder_for_up_uses_earliest_video()
|
|
test_fav_action_state_machine()
|
|
print("OK: 候选池、去重、通知记录与 fav_action 状态机测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|