`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>
225 lines
7.2 KiB
Python
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"])
|