Files
MajoorWaldi-ComfyUI-Majoor-…/tests/features/test_index_watcher.py
T
MajoorWaldi fbd1d182e8 fix(dnd): include avif images in ComfyUI metadata stripping
- Updated the _STRIPPABLE_EXTS set to include the .avif extension
- This change ensures that AVIF images are considered for ComfyUI metadata stripping during the drag-and-drop operation
2026-04-06 18:28:58 +02:00

379 lines
12 KiB
Python

import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
from mjr_am_backend.features.index import watcher as w
class _FakeTimer:
def __init__(self):
self.cancelled = False
def cancel(self):
self.cancelled = True
class _FakeLoop:
def __init__(self):
self.calls = []
def call_later(self, delay, fn):
self.calls.append((delay, fn))
return _FakeTimer()
class _FakeObserver:
def __init__(self):
self.started = False
self.stopped = False
self.joined = False
self.scheduled = []
self.unscheduled = []
def schedule(self, handler, path, recursive=True):
watch = {"handler": handler, "path": path, "recursive": recursive}
self.scheduled.append(watch)
return watch
def unschedule(self, watch):
self.unscheduled.append(watch)
def start(self):
self.started = True
def stop(self):
self.stopped = True
def join(self, timeout=0):
_ = timeout
self.joined = True
@pytest.fixture(autouse=True)
def _reset_watcher_globals():
w._RECENT_GENERATED.clear()
w._STREAM_EVENTS.clear()
w._STREAM_TOTAL_FILES = 0
w._LAST_STREAM_ALERT_TIME = 0.0
yield
w._RECENT_GENERATED.clear()
w._STREAM_EVENTS.clear()
w._STREAM_TOTAL_FILES = 0
w._LAST_STREAM_ALERT_TIME = 0.0
@pytest.fixture
def handler(monkeypatch):
async def _ready(_files):
return None
async def _removed(_files):
return None
async def _moved(_moves):
return None
monkeypatch.setattr(w, "get_watcher_settings", lambda: SimpleNamespace(debounce_ms=100, dedupe_ttl_ms=200))
return w.DebouncedWatchHandler(_ready, _removed, _moved, _FakeLoop(), debounce_ms=100, dedupe_ttl_ms=500)
def test_recent_generated_roundtrip_and_prune(monkeypatch):
monkeypatch.setattr(w.time, "time", lambda: 1000.0)
w.mark_recent_generated(["C:/a.png", ""])
assert w.is_recent_generated("C:/a.png") is True
monkeypatch.setattr(w.time, "time", lambda: 1000.0 + w._RECENT_GENERATED_TTL_S + 1)
assert w.is_recent_generated("C:/a.png") is False
def test_normalize_recent_key_handles_error(monkeypatch):
monkeypatch.setattr(w.os.path, "normpath", lambda _p: (_ for _ in ()).throw(RuntimeError("x")))
assert w._normalize_recent_key("x") == "x"
def test_stream_event_helpers(monkeypatch):
monkeypatch.setattr(w, "WATCHER_STREAM_ALERT_THRESHOLD", 3)
monkeypatch.setattr(w, "WATCHER_STREAM_ALERT_WINDOW_SECONDS", 5.0)
monkeypatch.setattr(w, "WATCHER_STREAM_ALERT_COOLDOWN_SECONDS", 0.0)
monkeypatch.setattr(w.time, "time", lambda: 100.0)
w._record_flush_volume(2)
assert w._STREAM_TOTAL_FILES == 2
assert w._should_emit_stream_alert(100.0, w._LAST_STREAM_ALERT_TIME) is False
w._record_flush_volume(2)
assert w._STREAM_TOTAL_FILES == 4
assert w._should_emit_stream_alert(100.0, w._LAST_STREAM_ALERT_TIME) is True
w._prune_stream_events(110.0, 5.0)
assert w._STREAM_TOTAL_FILES == 0
def test_apply_flush_limit(monkeypatch):
monkeypatch.setattr(w, "WATCHER_FLUSH_MAX_FILES", 2)
assert w._apply_flush_limit(["a", "b", "c"]) == ["a", "b"]
def test_handler_path_support_and_ignore(handler):
assert handler._is_supported("x.png") is True
assert handler._is_supported("x.avif") is True
assert handler._is_supported("x.txt") is False
assert handler._is_ignored_path("/tmp/.git/a.png") is True
assert handler._is_ignored_path("/tmp/ok/a.png") is False
def test_handler_normalize_path_fallback(handler, monkeypatch):
monkeypatch.setattr(w.os.path, "normpath", lambda _p: (_ for _ in ()).throw(RuntimeError("x")))
assert handler._normalize_path("A") == "A"
def test_handler_moved_file_mode_matrix():
assert w.DebouncedWatchHandler._moved_file_mode(src_ok=False, dst_ok=False) == "ignore"
assert w.DebouncedWatchHandler._moved_file_mode(src_ok=True, dst_ok=False) == "delete"
assert w.DebouncedWatchHandler._moved_file_mode(src_ok=False, dst_ok=True) == "create"
assert w.DebouncedWatchHandler._moved_file_mode(src_ok=True, dst_ok=True) == "move"
def test_handler_pending_count(handler):
handler._pending["a"] = 1.0
handler._overflow["b"] = 1.0
assert handler.get_pending_count() == 2
def test_handle_file_rejects_and_adds(handler):
handler._handle_file("")
handler._handle_file("/tmp/.git/a.png")
handler._handle_file("/tmp/a.txt")
assert not handler._pending
handler._handle_file("/tmp/a.png")
assert len(handler._pending) == 1
def test_handle_deleted_and_emit_paths(monkeypatch):
removed = []
async def _ready(_files):
return None
async def _removed(paths):
removed.extend(paths)
h = w.DebouncedWatchHandler(_ready, _removed, None, _FakeLoop(), debounce_ms=100, dedupe_ttl_ms=200)
calls = []
def _run_coroutine_threadsafe(coro, loop):
calls.append((coro, loop))
close = getattr(coro, "close", None)
if callable(close):
close()
monkeypatch.setattr(w.asyncio, "run_coroutine_threadsafe", _run_coroutine_threadsafe)
h._handle_deleted_file("x.txt")
h._handle_deleted_file("x.png")
assert len(calls) == 1
def test_handle_moved_file_paths(monkeypatch):
seen = {"removed": [], "moved": [], "new": []}
async def _ready(_files):
return None
async def _removed(paths):
seen["removed"].extend(paths)
async def _moved(moves):
seen["moved"].extend(moves)
h = w.DebouncedWatchHandler(_ready, _removed, _moved, _FakeLoop(), debounce_ms=100, dedupe_ttl_ms=200)
monkeypatch.setattr(h, "_handle_file", lambda p: seen["new"].append(p))
monkeypatch.setattr(h, "_emit_removed_files", lambda p: seen["removed"].extend(p))
monkeypatch.setattr(h, "_emit_moved_files", lambda m: (seen["moved"].extend(m), True)[1])
h._handle_moved_file("a.txt", "b.txt")
h._handle_moved_file("a.png", "b.txt")
h._handle_moved_file("a.txt", "b.png")
h._handle_moved_file("a.png", "b.png")
assert seen["removed"]
assert seen["new"]
assert seen["moved"]
@pytest.mark.asyncio
async def test_flush_pipeline_filters_backpressure_and_mark(monkeypatch, handler):
monkeypatch.setattr(w, "WATCHER_FLUSH_MAX_FILES", 2)
monkeypatch.setattr(w, "MAX_FILE_SIZE", 10_000)
monkeypatch.setattr(w.os.path, "getsize", lambda p: 100 if "ok" in p else 0)
handler._pending = {
"/tmp/ok1.png": 1,
"/tmp/ok2.png": 1,
"/tmp/ok3.png": 1,
"/tmp/no.txt": 1,
}
flushed = []
async def _ready(files):
flushed.extend(files)
handler._on_files_ready = _ready
await handler._flush()
assert len(flushed) == 2
assert handler._overflow
def test_filter_flush_candidates_size_and_recent(monkeypatch, handler):
monkeypatch.setattr(
w.os.path,
"getsize",
lambda p: 99999999 if p.endswith("big.png") else (1 if p.endswith(".png") else 99999999),
)
monkeypatch.setattr(w, "MIN_FILE_SIZE", 1)
monkeypatch.setattr(w, "MAX_FILE_SIZE", 100)
monkeypatch.setattr(w, "_is_recent_generated", lambda p: p.endswith("skip.png"))
out = handler._filter_flush_candidates(["a.png", "skip.png", "x.txt", "big.png"])
assert out == ["a.png"]
def test_output_watcher_helpers_grouping_and_split():
async def _index(_files, _base, _source, _rid):
return None
ow = w.OutputWatcher(_index)
ow._watched_paths = {
"1": {"path": "C:/root", "source": "output", "root_id": "r1", "watch": object()},
"2": {"path": "C:/root/sub", "source": "output", "root_id": "r2", "watch": object()},
}
best = ow._best_watched_entry_for_path("C:/root/sub/a.png")
assert best and best["root_id"] == "r2"
grouped = ow._group_files_by_watched_root(["C:/root/sub/a.png", "C:/root/b.png"])
assert "C:/root/sub" in grouped
assert "C:/root" in grouped
moves = ow._group_moves_by_watched_root([("a", "C:/root/sub/x.png"), ("bad",)])
assert "C:/root/sub" in moves
src, dst = ow._split_move_pairs([("a", "b"), ("c",), "x"])
assert src == ["a"] and dst == ["b"]
@pytest.mark.asyncio
async def test_output_watcher_dispatch_and_fallback():
idx, rem, mov = [], [], []
async def _index(files, base, source, rid):
idx.append((files, base, source, rid))
async def _remove(files, base, source, rid):
rem.append((files, base, source, rid))
async def _move(moves, base, source, rid):
mov.append((moves, base, source, rid))
ow = w.OutputWatcher(_index, _remove, _move)
grouped = {"C:/r": {"files": ["a"], "source": "output", "root_id": "r1"}}
await ow._dispatch_file_groups(grouped, _index, "index")
assert idx
await ow._dispatch_move_groups({"C:/r": {"moves": [("a", "b")], "source": "output", "root_id": "r1"}})
assert mov
ow2 = w.OutputWatcher(_index, _remove, None)
async def _ready(files):
idx.append((files, "ready", None, None))
async def _removed(files):
rem.append((files, "removed", None, None))
ow2._handle_ready_files = _ready
ow2._handle_removed_files = _removed
await ow2._handle_move_fallback([("a", "b")])
assert any(x[1] == "ready" for x in idx)
assert any(x[1] == "removed" for x in rem)
@pytest.mark.asyncio
async def test_output_watcher_start_add_remove_stop(monkeypatch, tmp_path: Path):
d1 = tmp_path / "a"
d2 = tmp_path / "b"
d1.mkdir()
d2.mkdir()
fake_observer = _FakeObserver()
monkeypatch.setattr(w, "Observer", lambda: fake_observer)
async def _index(_files, _base, _source, _rid):
return None
ow = w.OutputWatcher(_index)
await ow.start([{"path": str(d1), "source": "output", "root_id": "r1"}, {"path": str(tmp_path / "missing")}], loop=asyncio.get_running_loop())
assert ow.is_running is True
assert fake_observer.started is True
assert ow.watched_directories
ow.add_path(str(d2), source="output", root_id="r2")
assert any(Path(p) == d2 for p in ow.watched_directories)
ow.remove_path(str(d2))
assert not any(Path(p) == d2 for p in ow.watched_directories)
await ow.stop()
assert ow.is_running is False
assert fake_observer.stopped is True
def test_output_watcher_misc_helpers():
async def _index(_files, _base, _source, _rid):
return None
ow = w.OutputWatcher(_index)
assert ow._normalize_source(" OutPut ") == "output"
assert ow._normalize_source(None) is None
ow._allowed_sources = {"output"}
assert ow._allows_source("output") is True
assert ow._allows_source("input") is False
assert ow._allows_source(None) is False
@pytest.mark.asyncio
async def test_output_watcher_handler_passthroughs():
async def _index(_files, _base, _source, _rid):
return None
ow = w.OutputWatcher(_index)
assert ow.flush_pending() is False
assert ow.get_pending_count() == 0
class _H:
def refresh_runtime_settings(self):
return None
def flush_pending(self):
return True
def get_pending_count(self):
return 7
ow._handler = _H()
ow.refresh_runtime_settings()
assert ow.flush_pending() is True
assert ow.get_pending_count() == 7
def test_is_under_path_variants(monkeypatch):
root = w.os.path.normpath("C:/a")
child = w.os.path.normpath("C:/a/b")
other = w.os.path.normpath("C:/x")
assert w._is_under_path(child, root) is True
assert w._is_under_path(other, root) is False
monkeypatch.setattr(w.os.path, "commonpath", lambda _v: (_ for _ in ()).throw(ValueError("x")))
assert w._is_under_path(child, root) is True