"""归档保留策略(`app.cli.prune_experiments`)的候选选择单测。 删除归档是**不可恢复**操作,因此候选集合的判定必须被测试钉住:多删一条就是丢了 一份研究结论,少删一条则保留策略失效。这里只测纯函数与参数护栏(不连数据库)。 """ from __future__ import annotations from datetime import datetime, timedelta import pytest from app.cli.prune_experiments import _parse_args, select_prune_candidates class _Row: """最小归档摘要替身(只有 id / created_at 参与判定)。""" def __init__(self, id_: str, age_days: float | None) -> None: self.id = id_ self.created_at = None if age_days is None else NOW - timedelta(days=age_days) NOW = datetime(2026, 9, 20, 12, 0, 0) def _rows() -> list[_Row]: return [_Row("E1", 1), _Row("E2", 2), _Row("E3", 10), _Row("E4", 30), _Row("E5", None)] class TestSelectPruneCandidates: def test_keep_newest_n(self) -> None: got = [e.id for e in select_prune_candidates(_rows(), keep=2, older_than_days=None, now=NOW)] assert got == ["E3", "E4", "E5"], "只保留最新 2 条(E1/E2),其余都是候选" def test_keep_zero_deletes_all(self) -> None: got = [e.id for e in select_prune_candidates(_rows(), keep=0, older_than_days=None, now=NOW)] assert set(got) == {"E1", "E2", "E3", "E4", "E5"} def test_older_than_only(self) -> None: got = [e.id for e in select_prune_candidates(_rows(), keep=None, older_than_days=5, now=NOW)] assert got == ["E3", "E4", "E5"], "5 天内创建的 E1/E2 必须保留" def test_intersection_of_both_conditions(self) -> None: got = [e.id for e in select_prune_candidates(_rows(), keep=4, older_than_days=5, now=NOW)] # older_than 选出 E3/E4/E5,keep=4 保留 E1..E4 → 交集只剩 E5 assert got == ["E5"] def test_missing_created_at_treated_as_oldest(self) -> None: """created_at 为空按最旧处理:不能被当作「最新」从而永久留库。""" got = [e.id for e in select_prune_candidates(_rows(), keep=4, older_than_days=None, now=NOW)] assert "E5" in got def test_result_is_newest_first(self) -> None: got = [e.id for e in select_prune_candidates(_rows(), keep=0, older_than_days=None, now=NOW)] assert got == ["E1", "E2", "E3", "E4", "E5"], "输出按新→旧,便于人工核对" class TestArgGuard: def test_requires_keep_or_older_than(self) -> None: with pytest.raises(SystemExit): _parse_args([]) # 两个条件都不给 → 拒绝执行(避免误删全部) def test_negative_keep_rejected(self) -> None: with pytest.raises(SystemExit): _parse_args(["--keep", "-1"]) def test_default_is_dry_run(self) -> None: args = _parse_args(["--keep", "10"]) assert args.apply is False, "默认必须 dry-run,只有显式 --apply 才写库"