Spaces:
Running on Zero
Running on Zero
fix(ui): replace track_tqdm with a manual step callback for clear progress labels
b848e06 unverified | """Tests for backend.py — duration estimator, generate routing, and retry logic.""" | |
| from __future__ import annotations | |
| from unittest.mock import MagicMock | |
| import pytest | |
| import backend | |
| import modes | |
| # --------------------------------------------------------------------------- | |
| # Fixture: a QwenImageEditBackend whose _build_pipeline is replaced with a | |
| # MagicMock so no torch/diffusers import occurs. | |
| # --------------------------------------------------------------------------- | |
| def fake_backend(monkeypatch): | |
| monkeypatch.setattr(backend, "_build_pipeline", lambda *a, **k: MagicMock()) | |
| return backend.QwenImageEditBackend() | |
| # --------------------------------------------------------------------------- | |
| # duration_for — clamping and Fast shortcut | |
| # --------------------------------------------------------------------------- | |
| # The Space runs on the xlarge ZeroGPU tier (58 GB model), which DOUBLES the | |
| # requested duration for ZeroGPU's per-call ceiling check. Requests at/above the | |
| # ceiling are rejected ("ZeroGPU illegal duration"). These tests lock in the two | |
| # invariants that keep the GPU path working, rather than a magic number. | |
| _XLARGE_MULTIPLIER = 2 | |
| _ZEROGPU_CEILING = 300 # requested-seconds; reaching this is rejected on this Space | |
| _DURATION_CASES = ( | |
| {"speed": "Fast"}, | |
| {"speed": "Fast", "steps": 100}, | |
| {"speed": "Quality"}, | |
| {"speed": "Quality", "steps": 40}, | |
| {"speed": "Quality", "steps": 1000}, | |
| ) | |
| def test_duration_for_legal_on_xlarge(): | |
| """The doubled request must stay under ZeroGPU's per-call ceiling. | |
| This is the regression guard against the illegal-duration bug: a 180 s | |
| request became 360 s on xlarge and every call was rejected. | |
| """ | |
| for params in _DURATION_CASES: | |
| d = backend.duration_for("edit", params) | |
| assert d * _XLARGE_MULTIPLIER < _ZEROGPU_CEILING, ( | |
| f"duration {d}s -> {d * _XLARGE_MULTIPLIER}s requested >= {_ZEROGPU_CEILING}s ceiling (illegal on xlarge)" | |
| ) | |
| def test_duration_for_covers_materialization(): | |
| """Budget must cover the ~15-40 s per-call model materialization plus | |
| inference, else the GPU task aborts mid-load.""" | |
| for params in _DURATION_CASES: | |
| assert backend.duration_for("edit", params) >= 50 | |
| def test_duration_for_returns_legal_int(): | |
| """Default budget is a positive int whose doubled xlarge request stays under | |
| the 360 s value ZeroGPU rejected.""" | |
| d = backend.duration_for("edit", {"speed": "Fast", "steps": 4}) | |
| assert isinstance(d, int) | |
| assert 60 <= d * 2 < 360 | |
| def test_duration_for_env_override(monkeypatch): | |
| """QIE_GPU_DURATION retunes the budget without a code change; junk is ignored.""" | |
| monkeypatch.setenv("QIE_GPU_DURATION", "100") | |
| assert backend.duration_for("edit", {"speed": "Fast", "steps": 4}) == 100 | |
| monkeypatch.setenv("QIE_GPU_DURATION", "not-an-int") | |
| assert isinstance(backend.duration_for("edit", {"speed": "Fast"}), int) | |
| def test_duration_for_returns_int(): | |
| assert isinstance(backend.duration_for("edit", {"speed": "Quality", "steps": 40}), int) | |
| assert isinstance(backend.duration_for("edit", {"speed": "Fast"}), int) | |
| # --------------------------------------------------------------------------- | |
| # generate — dispatch routing and unknown-mode guard | |
| # --------------------------------------------------------------------------- | |
| def test_generate_routes_to_dispatch(monkeypatch, fake_backend): | |
| """generate must call modes.DISPATCH[mode] with (pipeline, params, progress).""" | |
| sentinel = ("output_image", {"meta": True}) | |
| fake_handler = MagicMock(return_value=sentinel) | |
| monkeypatch.setattr(modes, "DISPATCH", {"edit": fake_handler}) | |
| params = {"speed": "Fast", "prompt": "test"} | |
| result = fake_backend.generate("edit", params) | |
| # progress defaults to None when not supplied (e.g. non-UI callers / CI). | |
| fake_handler.assert_called_once_with(fake_backend.pipeline, params, None) | |
| assert result is sentinel | |
| def test_generate_unknown_mode_raises_value_error(monkeypatch, fake_backend): | |
| """generate must raise ValueError — not KeyError — for unrecognised modes.""" | |
| monkeypatch.setattr(modes, "DISPATCH", {"edit": MagicMock()}) | |
| with pytest.raises(ValueError, match="unknown mode"): | |
| fake_backend.generate("no_such_mode", {}) | |
| # --------------------------------------------------------------------------- | |
| # generate_with_retry — retry on GPU abort, re-raise on other errors | |
| # --------------------------------------------------------------------------- | |
| def test_generate_with_retry_retries_exactly_once_on_gpu_abort(monkeypatch, fake_backend): | |
| """generate_with_retry retries once when generate raises 'GPU task aborted'.""" | |
| good_result = ("img", {"seed": 0}) | |
| mock_generate = MagicMock(side_effect=[Exception("GPU task aborted"), good_result]) | |
| monkeypatch.setattr(fake_backend, "generate", mock_generate) | |
| result = backend.generate_with_retry(fake_backend, "edit", {"speed": "Fast"}) | |
| assert mock_generate.call_count == 2 | |
| assert result is good_result | |
| def test_generate_with_retry_case_insensitive_match(monkeypatch, fake_backend): | |
| """GPU-abort detection is case-insensitive.""" | |
| good_result = ("img", {}) | |
| mock_generate = MagicMock(side_effect=[Exception("GPU Task Aborted"), good_result]) | |
| monkeypatch.setattr(fake_backend, "generate", mock_generate) | |
| backend.generate_with_retry(fake_backend, "edit", {}) | |
| assert mock_generate.call_count == 2 | |
| def test_generate_with_retry_reraises_non_gpu_error(monkeypatch, fake_backend): | |
| """generate_with_retry must NOT retry on unrelated exceptions.""" | |
| mock_generate = MagicMock(side_effect=Exception("boom")) | |
| monkeypatch.setattr(fake_backend, "generate", mock_generate) | |
| with pytest.raises(Exception, match="boom"): | |
| backend.generate_with_retry(fake_backend, "edit", {}) | |
| assert mock_generate.call_count == 1 # no retry | |