210 lines
7.8 KiB
Python
210 lines
7.8 KiB
Python
"""Tests for resolution snapping + input validation (Tier 1: pure unit tests)."""
|
|
import pathlib
|
|
|
|
import pytest
|
|
|
|
from src.patch_utils import _snap_to_multiple
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSnapToMultiple:
|
|
def test_exact_multiple_unchanged(self):
|
|
assert _snap_to_multiple(1024) == 1024
|
|
|
|
def test_exact_multiple_4096(self):
|
|
assert _snap_to_multiple(4096) == 4096
|
|
|
|
def test_rounds_up_above_half(self):
|
|
# 1000 / 16 = 62.5 → round(62.5) = 62 (banker's rounding) or 63
|
|
# Python round(62.5) = 62 (rounds to even), so 62*16 = 992
|
|
# Actually round(1000/16) = round(62.5) = 62 → 992
|
|
result = _snap_to_multiple(1000)
|
|
assert result in (992, 1008) # Either is acceptable rounding behavior
|
|
assert result % 16 == 0
|
|
|
|
def test_rounds_down_below_half(self):
|
|
# 999 / 16 = 62.4375 → round = 62 → 992
|
|
assert _snap_to_multiple(999) == 992
|
|
|
|
def test_rounds_up_clearly_above(self):
|
|
# 1001 / 16 = 62.5625 → round = 63 → 1008
|
|
assert _snap_to_multiple(1001) == 1008
|
|
|
|
def test_minimum_is_multiple(self):
|
|
assert _snap_to_multiple(1) == 16
|
|
assert _snap_to_multiple(0) == 16
|
|
assert _snap_to_multiple(8) == 16
|
|
assert _snap_to_multiple(15) == 16
|
|
|
|
def test_value_16_unchanged(self):
|
|
assert _snap_to_multiple(16) == 16
|
|
|
|
def test_value_17_snaps_to_16(self):
|
|
# 17/16 = 1.0625 → round = 1 → 16
|
|
assert _snap_to_multiple(17) == 16
|
|
|
|
def test_value_24_snaps_to_16_or_32(self):
|
|
# 24/16 = 1.5 → round(1.5) = 2 (banker's) → 32
|
|
result = _snap_to_multiple(24)
|
|
assert result in (16, 32)
|
|
assert result % 16 == 0
|
|
|
|
def test_large_value(self):
|
|
assert _snap_to_multiple(4095) == 4096
|
|
assert _snap_to_multiple(4097) == 4096
|
|
|
|
def test_custom_multiple_32(self):
|
|
assert _snap_to_multiple(100, 32) == 96
|
|
assert _snap_to_multiple(113, 32) == 128
|
|
|
|
def test_custom_multiple_8(self):
|
|
# 100/8 = 12.5 → round(12.5) = 12 (banker's rounding) → 96
|
|
assert _snap_to_multiple(100, 8) == 96
|
|
# 101/8 = 12.625 → round = 13 → 104
|
|
assert _snap_to_multiple(101, 8) == 104
|
|
|
|
def test_always_returns_int(self):
|
|
assert isinstance(_snap_to_multiple(1000), int)
|
|
assert isinstance(_snap_to_multiple(0), int)
|
|
|
|
def test_never_returns_zero(self):
|
|
assert _snap_to_multiple(0) >= 16
|
|
assert _snap_to_multiple(-5) >= 16
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestValidateResolution:
|
|
"""W5.1 — shared resolution validator (IMP-002)."""
|
|
|
|
@pytest.mark.parametrize("w,h", [
|
|
(1024, 1024), # standard
|
|
(16, 16), # lower bound
|
|
(8192, 8192), # upper bound
|
|
(2048, 1024), # asymmetric valid
|
|
(4096, 4096), # 4K
|
|
])
|
|
def test_valid_resolutions_pass(self, w, h):
|
|
from src.validation import validate_resolution
|
|
|
|
assert validate_resolution(w, h) is True
|
|
|
|
@pytest.mark.parametrize("w,h", [
|
|
(504, 2000), # REGRESSION (2026-08-25): /8-aligned but not /16 —
|
|
# runtime snaps to /16 via _snap_to_multiple; the
|
|
# graph validator must NOT reject these.
|
|
(1000, 1000), # /8-aligned (1000 % 8 == 0) → accepted
|
|
(999, 1024), # 999 not /8 → rejected
|
|
(1023, 16),
|
|
])
|
|
def test_alignment_rules(self, w, h):
|
|
"""/8 is the hard VAE requirement; /16-only values are accepted and
|
|
snapped at apply time. Only non-/8 values are rejected."""
|
|
from src.validation import validate_resolution
|
|
|
|
result = validate_resolution(w, h)
|
|
if w % 8 or h % 8:
|
|
assert isinstance(result, str)
|
|
assert "multiples of 8" in result
|
|
else:
|
|
assert result is True
|
|
|
|
@pytest.mark.parametrize("w,h", [
|
|
(0, 1024), # below min
|
|
(8208, 1024), # above max (/8-aligned)
|
|
(16384, 16384), # way above max
|
|
])
|
|
def test_out_of_range_rejected(self, w, h):
|
|
from src.validation import validate_resolution
|
|
|
|
result = validate_resolution(w, h)
|
|
if w % 8 or h % 8:
|
|
assert "multiples of 8" in result
|
|
else:
|
|
assert isinstance(result, str)
|
|
assert "[16, 8192]" in result
|
|
|
|
def test_boundary_values_exact(self):
|
|
from src.validation import validate_resolution
|
|
|
|
assert validate_resolution(16, 16) is True
|
|
assert validate_resolution(8192, 8192) is True
|
|
# One step outside.
|
|
assert isinstance(validate_resolution(8200, 1024), str)
|
|
|
|
def test_custom_bounds(self):
|
|
from src.validation import validate_resolution
|
|
|
|
assert validate_resolution(32, 32, min_px=32, max_px=64) is True
|
|
assert isinstance(validate_resolution(16, 32, min_px=32, max_px=64), str)
|
|
|
|
def test_non_integer_inputs_rejected(self):
|
|
from src.validation import validate_resolution
|
|
|
|
assert isinstance(validate_resolution("abc", 1024), str)
|
|
assert isinstance(validate_resolution(None, 1024), str)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNodeValidateInputsWiring:
|
|
"""W5.2 — the resolution nodes implement validate_inputs via the shared
|
|
validator (graph-time rejection; runtime snapping retained as
|
|
defense-in-depth)."""
|
|
|
|
_NODES = ("DyPE_FLUX", "SEGA", "SPA")
|
|
|
|
@pytest.fixture
|
|
def init_mod(self):
|
|
import importlib.util
|
|
import pathlib
|
|
|
|
root = pathlib.Path(__file__).parent.parent
|
|
spec = importlib.util.spec_from_file_location(
|
|
"dype_init_wiring", root / "__init__.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
try:
|
|
spec.loader.exec_module(mod)
|
|
except Exception:
|
|
pytest.skip("__init__.py requires full comfy_api runtime")
|
|
return mod
|
|
|
|
def test_resolution_nodes_have_validate_inputs(self, init_mod):
|
|
for node_name in self._NODES:
|
|
node_cls = getattr(init_mod, node_name, None)
|
|
assert node_cls is not None, f"{node_name} missing"
|
|
assert callable(getattr(node_cls, "validate_inputs", None)), (
|
|
f"{node_name} does not implement validate_inputs")
|
|
|
|
def test_validate_inputs_delegates_to_shared_validator(self, init_mod):
|
|
from src.validation import validate_resolution
|
|
|
|
for node_name in self._NODES:
|
|
node_cls = getattr(init_mod, node_name)
|
|
result = node_cls.validate_inputs(width=1000, height=1000)
|
|
assert result == validate_resolution(1000, 1000), (
|
|
f"{node_name}.validate_inputs did not delegate to the shared "
|
|
f"validator")
|
|
|
|
def test_validate_inputs_signature_has_no_kwargs(self, init_mod):
|
|
"""REGRESSION (2026-08-25): a ``**kwargs`` validate_inputs signature
|
|
makes ComfyUI route EVERY input through validation and re-report one
|
|
failing string once per input name (16x duplicate errors on SEGA).
|
|
The validator must declare named width/height params so failures are
|
|
attributed to width/height only."""
|
|
import inspect
|
|
|
|
for node_name in self._NODES:
|
|
node_cls = getattr(init_mod, node_name)
|
|
sig = inspect.getfullargspec(node_cls.validate_inputs)
|
|
assert sig.varkw is None, (
|
|
f"{node_name}.validate_inputs must NOT use **kwargs "
|
|
f"(ComfyUI duplicates the error per input name)")
|
|
assert "width" in sig.args and "height" in sig.args, (
|
|
f"{node_name}.validate_inputs must take named width/height")
|
|
|
|
def test_hap_has_no_resolution_validation(self):
|
|
"""HAP has NO width/height inputs by design — no graph-I/O validation
|
|
is added there (plan W5.2 decision). This replaces the pre-W5 guard
|
|
that forbade validate_inputs entirely."""
|
|
content = (pathlib.Path(__file__).parent.parent / "__init__.py").read_text(encoding="utf-8")
|
|
assert "class HAP(" in content
|