Files
MoviePilot/tests/test_monitor_rescan.py
T

322 lines
11 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.
"""
目录监控延迟重扫机制的边界缺陷回归测试。
覆盖点:
- 同目录重复登记去重(4.1
- 整体扫描失败不燃烧轮次 + 失败次数上限(4.2)
- 目录删除后条目出队,不计入失败重试(4.2)
- MONITOR_RESCAN_DELAYS 配置解析(合法/非法回退)(4.3)
- 目录树整体移入时只登记顶层目录、祖先已登记时跳过子孙目录(4.4)
- 待重扫队列溢出时日志升级为 warn(4.4)
"""
import shutil
from pathlib import Path
from unittest.mock import MagicMock
from watchfiles import Change
from app.runtime.config import settings
from app.monitor.watcher import LocalDirectoryWatcher
def _build_watcher(tmp_path, force_polling=True):
"""
构造测试用目录监控。
:param tmp_path: 监控目录
:param force_polling: 是否强制轮询
:return: 目录监控
"""
return LocalDirectoryWatcher(tmp_path, callback=MagicMock(), force_polling=force_polling)
# ==================== 4.1 同目录重复登记去重 ====================
def test_schedule_rescan_dedups_same_directory(tmp_path):
"""
readdir 闪断可能让同一目录产生两次 added 事件,重复调用 _schedule_rescan
只应保留一条待重扫记录,不应重复登记。
"""
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
watcher._schedule_rescan(directory, seen={"already-seen"})
assert len(watcher._pending_rescans) == 1
# 保留原条目即可,不需要用新事件的 seen 覆盖
assert watcher._pending_rescans[0]["seen"] == set()
def test_expand_added_directories_dedups_repeated_added_event_across_batches(tmp_path):
"""
同一目录在不同批次的 changes 中各出现一次 added 事件时(模拟 readdir 闪断),
整体展开流程也不应重复登记重扫。
"""
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._expand_added_directories({(Change.added, directory.as_posix())})
watcher._expand_added_directories({(Change.added, directory.as_posix())})
assert len(watcher._pending_rescans) == 1
# ==================== 4.2 扫描失败不燃烧轮次 + 失败上限 + 目录删除终态 ====================
def test_collect_directory_files_reports_missing_directory(tmp_path):
"""
目录已不存在时应识别为终态(missing=True),而不是当作扫描失败。
"""
missing = tmp_path / "gone"
watcher = _build_watcher(tmp_path)
collected, is_missing, scan_failed = watcher._collect_directory_files(missing, exclude=set())
assert collected == set()
assert is_missing is True
assert scan_failed is False
def test_collect_directory_files_reports_scan_failure(tmp_path, monkeypatch):
"""
顶层 rglob 抛 OSError 应识别为整体扫描失败(scan_failed=True),
与目录已删除的终态区分开。
"""
directory = tmp_path / "season"
directory.mkdir()
def failing_rglob(self, pattern):
raise OSError("FUSE 抖动")
monkeypatch.setattr(Path, "rglob", failing_rglob)
watcher = _build_watcher(tmp_path)
collected, is_missing, scan_failed = watcher._collect_directory_files(directory, exclude=set())
assert collected == set()
assert is_missing is False
assert scan_failed is True
def test_process_pending_rescans_does_not_burn_round_on_scan_failure(tmp_path, monkeypatch):
"""
整体扫描失败时不应消耗重扫轮次,条目应保持在原轮次继续重试,
并累计一次失败计数。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0, 100))
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
def failing_rglob(self, pattern):
raise OSError("FUSE 抖动")
monkeypatch.setattr(Path, "rglob", failing_rglob)
watcher._process_pending_rescans()
assert len(watcher._pending_rescans) == 1
item = watcher._pending_rescans[0]
assert item["round"] == 0
assert item["failures"] == 1
def test_process_pending_rescans_drops_item_after_max_failures(tmp_path, monkeypatch):
"""
连续扫描失败达到 MAX_RESCAN_FAILURES 上限后应放弃重扫并 warn,
避免目录长期不可访问时无限重试。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0,))
monkeypatch.setattr(LocalDirectoryWatcher, "MAX_RESCAN_FAILURES", 2)
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
def failing_rglob(self, pattern):
raise OSError("FUSE 抖动")
monkeypatch.setattr(Path, "rglob", failing_rglob)
watcher._process_pending_rescans()
assert len(watcher._pending_rescans) == 1
watcher._process_pending_rescans()
assert watcher._pending_rescans == []
logger_warn.assert_called_once()
def test_process_pending_rescans_resets_failure_count_after_success(tmp_path, monkeypatch):
"""
扫描恢复成功后应重置失败计数,不应带着历史失败次数继续累积。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0, 100))
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
watcher._pending_rescans[0]["failures"] = 3
watcher._process_pending_rescans()
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["failures"] == 0
def test_process_pending_rescans_drops_deleted_directory_without_failure(tmp_path, monkeypatch):
"""
目录已被删除是终态,应直接出队,不计入失败重试次数。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "DIRECTORY_RESCAN_DELAYS", (0,))
directory = tmp_path / "season"
directory.mkdir()
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(directory, seen=set())
shutil.rmtree(directory)
watcher._process_pending_rescans()
assert watcher._pending_rescans == []
# ==================== 4.3 重扫窗口可配置 ====================
def test_parse_rescan_delays_accepts_valid_string():
"""
合法的逗号分隔正整数字符串应正确解析为元组。
"""
assert LocalDirectoryWatcher._parse_rescan_delays("30,120,600,1800") == (30, 120, 600, 1800)
assert LocalDirectoryWatcher._parse_rescan_delays(" 5 , 10 ") == (5, 10)
def test_parse_rescan_delays_falls_back_on_empty():
"""
空字符串/None 应回退到默认值。
"""
assert LocalDirectoryWatcher._parse_rescan_delays("") == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
assert LocalDirectoryWatcher._parse_rescan_delays(None) == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
def test_parse_rescan_delays_falls_back_on_invalid_format(monkeypatch):
"""
非法格式(无法转换为整数)应回退默认值并记录 warn 日志。
"""
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
assert LocalDirectoryWatcher._parse_rescan_delays("abc,def") == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
logger_warn.assert_called_once()
def test_parse_rescan_delays_rejects_non_positive_values(monkeypatch):
"""
包含非正整数(0 或负数)视为非法配置,应回退默认值并记录 warn 日志。
"""
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
assert LocalDirectoryWatcher._parse_rescan_delays("30,-5") == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
logger_warn.assert_called_once()
def test_directory_rescan_delays_property_reads_settings(tmp_path, monkeypatch):
"""
DIRECTORY_RESCAN_DELAYS 应实时反映 MONITOR_RESCAN_DELAYS 配置。
"""
monkeypatch.setattr(settings, "MONITOR_RESCAN_DELAYS", "5,10")
watcher = _build_watcher(tmp_path)
assert watcher.DIRECTORY_RESCAN_DELAYS == (5, 10)
def test_directory_rescan_delays_property_falls_back_on_invalid_settings(tmp_path, monkeypatch):
"""
配置非法时属性访问应回退默认值,而不是抛异常影响监控主流程。
"""
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
monkeypatch.setattr(settings, "MONITOR_RESCAN_DELAYS", "not-a-number")
watcher = _build_watcher(tmp_path)
assert watcher.DIRECTORY_RESCAN_DELAYS == LocalDirectoryWatcher.DEFAULT_RESCAN_DELAYS
# ==================== 4.4 目录树移入只登记顶层 + 溢出 warn ====================
def test_expand_added_directories_only_schedules_top_level(tmp_path):
"""
大目录树整体移入时,changes 里每一层子目录都会各自产生一次 added 事件,
但只有顶层目录需要登记重扫:子目录内容已被顶层目录的 rglob 覆盖。
"""
top = tmp_path / "task"
nested = top / "season1"
nested2 = nested / "sub"
nested2.mkdir(parents=True)
(top / "movie.mkv").write_bytes(b"x")
watcher = _build_watcher(tmp_path)
watcher._expand_added_directories({
(Change.added, top.as_posix()),
(Change.added, nested.as_posix()),
(Change.added, nested2.as_posix()),
})
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["path"] == top
def test_schedule_rescan_skips_descendant_of_pending_ancestor(tmp_path):
"""
某祖先目录已经在待重扫队列中时,其子孙目录不应再单独登记——
祖先条目的 rglob 会递归覆盖到子孙目录的新文件。
"""
parent = tmp_path / "task"
child = parent / "season"
child.mkdir(parents=True)
watcher = _build_watcher(tmp_path)
watcher._schedule_rescan(parent, seen=set())
watcher._schedule_rescan(child, seen=set())
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["path"] == parent
def test_is_descendant_of_any_does_not_match_sibling_with_prefix_name(tmp_path):
"""
路径前缀相同但并非真实父子关系的兄弟目录(如 task / task2)不应被
误判为祖先命中,避免字符串前缀匹配导致的误跳过。
"""
parent = tmp_path / "task"
sibling = tmp_path / "task2"
assert LocalDirectoryWatcher._is_descendant_of_any(sibling, {parent}) is False
def test_schedule_rescan_overflow_logs_warning(tmp_path, monkeypatch):
"""
待重扫队列已满时应放弃登记,且日志级别应为 warn(原实现仅 debug,
可能导致文件永久漏扫却难以被发现)。
"""
monkeypatch.setattr(LocalDirectoryWatcher, "MAX_PENDING_RESCANS", 1)
logger_warn = MagicMock()
monkeypatch.setattr("app.monitor.watcher.logger.warn", logger_warn)
watcher = _build_watcher(tmp_path)
first_dir = tmp_path / "a"
second_dir = tmp_path / "b"
first_dir.mkdir()
second_dir.mkdir()
watcher._schedule_rescan(first_dir, seen=set())
watcher._schedule_rescan(second_dir, seen=set())
assert len(watcher._pending_rescans) == 1
assert watcher._pending_rescans[0]["path"] == first_dir
logger_warn.assert_called_once()