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

225 lines
7.2 KiB
Python

"""
Unit tests for type system and validators.
Tests type guards, validators, and type definitions from src/types.py.
"""
import pytest
import torch
# Imported as `src.types`, never bare `types` -- that name is the stdlib module.
# conftest.py puts the project root on sys.path and mocks the ComfyUI modules.
from src.types import (
is_lora_tensors,
is_lora_stack,
validate_lora_tensors,
validate_lora_stack,
LORA_TENSORS,
LORA_STACK,
MIN_SINGULAR_VALUE,
)
class TestLoRATensorsTypeGuard:
"""Tests for is_lora_tensors type guard."""
def test_valid_lora_tensors(self):
"""Test that valid LORA_TENSORS tuple is recognized."""
up = torch.randn(10, 5)
down = torch.randn(5, 10)
alpha = torch.tensor(1.0)
tensors = (up, down, alpha)
assert is_lora_tensors(tensors)
def test_valid_lora_tensors_with_float_alpha(self):
"""Test that LORA_TENSORS with float alpha is recognized."""
up = torch.randn(10, 5)
down = torch.randn(5, 10)
alpha = 1.0 # Float instead of tensor
tensors = (up, down, alpha)
assert is_lora_tensors(tensors)
def test_invalid_not_tuple(self):
"""Test that non-tuple is rejected."""
assert not is_lora_tensors([torch.randn(10, 5), torch.randn(5, 10), 1.0])
def test_invalid_wrong_length(self):
"""Test that tuple with wrong length is rejected."""
assert not is_lora_tensors((torch.randn(10, 5), torch.randn(5, 10)))
def test_invalid_non_tensor_elements(self):
"""Test that tuple with non-tensor up/down is rejected."""
assert not is_lora_tensors(("not a tensor", torch.randn(5, 10), 1.0))
assert not is_lora_tensors((torch.randn(10, 5), "not a tensor", 1.0))
def test_invalid_alpha_type(self):
"""Test that invalid alpha type is rejected."""
up = torch.randn(10, 5)
down = torch.randn(5, 10)
assert not is_lora_tensors((up, down, "invalid"))
class TestLoRAStackTypeGuard:
"""Tests for is_lora_stack type guard."""
def test_valid_lora_stack(self):
"""Test that valid LORA_STACK is recognized."""
from comfy.weight_adapter import LoRAAdapter
# Create mock LoRAAdapter objects
adapter1 = LoRAAdapter("lora", (torch.randn(10, 5), torch.randn(5, 10), 1.0, None, None, None))
adapter2 = LoRAAdapter("lora", (torch.randn(10, 5), torch.randn(5, 10), 1.0, None, None, None))
stack = {
"lora1": {"layer1": adapter1},
"lora2": {"layer2": adapter2},
}
assert is_lora_stack(stack)
def test_invalid_not_dict(self):
"""Test that non-dict is rejected."""
assert not is_lora_stack([])
assert not is_lora_stack("not a dict")
def test_invalid_non_string_keys(self):
"""Test that dict with non-string keys is rejected."""
assert not is_lora_stack({1: {}})
def test_invalid_non_dict_values(self):
"""Test that dict with non-dict values is rejected."""
assert not is_lora_stack({"lora1": "not a dict"})
class TestValidateLoRATensors:
"""Tests for validate_lora_tensors validator."""
def test_valid_2d_tensors(self):
"""Test validation of valid 2D LoRA tensors."""
up = torch.randn(10, 5)
down = torch.randn(5, 10)
alpha = torch.tensor(1.0)
tensors = (up, down, alpha)
# Should not raise
validate_lora_tensors(tensors)
def test_valid_4d_conv_tensors(self):
"""Test validation of valid 4D convolutional LoRA tensors."""
up = torch.randn(10, 5, 1, 1)
down = torch.randn(5, 10, 1, 1)
alpha = torch.tensor(1.0)
tensors = (up, down, alpha)
# Should not raise
validate_lora_tensors(tensors)
def test_invalid_structure(self):
"""Test that invalid structure raises ValueError."""
with pytest.raises(ValueError, match="Invalid LORA_TENSORS structure"):
validate_lora_tensors("not a tuple")
def test_invalid_up_dimensions(self):
"""Test that invalid up tensor dimensions raise ValueError."""
up = torch.randn(10) # 1D tensor
down = torch.randn(5, 10)
alpha = torch.tensor(1.0)
tensors = (up, down, alpha)
with pytest.raises(ValueError, match="Invalid up tensor dimensions"):
validate_lora_tensors(tensors)
def test_invalid_down_dimensions(self):
"""Test that invalid down tensor dimensions raise ValueError."""
up = torch.randn(10, 5)
down = torch.randn(5) # 1D tensor
alpha = torch.tensor(1.0)
tensors = (up, down, alpha)
with pytest.raises(ValueError, match="Invalid down tensor dimensions"):
validate_lora_tensors(tensors)
class TestValidateLoRAStack:
"""Tests for validate_lora_stack validator."""
def test_valid_stack(self):
"""Test validation of valid LoRA stack."""
from comfy.weight_adapter import LoRAAdapter
adapter = LoRAAdapter("lora", (torch.randn(10, 5), torch.randn(5, 10), 1.0, None, None, None))
stack = {"lora1": {"layer1": adapter}}
# Should not raise
validate_lora_stack(stack)
def test_invalid_structure(self):
"""Test that invalid structure raises ValueError."""
with pytest.raises(ValueError, match="Invalid LORA_STACK structure"):
validate_lora_stack("not a dict")
def test_empty_stack(self):
"""Test that empty stack raises ValueError."""
with pytest.raises(ValueError, match="LORA_STACK cannot be empty"):
validate_lora_stack({})
class TestConstants:
"""Tests for constants defined in types module."""
def test_min_singular_value(self):
"""Test that MIN_SINGULAR_VALUE is defined correctly."""
assert MIN_SINGULAR_VALUE == 1e-6
assert isinstance(MIN_SINGULAR_VALUE, float)
# Fixtures for reusable test data
@pytest.fixture
def sample_lora_tensors():
"""Fixture providing sample LORA_TENSORS."""
up = torch.randn(10, 5)
down = torch.randn(5, 10)
alpha = torch.tensor(1.0)
return (up, down, alpha)
@pytest.fixture
def sample_lora_stack():
"""Fixture providing sample LORA_STACK."""
from comfy.weight_adapter import LoRAAdapter
adapter1 = LoRAAdapter("lora", (torch.randn(10, 5), torch.randn(5, 10), 1.0, None, None, None))
adapter2 = LoRAAdapter("lora", (torch.randn(8, 4), torch.randn(4, 8), 1.0, None, None, None))
return {
"lora_1": {
"layer.0.attn1": adapter1,
"layer.0.attn2": adapter1,
},
"lora_2": {
"layer.0.attn1": adapter2,
"layer.1.ff": adapter2,
},
}
class TestIntegrationWithFixtures:
"""Integration tests using fixtures."""
def test_sample_tensors_are_valid(self, sample_lora_tensors):
"""Test that sample tensors pass validation."""
assert is_lora_tensors(sample_lora_tensors)
validate_lora_tensors(sample_lora_tensors)
def test_sample_stack_is_valid(self, sample_lora_stack):
"""Test that sample stack passes validation."""
assert is_lora_stack(sample_lora_stack)
validate_lora_stack(sample_lora_stack)
if __name__ == "__main__":
pytest.main([__file__, "-v"])