mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-11 00:10:14 -03:00
253 lines
8.7 KiB
Python
253 lines
8.7 KiB
Python
"""Tests for queue-time LoRA validation helpers (validate_lora_entries)."""
|
|
|
|
import pytest
|
|
|
|
from py.nodes.utils import _find_missing_loras, validate_lora_entries
|
|
|
|
|
|
class _FakeCache:
|
|
def __init__(self, raw_data):
|
|
self.raw_data = raw_data
|
|
|
|
|
|
class _FakeScanner:
|
|
def __init__(self, raw_data):
|
|
self._raw_data = raw_data
|
|
# Non-None cache marks the scanner as initialized; None means the
|
|
# real scanner has not hydrated yet (validation must stay lenient).
|
|
self._cache = object()
|
|
self._is_initializing = False
|
|
|
|
async def get_cached_data(self, force_refresh=False):
|
|
return _FakeCache(self._raw_data)
|
|
|
|
|
|
@pytest.fixture
|
|
def lora_library(tmp_path):
|
|
"""Create a fake on-disk LoRA library plus a matching scanner cache."""
|
|
existing_a = tmp_path / "a.safetensors"
|
|
existing_b = tmp_path / "sub" / "b.safetensors"
|
|
existing_a.write_bytes(b"a")
|
|
existing_b.parent.mkdir()
|
|
existing_b.write_bytes(b"b")
|
|
|
|
# "gone" is referenced by the cache but the file was deleted afterwards,
|
|
# simulating a stale scanner cache.
|
|
gone = tmp_path / "gone.safetensors"
|
|
|
|
raw_data = [
|
|
{"file_name": "a.safetensors", "folder": "", "file_path": str(existing_a)},
|
|
{
|
|
"file_name": "b.safetensors",
|
|
"folder": "sub",
|
|
"file_path": str(existing_b),
|
|
},
|
|
{"file_name": "gone.safetensors", "folder": "", "file_path": str(gone)},
|
|
]
|
|
return raw_data
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_lora_scanner(lora_library, monkeypatch):
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
async def _fake_scanner():
|
|
return _FakeScanner(lora_library)
|
|
|
|
monkeypatch.setattr(
|
|
ServiceRegistry, "get_lora_scanner", _fake_scanner
|
|
)
|
|
|
|
|
|
def _entries(*names):
|
|
return [
|
|
{"active": True, "name": name, "strength": 1.0, "clipStrength": 1.0}
|
|
for name in names
|
|
]
|
|
|
|
|
|
def test_validate_flat_name_with_extension(mock_lora_scanner):
|
|
assert validate_lora_entries({"loras": _entries("a.safetensors")}) is None
|
|
|
|
|
|
def test_validate_flat_name_without_extension(mock_lora_scanner):
|
|
assert validate_lora_entries({"loras": _entries("a")}) is None
|
|
|
|
|
|
def test_validate_subfolder_name(mock_lora_scanner):
|
|
assert validate_lora_entries({"loras": _entries("sub/b.safetensors")}) is None
|
|
assert validate_lora_entries({"loras": _entries("sub/b")}) is None
|
|
|
|
|
|
def test_validate_stale_cache_entry_reported(mock_lora_scanner):
|
|
result = validate_lora_entries({"loras": _entries("gone.safetensors")})
|
|
assert result is not None
|
|
assert "gone.safetensors" in result
|
|
|
|
|
|
def test_validate_unknown_name_reported(mock_lora_scanner):
|
|
result = validate_lora_entries({"loras": _entries("nope.safetensors")})
|
|
assert result is not None
|
|
assert "nope.safetensors" in result
|
|
|
|
|
|
def test_validate_multiple_missing_all_listed(mock_lora_scanner):
|
|
result = validate_lora_entries(
|
|
{"loras": _entries("gone.safetensors", "nope.safetensors")}
|
|
)
|
|
assert result is not None
|
|
assert "gone.safetensors" in result
|
|
assert "nope.safetensors" in result
|
|
|
|
|
|
def test_validate_inactive_entries_ignored(mock_lora_scanner):
|
|
kwargs = {"loras": [{"active": False, "name": "gone.safetensors", "strength": 1.0}]}
|
|
assert validate_lora_entries(kwargs) is None
|
|
|
|
|
|
def test_validate_value_wrapper_format(mock_lora_scanner):
|
|
kwargs = {"loras": {"__value__": _entries("a.safetensors")}}
|
|
assert validate_lora_entries(kwargs) is None
|
|
kwargs_missing = {"loras": {"__value__": _entries("gone.safetensors")}}
|
|
assert validate_lora_entries(kwargs_missing) is not None
|
|
|
|
|
|
def test_validate_legacy_basename_fallback(mock_lora_scanner):
|
|
# A name with a folder that only matches by basename resolves at runtime
|
|
# via get_lora_info_absolute's fallback, so it must not be flagged.
|
|
assert validate_lora_entries({"loras": _entries("other/b.safetensors")}) is None
|
|
|
|
|
|
def test_validate_existing_absolute_path_ok(mock_lora_scanner, lora_library):
|
|
existing_a = lora_library[0]["file_path"]
|
|
assert validate_lora_entries({"loras": _entries(existing_a)}) is None
|
|
|
|
|
|
def test_validate_missing_absolute_path_reported(mock_lora_scanner):
|
|
result = validate_lora_entries(
|
|
{"loras": _entries("/nonexistent/path/x.safetensors")}
|
|
)
|
|
# Legacy syntax format normalizes to the basename, matching execution.
|
|
assert result is not None
|
|
assert "x.safetensors" in result
|
|
|
|
|
|
def test_validate_empty_or_missing_loras_is_valid(mock_lora_scanner):
|
|
assert validate_lora_entries({}) is None
|
|
assert validate_lora_entries({"loras": []}) is None
|
|
|
|
|
|
def test_validate_scanner_failure_is_lenient(monkeypatch):
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
def _boom():
|
|
raise RuntimeError("scanner not initialized")
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _boom)
|
|
assert validate_lora_entries({"loras": _entries("a.safetensors")}) is None
|
|
|
|
|
|
def test_find_missing_loras_empty(mock_lora_scanner):
|
|
assert _find_missing_loras([]) == []
|
|
|
|
|
|
def test_validate_inputs_only_reports_loras_input(mock_lora_scanner):
|
|
"""VALIDATE_INPUTS must fail for the single 'loras' input only — ComfyUI
|
|
creates one custom_validation_failed error per declared input, so an
|
|
explicit loras parameter keeps the report to a single input error."""
|
|
from py.nodes.lora_loader import LoraLoaderLM
|
|
from py.nodes.lora_stacker import LoraStackerLM
|
|
from py.nodes.create_hook_lora import CreateHookLoraLM
|
|
from py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
|
|
from py.nodes.lora_randomizer import LoraRandomizerLM
|
|
|
|
import inspect
|
|
|
|
for cls in (
|
|
LoraLoaderLM,
|
|
LoraStackerLM,
|
|
CreateHookLoraLM,
|
|
WanVideoLoraSelectLM,
|
|
LoraRandomizerLM,
|
|
):
|
|
spec = inspect.getfullargspec(getattr(cls, "VALIDATE_INPUTS"))
|
|
assert spec.varkw is None, f"{cls.__name__} VALIDATE_INPUTS must not accept **kwargs"
|
|
assert "loras" in spec.args, f"{cls.__name__} VALIDATE_INPUTS must declare loras"
|
|
|
|
result = LoraLoaderLM.VALIDATE_INPUTS(
|
|
loras=[
|
|
{"active": True, "name": "gone.safetensors", "strength": 1.0},
|
|
]
|
|
)
|
|
assert result is not None
|
|
assert "gone.safetensors" in result
|
|
|
|
assert LoraLoaderLM.VALIDATE_INPUTS(loras=None) is True
|
|
assert LoraLoaderLM.VALIDATE_INPUTS() is True
|
|
assert LoraLoaderLM.VALIDATE_INPUTS(loras=[]) is True
|
|
|
|
|
|
def test_validate_lenient_when_scanner_not_initialized(monkeypatch):
|
|
"""An unhydrated scanner (empty cache) must not reject every LoRA."""
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
class _UninitializedScanner(_FakeScanner):
|
|
def __init__(self, raw_data):
|
|
super().__init__(raw_data)
|
|
self._cache = None
|
|
|
|
async def _fake_scanner():
|
|
return _UninitializedScanner([])
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _fake_scanner)
|
|
assert validate_lora_entries({"loras": _entries("a.safetensors")}) is None
|
|
|
|
|
|
def test_validate_lenient_while_scanner_initializing(monkeypatch):
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
class _InitializingScanner(_FakeScanner):
|
|
def __init__(self, raw_data):
|
|
super().__init__(raw_data)
|
|
self._is_initializing = True
|
|
|
|
async def _fake_scanner():
|
|
return _InitializingScanner([])
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _fake_scanner)
|
|
assert validate_lora_entries({"loras": _entries("a.safetensors")}) is None
|
|
|
|
|
|
def test_find_missing_basename_fallback_prefers_folder_prefix(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Folder-prefix candidates win over the first basename match, mirroring
|
|
get_lora_info_absolute's fallback ordering."""
|
|
from py.services.service_registry import ServiceRegistry
|
|
|
|
root_a = tmp_path / "root_a"
|
|
root_b = tmp_path / "root_b"
|
|
root_a.mkdir()
|
|
root_b.mkdir()
|
|
live_a = root_a / "x.safetensors"
|
|
live_a.write_bytes(b"x")
|
|
stale_b = root_b / "x.safetensors" # same basename, file deleted
|
|
|
|
raw_data = [
|
|
{"file_name": "x.safetensors", "folder": "root_a", "file_path": str(live_a)},
|
|
{"file_name": "x.safetensors", "folder": "root_b", "file_path": str(stale_b)},
|
|
]
|
|
|
|
async def _fake_scanner():
|
|
return _FakeScanner(raw_data)
|
|
|
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", _fake_scanner)
|
|
# Exact path matches are authoritative.
|
|
assert _find_missing_loras(["root_b/x"]) == ["root_b/x"]
|
|
# Folder-prefix fallback beats the first basename match: root_a is live.
|
|
assert _find_missing_loras(["root_a/deep/x"]) == []
|
|
# Prefix match wins over the first candidate: root_b is stale.
|
|
assert _find_missing_loras(["root_b/deep/x"]) == ["root_b/deep/x"]
|
|
# No prefix match: falls back to the first basename candidate (root_a).
|
|
assert _find_missing_loras(["other/x"]) == []
|