Eight findings from a review of 1.2.0. None changes behaviour for a correctly paired LoRA and model; the first two matter when the pairing is wrong. The FLUX and SDXL classifiers never received the dot-boundary fix the Universal loader got, so an SDXL key "input_blocks.4.1.transformer_blocks.0.attn1.to_q. weight" matched the nested transformer_blocks and was read as double-stream block 0. The stack patterns are now anchored to the start of the module path, since the nested stack is dot-preceded too. Choosing the wrong loader for a model was silent: the keys still match, because ComfyUI builds the key map from the model, so every tensor lands in whichever single bucket the classifier reaches. A mismatch check now warns when 90% or more of the UNet tensors land in one block -- a threshold rather than "all of them" because cross-family names overlap slightly. Also: families register through register_family() instead of mutating module tables at import time; tooltips are stored per family so the shared "Text Encoder" name no longer collides; the "_head_" output token no longer matches multi_head_attention; unknown preset names warn; FLUX accepts negative block indices; and a bucket boundary guarding an impossible fraction was dropped. Tests grow from 55 to 71, adding tests/test_node_behaviour.py. The real ComfyUI integration harnesses still classify 8000+ authentic keys across 11 architectures with nothing falling through. Bumps version to 1.2.1.
169 lines
7.0 KiB
Python
169 lines
7.0 KiB
Python
"""Node-level tests: the parts of bobs_lora_loader that need no real model.
|
|
|
|
comfy.* and folder_paths are stubbed just enough to import the module; the
|
|
logic under test (mismatch detection, report formatting) is ours.
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
import types
|
|
import unittest
|
|
|
|
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, _REPO)
|
|
|
|
|
|
def _install_stubs():
|
|
comfy = types.ModuleType("comfy")
|
|
lora = types.ModuleType("comfy.lora")
|
|
utils = types.ModuleType("comfy.utils")
|
|
lora.model_lora_keys_unet = lambda model, key_map=None: key_map
|
|
lora.model_lora_keys_clip = lambda model, key_map=None: key_map
|
|
lora.load_lora = lambda sd, km, log_missing=True: {}
|
|
utils.load_torch_file = lambda p, safe_load=False, **kw: {}
|
|
comfy.lora, comfy.utils = lora, utils
|
|
sys.modules.setdefault("comfy", comfy)
|
|
sys.modules.setdefault("comfy.lora", lora)
|
|
sys.modules.setdefault("comfy.utils", utils)
|
|
fp = types.ModuleType("folder_paths")
|
|
fp.get_filename_list = lambda t: []
|
|
fp.get_full_path = lambda t, n: None
|
|
sys.modules.setdefault("folder_paths", fp)
|
|
|
|
|
|
_install_stubs()
|
|
|
|
_pkg = types.ModuleType("bobs_pkg")
|
|
_pkg.__path__ = [_REPO]
|
|
sys.modules.setdefault("bobs_pkg", _pkg)
|
|
|
|
|
|
def _load(name):
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"bobs_pkg.{name}", os.path.join(_REPO, f"{name}.py"))
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[f"bobs_pkg.{name}"] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
BB = _load("bobs_blocks")
|
|
BU = _load("bobs_universal")
|
|
NODE = _load("bobs_lora_loader")
|
|
|
|
|
|
def _group(counts):
|
|
"""Build a grouped-patches dict with the given per-block patch counts."""
|
|
return {name: {f"{name}.{i}": object() for i in range(n)}
|
|
for name, n in counts.items()}
|
|
|
|
|
|
class TestMismatchWarning(unittest.TestCase):
|
|
def test_everything_in_the_catch_all_warns(self):
|
|
grouped = _group({BB.FLUX_OTHER: 40})
|
|
msg = NODE._mismatch_warning("FLUX", BB.ALL_FLUX_BLOCKS, grouped,
|
|
BB.FLUX_TEXT_ENCODER, BB.FLUX_OTHER)
|
|
self.assertIsNotNone(msg)
|
|
self.assertIn("Other Tensors", msg)
|
|
self.assertIn("Universal", msg)
|
|
|
|
def test_everything_in_one_named_block_warns(self):
|
|
grouped = _group({BB.FLUX_EARLY_DOWN: 40})
|
|
msg = NODE._mismatch_warning("FLUX", BB.ALL_FLUX_BLOCKS, grouped,
|
|
BB.FLUX_TEXT_ENCODER, BB.FLUX_OTHER)
|
|
self.assertIsNotNone(msg)
|
|
self.assertIn("Early Downsampling", msg)
|
|
|
|
def test_near_total_pileup_warns(self):
|
|
"""Real SDXL keys through the FLUX classifier leave 98.7%, not 100%,
|
|
in the catch-all: a few hit the shared proj_out token."""
|
|
grouped = _group({BB.FLUX_OTHER: 1674, BB.FLUX_FINAL: 22})
|
|
msg = NODE._mismatch_warning("FLUX", BB.ALL_FLUX_BLOCKS, grouped,
|
|
BB.FLUX_TEXT_ENCODER, BB.FLUX_OTHER)
|
|
self.assertIsNotNone(msg)
|
|
self.assertIn("99%", msg)
|
|
|
|
def test_a_healthy_distribution_does_not_warn(self):
|
|
grouped = _group({BB.FLUX_EARLY_DOWN: 16, BB.FLUX_MID_DOWN: 16,
|
|
BB.FLUX_CORE: 60, BB.FLUX_LATE_UP: 18})
|
|
self.assertIsNone(
|
|
NODE._mismatch_warning("FLUX", BB.ALL_FLUX_BLOCKS, grouped,
|
|
BB.FLUX_TEXT_ENCODER, BB.FLUX_OTHER))
|
|
|
|
def test_text_encoder_only_lora_does_not_warn(self):
|
|
"""A TE-only LoRA is legitimate, not a family mismatch."""
|
|
grouped = _group({BB.FLUX_TEXT_ENCODER: 40})
|
|
self.assertIsNone(
|
|
NODE._mismatch_warning("FLUX", BB.ALL_FLUX_BLOCKS, grouped,
|
|
BB.FLUX_TEXT_ENCODER, BB.FLUX_OTHER))
|
|
|
|
def test_tiny_loras_are_not_judged(self):
|
|
grouped = _group({BB.FLUX_OTHER: 3})
|
|
self.assertIsNone(
|
|
NODE._mismatch_warning("FLUX", BB.ALL_FLUX_BLOCKS, grouped,
|
|
BB.FLUX_TEXT_ENCODER, BB.FLUX_OTHER))
|
|
|
|
def test_each_node_declares_its_catch_all(self):
|
|
self.assertEqual(NODE.BobsLoraLoaderFlux.CATCH_ALL_BLOCK, BB.FLUX_OTHER)
|
|
self.assertEqual(NODE.BobsLoraLoaderSdxl.CATCH_ALL_BLOCK, BB.SDXL_OTHER)
|
|
self.assertEqual(NODE.BobsLoraLoaderUniversal.CATCH_ALL_BLOCK, BU.UNIVERSAL_OTHER)
|
|
for cls in (NODE.BobsLoraLoaderFlux, NODE.BobsLoraLoaderSdxl,
|
|
NODE.BobsLoraLoaderUniversal):
|
|
self.assertIn(cls.CATCH_ALL_BLOCK, cls.BLOCKS)
|
|
|
|
|
|
class TestReport(unittest.TestCase):
|
|
def test_block_names_fit_the_report_column(self):
|
|
every = BB.ALL_FLUX_BLOCKS + BB.ALL_SDXL_BLOCKS + BU.ALL_UNIVERSAL_BLOCKS
|
|
self.assertLessEqual(max(len(b) for b in every), 40)
|
|
|
|
def test_report_lists_every_block_and_a_total(self):
|
|
grouped = _group({BB.SDXL_INPUT_BLOCKS: 9})
|
|
strengths = {b: 1.0 for b in BB.ALL_SDXL_BLOCKS}
|
|
applied = {BB.SDXL_INPUT_BLOCKS: 9}
|
|
report = NODE._format_report("SDXL", "x.safetensors", "Custom",
|
|
BB.ALL_SDXL_BLOCKS, grouped, strengths,
|
|
applied, "architecture: SDXL")
|
|
for block in BB.ALL_SDXL_BLOCKS:
|
|
self.assertIn(block, report)
|
|
self.assertIn("architecture: SDXL", report)
|
|
self.assertIn("TOTAL", report)
|
|
|
|
|
|
class TestNodeRegistration(unittest.TestCase):
|
|
def test_three_nodes_registered_with_display_names(self):
|
|
self.assertEqual(set(NODE.NODE_CLASS_MAPPINGS),
|
|
{"BobsLoraLoaderFlux", "BobsLoraLoaderSdxl",
|
|
"BobsLoraLoaderUniversal"})
|
|
self.assertEqual(set(NODE.NODE_CLASS_MAPPINGS),
|
|
set(NODE.NODE_DISPLAY_NAME_MAPPINGS))
|
|
|
|
def test_input_types_are_well_formed(self):
|
|
for cls in NODE.NODE_CLASS_MAPPINGS.values():
|
|
spec = cls.INPUT_TYPES()
|
|
self.assertEqual(set(spec), {"required", "optional"}, cls.__name__)
|
|
self.assertIn("clip", spec["optional"])
|
|
for field in ("model", "lora_name", "strength", "preset"):
|
|
self.assertIn(field, spec["required"], cls.__name__)
|
|
for block in cls.BLOCKS:
|
|
self.assertIn(block, spec["required"], f"{cls.__name__}/{block}")
|
|
opts = spec["required"][block][1]
|
|
self.assertTrue(opts["tooltip"], block)
|
|
self.assertNotEqual(opts["tooltip"], block, block)
|
|
|
|
def test_widget_order_places_blocks_after_the_fixed_widgets(self):
|
|
for cls in NODE.NODE_CLASS_MAPPINGS.values():
|
|
names = list(cls.INPUT_TYPES()["required"])
|
|
self.assertEqual(names[:4], ["model", "lora_name", "strength", "preset"])
|
|
self.assertEqual(names[4:], list(cls.BLOCKS), cls.__name__)
|
|
|
|
def test_outputs_are_model_clip_info(self):
|
|
for cls in NODE.NODE_CLASS_MAPPINGS.values():
|
|
self.assertEqual(cls.RETURN_TYPES, ("MODEL", "CLIP", "STRING"))
|
|
self.assertEqual(cls.RETURN_NAMES, ("MODEL", "CLIP", "info"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|