test(image_generate): replace unit tests with thin e2e

Drop the granular unit/invariant tests in favor of one thin e2e that drives
the real image_generate handler end-to-end (real catalog, real payload build,
real local-file→data-URI encoding; only the FAL HTTP submit is stubbed):
one image-edit happy path + one text-to-image regression.
This commit is contained in:
alt-glitch 2026-06-15 16:33:41 +05:30
parent 80d1400ce6
commit 1a80ae1772
2 changed files with 59 additions and 318 deletions

View File

@ -363,24 +363,12 @@ class TestAspectRatioNormalization:
class TestRegistryIntegration:
def test_schema_stays_tight_no_model_selection(self, image_tool):
def test_schema_exposes_only_prompt_aspect_ratio_image_urls(self, image_tool):
"""The agent-facing schema must stay tight — model selection is a
user-level config choice, not an agent-level arg. The agent may pass
prompt, aspect_ratio, and image_urls (for image-to-image / edit), but
never model/provider/quality/step knobs."""
user-level config choice, not an agent-level arg. (image_urls added
for image-to-image editing.)"""
props = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]
assert set(props.keys()) == {"prompt", "aspect_ratio", "image_urls"}
# Invariant: model-selection / tuning params never leak into the schema.
forbidden = {"model", "provider", "quality", "num_inference_steps",
"guidance_scale", "num_images", "seed"}
assert not (set(props.keys()) & forbidden)
def test_image_urls_is_a_string_array(self, image_tool):
spec = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]["image_urls"]
assert spec["type"] == "array"
assert spec["items"]["type"] == "string"
# Optional — never required, so text-to-image stays a one-arg call.
assert "image_urls" not in image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["required"]
def test_aspect_ratio_enum_is_three_values(self, image_tool):
enum = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]["aspect_ratio"]["enum"]

View File

@ -1,337 +1,90 @@
"""Tests for image-to-image / edit input on tools/image_generation_tool.py.
"""Thin end-to-end test for image_generate's image-to-image / edit path.
Covers the ``image_urls`` parameter added to ``image_generate``:
* input-ref resolution (http(s) URL / data: URI / local path data URI),
* edit-endpoint routing (models with an ``edit`` entry switch model + payload),
* the fall-through when a model has no edit endpoint,
* the end-to-end submit path (which model id and arguments reach FAL).
These are behavior/invariant tests they assert *relationships* (the image
key lands in the payload, the submitted model id is the edit endpoint, a model
without edit is untouched), not frozen catalog snapshots, so they survive
catalog churn.
Drives the real registered ``image_generate`` handler through the real module
real catalog, real payload construction, real local-file data-URI
encoding and only stubs the outbound FAL HTTP submit (so it needs no FAL key
and spends no credits). One happy-path edit and one no-edit fallback; that's
the whole feature surface.
"""
from __future__ import annotations
import base64
import importlib
import json
import struct
import zlib
from pathlib import Path
from unittest.mock import patch
import pytest
@pytest.fixture
def image_tool():
import importlib
import tools.image_generation_tool as mod
return importlib.reload(mod)
def _tiny_png_bytes() -> bytes:
"""A minimal valid 1x1 PNG (so MIME sniffing + Pillow open succeed)."""
def chunk(typ: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data)) + typ + data
+ struct.pack(">I", zlib.crc32(typ + data) & 0xFFFFFFFF)
)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
raw = b"\x00\xff\x00\x00" # one filtered RGB pixel
idat = chunk(b"IDAT", zlib.compress(raw))
iend = chunk(b"IEND", b"")
return sig + ihdr + idat + iend
def _tiny_png(path) -> str:
"""Write a minimal valid 1x1 PNG so the encoder can sniff + open it."""
def chunk(typ, data):
return (struct.pack(">I", len(data)) + typ + data
+ struct.pack(">I", zlib.crc32(typ + data) & 0xFFFFFFFF))
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00"))
+ chunk(b"IEND", b""))
path.write_bytes(png)
return str(path)
# ---------------------------------------------------------------------------
# Catalog invariants for edit endpoints
# ---------------------------------------------------------------------------
class TestEditCatalog:
def test_edit_entries_have_required_keys(self, image_tool):
for mid, meta in image_tool.FAL_MODELS.items():
edit = meta.get("edit")
if edit is None:
continue
assert isinstance(edit, dict), f"{mid}.edit must be a dict"
for key in ("model", "image_key", "max_images"):
assert key in edit, f"{mid}.edit missing {key!r}"
assert isinstance(edit["model"], str) and edit["model"]
assert isinstance(edit["image_key"], str) and edit["image_key"]
def test_synthesized_edit_meta_lets_image_key_through(self, image_tool):
"""Every declared edit endpoint must whitelist its own image key, or
_build_fal_payload would silently strip the reference images."""
for mid, meta in image_tool.FAL_MODELS.items():
if not meta.get("edit"):
continue
target = image_tool._resolve_edit_target(mid, ["http://x/y.png"])
assert target is not None, f"{mid} should resolve an edit target"
edit_model_id, edit_meta = target
assert edit_model_id == meta["edit"]["model"]
assert meta["edit"]["image_key"] in edit_meta["supports"]
# Edits infer output size from the input — no forced size mapping.
assert edit_meta["size_style"] == "none"
# Editing never chains the upscaler.
assert edit_meta["upscale"] is False
def test_edit_supports_does_not_inherit_generate_only_keys(self, image_tool):
"""Regression: edit endpoints must declare their OWN supports, not
inherit the generate whitelist. flux-2-pro/edit rejects
num_inference_steps/guidance_scale that flux-2-pro generate accepts
if we inherited, those would 422 the edit call."""
meta = image_tool.FAL_MODELS["fal-ai/flux-2-pro"]
_, edit_meta = image_tool._resolve_edit_target(
"fal-ai/flux-2-pro", ["http://x/y.png"]
)
assert "num_inference_steps" not in edit_meta["supports"]
assert "guidance_scale" not in edit_meta["supports"]
# but the generate endpoint DOES accept them (proves divergence).
assert "num_inference_steps" in meta["supports"]
def test_multi_flag_matches_image_key_plurality(self, image_tool):
"""Single-image endpoints use the singular ``image_url`` key; multi-
image endpoints use ``image_urls``."""
for mid, meta in image_tool.FAL_MODELS.items():
edit = meta.get("edit")
if not edit:
continue
_, edit_meta = image_tool._resolve_edit_target(mid, ["http://x/y.png"])
if edit_meta["_image_key"] == "image_url":
assert edit_meta["_multi"] is False, f"{mid} singular key must be _multi=False"
assert edit["max_images"] == 1
else:
assert edit_meta["_image_key"] == "image_urls"
# ---------------------------------------------------------------------------
# Edit-target routing decision
# ---------------------------------------------------------------------------
class TestResolveEditTarget:
def test_no_images_means_no_edit(self, image_tool):
assert image_tool._resolve_edit_target("fal-ai/nano-banana-pro", []) is None
def test_model_without_edit_endpoint_returns_none(self, image_tool):
# z-image/turbo and ideogram/v3 deliberately have no edit endpoint.
assert image_tool._resolve_edit_target(
"fal-ai/z-image/turbo", ["http://x/y.png"]
) is None
assert image_tool._resolve_edit_target(
"fal-ai/ideogram/v3", ["http://x/y.png"]
) is None
def test_unknown_model_returns_none(self, image_tool):
assert image_tool._resolve_edit_target(
"fal-ai/does-not-exist", ["http://x/y.png"]
) is None
# ---------------------------------------------------------------------------
# Input image resolution
# ---------------------------------------------------------------------------
class TestResolveInputImages:
def test_http_url_passthrough(self, image_tool):
assert image_tool._resolve_input_images("https://a/b.png") == ["https://a/b.png"]
def test_data_uri_passthrough(self, image_tool):
uri = "data:image/png;base64,ZZ"
assert image_tool._resolve_input_images([uri]) == [uri]
def test_none_is_empty(self, image_tool):
assert image_tool._resolve_input_images(None) == []
def test_missing_local_file_dropped(self, image_tool):
assert image_tool._resolve_input_images(["/no/such/file.png"]) == []
def test_max_images_truncates(self, image_tool):
urls = ["https://a/1.png", "https://a/2.png", "https://a/3.png"]
assert image_tool._resolve_input_images(urls, max_images=2) == urls[:2]
def test_local_file_encoded_to_data_uri(self, image_tool, tmp_path):
p = tmp_path / "ref.png"
p.write_bytes(_tiny_png_bytes())
out = image_tool._resolve_input_images([str(p)])
assert len(out) == 1
assert out[0].startswith("data:image/")
assert ";base64," in out[0]
# round-trips to non-empty bytes
b64 = out[0].split(";base64,", 1)[1]
assert base64.b64decode(b64)
# ---------------------------------------------------------------------------
# End-to-end submit path (mocked FAL)
# ---------------------------------------------------------------------------
class _FakeHandler:
def __init__(self, result):
self._result = result
def get(self):
return self._result
def _patch_backend(image_tool, monkeypatch, captured):
def _stub_fal(image_tool, monkeypatch, captured):
"""Stub the FAL backend so we run everything except the network call."""
monkeypatch.setattr(image_tool, "fal_key_is_configured", lambda: True)
monkeypatch.setattr(image_tool, "_resolve_managed_fal_gateway", lambda: None)
def fake_submit(model, arguments=None, **kw):
class _Handler:
def get(self):
return {"images": [{"url": "https://out/result.png", "width": 1, "height": 1}]}
def _submit(model, arguments=None, **kw):
captured["model"] = model
captured["arguments"] = arguments
return _FakeHandler({"images": [{"url": "https://out/img.png", "width": 1, "height": 1}]})
return _Handler()
monkeypatch.setattr(image_tool, "_submit_fal_request", fake_submit)
monkeypatch.setattr(image_tool, "_submit_fal_request", _submit)
# Pin an edit-capable generate model regardless of local config.
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/nano-banana-pro",
image_tool.FAL_MODELS["fal-ai/nano-banana-pro"]),
)
class TestEndToEndSubmit:
def test_text_to_image_untouched_without_images(self, image_tool, monkeypatch):
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/nano-banana-pro", image_tool.FAL_MODELS["fal-ai/nano-banana-pro"]),
)
out = json.loads(image_tool.image_generate_tool(prompt="a cat"))
assert out["success"] is True
# No edit routing: the generate model id is submitted, no image key.
assert captured["model"] == "fal-ai/nano-banana-pro"
assert "image_urls" not in captured["arguments"]
def test_image_edit_e2e(image_tool, monkeypatch, tmp_path):
"""A local image + prompt routes to the edit endpoint with the image
encoded as a data URI, and reports success."""
captured = {}
_stub_fal(image_tool, monkeypatch, captured)
ref = _tiny_png(tmp_path / "ref.png")
def test_edit_routing_switches_model_and_adds_images(self, image_tool, monkeypatch):
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/nano-banana-pro", image_tool.FAL_MODELS["fal-ai/nano-banana-pro"]),
)
out = json.loads(image_tool.image_generate_tool(
prompt="make it night",
image_urls=["https://in/ref.png"],
))
assert out["success"] is True
assert out.get("edited") is True
# Routed to the edit endpoint, with the reference image in the payload.
assert captured["model"] == "fal-ai/nano-banana-pro/edit"
assert captured["arguments"]["image_urls"] == ["https://in/ref.png"]
assert captured["arguments"]["prompt"] == "make it night"
out = json.loads(image_tool._handle_image_generate(
{"prompt": "make it night", "image_urls": [ref]}
))
def test_edit_payload_omits_forced_image_size(self, image_tool, monkeypatch):
"""Edits must not inject an explicit image_size/aspect_ratio — FAL
infers output dims from the input image."""
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2-pro", image_tool.FAL_MODELS["fal-ai/flux-2-pro"]),
)
image_tool.image_generate_tool(prompt="x", image_urls=["https://in/r.png"])
# flux-2-pro/edit DOES accept image_size, but we don't force one.
assert "image_size" not in captured["arguments"]
assert out["success"] is True
assert out["image"] == "https://out/result.png"
# Routed to the edit endpoint, local file encoded to a data URI.
assert captured["model"] == "fal-ai/nano-banana-pro/edit"
assert captured["arguments"]["image_urls"][0].startswith("data:image/")
assert captured["arguments"]["prompt"] == "make it night"
def test_flux2_pro_edit_drops_generate_only_params(self, image_tool, monkeypatch):
"""num_inference_steps/guidance_scale must NOT reach flux-2-pro/edit
even when passed as overrides (they'd 422)."""
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2-pro", image_tool.FAL_MODELS["fal-ai/flux-2-pro"]),
)
image_tool.image_generate_tool(
prompt="x",
image_urls=["https://in/r.png"],
num_inference_steps=50,
guidance_scale=4.5,
)
assert "num_inference_steps" not in captured["arguments"]
assert "guidance_scale" not in captured["arguments"]
def test_qwen_edit_uses_singular_image_url(self, image_tool, monkeypatch):
"""Qwen's edit endpoint takes a single ``image_url`` string, not a list."""
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/qwen-image", image_tool.FAL_MODELS["fal-ai/qwen-image"]),
)
image_tool.image_generate_tool(
prompt="x",
image_urls=["https://in/a.png", "https://in/b.png"],
)
assert captured["model"] == "fal-ai/qwen-image-edit"
# Singular string, first image only.
assert captured["arguments"]["image_url"] == "https://in/a.png"
assert "image_urls" not in captured["arguments"]
def test_text_to_image_still_works(image_tool, monkeypatch):
"""No image_urls → unchanged text-to-image on the generate endpoint."""
captured = {}
_stub_fal(image_tool, monkeypatch, captured)
def test_no_edit_model_surfaces_note_in_result(self, image_tool, monkeypatch):
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/z-image/turbo", image_tool.FAL_MODELS["fal-ai/z-image/turbo"]),
)
out = json.loads(image_tool.image_generate_tool(
prompt="a dog",
image_urls=["https://in/ref.png"],
))
assert out["success"] is True
assert "note" in out and "does not support image" in out["note"]
out = json.loads(image_tool._handle_image_generate({"prompt": "a cat"}))
def test_images_on_model_without_edit_fall_back_to_text(self, image_tool, monkeypatch):
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
# z-image/turbo has no edit endpoint.
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/z-image/turbo", image_tool.FAL_MODELS["fal-ai/z-image/turbo"]),
)
out = json.loads(image_tool.image_generate_tool(
prompt="a dog",
image_urls=["https://in/ref.png"],
))
assert out["success"] is True
# Stays on the generate model; images dropped (no edit endpoint).
assert captured["model"] == "fal-ai/z-image/turbo"
assert "image_urls" not in captured["arguments"]
def test_edit_does_not_chain_upscaler(self, image_tool, monkeypatch):
"""flux-2-pro has upscale=True for generate, but its edit endpoint must
not chain the upscaler (would double-submit)."""
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2-pro", image_tool.FAL_MODELS["fal-ai/flux-2-pro"]),
)
# If the upscaler were chained, _submit_fal_request would be called a
# second time with the clarity-upscaler model and overwrite captured.
out = json.loads(image_tool.image_generate_tool(
prompt="add flames",
image_urls=["https://in/ref.png"],
))
assert out["success"] is True
assert captured["model"] == "fal-ai/flux-2-pro/edit" # not the upscaler
def test_local_path_reaches_fal_as_data_uri(self, image_tool, monkeypatch, tmp_path):
captured = {}
_patch_backend(image_tool, monkeypatch, captured)
monkeypatch.setattr(
image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2/klein/9b", image_tool.FAL_MODELS["fal-ai/flux-2/klein/9b"]),
)
p = tmp_path / "ref.png"
p.write_bytes(_tiny_png_bytes())
out = json.loads(image_tool.image_generate_tool(
prompt="stylize",
image_urls=[str(p)],
))
assert out["success"] is True
assert captured["model"] == "fal-ai/flux-2/klein/9b/edit"
imgs = captured["arguments"]["image_urls"]
assert len(imgs) == 1 and imgs[0].startswith("data:image/")
assert out["success"] is True
assert captured["model"] == "fal-ai/nano-banana-pro"
assert "image_urls" not in captured["arguments"]