Files
larsupbandClaude Opus 5 7973203f38 test: run the whole suite without a ComfyUI installation
`pytest tests/` previously crashed during collection and 7 of 17 test files
were dead: 61 tests were reachable, all via ad-hoc standalone scripts. Now a
bare `pytest` collects everything and passes 238 tests with ComfyUI absent
(verified by running the suite from outside the ComfyUI tree, where
`import comfy` raises ModuleNotFoundError).

Import structure:
- Drop tests/__init__.py. With it, pytest walks up to the project root's
  __init__.py -- the ComfyUI node entry point -- and imports ComfyUI before
  any test runs.
- Import project code as `src.<module>` instead of putting src/ on sys.path
  and importing bare `merge.algorithms` / `validation` / `types`. Modules in
  src/ use package-relative imports (`from ..types import ...`) that cannot
  resolve when loaded top-level, and `types` collided with the stdlib module.
  Same change for the mock.patch targets in test_algorithms.
- Consolidate conftest.py in tests/, mocking comfy, folder_paths,
  comfy_extras and nodes. It stays in tests/ rather than the project root
  because pytest imports a root-level conftest as part of the root package,
  executing the ComfyUI entry point.
- Guard the script-style runners behind `if __name__ == "__main__":` so they
  no longer sys.exit() during collection. Those files still run standalone.
- Drop run_pytest.py: a mocking wrapper made redundant by conftest, unused
  and pointing at an unresolvable default path.

Bugs the dead tests were hiding:
- validators: the INCOMPATIBLE_DIMENSIONS check sat after the `continue` that
  skips the reference tensor, so a lone LoRA with mismatched up/down ranks
  passed validation unchecked. It is a per-LoRA check and now runs for every
  entry.
- decomposition: __init__ exported a QRDecomposer that exists nowhere, so
  `import src.decomposition` raised ImportError. Export and tests removed.

Stale expectations corrected:
- return_statistics is a constructor argument, not a decompose() kwarg.
- The zero-matrix rank guard only applies under dynamic rank selection; the
  test now exercises that path, plus a new case pinning fixed-rank behavior.
- `reconstruction_error < 0.5` for a rank-10 truncation of a random 100x50
  Gaussian is unreachable -- the optimum is 0.7557 and the decomposer hits
  0.7568. Assert near-optimality instead, and add a genuinely low-rank case
  that reconstructs to 0.003.
- sym/asym distributions differ only by float32 rounding (~5e-7), below the
  default atol of 1e-8.

RUN_TESTS.md is rewritten against the real setup: correct interpreter path,
the two test-file styles, the import rules for adding tests, and a per-file
coverage table. It no longer documents test_gradient_analyzer_integration.py,
which is not in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:41:31 +02:00

127 lines
5.8 KiB
Python

# tests/test_merge_node_names.py
# Standalone script test (repo pytest is broken). Loads the custom-node package
# under a synthetic name so the relative imports resolve, then asserts the
# renamed widget keys and the unchanged internal settings/context keys.
import importlib.util, os, sys, inspect, traceback
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PARENT = os.path.dirname(REPO) # .../custom_nodes
COMFY_ROOT = os.path.dirname(PARENT) # ComfyUI root, so `import comfy` resolves
PKG = "LoRA_Merger_ComfyUI_test"
sys.path.insert(0, COMFY_ROOT) # lora_mergekit_merge imports comfy.*
sys.path.insert(0, PARENT)
spec = importlib.util.spec_from_file_location(
PKG, os.path.join(REPO, "__init__.py"), submodule_search_locations=[REPO])
pkg = importlib.util.module_from_spec(spec)
sys.modules[PKG] = pkg
spec.loader.exec_module(pkg)
from LoRA_Merger_ComfyUI_test.src import nodes_merge_methods as N
from LoRA_Merger_ComfyUI_test.src.lora_mergekit_merge import LoraMergerMergekit
EXPECTED_ORDER = {
"TaskArithmeticMergeMethod": ["rescale_norm", "average_weights"],
"TIESMergeMethod": ["rescale_norm", "average_weights", "density"],
"DAREMergeMethod": ["sign_consensus", "rescale_norm", "density", "average_weights"],
"BreadcrumbsMergeMethod": ["sign_consensus", "rescale_norm", "density", "gamma", "average_weights"],
"DELLAMergeMethod": ["sign_consensus", "rescale_norm", "density", "epsilon", "average_weights"],
}
def test_input_types_renamed_same_order():
for cls_name, expected in EXPECTED_ORDER.items():
cls = getattr(N, cls_name)
keys = list(cls.INPUT_TYPES()["required"].keys())
assert keys == expected, f"{cls_name}: {keys} != {expected}"
def test_settings_internal_keys_unchanged():
# average_weights -> internal "normalize"; sign_consensus -> "sign_consensus_algorithm"
for cls_name in EXPECTED_ORDER:
cls = getattr(N, cls_name)
s = cls().get_method(average_weights=False)[0]["settings"]
assert s["normalize"] is False, f"{cls_name}: normalize not wired from average_weights"
for cls_name in ("DAREMergeMethod", "BreadcrumbsMergeMethod", "DELLAMergeMethod"):
cls = getattr(N, cls_name)
s = cls().get_method(sign_consensus=True)[0]["settings"]
assert s["sign_consensus_algorithm"] is True, f"{cls_name}: sign_consensus not wired"
def test_average_weights_defaults_off():
# Default OFF matches ComfyUI's native additive LoRA stacking (sum, not average).
for cls_name in EXPECTED_ORDER:
cls = getattr(N, cls_name)
default = cls.INPUT_TYPES()["required"]["average_weights"][1]["default"]
assert default is False, f"{cls_name}: average_weights default {default!r} != False"
def test_sce_additive_default_and_wiring():
# SCE exposes select_topk + average_weights; average_weights wires to the
# internal "normalize" and defaults OFF (additive), like the GTA family.
cls = N.SCEMergeMethod
keys = list(cls.INPUT_TYPES()["required"].keys())
assert keys == ["select_topk", "average_weights"], keys
assert cls.INPUT_TYPES()["required"]["average_weights"][1]["default"] is False
s = cls().get_method(select_topk=0.5, average_weights=False)[0]["settings"]
assert s["normalize"] is False, f"SCE: normalize not wired from average_weights ({s})"
assert s["select_topk"] == 0.5, s
# ON -> normalized average
s_on = cls().get_method(select_topk=1.0, average_weights=True)[0]["settings"]
assert s_on["normalize"] is True, s_on
def test_interp_nodes_average_weights_wiring():
cases = {
"SLERPMergeMethod": dict(),
"NuSlerpMergeMethod": dict(),
"KArcherMergeMethod": dict(),
"NearSwapMergeMethod": dict(),
}
for cls_name in cases:
cls = getattr(N, cls_name)
req = cls.INPUT_TYPES()["required"]
assert "average_weights" in req, f"{cls_name}: no average_weights widget"
assert req["average_weights"][1]["default"] is False, \
f"{cls_name}: average_weights default != False"
s_off = cls().get_method(average_weights=False)[0]["settings"]
assert s_off["normalize"] is False, f"{cls_name}: normalize not wired ({s_off})"
s_on = cls().get_method(average_weights=True)[0]["settings"]
assert s_on["normalize"] is True, f"{cls_name}: normalize ON not wired"
def test_merger_output_scale():
keys = list(LoraMergerMergekit.INPUT_TYPES()["required"].keys())
expected = ["method", "components", "strengths", "output_scale",
"spectral_norm_scale", "merge_clip", "device", "dtype", "refactor_method",
"offload_models"]
assert keys == expected, f"merger widget order {keys} != {expected}"
params = inspect.signature(LoraMergerMergekit.lora_mergekit).parameters
assert "output_scale" in params and "lambda_" not in params, list(params)
def run():
failed = 0
for name, fn in [
("input_types_renamed_same_order", test_input_types_renamed_same_order),
("settings_internal_keys_unchanged", test_settings_internal_keys_unchanged),
("average_weights_defaults_off", test_average_weights_defaults_off),
("sce_additive_default_and_wiring", test_sce_additive_default_and_wiring),
("merger_output_scale", test_merger_output_scale),
("interp_nodes_average_weights_wiring", test_interp_nodes_average_weights_wiring),
]:
try:
fn(); print(f"PASS {name}")
except Exception:
failed += 1; print(f"FAIL {name}"); traceback.print_exc()
if failed:
print(f"\n{failed} FAILED"); sys.exit(1)
print(f"\nAll 6 passed")
# Runnable as a plain script (`python tests/<file>.py`); under pytest the
# test_* functions are collected directly, so the script runner must not fire
# at import time -- it calls sys.exit() and would abort collection.
if __name__ == "__main__":
run()