- palette.py + rembg.py: implement from stubs (Pillow median-cut + rembg u2net) - vlm.py: rename Spark2→steev (Strix Halo / Ollama); bump max_tokens 1024→4096 (qwen3-vl:32b thinking mode consumes budget tokens — 4096 min for valid output) - settings.py: rename spark2_vlm_*/spark1_flux_* → vlm_*/flux_*; real defaults (steev 100.88.167.87:11434 Ollama, gx10 100.90.100.10:8188 ComfyUI) - tests/: conftest.py + test_palette.py + test_rembg.py + test_integration_e2e.py (28 unit + 10 integration; 38/38 passing — VLM raw/polished/ugc + FLUX render) - CLAUDE.md: rewrite to accurate phase status + infra + layout - requirements.txt + pyproject.toml: add Pillow, rembg, pytest-asyncio deps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
277 lines
8.9 KiB
Python
277 lines
8.9 KiB
Python
"""End-to-end integration tests — hit live Spark hosts via svrnty-vision.
|
||
|
||
Run with: pytest -m integration -v
|
||
Skip by default in CI / offline environments.
|
||
|
||
Hosts required:
|
||
VLM — svrnty-steev (Strix Halo) · 100.88.167.87:11434 · qwen3-vl:32b on Ollama
|
||
FLUX — gx10-f38f · 100.90.100.10:8188 · ComfyUI + flux2_dev_fp8mixed
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
import os
|
||
from decimal import Decimal
|
||
|
||
import httpx
|
||
import pytest
|
||
from PIL import Image
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
BASE_URL = os.environ.get("SVRNTY_VISION_URL", "http://localhost:8092")
|
||
VLM_HOST = "100.88.167.87"
|
||
FLUX_HOST = "100.90.100.10"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_png_b64(color: tuple[int, int, int] = (220, 80, 60), size: int = 128) -> str:
|
||
img = Image.new("RGB", (size, size), color=color)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||
|
||
|
||
def _host_reachable(host: str, port: int, timeout: float = 2.0) -> bool:
|
||
import socket
|
||
try:
|
||
with socket.create_connection((host, port), timeout=timeout):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Skip conditions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
vlm_available = pytest.mark.skipif(
|
||
not _host_reachable(VLM_HOST, 11434),
|
||
reason=f"VLM host {VLM_HOST}:11434 (svrnty-steev Ollama) not reachable",
|
||
)
|
||
|
||
flux_available = pytest.mark.skipif(
|
||
not _host_reachable(FLUX_HOST, 8188),
|
||
reason=f"FLUX host {FLUX_HOST}:8188 (gx10 ComfyUI) not reachable",
|
||
)
|
||
|
||
gateway_available = pytest.mark.skipif(
|
||
not _host_reachable("127.0.0.1", 8092),
|
||
reason="svrnty-vision gateway not running on localhost:8092",
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Gateway health
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
def test_gateway_healthz() -> None:
|
||
resp = httpx.get(f"{BASE_URL}/healthz", timeout=5)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["status"] == "ok"
|
||
assert "version" in body
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# VLM — Qwen3-VL 32B on svrnty-steev
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
@vlm_available
|
||
def test_vlm_analyze_raw_mode_returns_text() -> None:
|
||
"""Raw mode: VLM describes the image freely — no score parsing."""
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/vlm/analyze",
|
||
json={
|
||
"image_base64": _make_png_b64((220, 80, 60)),
|
||
"brand_context": "Describe what you see in this image.",
|
||
"rubric_mode": "raw",
|
||
},
|
||
timeout=60,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["rubric_mode"] == "raw"
|
||
assert body["brand_fit_score"] is None
|
||
assert isinstance(body["raw_scores_json"], str)
|
||
assert len(body["raw_scores_json"]) > 0
|
||
assert "qwen" in body["model_id"].lower()
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
@vlm_available
|
||
def test_vlm_analyze_polished_returns_scores() -> None:
|
||
"""Polished mode: VLM returns brand_fit + visual_polish 0–5 scores."""
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/vlm/analyze",
|
||
json={
|
||
"image_base64": _make_png_b64((50, 120, 200)),
|
||
"brand_context": "Modern tech brand — clean, minimal, confident.",
|
||
"rubric_mode": "polished",
|
||
},
|
||
timeout=120,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["rubric_mode"] == "polished"
|
||
|
||
brand_fit = Decimal(str(body["brand_fit_score"]))
|
||
visual_polish = Decimal(str(body["visual_polish_score"]))
|
||
assert Decimal("0") <= brand_fit <= Decimal("5"), f"brand_fit out of range: {brand_fit}"
|
||
assert Decimal("0") <= visual_polish <= Decimal("5"), f"visual_polish out of range: {visual_polish}"
|
||
assert isinstance(body["justification"], str)
|
||
assert len(body["justification"]) > 0
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
@vlm_available
|
||
def test_vlm_analyze_ugc_mode() -> None:
|
||
"""UGC mode: same structure as polished, different rubric framing."""
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/vlm/analyze",
|
||
json={
|
||
"image_base64": _make_png_b64((80, 180, 80)),
|
||
"brand_context": "Fresh food delivery — organic, home-style.",
|
||
"rubric_mode": "ugc",
|
||
},
|
||
timeout=120,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["rubric_mode"] == "ugc"
|
||
assert body["brand_fit_score"] is not None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# FLUX — ComfyUI on gx10-f38f
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
@flux_available
|
||
def test_flux_render_returns_valid_png() -> None:
|
||
"""Minimal FLUX render — 4 steps for speed, verifies PNG round-trip."""
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/flux/render",
|
||
json={
|
||
"prompt": "a plain white circle on black background",
|
||
"width": 512,
|
||
"height": 512,
|
||
"steps": 4,
|
||
"guidance": 2.5,
|
||
},
|
||
timeout=300,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
|
||
assert "image_base64" in body
|
||
assert body["content_type"] == "image/png"
|
||
assert body["provider"] == "local"
|
||
assert isinstance(body["duration_ms"], int)
|
||
assert body["duration_ms"] > 0
|
||
|
||
raw = base64.b64decode(body["image_base64"])
|
||
img = Image.open(io.BytesIO(raw))
|
||
assert img.width == 512
|
||
assert img.height == 512
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
@flux_available
|
||
def test_flux_render_seeds_produce_different_images() -> None:
|
||
"""Two renders with different prompts → different images (non-trivial output)."""
|
||
def render(prompt: str) -> bytes:
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/flux/render",
|
||
json={"prompt": prompt, "width": 512, "height": 512, "steps": 4},
|
||
timeout=300,
|
||
)
|
||
assert resp.status_code == 200
|
||
return base64.b64decode(resp.json()["image_base64"])
|
||
|
||
img_a = render("solid red background, nothing else")
|
||
img_b = render("solid blue background, nothing else")
|
||
assert img_a != img_b, "Two different prompts produced identical output — likely cached/deduped"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Palette — in-process (Pillow)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
def test_palette_extract_live() -> None:
|
||
"""Palette extraction is in-process — always passes when gateway is up."""
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/palette/extract",
|
||
json={"image_base64": _make_png_b64((200, 50, 50)), "color_count": 4},
|
||
timeout=10,
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
r, g, b = body["dominant"]
|
||
assert r > 150, "dominant color should be red-dominant"
|
||
assert body["color_count"] <= 4
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rembg — in-process
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
def test_rembg_cutout_live() -> None:
|
||
"""Background removal — always passes when gateway is up (model downloads on first call)."""
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/rembg/cutout",
|
||
json={"image_base64": _make_png_b64()},
|
||
timeout=120, # first call downloads u2net ONNX model
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
raw = base64.b64decode(body["image_base64"])
|
||
img = Image.open(io.BytesIO(raw))
|
||
assert img.mode == "RGBA"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Error surface — gateway must return correct HTTP codes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
def test_vlm_analyze_missing_image_returns_400() -> None:
|
||
resp = httpx.post(
|
||
f"{BASE_URL}/vlm/analyze",
|
||
json={"brand_context": "test", "rubric_mode": "raw"},
|
||
timeout=10,
|
||
)
|
||
assert resp.status_code == 400
|
||
|
||
|
||
@pytest.mark.integration
|
||
@gateway_available
|
||
def test_flux_render_missing_prompt_returns_400() -> None:
|
||
resp = httpx.post(f"{BASE_URL}/flux/render", json={"width": 512, "height": 512}, timeout=10)
|
||
assert resp.status_code == 400
|