qwen-image-editor / tests /test_modes.py
techfreakworm's picture
Add custom user-LoRA (Quality mode) for the MPS route
47baa19 unverified
Raw
History Blame Contribute Delete
15.4 kB
"""L2 mocked tests for modes.py — no GPU, no torch, no diffusers required."""
from __future__ import annotations
import sys
from typing import Any
from unittest.mock import MagicMock
import pytest
from PIL import Image
import models
import modes
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_DUMMY_IMAGE = Image.new("RGB", (8, 8))
def _pipe_result() -> MagicMock:
"""A fake pipeline output whose .images[0] is a small PIL image."""
result = MagicMock()
result.images = [Image.new("RGB", (8, 8))]
return result
def _make_params(
*,
speed: str = "Fast",
steps: int = 4,
true_cfg: float = 1.0,
seed: int = 42,
images: list[Any] | None = None,
) -> dict[str, Any]:
return {
"prompt": "make it raining",
"images": images if images is not None else [_DUMMY_IMAGE],
"speed": speed,
"steps": steps,
"true_cfg": true_cfg,
"negative_prompt": " ",
"seed": seed,
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def fake_pipe() -> MagicMock:
"""A MagicMock pipeline with all attributes needed by modes._run."""
pipe = MagicMock()
pipe.device = "cpu"
pipe.return_value = _pipe_result()
pipe._qie_lightning_scheduler = MagicMock()
pipe._qie_default_scheduler = MagicMock()
return pipe
@pytest.fixture(autouse=True)
def _patch_fit_dimensions(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch models.fit_dimensions to return (64, 64) so no real image maths runs."""
monkeypatch.setattr(models, "fit_dimensions", lambda img: (64, 64))
@pytest.fixture(autouse=True)
def _patch_torch(monkeypatch: pytest.MonkeyPatch) -> None:
"""Inject a stub torch module so the lazy 'import torch' in _run succeeds in CI."""
mock_torch = MagicMock()
gen = MagicMock()
mock_torch.Generator.return_value = gen
gen.manual_seed.return_value = gen
monkeypatch.setitem(sys.modules, "torch", mock_torch)
# ---------------------------------------------------------------------------
# call_edit
# ---------------------------------------------------------------------------
def test_edit_passes_target_image_in_image_kwarg(fake_pipe: MagicMock) -> None:
"""call_edit must forward exactly [target] in the image= positional kwarg."""
target = Image.new("RGB", (16, 16))
params = _make_params(images=[target])
modes.call_edit(fake_pipe, params)
_, call_kwargs = fake_pipe.call_args
assert call_kwargs["image"] == [target]
def test_edit_sets_mode_in_meta(fake_pipe: MagicMock) -> None:
"""call_edit must record mode='edit' in the returned metadata."""
_, meta = modes.call_edit(fake_pipe, _make_params())
assert meta["mode"] == "edit"
# ---------------------------------------------------------------------------
# call_compose
# ---------------------------------------------------------------------------
def test_compose_drops_none_images(fake_pipe: MagicMock) -> None:
"""Compose with [target, None, ref2] must call pipe with image=[target, ref2]."""
target = Image.new("RGB", (8, 8))
ref2 = Image.new("RGB", (8, 8))
params = _make_params(images=[target, None, ref2])
modes.call_compose(fake_pipe, params)
_, call_kwargs = fake_pipe.call_args
assert call_kwargs["image"] == [target, ref2]
assert len(call_kwargs["image"]) == 2
def test_compose_sets_mode_in_meta(fake_pipe: MagicMock) -> None:
"""call_compose must record mode='compose' in the returned metadata."""
_, meta = modes.call_compose(fake_pipe, _make_params())
assert meta["mode"] == "compose"
# ---------------------------------------------------------------------------
# Speed modes
# ---------------------------------------------------------------------------
def test_fast_speed_calls_set_adapters(fake_pipe: MagicMock) -> None:
"""Fast speed must call pipe.set_adapters with the Lightning adapter at weight 1.0."""
modes.call_edit(fake_pipe, _make_params(speed="Fast", steps=4, true_cfg=1.0))
fake_pipe.set_adapters.assert_called_once_with([models.LORA_ADAPTER_NAME], [1.0])
def test_fast_speed_pipe_call_kwargs(fake_pipe: MagicMock) -> None:
"""Fast speed must pass num_inference_steps=4 and true_cfg_scale=1.0 to pipe."""
modes.call_edit(fake_pipe, _make_params(speed="Fast", steps=4, true_cfg=1.0))
_, call_kwargs = fake_pipe.call_args
assert call_kwargs["num_inference_steps"] == 4
assert call_kwargs["true_cfg_scale"] == 1.0
def test_fast_speed_uses_lightning_scheduler(fake_pipe: MagicMock) -> None:
"""Fast speed must swap in the Lightning scheduler before the pipe call."""
modes.call_edit(fake_pipe, _make_params(speed="Fast"))
assert fake_pipe.scheduler is fake_pipe._qie_lightning_scheduler
def _call_order(pipe: MagicMock, *names: str) -> list[str]:
"""Ordered list of the named methods as actually invoked on the pipe (for sequencing)."""
return [c[0] for c in pipe.mock_calls if c[0] in names]
def test_fast_speed_enables_lora_before_set_adapters(fake_pipe: MagicMock) -> None:
"""Regression: set_adapters() only flips the active adapter NAME — it does NOT re-enable
layers a prior Quality disable_lora() turned off. Fast must call enable_lora() FIRST or
Lightning is silently bypassed on the long-lived MPS process (each request reuses one pipe)."""
modes.call_edit(fake_pipe, _make_params(speed="Fast", steps=4, true_cfg=1.0))
order = _call_order(fake_pipe, "enable_lora", "set_adapters")
assert order[: 2] == ["enable_lora", "set_adapters"], f"enable_lora must precede set_adapters; got {order}"
def test_quality_speed_calls_disable_lora(fake_pipe: MagicMock) -> None:
"""Quality speed must call pipe.disable_lora() to deactivate the Lightning adapter."""
modes.call_edit(fake_pipe, _make_params(speed="Quality", steps=40, true_cfg=4.0))
fake_pipe.disable_lora.assert_called_once()
def test_quality_speed_pipe_call_kwargs(fake_pipe: MagicMock) -> None:
"""Quality speed must pass num_inference_steps=40 and true_cfg_scale=4.0 to pipe."""
modes.call_edit(fake_pipe, _make_params(speed="Quality", steps=40, true_cfg=4.0))
_, call_kwargs = fake_pipe.call_args
assert call_kwargs["num_inference_steps"] == 40
assert call_kwargs["true_cfg_scale"] == 4.0
def test_quality_speed_uses_default_scheduler(fake_pipe: MagicMock) -> None:
"""Quality speed must restore the default scheduler before the pipe call."""
modes.call_edit(fake_pipe, _make_params(speed="Quality"))
assert fake_pipe.scheduler is fake_pipe._qie_default_scheduler
# ---------------------------------------------------------------------------
# GPU-path user LoRA (device="mps" → the budgeted GPU branch, where LoRA applies)
# ---------------------------------------------------------------------------
def _mock_gpu_memory(monkeypatch: pytest.MonkeyPatch, *, speed: str = "Quality") -> None:
"""Stub memory.* so modes._run's GPU branch runs without real budgeting, and make the
torch stub's mps allocator return a real number."""
import memory
plan = {
"refused": False, "width": 1024, "height": 1024, "steps": 8, "true_cfg": 4.0,
"speed": speed, "n_ref": 0, "degrades": [], "note": "ok", "budget_gb": 90.0, "need_gb": 60.0,
}
monkeypatch.setattr(memory, "plan_request", lambda *a, **k: dict(plan))
monkeypatch.setattr(memory, "record_peak", lambda *a, **k: None)
monkeypatch.setattr(memory, "penalize", lambda *a, **k: None)
monkeypatch.setattr(memory, "activation_budget_gb", lambda *a, **k: 90.0)
sys.modules["torch"].mps.driver_allocated_memory.return_value = 60 * 1024**3
def test_gpu_quality_user_lora_loads_sets_and_cleans_up(fake_pipe: MagicMock, monkeypatch) -> None:
"""Quality + user LoRA on the GPU path: load 'user', activate it, surface it in meta, and
ALWAYS delete it afterwards (mandatory MPS cleanup — no per-call re-fork)."""
fake_pipe.device = "mps"
_mock_gpu_memory(monkeypatch, speed="Quality")
p = _make_params(speed="Quality", steps=8, true_cfg=4.0)
p["lora_path"] = "/tmp/foo.safetensors"
p["lora_weight"] = 0.8
_, meta = modes.call_edit(fake_pipe, p)
fake_pipe.load_lora_weights.assert_called_once_with("/tmp/foo.safetensors", adapter_name="user")
assert any(c.args == (["user"], [0.8]) for c in fake_pipe.set_adapters.call_args_list)
fake_pipe.delete_adapters.assert_any_call("user") # cleanup ran
assert meta.get("lora", {}).get("weight") == 0.8
# The activate sequence must be enable_lora() THEN set_adapters(["user"], ...): Quality's
# disable_lora() left the layers off, and set_adapters alone wouldn't re-enable them —
# without enable_lora() the LoRA loads but has ZERO effect (output identical to no-LoRA).
seq = [(c[0], c[1]) for c in fake_pipe.mock_calls if c[0] in ("enable_lora", "set_adapters")]
user_idx = next(i for i, (n, a) in enumerate(seq) if n == "set_adapters" and a == (["user"], [0.8]))
assert any(n == "enable_lora" for n, _ in seq[:user_idx]), f"enable_lora must precede user set_adapters; got {seq}"
def test_gpu_quality_without_lora_touches_no_user_adapter(fake_pipe: MagicMock, monkeypatch) -> None:
"""No LoRA requested → never load or delete a 'user' adapter; meta has no 'lora'."""
fake_pipe.device = "mps"
_mock_gpu_memory(monkeypatch, speed="Quality")
_, meta = modes.call_edit(fake_pipe, _make_params(speed="Quality", steps=8, true_cfg=4.0))
fake_pipe.load_lora_weights.assert_not_called()
fake_pipe.delete_adapters.assert_not_called()
assert "lora" not in meta
def test_gpu_fast_ignores_user_lora(fake_pipe: MagicMock, monkeypatch) -> None:
"""A user LoRA is Quality-only — in Fast it must NOT be loaded or applied."""
fake_pipe.device = "mps"
_mock_gpu_memory(monkeypatch, speed="Fast")
p = _make_params(speed="Fast", steps=4, true_cfg=1.0)
p["lora_path"] = "/tmp/foo.safetensors"
p["lora_weight"] = 0.9
_, meta = modes.call_edit(fake_pipe, p)
fake_pipe.load_lora_weights.assert_not_called()
assert "lora" not in meta
def test_gpu_user_lora_cleaned_up_on_error(fake_pipe: MagicMock, monkeypatch) -> None:
"""If inference raises (non-OOM), the 'user' adapter must STILL be deleted (finally)."""
fake_pipe.device = "mps"
_mock_gpu_memory(monkeypatch, speed="Quality")
fake_pipe.side_effect = ValueError("boom") # pipe(...) raises
p = _make_params(speed="Quality", steps=8, true_cfg=4.0)
p["lora_path"] = "/tmp/foo.safetensors"
p["lora_weight"] = 0.8
with pytest.raises(ValueError, match="boom"):
modes.call_edit(fake_pipe, p)
fake_pipe.delete_adapters.assert_any_call("user") # leak-proof cleanup
# ---------------------------------------------------------------------------
# Seed handling
# ---------------------------------------------------------------------------
def test_negative_seed_produces_int_in_meta(fake_pipe: MagicMock) -> None:
"""seed=-1 must resolve to a non-negative int stored in meta['seed']."""
_, meta = modes.call_edit(fake_pipe, _make_params(seed=-1))
assert isinstance(meta["seed"], int)
assert meta["seed"] >= 0
def test_explicit_seed_preserved_in_meta(fake_pipe: MagicMock) -> None:
"""An explicit non-negative seed must appear unchanged in meta['seed']."""
_, meta = modes.call_edit(fake_pipe, _make_params(seed=123))
assert meta["seed"] == 123
def test_negative_seed_varies(fake_pipe: MagicMock) -> None:
"""Two calls with seed=-1 should (with overwhelming probability) produce different seeds."""
_, meta1 = modes.call_edit(fake_pipe, _make_params(seed=-1))
_, meta2 = modes.call_edit(fake_pipe, _make_params(seed=-1))
# Not deterministically different, but the chance of collision is < 1/2^32.
# We just assert both are valid ints — determinism is tested via explicit seed.
assert isinstance(meta1["seed"], int)
assert isinstance(meta2["seed"], int)
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_empty_images_raises_value_error(fake_pipe: MagicMock) -> None:
"""_run must raise ValueError when the images list is empty."""
params = _make_params(images=[])
with pytest.raises(ValueError):
modes.call_edit(fake_pipe, params)
def test_compose_all_none_raises_value_error(fake_pipe: MagicMock) -> None:
"""call_compose with all-None images must raise ValueError after filtering."""
params = _make_params(images=[None, None])
with pytest.raises(ValueError):
modes.call_compose(fake_pipe, params)
# ---------------------------------------------------------------------------
# Meta dict completeness
# ---------------------------------------------------------------------------
_META_KEYS = {"mode", "speed", "steps", "true_cfg", "seed", "width", "height", "num_inputs"}
def test_meta_has_all_documented_keys_edit(fake_pipe: MagicMock) -> None:
"""call_edit must return a metadata dict containing every documented key."""
_, meta = modes.call_edit(fake_pipe, _make_params())
assert _META_KEYS <= set(meta.keys())
def test_meta_has_all_documented_keys_compose(fake_pipe: MagicMock) -> None:
"""call_compose must return a metadata dict containing every documented key."""
target = Image.new("RGB", (8, 8))
ref = Image.new("RGB", (8, 8))
_, meta = modes.call_compose(fake_pipe, _make_params(images=[target, ref]))
assert _META_KEYS <= set(meta.keys())
def test_meta_num_inputs_reflects_image_count(fake_pipe: MagicMock) -> None:
"""meta['num_inputs'] must equal the number of images actually passed to the pipeline."""
target = Image.new("RGB", (8, 8))
ref = Image.new("RGB", (8, 8))
# compose with [target, None, ref] — None is dropped, so num_inputs should be 2
_, meta = modes.call_compose(fake_pipe, _make_params(images=[target, None, ref]))
assert meta["num_inputs"] == 2
def test_meta_dimensions_come_from_fit_dimensions(fake_pipe: MagicMock) -> None:
"""meta width/height must match the values returned by models.fit_dimensions (mocked to 64,64)."""
_, meta = modes.call_edit(fake_pipe, _make_params())
assert meta["width"] == 64
assert meta["height"] == 64
# ---------------------------------------------------------------------------
# DISPATCH table
# ---------------------------------------------------------------------------
def test_dispatch_contains_edit_and_compose() -> None:
"""DISPATCH must map 'edit' and 'compose' to the correct callables."""
assert modes.DISPATCH["edit"] is modes.call_edit
assert modes.DISPATCH["compose"] is modes.call_compose
def test_dispatch_via_table(fake_pipe: MagicMock) -> None:
"""Calling DISPATCH['edit'] must behave identically to calling call_edit directly."""
params = _make_params()
img, meta = modes.DISPATCH["edit"](fake_pipe, params)
assert isinstance(img, Image.Image)
assert meta["mode"] == "edit"