""" 目录监控延迟重扫机制的边界缺陷回归测试。 覆盖点: - 同目录重复登记去重(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.core.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()