Files

214 lines
7.7 KiB
Python
Raw Permalink Normal View History

"""temp 目录清理逻辑(video_analysis/cleanup.py)单元测试
这组函数现在同时被「/清理temp 命令」「每日定时」「Web 缓存面板的手动清理」
三条路复用,是**真的会删文件**的代码,所以单测覆盖"删什么/不删什么"。
cleanup.py 有插件内相对导入(`.utils`)和 import 期副作用(`get_plugin_config`
建配置对象、`_register_auto_job()` 注册定时任务),无法裸加载 —— 按
test_video_sender_link.py 的办法:sys.modules 预置替身 + monkeypatch nonebot 的
几个工厂函数,再按路径加载目标模块。
文件一律用 tmp_path 造真实文件/目录 + `os.utime` 改 mtime,**不 mock 文件系统**:
这些判断(mtime 过期、目录为空、60s 目录下限)全在真实 stat 语义上。
"""
import importlib.util
import os
import sys
import time
import types
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_PLUGIN = _REPO / "hexi" / "plugins" / "nonebot_plugin_video_analysis"
_PKG = "hexi.plugins.nonebot_plugin_video_analysis"
_STUBS = {
"hexi": {},
"hexi.core": {},
"hexi.core.custom_utils": {"check_manage": None},
"hexi.core.scheduler": {"add_job": lambda *a, **k: None},
# cleanup.py 现在把清理提交到任务槽;这里只测 clean_temp_files / temp_stats,
# 槽本身由 tests/test_task_slots.py 覆盖,所以给个够 import 的替身即可。
"hexi.core.task_slots": {"TASKS": None, "Task": type("Task", (), {})},
"nonebot_plugin_apscheduler": {"scheduler": types.SimpleNamespace(remove_job=None)},
"nonebot_plugin_alconna": {"UniMessage": type("UniMessage", (), {})},
}
class _FakeMatcher:
"""`on_command(...)` 的替身:`.handle()` 当装饰器用。"""
def handle(self, *args, **kwargs):
def deco(func):
return func
return deco
class _TempRoot:
"""`utils.get_temp_root` 的替身:指向测试用的临时根。"""
def __init__(self) -> None:
self.root: Path | None = None
def __call__(self, sub: str = "") -> Path:
assert self.root is not None, "测试里要先 sched.get_temp_root.root = ..."
return self.root / sub if sub else self.root
@pytest.fixture
def cleanup(monkeypatch, tmp_path):
saved = {name: sys.modules.get(name) for name in _STUBS}
for name, attrs in _STUBS.items():
module = types.ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
# 插件包本体也用替身,避免触发 __init__ 的 NoneBot 初始化
saved[_PKG] = sys.modules.get(_PKG)
sys.modules[_PKG] = types.ModuleType(_PKG)
temp_root = _TempRoot()
utils_mod = types.ModuleType(f"{_PKG}.utils")
utils_mod.get_temp_root = temp_root
saved[f"{_PKG}.utils"] = sys.modules.get(f"{_PKG}.utils")
sys.modules[f"{_PKG}.utils"] = utils_mod
import nonebot
monkeypatch.setattr(nonebot, "require", lambda name: None, raising=False)
monkeypatch.setattr(
nonebot, "get_plugin_config", lambda cls: cls(), raising=False
) # 默认配置:auto / 7 天 / 4 点
monkeypatch.setattr(nonebot, "on_command", lambda *a, **k: _FakeMatcher(), raising=False)
target = f"{_PKG}.cleanup"
saved[target] = sys.modules.get(target)
spec = importlib.util.spec_from_file_location(target, _PLUGIN / "cleanup.py")
module = importlib.util.module_from_spec(spec)
sys.modules[target] = module
spec.loader.exec_module(module)
temp_root.root = tmp_path / "temp"
temp_root.root.mkdir()
module._tmp = temp_root.root # 方便测试直接拿到根
yield module
for name, previous in saved.items():
if previous is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = previous
def _make(root: Path, rel: str, age_days: float = 0.0, data: bytes = b"x") -> Path:
"""造一个文件,并把 mtime 往回拨 age_days 天。"""
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
if age_days:
t = time.time() - age_days * 86400
os.utime(path, (t, t))
return path
# ───────────────────────── 清理 ─────────────────────────
def test_removes_stale_keeps_fresh(cleanup):
root = cleanup._tmp
stale = _make(root, "douyin/作者_1/旧作品.mp4", age_days=2)
fresh = _make(root, "douyin/作者_1/新作品.mp4", age_days=0)
removed, total = cleanup.clean_temp_files(days=1)
assert (removed, total) == (1, 2)
assert not stale.exists()
assert fresh.exists()
def test_days_zero_clears_files_but_keeps_fresh_dirs(cleanup):
"""`/清理temp 0`(= Web 的「全部清空」):文件全删,但刚建的目录留着。
目录另有 60s 下限(见 `_prune_empty_dirs`)—— 下载落盘是先 mkdir 再写文件,
把目录一起删掉会让正在进行的下载失败。
"""
root = cleanup._tmp
a = _make(root, "kuaishou/作者_2/作品.mp4", age_days=0)
b = _make(root, "xiaohongshu/作者_3/笔记/001.jpg", age_days=0)
removed, total = cleanup.clean_temp_files(days=0)
assert (removed, total) == (2, 2)
assert not a.exists() and not b.exists()
assert (root / "kuaishou/作者_2").is_dir() # 新鲜目录保留
assert (root / "xiaohongshu/作者_3/笔记").is_dir()
def test_prunes_stale_empty_dirs_bottom_up(cleanup):
"""过期空目录会被收掉,但是**逐轮**回收而不是同一轮全清。
删文件(或删子目录)会把所在目录的 mtime 刷成"现在",当轮它就不再"过期",
要等下一轮 —— 这正是 `_prune_empty_dirs` 注释里写的行为(也被这条测试钉住)。
"""
root = cleanup._tmp
_make(root, "bilibili/作者_4/作品/001.jpg", age_days=3)
nested = root / "bilibili/作者_4/作品"
author = root / "bilibili/作者_4"
removed, _ = cleanup.clean_temp_files(days=1)
assert removed == 1
assert nested.is_dir() and author.is_dir() # 文件刚删,目录 mtime 是新的
old = time.time() - 3 * 86400 # 模拟又过了几天
os.utime(nested, (old, old))
os.utime(author, (old, old))
cleanup.clean_temp_files(days=1)
assert not nested.exists() # 深的先走
os.utime(author, (old, old)) # 上一轮删子目录,又把父目录的 mtime 刷新了
cleanup.clean_temp_files(days=1)
assert not author.exists()
def test_keeps_stale_but_nonempty_dirs(cleanup):
"""过期但里面还有新鲜文件的目录不能删。"""
root = cleanup._tmp
_make(root, "douyin/作者_5/新片.mp4", age_days=0)
old_dir = root / "douyin/作者_5"
os.utime(old_dir, (time.time() - 3 * 86400,) * 2)
cleanup.clean_temp_files(days=1)
assert old_dir.is_dir()
def test_missing_root_is_noop(cleanup):
"""根目录不存在(还没下载过任何东西)→ (0, 0),不抛。"""
cleanup._tmp.rmdir()
assert cleanup.clean_temp_files(days=1) == (0, 0)
# ───────────────────────── 统计 ─────────────────────────
def test_stats_counts_files_bytes_dirs(cleanup):
root = cleanup._tmp
_make(root, "douyin/作者_6/a.mp4", age_days=2, data=b"12345")
_make(root, "douyin/作者_6/b.mp4", age_days=0, data=b"123")
_make(root, "douyin/作者_7/c.jpg", age_days=5, data=b"1")
st = cleanup.temp_stats()
assert st["files"] == 3
assert st["bytes"] == 9
assert st["dirs"] == 3 # douyin / 作者_6 / 作者_7(子目录各算一层)
def test_stats_missing_root(cleanup):
cleanup._tmp.rmdir()
assert cleanup.temp_stats() == {"files": 0, "bytes": 0, "dirs": 0}