`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>
103 lines
3.8 KiB
Python
103 lines
3.8 KiB
Python
# tests/test_interp_integration.py
|
|
# Standalone end-to-end test: run LoraMergerMergekit.merge() for the interpolation
|
|
# modes on CPU and confirm they produce a valid, non-zero merged LoRA.
|
|
import os, sys, traceback
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
PARENT = os.path.dirname(REPO)
|
|
COMFY_ROOT = os.path.dirname(PARENT)
|
|
sys.path.insert(0, COMFY_ROOT)
|
|
sys.path.insert(0, PARENT)
|
|
|
|
import torch
|
|
import importlib.util
|
|
PKG = "LoRA_Merger_ComfyUI_test"
|
|
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.lora_mergekit_merge import LoraMergerMergekit
|
|
from LoRA_Merger_ComfyUI_test.src.merge import get_merge_method, prepare_method_args
|
|
|
|
|
|
def _lora(seed, rank=8, out=64, inn=96):
|
|
torch.manual_seed(seed)
|
|
up = torch.randn(out, rank) * 0.1
|
|
down = torch.randn(rank, inn) * 0.1
|
|
return (up, down, torch.tensor(float(rank)))
|
|
|
|
|
|
def _run(mode, settings, n_loras=2, key="lora_unet_test_layer"):
|
|
node = LoraMergerMergekit()
|
|
names = [f"loraA", f"loraB", f"loraC"][:n_loras]
|
|
node.components = {key: {nm: _lora(i) for i, nm in enumerate(names)}}
|
|
node.strengths = {nm: {"strength_model": 1.0, "strength_clip": 1.0} for nm in names}
|
|
method = get_merge_method(mode)
|
|
margs = prepare_method_args(mode, settings)
|
|
result = node.merge(
|
|
method=method, method_args=margs, lambda_=1.0, spectral_norm_scale=0.0,
|
|
merge_clip=False, device=torch.device("cpu"), dtype=torch.float32)
|
|
return result[0]
|
|
|
|
|
|
SETTINGS = {
|
|
"slerp": {"t": 0.5, "normalize": False},
|
|
"nuslerp": {"nuslerp_flatten": True, "nuslerp_row_wise": False, "normalize": False},
|
|
"karcher": {"max_iter": 10, "tol": 1e-5, "normalize": False},
|
|
"nearswap": {"similarity_threshold": 0.001, "normalize": False},
|
|
}
|
|
|
|
|
|
def test_each_mode_produces_nonzero_lora():
|
|
for mode, st in SETTINGS.items():
|
|
out = _run(mode, st)
|
|
adapters = out["lora"]
|
|
assert adapters, f"{mode}: empty adapter dict"
|
|
for k, adapter in adapters.items():
|
|
up, down, alpha = adapter.weights[0], adapter.weights[1], adapter.weights[2]
|
|
assert torch.isfinite(up).all() and torch.isfinite(down).all(), f"{mode}: non-finite"
|
|
recon = up @ down
|
|
assert recon.norm().item() > 1e-6, f"{mode}: near-zero merge ({recon.norm()})"
|
|
|
|
|
|
def test_additive_stronger_than_average():
|
|
def recon_norm(normalize):
|
|
st = dict(SETTINGS["slerp"]); st["normalize"] = normalize
|
|
out = _run("slerp", st)
|
|
a = next(iter(out["lora"].values()))
|
|
return (a.weights[0] @ a.weights[1]).norm().item()
|
|
off = recon_norm(False)
|
|
on = recon_norm(True)
|
|
assert off > on * 1.5, f"additive({off}) not > average({on})"
|
|
|
|
|
|
def test_single_owner_key_not_zero():
|
|
out = _run("slerp", SETTINGS["slerp"], n_loras=1)
|
|
a = next(iter(out["lora"].values()))
|
|
assert (a.weights[0] @ a.weights[1]).norm().item() > 1e-6
|
|
|
|
|
|
def run(tests):
|
|
failed = 0
|
|
for name, fn in tests:
|
|
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 {len(tests)} 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([
|
|
("each_mode_nonzero", test_each_mode_produces_nonzero_lora),
|
|
("additive_stronger_than_average", test_additive_stronger_than_average),
|
|
("single_owner_not_zero", test_single_owner_key_not_zero),
|
|
])
|