Files
wildminder-ComfyUI-KEEP/tests/test_nodes_execute.py
T
WildAi a203c62a7c test: skip the progress-stub assertion when the harness mocks are off
The test asserts on the stub that records the progress calls. With
KEEP_NO_COMFY_MOCKS=1 the node uses the real comfy_api.latest API, whose
execution recorder has no calls attribute, so the test failed in the
mocks-off configuration (17 passed, 1 failed). It now skips there, with the
reason naming the configuration. The gate and the headless tier are
unchanged and green.
2026-10-24 22:52:42 +03:00

599 lines
20 KiB
Python

"""P3.2 / P4.1 - the mock-integration execution of the node classes.
The loader node is a V3 node (P4.1) - its ``execute`` is a classmethod that
returns an ``io.NodeOutput``. The tests drive it with a **stubbed loader**
(the model build is stubbed out - no weights are needed) and assert the
contract of the node: the pack comes back, the widgets reach the loader
unchanged and the second call is served from the loader's cache.
"""
from __future__ import annotations
import importlib
import os
import numpy as np
import pytest
import torch
#: The harness mocks are switched off in the headless / real-ComfyUI
#: configurations (``KEEP_NO_COMFY_MOCKS``). The progress test asserts on
#: the stub that records the calls; without the mocks the node uses the
#: real ``comfy_api.latest`` API, whose execution recorder has no
#: ``calls`` attribute.
MOCKS_DISABLED = bool(os.environ.get("KEEP_NO_COMFY_MOCKS", "").strip())
@pytest.fixture(scope="module")
def node_module():
return importlib.import_module("ComfyUI_KEEP.nodes.keep_face_upscale_image")
@pytest.fixture(scope="module")
def loader_module():
return importlib.import_module("ComfyUI_KEEP.src.keep_model_loader")
@pytest.fixture(scope="module")
def loader_node_module():
return importlib.import_module("ComfyUI_KEEP.nodes.keep_model_loader")
@pytest.fixture(scope="module")
def sequence_node_module():
return importlib.import_module("ComfyUI_KEEP.nodes.keep_process_image_sequence")
@pytest.fixture
def stub_pack(loader_module, monkeypatch):
"""A real ``KEEPModelPack`` (the node checks the type) with stubs."""
pack = loader_module.KEEPModelPack(
keep_net=None,
face_helper=None,
bg_upscale_model=None,
face_upscale_model=None,
model_type_str="KEEP",
)
calls: list[str] = []
monkeypatch.setattr(pack, "load_device", lambda: calls.append("load_device"))
monkeypatch.setattr(pack, "offload", lambda: calls.append("offload"))
monkeypatch.setattr(pack, "ensure_flow", lambda: calls.append("ensure_flow"))
pack.calls = calls
return pack
@pytest.fixture
def stubbed_loader(loader_module, monkeypatch, tmp_path):
"""A real ``KEEPModelLoader`` whose model build is stubbed out.
The stub replaces the four heavy parts of the build (the architecture, the
checkpoint download, the checkpoint load and the face helper) - the cache
logic of the loader itself stays **real**.
"""
class _StubArch:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.loaded = None
self.state = {"weight": torch.zeros(1)}
def to(self, device):
return self
def state_dict(self):
# the real arch owns the flownet (keep_arch.py:882); this stub
# has no flownet tensors, so nothing is merged into the
# checkpoint state dict before the strict load
return dict(self.state)
def load_state_dict(self, state_dict, strict=True):
self.loaded = state_dict
return self
def eval(self):
return self
class _StubHelper:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr(loader_module, "ARCH_REGISTRY", {"KEEP": _StubArch})
# P5.3: the weights are resolved through the manifest downloader
monkeypatch.setattr(
loader_module.keep_download,
"ensure_weight",
lambda entry, progress=True: str(tmp_path / "checkpoint.pth"),
)
monkeypatch.setattr(
loader_module.keep_download,
"ensure_required",
lambda entry, progress=True: str(tmp_path / "checkpoint.pth"),
)
monkeypatch.setattr(
loader_module.keep_download,
"ensure_optional",
lambda entry, progress=True: None,
)
monkeypatch.setattr(
loader_module.torch,
"load",
lambda *args, **kwargs: {"params_ema": {"weight": torch.zeros(1)}},
)
monkeypatch.setattr(loader_module, "_face_restore_helper", lambda: _StubHelper)
monkeypatch.setattr(
loader_module.keep_paths, "resolve_model_root", lambda *args, **kwargs: str(tmp_path)
)
return loader_module.KEEPModelLoader()
# ---------------------------------------------------------------------------
# P4.1 - the model loader node
# ---------------------------------------------------------------------------
@pytest.mark.mock_integration
def test_the_loader_node_returns_the_pack(loader_node_module, stubbed_loader, monkeypatch):
"""The node returns the pack of the loader as a ``NodeOutput``."""
from comfy_api.latest import io
monkeypatch.setattr(loader_node_module, "GLOBAL_KEEP_MODEL_LOADER", stubbed_loader)
output = loader_node_module.KEEP_ModelLoaderNode.execute(
model="KEEP",
detection_model="retinaface_resnet50",
)
assert isinstance(output, io.NodeOutput)
assert len(output.result) == 1
pack = output.result[0]
assert pack.model_type_str == "KEEP"
assert pack.keep_net is not None
@pytest.mark.mock_integration
def test_the_loader_node_serves_the_second_call_from_the_cache(
loader_node_module, stubbed_loader, monkeypatch
):
"""The second call with the same widgets reuses the cached models."""
monkeypatch.setattr(loader_node_module, "GLOBAL_KEEP_MODEL_LOADER", stubbed_loader)
first = loader_node_module.KEEP_ModelLoaderNode.execute(
model="KEEP", detection_model="retinaface_resnet50"
).result[0]
second = loader_node_module.KEEP_ModelLoaderNode.execute(
model="KEEP", detection_model="retinaface_resnet50"
).result[0]
assert first is not second, "every call returns its own pack"
assert first.keep_net is second.keep_net, "the models are cached"
assert first.face_helper is second.face_helper, "the models are cached"
assert len(stubbed_loader.loaded_models) == 1, stubbed_loader.loaded_models
@pytest.mark.mock_integration
def test_the_loader_node_passes_the_widgets_to_the_loader(
loader_node_module, monkeypatch
):
"""The widget values reach the loader unchanged."""
received: list[dict] = []
class _RecordingLoader:
def load_keep_model_pack(self, **kwargs):
received.append(kwargs)
return "pack"
monkeypatch.setattr(loader_node_module, "GLOBAL_KEEP_MODEL_LOADER", _RecordingLoader())
output = loader_node_module.KEEP_ModelLoaderNode.execute(
model="KEEP",
detection_model="retinaface_mobile0.25",
bg_upscale_model="bg",
face_upscale_model="face",
)
assert output.result == ("pack",)
assert received == [
{
"model_type_str": "KEEP",
"detection_model_str": "retinaface_mobile0.25",
"bg_upscale_model": "bg",
"face_upscale_model": "face",
}
]
# ---------------------------------------------------------------------------
# P4.2 - the single image node
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_validate_inputs_refuses_a_foreign_pack(node_module):
"""A pack of the wrong type is refused with an actionable message."""
message = node_module.KEEP_FaceUpscaleImageNode.validate_inputs(
image=torch.rand((1, 4, 4, 3), dtype=torch.float32),
keep_model=object(),
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
assert isinstance(message, str), message
assert "KEEPModelPack" in message, message
assert "Load KEEP Models" in message, message
@pytest.mark.unit
def test_validate_inputs_accepts_a_real_pack(node_module, stub_pack):
"""A real pack passes the validation."""
assert (
node_module.KEEP_FaceUpscaleImageNode.validate_inputs(
image=torch.rand((1, 4, 4, 3), dtype=torch.float32),
keep_model=stub_pack,
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
is True
)
@pytest.mark.unit
def test_validate_inputs_accepts_an_unresolved_pack(node_module):
"""A linked pack has no value during the prompt validation.
ComfyUI resolves the links only during the execution, so the pack arrives
as ``None`` in the validation phase. Rejecting it there broke every
prompt - the error was reported once per input of the node.
"""
assert (
node_module.KEEP_FaceUpscaleImageNode.validate_inputs(
image=torch.rand((1, 4, 4, 3), dtype=torch.float32),
keep_model=None,
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
is True
)
# ComfyUI marks an unresolved link with a one element tuple
# (``execution.get_input_data`` -> ``mark_missing``).
assert (
node_module.KEEP_FaceUpscaleImageNode.validate_inputs(
image=torch.rand((1, 4, 4, 3), dtype=torch.float32),
keep_model=(None,),
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
is True
)
@pytest.mark.unit
def test_execute_raises_for_a_foreign_pack(node_module):
"""The pack is type checked in ``execute`` - which raises, never returns None."""
with pytest.raises(RuntimeError) as error:
node_module.KEEP_FaceUpscaleImageNode.execute(
image=torch.rand((1, 4, 4, 3), dtype=torch.float32),
keep_model=object(),
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
assert "KEEPModelPack" in str(error.value), str(error.value)
assert "Load KEEP Models" in str(error.value), str(error.value)
@pytest.mark.mock_integration
def test_upscale_face_image_returns_the_processed_image(node_module, stub_pack, monkeypatch):
"""The node returns the processed image and uses the pack once."""
from comfy_api.latest import io
processed = np.full((4, 6, 3), 128, dtype=np.uint8)
processor_calls: list[dict] = []
class _StubProcessor:
def __init__(self, model_pack):
processor_calls.append({"pack": model_pack})
def process_image(self, **kwargs):
processor_calls.append({"kwargs": kwargs})
return processed
monkeypatch.setattr(node_module, "KEEPFaceProcessor", _StubProcessor)
image = torch.rand((1, 4, 6, 3), dtype=torch.float32)
result = node_module.KEEP_FaceUpscaleImageNode.execute(
image,
stub_pack,
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
assert isinstance(result, io.NodeOutput)
assert len(result.result) == 1
output = result.result[0]
assert tuple(output.shape) == (1, 4, 6, 3), output.shape
# the stub's image (128/255) came back
assert torch.allclose(output, torch.full((1, 4, 6, 3), 128 / 255), atol=1e-3), output
# the processor was built once and called once
assert len(processor_calls) == 2, processor_calls
assert processor_calls[0]["pack"] is stub_pack
assert processor_calls[1]["kwargs"]["final_upscale_factor"] == 1.0
# the pack was loaded and offloaded exactly once each
assert stub_pack.calls == ["load_device", "offload"], stub_pack.calls
@pytest.mark.mock_integration
def test_upscale_face_image_scales_the_output(node_module, stub_pack, monkeypatch):
"""The output of the node has the scaled shape ``(1, H*f, W*f, 3)``."""
factor = 2.0
class _StubProcessor:
def __init__(self, model_pack):
pass
def process_image(self, cv2_image_orig, final_upscale_factor, **kwargs):
height, width = cv2_image_orig.shape[:2]
return np.full(
(int(height * final_upscale_factor), int(width * final_upscale_factor), 3),
64,
dtype=np.uint8,
)
monkeypatch.setattr(node_module, "KEEPFaceProcessor", _StubProcessor)
result = node_module.KEEP_FaceUpscaleImageNode.execute(
torch.rand((1, 4, 6, 3), dtype=torch.float32),
stub_pack,
final_upscale_factor=factor,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
output = result.result[0]
assert tuple(output.shape) == (1, 8, 12, 3), output.shape
@pytest.mark.mock_integration
def test_upscale_face_image_raises_on_a_failure(node_module, stub_pack, monkeypatch):
"""A failing processing raises (B8: no ``(None,)`` behind an IMAGE socket)."""
class _FailingProcessor:
def __init__(self, model_pack):
pass
def process_image(self, **kwargs):
raise RuntimeError("the restoration failed")
monkeypatch.setattr(node_module, "KEEPFaceProcessor", _FailingProcessor)
with pytest.raises(RuntimeError, match="the restoration failed"):
node_module.KEEP_FaceUpscaleImageNode.execute(
torch.rand((1, 4, 6, 3), dtype=torch.float32),
stub_pack,
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
# the pack is offloaded even when the processing fails
assert stub_pack.calls == ["load_device", "offload"], stub_pack.calls
@pytest.mark.mock_integration
@pytest.mark.skipif(
MOCKS_DISABLED,
reason="the progress stub only records the calls while the harness mocks are installed",
)
def test_upscale_face_image_reports_the_progress(node_module, stub_pack, monkeypatch):
"""The node reports its progress through the ComfyUI execution API."""
progress_module = importlib.import_module("ComfyUI_KEEP.src.keep_progress")
class _StubProcessor:
def __init__(self, model_pack):
pass
def process_image(self, **kwargs):
return np.zeros((4, 6, 3), dtype=np.uint8)
monkeypatch.setattr(node_module, "KEEPFaceProcessor", _StubProcessor)
# the cached API instance of the engine (built on the first report)
api = progress_module.get_api_sync()
api.execution.calls.clear()
node_module.KEEP_FaceUpscaleImageNode.execute(
torch.rand((1, 4, 6, 3), dtype=torch.float32),
stub_pack,
final_upscale_factor=1.0,
has_aligned_face=True,
only_center_face=True,
draw_bounding_box=False,
)
assert api.execution.calls == [(0.0, 1.0), (1.0, 1.0)], api.execution.calls
# ---------------------------------------------------------------------------
# P4.3 - the image sequence node
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_the_sequence_node_refuses_a_foreign_pack(sequence_node_module):
"""A pack of the wrong type is refused with an actionable message."""
message = sequence_node_module.KEEP_ProcessImageSequenceNode.validate_inputs(
images=torch.rand((2, 4, 4, 3), dtype=torch.float32),
keep_model=object(),
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
assert isinstance(message, str), message
assert "KEEPModelPack" in message, message
@pytest.mark.unit
def test_the_sequence_node_accepts_an_unresolved_pack(sequence_node_module):
"""A linked pack has no value during the prompt validation - it is fine."""
assert (
sequence_node_module.KEEP_ProcessImageSequenceNode.validate_inputs(
images=torch.rand((2, 4, 4, 3), dtype=torch.float32),
keep_model=None,
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
is True
)
@pytest.mark.unit
def test_the_sequence_node_raises_for_a_foreign_pack(sequence_node_module):
"""The pack is type checked in ``execute`` - which raises, never returns None."""
with pytest.raises(RuntimeError) as error:
sequence_node_module.KEEP_ProcessImageSequenceNode.execute(
images=torch.rand((2, 4, 4, 3), dtype=torch.float32),
keep_model=object(),
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
assert "KEEPModelPack" in str(error.value), str(error.value)
@pytest.mark.mock_integration
def test_the_sequence_node_calls_the_processor_once(
sequence_node_module, stub_pack, monkeypatch
):
"""30 frames with ``max_clip_length=20`` - the processor is called once."""
from comfy_api.latest import io
calls: list[dict] = []
class _StubProcessor:
def __init__(self, model_pack):
calls.append({"pack": model_pack})
def process_image_sequence(self, **kwargs):
calls.append({"kwargs": kwargs})
return kwargs["image_sequence_tensor"]
monkeypatch.setattr(sequence_node_module, "KEEPFaceProcessor", _StubProcessor)
frames = torch.rand((30, 4, 6, 3), dtype=torch.float32)
output = sequence_node_module.KEEP_ProcessImageSequenceNode.execute(
frames,
stub_pack,
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
assert isinstance(output, io.NodeOutput)
assert len(output.result) == 1
assert output.result[0].shape == frames.shape
# the processor was built once and called once
assert len(calls) == 2, calls
assert calls[0]["pack"] is stub_pack
assert calls[1]["kwargs"]["max_clip_length"] == 20
assert calls[1]["kwargs"]["image_sequence_tensor"] is frames
@pytest.mark.mock_integration
def test_the_sequence_node_calls_ensure_flow_on_a_multi_frame_run(
sequence_node_module, stub_pack, monkeypatch
):
"""The flow hook is called exactly once - between load and offload."""
class _StubProcessor:
def __init__(self, model_pack):
pass
def process_image_sequence(self, **kwargs):
return kwargs["image_sequence_tensor"]
monkeypatch.setattr(sequence_node_module, "KEEPFaceProcessor", _StubProcessor)
sequence_node_module.KEEP_ProcessImageSequenceNode.execute(
torch.rand((30, 4, 6, 3), dtype=torch.float32),
stub_pack,
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
assert stub_pack.calls == ["load_device", "ensure_flow", "offload"], stub_pack.calls
@pytest.mark.mock_integration
def test_the_sequence_node_skips_ensure_flow_for_a_single_frame(
sequence_node_module, stub_pack, monkeypatch
):
"""A single frame needs no optical flow - the hook is not called."""
class _StubProcessor:
def __init__(self, model_pack):
pass
def process_image_sequence(self, **kwargs):
return kwargs["image_sequence_tensor"]
monkeypatch.setattr(sequence_node_module, "KEEPFaceProcessor", _StubProcessor)
sequence_node_module.KEEP_ProcessImageSequenceNode.execute(
torch.rand((1, 4, 6, 3), dtype=torch.float32),
stub_pack,
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
assert stub_pack.calls == ["load_device", "offload"], stub_pack.calls
@pytest.mark.mock_integration
def test_the_sequence_node_raises_on_a_failure(
sequence_node_module, stub_pack, monkeypatch
):
"""A failing processing raises and the pack is offloaded anyway."""
class _FailingProcessor:
def __init__(self, model_pack):
pass
def process_image_sequence(self, **kwargs):
raise RuntimeError("the sequence failed")
monkeypatch.setattr(sequence_node_module, "KEEPFaceProcessor", _FailingProcessor)
with pytest.raises(RuntimeError, match="the sequence failed"):
sequence_node_module.KEEP_ProcessImageSequenceNode.execute(
torch.rand((2, 4, 6, 3), dtype=torch.float32),
stub_pack,
final_upscale_factor=1.0,
has_aligned_frames=False,
only_center_face=True,
draw_bounding_box=False,
max_clip_length=20,
)
assert stub_pack.calls == ["load_device", "ensure_flow", "offload"], stub_pack.calls