mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 11:52:25 +02:00
feat(photo-editor): NL editor planner, image ops, email/gallery server
Server-side photo editor: natural-language editor agent route
(/api/editor/agent), photo_editor tool family + style guide, image-op
provider proxy with progress, email server hardening, gallery/hwfit
routes, and graceful shutdown.
Source: vendor/photocraft (ad1cff44) editor-server files, merged onto
upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/B_conflicts.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
eb940f24a3
@@ -0,0 +1,36 @@
|
||||
"""Tests for src/activity.py — in-flight heavy-image-op counters (safe restart)."""
|
||||
from src import activity
|
||||
|
||||
|
||||
def test_image_op_counter_increments_and_decrements():
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
activity.begin_image_op("a")
|
||||
activity.begin_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 2
|
||||
activity.end_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 1
|
||||
activity.end_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
# never goes negative
|
||||
activity.end_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
|
||||
|
||||
def test_image_op_context_manager_and_busy():
|
||||
assert activity.busy() is False
|
||||
with activity.image_op("/api/image/inpaint"):
|
||||
assert activity.busy() is True
|
||||
snap = activity.activity()
|
||||
assert snap["image_ops"] == 1
|
||||
assert snap["image_ops_by_name"].get("/api/image/inpaint") == 1
|
||||
assert activity.busy() is False
|
||||
assert activity.activity()["image_ops"] == 0
|
||||
|
||||
|
||||
def test_image_op_released_on_exception():
|
||||
try:
|
||||
with activity.image_op("boom"):
|
||||
raise RuntimeError("x")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
||||
"""Repository asset ownership guards for issues #1335 and #6175.
|
||||
|
||||
Public Markdown and landing-page media belong in website/, while shared
|
||||
optional README/packaging imagery belongs in assets/branding/. Retained images
|
||||
in either managed root must be referenced by tracked text, and every retained
|
||||
website video must be referenced by the site's entry point. Publication can
|
||||
operate with no imagery or videos.
|
||||
README/packaging imagery belongs in assets/branding/. Images in either managed
|
||||
root must be referenced by tracked text, and every tracked website video must
|
||||
be referenced by the site's entry point.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
@@ -42,8 +41,7 @@ def _tracked(*paths_under):
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
return [REPO / line for line in out.stdout.splitlines()
|
||||
if line.strip() and (REPO / line).is_file()]
|
||||
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def test_no_orphan_documentation_or_branding_images():
|
||||
@@ -51,6 +49,9 @@ def test_no_orphan_documentation_or_branding_images():
|
||||
if managed_files is None:
|
||||
pytest.skip("not a git checkout")
|
||||
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
|
||||
assert any("assets/branding" in p.as_posix() for p in managed_images), (
|
||||
"expected assets/branding/ to contain the shared project imagery"
|
||||
)
|
||||
|
||||
# All tracked text we might reference an image from.
|
||||
all_tracked = _tracked(".") or []
|
||||
@@ -93,6 +94,7 @@ def test_pages_site_owns_its_entrypoint_and_media():
|
||||
assert text.startswith("---\nlayout: default\n---\n"), guide
|
||||
|
||||
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
|
||||
assert website_videos, "expected website/ to contain the landing-page videos"
|
||||
|
||||
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
|
||||
unreferenced = [
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.tools.image import do_edit_image
|
||||
@@ -122,7 +123,8 @@ def test_edit_image_rejects_actions_without_complete_input_contract():
|
||||
assert "Use prompt, upscale or rembg" in result["error"]
|
||||
|
||||
|
||||
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path):
|
||||
@pytest.mark.parametrize('incognito', [False, True])
|
||||
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path, incognito):
|
||||
source_path = tmp_path / 'source.png'
|
||||
Image.new('RGB', (3, 2), 'red').save(source_path)
|
||||
original = source_path.read_bytes()
|
||||
@@ -135,9 +137,9 @@ def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, t
|
||||
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
|
||||
from pathlib import Path
|
||||
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
||||
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice'))
|
||||
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice', incognito=incognito))
|
||||
assert result['image_id'] == 'edited'
|
||||
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto'})]
|
||||
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto', 'incognito': incognito})]
|
||||
assert source_path.read_bytes() == original
|
||||
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
|
||||
assert denied['error'] == 'Image not found'
|
||||
@@ -165,7 +167,7 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
||||
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
|
||||
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
|
||||
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto'})]
|
||||
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto', 'incognito': False})]
|
||||
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
|
||||
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
|
||||
assert len(calls) == 1
|
||||
@@ -173,3 +175,23 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
|
||||
disabled = asyncio.run(do_edit_image(args, owner='alice'))
|
||||
assert 'disabled' in disabled['error']
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_private_upscale_does_not_save_image_or_gallery_record(monkeypatch, tmp_path):
|
||||
source_path = tmp_path / 'source.png'
|
||||
Image.new('RGB', (3, 2), 'red').save(source_path)
|
||||
original = source_path.read_bytes()
|
||||
db = _Db(_source(source_path.name))
|
||||
monkeypatch.setattr('core.database.SessionLocal', lambda: db)
|
||||
monkeypatch.setattr('src.constants.GENERATED_IMAGES_DIR', str(tmp_path))
|
||||
result = asyncio.run(do_edit_image(
|
||||
'{"image_id":"source-id","action":"upscale","scale":2}',
|
||||
owner='alice', incognito=True,
|
||||
))
|
||||
assert result['exit_code'] == 0
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert not result['image_id']
|
||||
assert db.added == []
|
||||
assert not db.committed
|
||||
assert list(tmp_path.iterdir()) == [source_path]
|
||||
assert source_path.read_bytes() == original
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Server health audit fix: the local editor VLM returned 400s when asked for a
|
||||
model id it does not serve (the client default 'Qwen3.5-9B-MLX-8bit', or the
|
||||
stale caption model 'Qwen/Qwen3-VL-30B'). ``_resolve_model`` maps the requested
|
||||
id to one the base actually serves."""
|
||||
import asyncio
|
||||
|
||||
from src import editor_agent_model as eam
|
||||
|
||||
|
||||
def _cache(base, ids):
|
||||
eam._MODELS_CACHE[base] = set(ids)
|
||||
|
||||
|
||||
def test_resolve_model_keeps_a_served_request():
|
||||
base = "http://x:1/v1"
|
||||
_cache(base, ["Qwen/Qwen3.5-9B", "other"])
|
||||
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3.5-9B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||
|
||||
|
||||
def test_resolve_model_falls_back_when_requested_is_not_served():
|
||||
base = "http://x:2/v1"
|
||||
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||
# e.g. the client's MLX build name, or a stale caption model id.
|
||||
assert asyncio.run(eam._resolve_model(base, "Qwen3.5-9B-MLX-8bit", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3-VL-30B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||
|
||||
|
||||
def test_resolve_model_picks_a_served_id_when_fallback_absent():
|
||||
base = "http://x:3/v1"
|
||||
_cache(base, ["B", "A"])
|
||||
assert asyncio.run(eam._resolve_model(base, "nope", "also-nope")) == "A"
|
||||
|
||||
|
||||
def test_resolve_model_keeps_request_when_served_list_unknown():
|
||||
base = "http://x:4/v1"
|
||||
_cache(base, [])
|
||||
# Server unreachable / no models listed -> don't rewrite the request.
|
||||
assert asyncio.run(eam._resolve_model(base, "some/model", "fallback")) == "some/model"
|
||||
|
||||
|
||||
def test_self_check_resolves_the_model_before_calling(monkeypatch):
|
||||
base = eam.local_bases()[0]
|
||||
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||
seen = {}
|
||||
|
||||
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||
seen["model"] = model
|
||||
return "DONE"
|
||||
|
||||
monkeypatch.setattr(eam, "_post_chat", fake_post)
|
||||
out = asyncio.run(eam.self_check(
|
||||
"data:image/png;base64,AAAA", "data:image/png;base64,BBBB",
|
||||
"make it brighter", model="Qwen3.5-9B-MLX-8bit"))
|
||||
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||
assert out["done"] is True
|
||||
|
||||
|
||||
def test_caption_resolves_the_caption_model(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||
seen["base"] = b
|
||||
seen["model"] = model
|
||||
return "a photo"
|
||||
|
||||
monkeypatch.setattr(eam, "_post_chat", fake_post)
|
||||
import src.editor_agent_model as m
|
||||
_cache(m.caption_base(), ["Qwen/Qwen3.5-9B"])
|
||||
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
|
||||
assert txt == "a photo"
|
||||
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Planner routing regression suite (HARNESS-owned).
|
||||
|
||||
Turns PHOTOSHOP's bench prompt families (vague, restyle, bg-remove/scene,
|
||||
tone/auto, generative, structural) into assertions on the ROUTE CLASS the
|
||||
server planner picks, so a future planner change cannot silently re-break
|
||||
routing. The classes are:
|
||||
|
||||
ask_user -> the planner is unsure; it must ask with 2-3 choices and
|
||||
apply NO operations (never a silent tone default)
|
||||
deterministic -> a deterministic editor op / recipe (adjustments, filters,
|
||||
crop, text, rotate, fill, recolour ...)
|
||||
ai_edit_image -> a generative image-model edit (content add/remove/replace,
|
||||
restyle, look)
|
||||
generate_image -> text-to-image for a brand-new image
|
||||
|
||||
These use the pure routing helpers (``_plan_special`` / ``_generative_kind`` /
|
||||
``_fallback_plan``), so no model or network call is made.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes import editor_agent_routes as eaar
|
||||
|
||||
_STATE = {"width": 900, "height": 600}
|
||||
_PROVIDER = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_planner():
|
||||
eaar.set_editor_planner(None)
|
||||
yield
|
||||
eaar.set_editor_planner(None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(eaar.setup_editor_agent_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _classify(prompt, provider=_PROVIDER):
|
||||
"""Return (route_class, ops) for a prompt using the deterministic helpers."""
|
||||
special = eaar._plan_special(prompt, _STATE, provider)
|
||||
if special:
|
||||
steps = list(special[0])
|
||||
else:
|
||||
fallback = eaar._fallback_plan(prompt)
|
||||
if fallback.get("ask_user"):
|
||||
return "ask_user", []
|
||||
steps = list(fallback.get("steps") or [])
|
||||
ops = [str(s.get("op") or "") for s in steps]
|
||||
if "generate_image" in ops:
|
||||
return "generate_image", ops
|
||||
if "ai_edit_image" in ops:
|
||||
return "ai_edit_image", ops
|
||||
return "deterministic", ops
|
||||
|
||||
|
||||
# (prompt, expected route class)
|
||||
BENCH_ROUTING = [
|
||||
# vague -> ask (never a silent tone tweak)
|
||||
("do the thing", "ask_user"),
|
||||
("give it some oomph", "ask_user"),
|
||||
("make it better", "ask_user"),
|
||||
# whole-image restyle -> model edit
|
||||
("make it look like an oil painting", "ai_edit_image"),
|
||||
("turn it into an anime", "ai_edit_image"),
|
||||
("make it a comic book illustration", "ai_edit_image"),
|
||||
("make it look like cyberpunk", "ai_edit_image"),
|
||||
# background / scene replacement -> model edit
|
||||
("put her on a beach", "ai_edit_image"),
|
||||
("make the background nicer", "ai_edit_image"),
|
||||
("make it snowy", "ai_edit_image"),
|
||||
# generative content add / remove -> model edit
|
||||
("add a full moon in the sky", "ai_edit_image"),
|
||||
("remove the distractions", "ai_edit_image"),
|
||||
("remove the person on the left", "ai_edit_image"),
|
||||
# brand-new image -> text-to-image
|
||||
("create a new image of a full moon", "generate_image"),
|
||||
("generate an image of a sunset", "generate_image"),
|
||||
("make a logo", "generate_image"),
|
||||
# tone / auto / look / structural -> deterministic
|
||||
("make it black and white", "deterministic"),
|
||||
("make it pop", "deterministic"),
|
||||
("make it look professional", "deterministic"),
|
||||
("make only the red car blue", "deterministic"),
|
||||
("make a 1080x1350 Instagram post from this", "deterministic"),
|
||||
("add a title TOKYO with a drop shadow", "deterministic"),
|
||||
("extend the canvas to 16:9", "deterministic"),
|
||||
("save as PSD with layers", "deterministic"),
|
||||
("fill the white area with red", "deterministic"),
|
||||
("rotate the whole image 90 degrees", "deterministic"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt,expected", BENCH_ROUTING)
|
||||
def test_bench_prompts_route_to_expected_class(prompt, expected):
|
||||
route_class, ops = _classify(prompt)
|
||||
assert route_class == expected, f"{prompt!r} -> {route_class} {ops} (want {expected})"
|
||||
|
||||
|
||||
def test_ask_plans_offer_two_to_three_choices():
|
||||
for prompt in ("do the thing", "give it some oomph", "make it better"):
|
||||
plan = eaar._fallback_plan(prompt)
|
||||
ask = plan.get("ask_user")
|
||||
assert isinstance(ask, dict), prompt
|
||||
opts = ask.get("options") or []
|
||||
assert 2 <= len(opts) <= 3, (prompt, opts)
|
||||
assert plan.get("steps") == []
|
||||
|
||||
|
||||
def test_object_removal_uses_selection_plus_model_edit():
|
||||
_cls, ops = _classify("remove the person on the left")
|
||||
assert "ai_edit_image" in ops
|
||||
assert any(op.startswith("selection.") for op in ops), ops
|
||||
|
||||
|
||||
def test_recolour_never_uses_a_flat_fill_or_a_global_tint():
|
||||
# Every recolour selects the object by description and either shifts the
|
||||
# masked hue or asks the image model for the masked region — never a flat
|
||||
# selection.fill and never a whole-image adjustments.apply.
|
||||
for prompt in ("make only the red car blue", "make the red car white", "make the flowers bright pink"):
|
||||
_cls, ops = _classify(prompt)
|
||||
assert "selection.samText" in ops, (prompt, ops)
|
||||
assert "selection.fill" not in ops, (prompt, ops)
|
||||
assert "adjustments.apply" not in ops, (prompt, ops)
|
||||
|
||||
|
||||
def test_chromatic_recolour_is_a_deterministic_masked_hue_shift():
|
||||
cls, ops = _classify("make only the red car blue")
|
||||
assert cls == "deterministic", ops
|
||||
assert ops == ["selection.samText", "adjustments.applyMasked"], ops
|
||||
|
||||
|
||||
def test_new_image_request_from_scratch_vs_edit_of_current_photo():
|
||||
# A photo edit ("make it a <style>") must NOT be treated as a new image.
|
||||
assert eaar._generative_kind("make it a comic book illustration") == "restyle"
|
||||
assert eaar._is_new_image_request("make it a comic book illustration") is False
|
||||
# A from-scratch generation still is.
|
||||
assert eaar._is_new_image_request("create an illustration of a cat") is True
|
||||
assert eaar._generative_kind("create an illustration of a cat") == "add"
|
||||
|
||||
|
||||
def test_plan_route_returns_deterministic_recolour(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||
r = client.post("/api/editor/plan", json={"prompt": "make only the red car blue", "state": _STATE})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["planner"] == "deterministic"
|
||||
assert [s["op"] for s in data["steps"]] == ["selection.samText", "adjustments.applyMasked"]
|
||||
|
||||
|
||||
def test_plan_route_asks_for_a_vague_prompt(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||
r = client.post("/api/editor/plan", json={"prompt": "do the thing", "state": _STATE})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("ask_user"), data
|
||||
assert data.get("steps") == []
|
||||
|
||||
|
||||
def test_plan_route_routes_restyle_to_model_edit(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||
r = client.post("/api/editor/plan", json={"prompt": "turn it into an anime", "state": _STATE})
|
||||
assert r.status_code == 200
|
||||
ops = [s["op"] for s in r.json()["steps"]]
|
||||
assert "ai_edit_image" in ops, ops
|
||||
@@ -11,6 +11,7 @@ from src.clean_agent_preview import (
|
||||
from src.turn_contract import (
|
||||
editor_request_instructions, requested_capabilities, selected_tools_for_request,
|
||||
preserve_bound_editor_selected_tools, resolve_turn_contract,
|
||||
targets_bound_editor_request,
|
||||
)
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.tool_policy import ToolPolicy
|
||||
@@ -72,6 +73,16 @@ def test_direct_edits_share_the_capability_router(prompt):
|
||||
assert not active_editor_suggestion_request(doc, prompt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prompt', [
|
||||
'ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist trivs sa bra har',
|
||||
'Go more into how we are thinking about the future.',
|
||||
])
|
||||
def test_implicit_revision_followups_route_to_active_document_only(prompt):
|
||||
assert requested_capabilities(prompt, active_document=True) == {'documents'}
|
||||
assert requested_capabilities(prompt, active_document=False) == frozenset()
|
||||
assert targets_bound_editor_request(prompt)
|
||||
|
||||
|
||||
def test_source_review_still_requires_tool_completion_after_research():
|
||||
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
|
||||
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
|
||||
|
||||
@@ -26,7 +26,7 @@ class _FakeSock:
|
||||
class _FakeIMAP:
|
||||
calls = []
|
||||
|
||||
def __init__(self, host, port, timeout=None, **_policy):
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
@@ -60,8 +60,8 @@ def test_open_imap_connection_uses_shared_timeout_for_implicit_ssl(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeIMAP.calls = []
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
|
||||
|
||||
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
|
||||
|
||||
@@ -75,8 +75,8 @@ def test_open_imap_connection_supports_starttls(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeIMAP.calls = []
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
|
||||
|
||||
_open_imap_connection("imap.local", 143, starttls=True)
|
||||
|
||||
@@ -99,7 +99,7 @@ async def test_account_config_uses_shared_imap_timeout(monkeypatch):
|
||||
def logout(self):
|
||||
captured["logout"] = True
|
||||
|
||||
def fake_open(host, port, *, starttls, timeout, **_policy):
|
||||
def fake_open(host, port, *, starttls, timeout):
|
||||
captured["open"] = (host, port, starttls, timeout)
|
||||
return _Conn()
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Email list stale-while-revalidate (SWR) regression tests.
|
||||
|
||||
A plain list view must never block ~2-5 s on IMAP while we already hold a
|
||||
recent snapshot or a durable index: it should return the snapshot immediately
|
||||
(with ``sync.stale``) and refresh in the background. Blocking IMAP only remains
|
||||
for a genuine cold start (no snapshot AND no indexed rows).
|
||||
"""
|
||||
import asyncio
|
||||
import pathlib
|
||||
|
||||
from routes.email import email_routes as er
|
||||
|
||||
SRC = pathlib.Path(er.__file__).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _list_endpoint():
|
||||
router = er.setup_email_routes()
|
||||
for route in router.routes:
|
||||
if getattr(route, "name", "") == "list_emails":
|
||||
return route.endpoint
|
||||
raise AssertionError("list_emails route not found")
|
||||
|
||||
|
||||
def _call(endpoint, **over):
|
||||
kwargs = dict(
|
||||
folder="INBOX", limit=50, offset=0, filter="all", from_addr=None,
|
||||
account_id=None, has_attachments=0, date_from=None, date_to=None,
|
||||
cached_only=0, cache_bust=None, refresh=0, owner="pewds",
|
||||
)
|
||||
kwargs.update(over)
|
||||
return asyncio.run(endpoint(**kwargs))
|
||||
|
||||
|
||||
def test_list_returns_index_snapshot_with_a_stale_marker_without_blocking_on_imap(monkeypatch):
|
||||
endpoint = _list_endpoint()
|
||||
monkeypatch.setattr(
|
||||
er, "_email_index_list",
|
||||
lambda owner, account_id, folder, filter_, limit, offset, has_att: (
|
||||
[{"uid": "11", "subject": "Indexed", "is_read": False}], 1, "2026-10-11T00:00:00Z",
|
||||
),
|
||||
)
|
||||
# The synchronous path must never open IMAP; if it did the request would
|
||||
# raise instead of returning the index snapshot immediately.
|
||||
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("SWR must not open IMAP when an index snapshot exists")))
|
||||
|
||||
result = _call(endpoint, cache_bust="123")
|
||||
assert result["total"] == 1
|
||||
assert [e["uid"] for e in result["emails"]] == ["11"]
|
||||
assert result["sync"]["stale"] is True
|
||||
assert result["sync"]["source"] in ("index", "cache_stale")
|
||||
|
||||
|
||||
def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatch):
|
||||
# With neither a snapshot nor an index, SWR must NOT fire: the request
|
||||
# falls through to the real (blocking) path, so the response is not a
|
||||
# stale-marked snapshot.
|
||||
endpoint = _list_endpoint()
|
||||
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
|
||||
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||
RuntimeError("offline test")))
|
||||
try:
|
||||
result = _call(endpoint)
|
||||
except Exception:
|
||||
return # the cold path legitimately attempted real IMAP
|
||||
assert isinstance(result, dict)
|
||||
assert not (result.get("sync") or {}).get("stale")
|
||||
|
||||
|
||||
def test_swr_contract_is_wired_into_the_list_path():
|
||||
assert "_LIST_STALE" in SRC
|
||||
assert "def _list_stale_put" in SRC
|
||||
assert "def _schedule_list_refresh" in SRC
|
||||
assert 'sync_meta["source"] = "cache_stale"' in SRC
|
||||
assert 'sync_meta["stale"] = True' in SRC
|
||||
# The manual-refresh contract the UI relies on must stay intact.
|
||||
assert "manual_refresh = bool(refresh)" in SRC
|
||||
assert "_invalidate_list_cache(account_id, folder)" in SRC
|
||||
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
|
||||
@@ -64,7 +64,7 @@ def test_imap_connect_with_host_still_connects(monkeypatch):
|
||||
def login(self, user, password):
|
||||
opened["login"] = (user, password)
|
||||
|
||||
def _fake_open(host, port, *, starttls, timeout, **_policy):
|
||||
def _fake_open(host, port, *, starttls, timeout):
|
||||
opened["host"] = host
|
||||
return _FakeConn()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from routes.email_helpers import _send_smtp_message
|
||||
class _FakeSMTP:
|
||||
calls = []
|
||||
|
||||
def __init__(self, host, port, timeout=None, **_policy):
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
@@ -54,8 +54,8 @@ def test_send_smtp_message_supports_plain_smtp(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
|
||||
|
||||
@@ -68,8 +68,8 @@ def test_send_smtp_message_supports_explicit_starttls(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
|
||||
|
||||
@@ -82,8 +82,8 @@ def test_send_smtp_message_defaults_587_to_starttls(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
cfg = _cfg("", port=587)
|
||||
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
|
||||
@@ -96,8 +96,8 @@ def test_send_smtp_message_uses_ssl_when_configured(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@ async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
@@ -141,8 +141,8 @@ async def test_test_connection_password_account_still_uses_login():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn):
|
||||
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is True
|
||||
@@ -187,8 +187,8 @@ async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
@@ -238,8 +238,8 @@ async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
@@ -336,10 +336,10 @@ async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
|
||||
"routes.email_routes.ssl.create_default_context",
|
||||
return_value=context,
|
||||
), mock.patch(
|
||||
"routes.email_helpers._PolicyIMAP4",
|
||||
"routes.email_helpers.imaplib.IMAP4",
|
||||
return_value=starttls_conn,
|
||||
) as imap_cls, mock.patch(
|
||||
"routes.email_helpers._PolicyIMAP4_SSL",
|
||||
"routes.email_helpers.imaplib.IMAP4_SSL",
|
||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||
) as imap_ssl_cls, mock.patch(
|
||||
"routes.email_routes._get_valid_google_token"
|
||||
@@ -412,10 +412,10 @@ async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
|
||||
"routes.email_routes.ssl.create_default_context",
|
||||
return_value=context,
|
||||
), mock.patch(
|
||||
"routes.email_routes._PolicySMTP",
|
||||
"routes.email_routes.smtplib.SMTP",
|
||||
return_value=starttls_smtp,
|
||||
) as smtp_cls, mock.patch(
|
||||
"routes.email_routes._PolicySMTP_SSL",
|
||||
"routes.email_routes.smtplib.SMTP_SSL",
|
||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||
) as smtp_ssl_cls, mock.patch(
|
||||
"routes.email_routes._get_valid_google_token"
|
||||
|
||||
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
||||
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
|
||||
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
|
||||
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
|
||||
|
||||
|
||||
def _form_fields(content, content_type):
|
||||
"""Parse simple multipart form fields (text parts only) from a request body."""
|
||||
boundary = content_type.split('boundary=')[1].strip().strip('"')
|
||||
fields = {}
|
||||
for part in content.split(('--' + boundary).encode()):
|
||||
if b'name="' not in part:
|
||||
continue
|
||||
head, _, body = part.partition(b'\r\n\r\n')
|
||||
import re as _re
|
||||
match = _re.search(rb'name="([^"]+)"', head)
|
||||
if not match:
|
||||
continue
|
||||
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
|
||||
return fields
|
||||
|
||||
|
||||
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
|
||||
base_url = 'http://100.69.120.65:8188/v1'
|
||||
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
|
||||
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||
_real_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient',
|
||||
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
|
||||
if include_mask:
|
||||
payload['mask'] = png(mask)
|
||||
payload.update(body_extra)
|
||||
return payload
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
return asyncio.run(route.endpoint(Request()))
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
|
||||
Image.new('L', (1600, 1200), 255),
|
||||
{'steps': 8}, respond)
|
||||
# Working size is capped to a model-friendly size (<=768 long side, /16).
|
||||
assert seen['size'] == '768x576'
|
||||
# A requested step budget passes through up to the cap (default 8).
|
||||
assert seen['steps'] == '8'
|
||||
# The returned image keeps the original dimensions.
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
|
||||
|
||||
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
|
||||
# Over-cap requests are clamped to the configured maximum (default 8).
|
||||
assert seen['steps'] == '8'
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||
Image.new('L', (640, 480), 255), {}, respond)
|
||||
# Under the cap: dimensions pass through untouched; with no requested count
|
||||
# sent the moderate default (4) is used.
|
||||
assert seen['size'] == '640x480'
|
||||
assert seen['steps'] == '4'
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
|
||||
"""A whole-image instruction edit may omit the mask; it must not raise
|
||||
KeyError -> 400 (the health-audit regression)."""
|
||||
seen = {}
|
||||
mask_bytes = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
# The provider must receive a (full-white) mask, not a missing one.
|
||||
import re as _re
|
||||
parts = request.content.split(b'--')
|
||||
for part in parts:
|
||||
if b'name="mask"' in part:
|
||||
head, _, body = part.partition(b'\r\n\r\n')
|
||||
mask_bytes['png'] = body.rstrip(b'\r\n-')
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||
None, {}, respond, include_mask=False)
|
||||
assert 'image' in out, out
|
||||
assert seen.get('size') == '32x32'
|
||||
# The synthesized mask is image-sized and all-white (whole image editable).
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
|
||||
assert mask_img.size == (32, 32)
|
||||
assert mask_img.getextrema() == (255, 255)
|
||||
|
||||
|
||||
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
|
||||
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
|
||||
KeyError traceback."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
|
||||
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'_endpoint': 'https://api.openai.com/v1',
|
||||
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
try:
|
||||
asyncio.run(route.endpoint(Request()))
|
||||
raise AssertionError('expected a 400 for a missing mask')
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
assert 'mask' in str(exc.detail).lower()
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
|
||||
"""A client-supplied request_id is forwarded to the provider so the client
|
||||
can poll GET /api/image/edit-progress for step progress."""
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
|
||||
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
|
||||
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
|
||||
assert 'image' in out, out
|
||||
assert seen.get('request_id') == 'job-777'
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the image-op progress contract (HARNESS-owned).
|
||||
|
||||
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
|
||||
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
|
||||
``/v1/images/progress/{request_id}`` route. When the provider has no such route
|
||||
the endpoint returns ``{"available": false}`` so the client keeps an
|
||||
indeterminate bar.
|
||||
"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
|
||||
def _route(path):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, query):
|
||||
self.query_params = query
|
||||
|
||||
|
||||
def _fake_db():
|
||||
class Db:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
return Db()
|
||||
|
||||
|
||||
def test_edit_progress_requires_request_id(monkeypatch):
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
route = _route("/api/image/edit-progress")
|
||||
try:
|
||||
asyncio.run(route.endpoint(_Req({})))
|
||||
except Exception as exc: # FastAPI HTTPException
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
else:
|
||||
raise AssertionError("expected a 400 for a missing request_id")
|
||||
|
||||
|
||||
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
route = _route("/api/image/edit-progress")
|
||||
try:
|
||||
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
else:
|
||||
raise AssertionError("expected a 400 for an unsafe request_id")
|
||||
|
||||
|
||||
def test_edit_progress_returns_provider_payload(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen["url"] = str(request.url)
|
||||
# Live seneca contract stages: loading | editing | compositing | done | error.
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
|
||||
assert out["available"] is True
|
||||
assert out["stage"] == "editing" # passed through unchanged
|
||||
assert out["step"] == 3 and out["steps"] == 8
|
||||
assert seen["url"].endswith("/images/progress/abc-123")
|
||||
|
||||
|
||||
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient",
|
||||
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
|
||||
)
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||
assert out["available"] is False
|
||||
|
||||
|
||||
def test_edit_progress_available_false_when_unreachable(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
|
||||
class Boom:
|
||||
async def __aenter__(self):
|
||||
raise httpx.ConnectError("nope")
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||
assert out["available"] is False
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Heavy in-process image ops must not load GB-scale models by default.
|
||||
|
||||
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
|
||||
models inside the web process and OOM-killed the host. They now default to a
|
||||
graceful 503, proxy to the seneca remote ops service when
|
||||
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
|
||||
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes.gallery import gallery_routes as gr
|
||||
|
||||
|
||||
HEAVY_ROUTES = [
|
||||
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
|
||||
("/api/image/mask", {"image": "x"}, "Smart mask"),
|
||||
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
|
||||
("/api/image/denoise", {"image": "x"}, "AI denoise"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
|
||||
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
|
||||
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_local_image_models_disabled_by_default():
|
||||
assert gr._local_image_models_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
|
||||
def test_local_image_models_enabled_env(monkeypatch, value):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||
assert gr._local_image_models_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
|
||||
def test_local_image_models_disabled_env(monkeypatch, value):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||
assert gr._local_image_models_enabled() is False
|
||||
|
||||
|
||||
def test_rss_mb_is_positive():
|
||||
assert gr._rss_mb() > 0
|
||||
|
||||
|
||||
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
|
||||
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
|
||||
def test_heavy_ops_503_when_disabled(client, path, body, label):
|
||||
resp = client.post(path, json=body)
|
||||
assert resp.status_code == 503
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "temporarily unavailable" in detail
|
||||
assert label in detail
|
||||
|
||||
|
||||
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
|
||||
import sys
|
||||
|
||||
monkeypatch.setitem(sys.modules, "rembg", None)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/image/remove-bg", json={"image": "x"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls = []
|
||||
response = _FakeResponse({})
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, files=None, data=None):
|
||||
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
|
||||
return _FakeAsyncClient.response
|
||||
|
||||
|
||||
def _client():
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
|
||||
)
|
||||
|
||||
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["image"] == "RB"
|
||||
url, kw = _FakeAsyncClient.calls[-1]
|
||||
assert url == "http://seneca.test:8188/v1/images/remove-background"
|
||||
assert "image" in kw["files"]
|
||||
|
||||
|
||||
def test_mask_proxies_to_remote_contract(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
|
||||
"model": "sam+grounding"}
|
||||
)
|
||||
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mask"] == "MASK"
|
||||
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
|
||||
assert body["bbox"] == [10, 20, 40, 60]
|
||||
url, kw = _FakeAsyncClient.calls[-1]
|
||||
assert url == "http://seneca.test:8188/v1/images/mask"
|
||||
assert kw["data"]["prompt"] == "person"
|
||||
|
||||
|
||||
def test_mask_picks_top_score_not_first(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"masks": [
|
||||
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
|
||||
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
|
||||
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
|
||||
], "model": "sam+grounding"}
|
||||
)
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mask"] == "HIGH"
|
||||
|
||||
|
||||
def test_mask_box_is_sent_as_xywh(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
|
||||
assert resp.status_code == 200
|
||||
_, kw = _FakeAsyncClient.calls[-1]
|
||||
assert kw["data"]["box"] == "10,20,30,40"
|
||||
|
||||
|
||||
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mask"] is None
|
||||
assert body["masks"] == []
|
||||
assert "Nothing selected" in body["error"]
|
||||
|
||||
|
||||
def test_remote_proxy_surfaces_error_status(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
|
||||
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||
assert resp.status_code == 503
|
||||
assert "temporarily unavailable" in resp.json().get("detail", "")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
|
||||
|
||||
Chat image generation resolves its model by name across every enabled endpoint,
|
||||
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
|
||||
OpenAI-compatible gateway works there. The editor's generative ops used a
|
||||
stricter image-only lookup and reported "No image generation endpoint
|
||||
configured" on those same setups. These tests pin the fallback that makes the
|
||||
editor find the same endpoint.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
class _Predicate:
|
||||
def __init__(self, check):
|
||||
self._check = check
|
||||
|
||||
def __call__(self, row):
|
||||
return self._check(row)
|
||||
|
||||
def __or__(self, other):
|
||||
return _Predicate(lambda row: self(row) or other(row))
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return _Predicate(lambda row: getattr(row, self.name) == value)
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
base_url = _Column("base_url")
|
||||
model_type = _Column("model_type")
|
||||
is_enabled = _Column("is_enabled")
|
||||
owner = _Column("owner")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *predicates):
|
||||
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def query(self, model):
|
||||
assert model is _ModelEndpoint
|
||||
return _Query(self._rows)
|
||||
|
||||
|
||||
def _ep(base_url, owner=None, *, enabled=True, model_type="chat", models=None):
|
||||
return SimpleNamespace(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
is_enabled=enabled,
|
||||
model_type=model_type,
|
||||
api_key="sk-secret",
|
||||
cached_models=models or [],
|
||||
pinned_models=[],
|
||||
)
|
||||
|
||||
|
||||
def _patch_model(monkeypatch):
|
||||
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
|
||||
|
||||
|
||||
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
|
||||
|
||||
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||
|
||||
assert ep is not None
|
||||
assert ep.base_url == "https://gateway.example.com/v1"
|
||||
|
||||
|
||||
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
|
||||
|
||||
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
|
||||
|
||||
|
||||
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [
|
||||
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
|
||||
_ep("https://images.example.com/v1", model_type="image"),
|
||||
]
|
||||
|
||||
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||
|
||||
assert ep is not None
|
||||
assert ep.model_type == "image"
|
||||
|
||||
|
||||
def test_unreachable_endpoint_reports_502():
|
||||
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
|
||||
|
||||
502 (gateway) is reserved for "the image server could not be reached"; 503 is
|
||||
reserved for "generation is switched off on the server", which the editor maps
|
||||
to its own fixed message.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
src = (
|
||||
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
|
||||
).read_text()
|
||||
assert "Image server unreachable" in src
|
||||
assert "except httpx.TransportError as exc:" in src
|
||||
assert "provider HTTP" in src
|
||||
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Source checks for the gallery Settings-tab image-model picker.
|
||||
|
||||
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
|
||||
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
|
||||
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_settings_tab_has_image_model_picker():
|
||||
assert 'id="gallery-image-model"' in GALLERY_JS
|
||||
assert "/api/editor/image-models" in GALLERY_JS
|
||||
assert "ge-ai-command-model" in GALLERY_JS
|
||||
|
||||
|
||||
def test_image_model_picker_lives_in_the_settings_container():
|
||||
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
|
||||
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
|
||||
assert picker_idx > settings_idx
|
||||
|
||||
|
||||
def test_picker_persists_with_the_shared_localstorage_key():
|
||||
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
|
||||
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GALLERY = ROOT / "static/js/gallery.js"
|
||||
|
||||
|
||||
def test_editor_landing_has_an_import_button_next_to_new_canvas():
|
||||
"""The editor landing must offer a direct "Import" action.
|
||||
|
||||
It sits in the same action row as "New canvas..." / "Browse gallery" and,
|
||||
on file pick, opens the editor on the chosen raster image (``openEditor``
|
||||
takes an image data URL as its first argument).
|
||||
"""
|
||||
src = GALLERY.read_text(encoding="utf-8")
|
||||
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
|
||||
# Button order: New canvas → Import → Browse gallery.
|
||||
new_at = src.index('id="gallery-editor-new"')
|
||||
import_at = src.index('id="gallery-editor-import"')
|
||||
pick_at = src.index('id="gallery-editor-pick"')
|
||||
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
|
||||
# Wiring opens the editor on the selected file's data URL.
|
||||
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
|
||||
assert "readAsDataURL(file)" in src
|
||||
assert "await openEditor(reader.result, null, null, name)" in src
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the seneca single-image op proxies (HARNESS-owned).
|
||||
|
||||
POST /api/image/replace-background -> provider /v1/images/replace-background
|
||||
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
|
||||
|
||||
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
|
||||
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
|
||||
return the provider image as ``{image}``.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
|
||||
|
||||
|
||||
def _route(path):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _fake_db():
|
||||
class Db:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
return Db()
|
||||
|
||||
|
||||
def _setup(monkeypatch, respond):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
seen = {}
|
||||
|
||||
def _respond(request):
|
||||
seen["url"] = str(request.url)
|
||||
seen["body"] = bytes(request.content)
|
||||
return respond(request)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient",
|
||||
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def _ok(_req):
|
||||
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
|
||||
|
||||
|
||||
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
|
||||
"harmonise": True, "shadow": False, "request_id": "rb-123",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/replace-background")
|
||||
body = seen["body"]
|
||||
assert b"put her on a beach" in body
|
||||
assert b"rb-123" in body # request_id forwarded for progress
|
||||
assert b"harmonise" in body and b"shadow" in body
|
||||
assert b"8" in body # steps
|
||||
|
||||
|
||||
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
|
||||
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
|
||||
"request_id": "op-9",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/outpaint")
|
||||
body = seen["body"]
|
||||
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
|
||||
assert token in body, token
|
||||
|
||||
|
||||
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/outpaint")
|
||||
|
||||
|
||||
def test_outpaint_reads_openai_style_url_result(monkeypatch):
|
||||
def _respond(_req):
|
||||
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
|
||||
|
||||
_setup(monkeypatch, _respond)
|
||||
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
|
||||
assert out["image"] == "ZZZZ"
|
||||
|
||||
|
||||
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
|
||||
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
|
||||
try:
|
||||
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 502
|
||||
else:
|
||||
raise AssertionError("expected a 502 when the provider returns no image")
|
||||
|
||||
|
||||
async def _async_return(value):
|
||||
return value
|
||||
|
||||
|
||||
def _capture_forward(monkeypatch):
|
||||
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
|
||||
captured = {}
|
||||
|
||||
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
|
||||
captured["label"] = label
|
||||
captured["provider_path"] = provider_path
|
||||
captured["timeout"] = timeout
|
||||
return {"image": "AAAA"}
|
||||
|
||||
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(
|
||||
gallery, "_first_visible_image_endpoint",
|
||||
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert captured["provider_path"] == "/images/outpaint"
|
||||
assert captured["timeout"] >= 900, captured
|
||||
|
||||
|
||||
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
|
||||
assert captured["timeout"] >= 900, captured
|
||||
|
||||
|
||||
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
|
||||
assert captured["timeout"] == 300.0, captured
|
||||
|
||||
|
||||
def test_outpaint_timeout_env_override(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
|
||||
assert gallery._outpaint_timeout() == 720.0
|
||||
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
|
||||
assert gallery._outpaint_timeout() == 900.0
|
||||
|
||||
|
||||
def test_graceful_shutdown_window_covers_a_long_outpaint():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
|
||||
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
|
||||
@@ -1,11 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, is_prequantized
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
# Rank authored inputs rather than publication catalog snapshots.
|
||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
||||
|
||||
|
||||
def _8gb_vram_system():
|
||||
@@ -20,63 +14,38 @@ def _8gb_vram_system():
|
||||
}
|
||||
|
||||
|
||||
GEMMA = "google/gemma-4-12B-it"
|
||||
def test_gemma4_12b_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
|
||||
|
||||
|
||||
def _input_row(rows, name):
|
||||
return next(r for r in rows if r["name"] == name)
|
||||
|
||||
|
||||
def _qat_rows(rows):
|
||||
return [r for r in rows if r["name"].startswith(GEMMA + "-qat-")]
|
||||
|
||||
|
||||
def test_gemma4_12b_user_catalog_row_wins_the_merge(publication_catalog, monkeypatch):
|
||||
"""A dynamic cache listing the same repo must not replace or duplicate the
|
||||
user-catalog row; rows only the cache knows are still merged in."""
|
||||
from services.hwfit import hf_discovery
|
||||
|
||||
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [
|
||||
{"name": GEMMA, "quantization": "F16", "gguf_sources": [{"repo": "test/cache-GGUF", "file": "cache.gguf"}]},
|
||||
{"name": "test/cache-only", "quantization": "F16", "gguf_sources": []},
|
||||
])
|
||||
merged = get_models()
|
||||
names = [m["name"] for m in merged]
|
||||
|
||||
assert names.count(GEMMA) == 1
|
||||
assert "test/cache-only" in names
|
||||
entry = next(m for m in merged if m["name"] == GEMMA)
|
||||
assert entry["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
|
||||
|
||||
|
||||
def test_gemma4_12b_ranked_row_carries_its_gguf_source(publication_catalog):
|
||||
hit = next(r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20) if r["name"] == GEMMA)
|
||||
assert hit["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
|
||||
# The F16 input does not fit 8 GB, so the fit falls back to a GGUF quant.
|
||||
assert hit["quant"] == "Q4_K_M"
|
||||
def test_gemma4_12b_has_gguf_source():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
entry = catalog["google/gemma-4-12B-it"]
|
||||
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
|
||||
repos = [s["repo"] for s in entry["gguf_sources"]]
|
||||
assert "unsloth/gemma-4-12B-it-GGUF" in repos
|
||||
|
||||
|
||||
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
||||
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
||||
names = [r["name"] for r in results]
|
||||
assert GEMMA in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_rank_with_their_native_quant(publication_catalog):
|
||||
qat = _qat_rows(publication_catalog)
|
||||
assert len(qat) == 2
|
||||
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
|
||||
for row in qat:
|
||||
assert ranked[row["name"]]["quant"] == row["quantization"]
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_are_prequantized(publication_catalog):
|
||||
def test_gemma4_12b_qat_entries_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
for row in _qat_rows(publication_catalog):
|
||||
assert is_prequantized(catalog[row["name"]])
|
||||
assert "google/gemma-4-12B-it-qat-int4" in catalog
|
||||
assert "google/gemma-4-12B-it-qat-int8" in catalog
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_have_no_gguf(publication_catalog):
|
||||
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
|
||||
for row in _qat_rows(publication_catalog):
|
||||
assert ranked[row["name"]]["gguf_sources"] == []
|
||||
def test_gemma4_12b_qat_entries_are_prequantized():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_have_no_gguf():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
|
||||
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
|
||||
|
||||
@@ -3,33 +3,8 @@
|
||||
The handler did `n = int(gpu_count)` with no guard, so `?gpu_count=abc` (or any
|
||||
non-integer) raised ValueError -> HTTP 500. A malformed count is now ignored,
|
||||
matching how the neighbouring gpu_group param is already parsed.
|
||||
|
||||
The count parsing runs only after the empty-catalog early return, so these
|
||||
tests rank the authored publication catalog against a fixed two-GPU system and
|
||||
spy on rank_models to prove the parsing path actually ran.
|
||||
"""
|
||||
import functools
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from routes.hwfit_routes import setup_hwfit_routes
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
SYSTEM = {
|
||||
"has_gpu": True,
|
||||
"backend": "cuda",
|
||||
"gpu_name": "Synthetic GPU",
|
||||
"gpu_vram_gb": 24.0,
|
||||
"gpu_count": 2,
|
||||
"gpus": [
|
||||
{"index": 0, "name": "Synthetic GPU", "vram_gb": 12.0},
|
||||
{"index": 1, "name": "Synthetic GPU", "vram_gb": 12.0},
|
||||
],
|
||||
"gpu_groups": [{"name": "Synthetic GPU", "vram_each": 12.0, "count": 2, "indices": [0, 1], "vram_total": 24.0}],
|
||||
"available_ram_gb": 32.0,
|
||||
"total_ram_gb": 32.0,
|
||||
}
|
||||
|
||||
|
||||
def _get_models():
|
||||
@@ -40,80 +15,24 @@ def _get_models():
|
||||
raise AssertionError("hwfit /models route not found")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ranked_systems(publication_catalog, monkeypatch):
|
||||
"""Fix detection and record every system the handler hands to rank_models."""
|
||||
from services.hwfit import fit, hardware
|
||||
|
||||
monkeypatch.setattr(hardware, "detect_system", lambda **_: deepcopy(SYSTEM))
|
||||
calls = []
|
||||
real_rank = fit.rank_models
|
||||
|
||||
@functools.wraps(real_rank)
|
||||
def spy(system, **kwargs):
|
||||
calls.append(deepcopy(system))
|
||||
return real_rank(system, **kwargs)
|
||||
|
||||
monkeypatch.setattr(fit, "rank_models", spy)
|
||||
return calls
|
||||
|
||||
|
||||
def _assert_ranked(result, ranked_systems):
|
||||
assert isinstance(result, dict)
|
||||
assert "error" not in result
|
||||
assert isinstance(result["models"], list) and result["models"]
|
||||
# Reaching rank_models means the empty-catalog return was not taken and the
|
||||
# manual-hardware and gpu_count parsing ran first.
|
||||
assert len(ranked_systems) == 1
|
||||
assert ranked_systems[0] == result["system"]
|
||||
return result["system"]
|
||||
|
||||
|
||||
def test_non_numeric_gpu_count_does_not_raise(ranked_systems):
|
||||
def test_non_numeric_gpu_count_does_not_raise():
|
||||
handler = _get_models()
|
||||
# Previously raised ValueError (HTTP 500); now degrades to a normal ranking.
|
||||
system = _assert_ranked(handler(gpu_count="abc"), ranked_systems)
|
||||
# Ignored like an omitted count: the auto pool keeps both GPUs and no
|
||||
# explicit-count GPU-only pin is applied.
|
||||
assert system["detected_gpu_count"] == 2
|
||||
assert system["active_group"]["use_count"] == 2
|
||||
assert system["gpu_count"] == 2
|
||||
assert "gpu_only" not in system
|
||||
result = handler(gpu_count="abc")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_numeric_gpu_count_still_accepted(ranked_systems):
|
||||
def test_numeric_gpu_count_still_accepted():
|
||||
handler = _get_models()
|
||||
system = _assert_ranked(handler(gpu_count="0"), ranked_systems)
|
||||
# 0 switches to RAM-only ranking.
|
||||
assert system["detected_gpu_count"] == 2
|
||||
assert system["has_gpu"] is False
|
||||
assert system["gpu_count"] == 0
|
||||
assert system["gpu_only"] is False
|
||||
assert "active_group" not in system
|
||||
result = handler(gpu_count="0")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_non_numeric_manual_gpu_count_does_not_raise(ranked_systems):
|
||||
def test_non_numeric_manual_gpu_count_does_not_raise():
|
||||
# manual_gpu_count is the other count param on this endpoint (the hardware
|
||||
# simulator in _apply_manual_hardware). A non-numeric value must also degrade
|
||||
# (default to 1) rather than 500, so the endpoint's count parsing is fully
|
||||
# covered.
|
||||
handler = _get_models()
|
||||
system = _assert_ranked(handler(manual_mode="gpu", manual_gpu_count="abc"), ranked_systems)
|
||||
assert system["manual_hardware"] is True
|
||||
assert system["gpu_name"] == "Simulated CUDA GPU"
|
||||
assert system["gpu_count"] == 1
|
||||
assert system["gpu_groups"][0]["count"] == 1
|
||||
assert system["detected_gpu_count"] == 1
|
||||
|
||||
|
||||
def test_empty_catalog_returns_before_count_parsing(ranked_systems, monkeypatch):
|
||||
# Control: without catalog rows the handler stops before the parsing above,
|
||||
# which is why the tests in this module must rank the authored catalog.
|
||||
from services.hwfit import models
|
||||
|
||||
monkeypatch.setattr(models, "get_models", lambda: [])
|
||||
result = _get_models()(gpu_count="abc", manual_mode="gpu", manual_gpu_count="abc")
|
||||
assert result["models"] == []
|
||||
assert "Model catalog is empty" in result["error"]
|
||||
assert ranked_systems == []
|
||||
assert "detected_gpu_count" not in result["system"]
|
||||
result = handler(manual_mode="gpu", manual_gpu_count="abc")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
+10
-17
@@ -6,15 +6,9 @@ guarantee that non-macOS (Linux/Windows) detection is unchanged.
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.hwfit import hardware
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
# Rank authored inputs rather than publication catalog snapshots.
|
||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
||||
|
||||
|
||||
def _metal_system(ram_gb=16.0, vram_gb=10.7):
|
||||
@@ -84,20 +78,19 @@ def test_only_gguf_or_mlx_models_recommended_on_metal():
|
||||
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
|
||||
|
||||
|
||||
def test_qwen_gguf_rows_rank_with_their_gguf_downloads_on_metal(publication_catalog):
|
||||
"""Qwen GGUF-looking Cookbook rows must download the GGUF repo/file their
|
||||
catalog row names, not the base safetensors repository."""
|
||||
def test_qwen_catalog_entries_point_at_verified_gguf_repos():
|
||||
"""Qwen GGUF-looking Cookbook rows must download GGUF repos, not the base
|
||||
safetensors repositories."""
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
expected = {
|
||||
r["name"]: r["gguf_sources"]
|
||||
for r in publication_catalog
|
||||
if r["name"].startswith("Qwen/Qwen3.") and r["gguf_sources"]
|
||||
"Qwen/Qwen3.5-9B": ("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"),
|
||||
"Qwen/Qwen3.6-27B": ("unsloth/Qwen3.6-27B-GGUF", "Qwen3.6-27B-Q4_K_M.gguf"),
|
||||
"Qwen/Qwen3.6-35B-A3B": ("unsloth/Qwen3.6-35B-A3B-GGUF", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"),
|
||||
}
|
||||
assert len(expected) == 3
|
||||
ranked = {r["name"]: r for r in rank_models(_metal_system(), search="Qwen/Qwen3.", limit=50)}
|
||||
|
||||
for model_name, sources in expected.items():
|
||||
assert ranked[model_name]["gguf_sources"] == sources
|
||||
assert all(src["repo"] != model_name and src["file"].endswith(".gguf") for src in ranked[model_name]["gguf_sources"])
|
||||
for model_name, (repo, filename) in expected.items():
|
||||
sources = catalog[model_name].get("gguf_sources") or []
|
||||
assert any(src.get("repo") == repo and src.get("file") == filename for src in sources)
|
||||
|
||||
|
||||
def test_safetensors_models_still_recommended_on_cuda():
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from services.hwfit.fit import analyze_model, rank_models
|
||||
from services.hwfit.models import (
|
||||
get_models,
|
||||
infer_quantization_from_name,
|
||||
is_prequantized,
|
||||
)
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
# Rank authored inputs rather than publication catalog snapshots.
|
||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
||||
|
||||
|
||||
def _dual_5060ti_system():
|
||||
@@ -41,7 +35,7 @@ def test_infers_native_hf_quant_formats_from_repo_names():
|
||||
|
||||
def test_nvfp4_catalog_quant_is_preserved():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
model = catalog["test/native-NVFP4"]
|
||||
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
|
||||
|
||||
assert model["quantization"] == "NVFP4"
|
||||
assert is_prequantized(model)
|
||||
@@ -49,7 +43,7 @@ def test_nvfp4_catalog_quant_is_preserved():
|
||||
|
||||
def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
model = catalog["test/native-NVFP4"]
|
||||
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
|
||||
|
||||
fit = analyze_model(model, _dual_5060ti_system())
|
||||
assert fit["quant"] == "NVFP4"
|
||||
@@ -57,10 +51,10 @@ def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
||||
|
||||
results = rank_models(
|
||||
_dual_5060ti_system(),
|
||||
search="native-NVFP4",
|
||||
search="Qwen3.5-122B-A10B-NVFP4",
|
||||
limit=10,
|
||||
)
|
||||
hit = next(r for r in results if r["name"] == "test/native-NVFP4")
|
||||
hit = next(r for r in results if r["name"] == "txn545/Qwen3.5-122B-A10B-NVFP4")
|
||||
assert hit["quant"] == "NVFP4"
|
||||
assert hit["run_mode"] != "cpu_offload"
|
||||
|
||||
|
||||
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('incognito', [False, True])
|
||||
@pytest.mark.parametrize('exit_code', [None, 0])
|
||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
|
||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
|
||||
from types import SimpleNamespace
|
||||
from src import ai_interaction, tool_execution
|
||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
||||
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
_, result = await execute(block, owner='pewds', session_id='fixture',
|
||||
client_runtime_context={'web_ui_state': {'incognito': incognito}},
|
||||
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
assert denied['exit_code'] != 0
|
||||
assert journal.actions[0].execution_id is None
|
||||
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
assert journal.actions[1].execution_id is not None
|
||||
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
|
||||
assert result['image_id'] == 'test'
|
||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
|
||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""The heavy image-edit CPU work must run off the event loop, and the app must
|
||||
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
|
||||
not drop a 60-150 s edit."""
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from routes.gallery import gallery_routes as gr
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _png_b64(w, h, color=(120, 160, 210)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def test_prepare_local_edit_caps_large_input():
|
||||
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
|
||||
assert orig == (4000, 3000)
|
||||
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
|
||||
assert mask # a full-white mask is synthesised when none is supplied
|
||||
assert Image.open(io.BytesIO(src)).size == (mw, mh)
|
||||
|
||||
|
||||
def test_prepare_local_edit_leaves_small_input_unchanged():
|
||||
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
|
||||
assert orig == (96, 96) and (mw, mh) == (96, 96)
|
||||
|
||||
|
||||
def test_restore_local_b64_resizes_back_to_original():
|
||||
raw = _png_b64(768, 576)
|
||||
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
|
||||
# a same-size result is returned untouched
|
||||
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
|
||||
|
||||
|
||||
def test_inpaint_offloads_cpu_work_to_a_thread():
|
||||
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
|
||||
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
|
||||
assert "asyncio.to_thread(\n _restore_local_b64" in src
|
||||
|
||||
|
||||
def test_app_exposes_busy_count_and_graceful_shutdown():
|
||||
src = (ROOT / "app.py").read_text()
|
||||
assert "busy_image_ops" in src
|
||||
assert '@app.get("/api/busy")' in src
|
||||
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
|
||||
assert "timeout_graceful_shutdown" in src
|
||||
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
|
||||
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
|
||||
assert 'disabled' in result['error']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('editing', [False, True])
|
||||
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://openrouter.ai/api/v1')
|
||||
if editing:
|
||||
result = asyncio.run(ai_interaction.do_edit_image(
|
||||
'Private edit', str(source), model_spec='openai/gpt-5-image',
|
||||
owner='alice', session_id='private-chat', incognito=True))
|
||||
else:
|
||||
result = asyncio.run(ai_interaction.do_generate_image(
|
||||
'Private image\nopenai/gpt-5-image', owner='alice',
|
||||
session_id='private-chat', incognito=True))
|
||||
assert 'error' not in result
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
|
||||
assert not result['image_id']
|
||||
assert not rows
|
||||
assert not (source.parent / 'generated').exists()
|
||||
|
||||
|
||||
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
|
||||
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
||||
result = asyncio.run(ai_interaction.do_generate_image(
|
||||
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert not rows
|
||||
assert not (source.parent / 'generated').exists()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_transport(monkeypatch, tmp_path):
|
||||
rows = []
|
||||
|
||||
@@ -281,7 +281,7 @@ def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
|
||||
))
|
||||
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
|
||||
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", lambda *a, **kw: conn)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
|
||||
|
||||
raised = False
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Scoped ``photo_editor`` capability family: routing, tools, and the
|
||||
Quick Edit agent endpoint.
|
||||
|
||||
The family is context-only: it is offered to the model ONLY when an editor
|
||||
session / edit-this-photo request explicitly selects it, never inferred from
|
||||
message text and never part of the general tool list.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from src.turn_contract import (
|
||||
FAMILY_TOOLS,
|
||||
requested_capabilities,
|
||||
resolve_full_inventory_contract,
|
||||
resolve_turn_contract,
|
||||
)
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.tool_types import TOOL_TAGS
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
from src.tool_index import BUILTIN_TOOL_DESCRIPTIONS
|
||||
from src.tool_capabilities import KNOWN_CAPABILITY_TOOLS
|
||||
from src.tool_security import PLAN_MODE_READONLY_TOOLS, _PLAN_MODE_KNOWN_MUTATORS
|
||||
|
||||
PHOTO_TOOLS = frozenset(
|
||||
{"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image"}
|
||||
)
|
||||
FAMILY = FAMILY_TOOLS["photo_editor"]
|
||||
CORE_NAMES = frozenset({"bash", "python", "web_search", "web_fetch", "write_file"})
|
||||
|
||||
|
||||
def _schema_names():
|
||||
return {s["function"]["name"] for s in FUNCTION_TOOL_SCHEMAS}
|
||||
|
||||
|
||||
def test_family_contains_scoped_tools_only():
|
||||
assert FAMILY == PHOTO_TOOLS | {"generate_image"}
|
||||
|
||||
|
||||
def test_contract_offers_only_editor_tools_and_ask_user():
|
||||
contract = resolve_turn_contract(
|
||||
capabilities={"photo_editor"},
|
||||
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=ToolPolicy(),
|
||||
required_capabilities={"photo_editor"},
|
||||
)
|
||||
assert contract.offered == FAMILY | {"ask_user", "update_plan"}
|
||||
assert not (CORE_NAMES & contract.offered)
|
||||
|
||||
|
||||
def test_photo_editor_is_never_inferred_from_message_text():
|
||||
for message in (
|
||||
"make it black and white",
|
||||
"generate an image of a cat",
|
||||
"remove the person on the left",
|
||||
"add a full moon in the sky",
|
||||
"blur the background behind the person",
|
||||
):
|
||||
caps = requested_capabilities(message)
|
||||
assert "photo_editor" not in caps, message
|
||||
|
||||
|
||||
def test_generate_image_keeps_its_own_family_without_leaking_photo_editor():
|
||||
caps = requested_capabilities("Create a note and generate an image of a cat")
|
||||
assert "image_generation" in caps
|
||||
assert "photo_editor" not in caps
|
||||
|
||||
|
||||
def test_editor_tools_are_registered_everywhere():
|
||||
schema_names = _schema_names()
|
||||
for name in PHOTO_TOOLS:
|
||||
assert name in TOOL_TAGS, name
|
||||
assert name in TOOL_HANDLERS, name
|
||||
assert name in BUILTIN_TOOL_DESCRIPTIONS, name
|
||||
assert name in KNOWN_CAPABILITY_TOOLS, name
|
||||
assert name in schema_names, name
|
||||
assert "generate_image" in FAMILY
|
||||
|
||||
|
||||
def test_editor_tools_plan_mode_partition():
|
||||
assert "get_editor_screenshot" in PLAN_MODE_READONLY_TOOLS
|
||||
for name in ("plan_edit", "run_editor_command", "ai_edit_image"):
|
||||
assert name in _PLAN_MODE_KNOWN_MUTATORS, name
|
||||
|
||||
|
||||
def test_full_inventory_contains_the_editor_tools():
|
||||
preview = resolve_full_inventory_contract(
|
||||
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()
|
||||
)
|
||||
assert FAMILY <= set(preview.offered)
|
||||
|
||||
|
||||
def test_scope_preview_contract_keeps_only_the_editor_family():
|
||||
from dataclasses import replace
|
||||
|
||||
from src.clean_agent_preview import scope_preview_contract
|
||||
|
||||
preview = resolve_full_inventory_contract(
|
||||
schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()
|
||||
)
|
||||
routed = resolve_turn_contract(
|
||||
capabilities={"photo_editor"},
|
||||
schemas=FUNCTION_TOOL_SCHEMAS,
|
||||
policy=ToolPolicy(),
|
||||
required_capabilities={"photo_editor"},
|
||||
)
|
||||
scoped = scope_preview_contract(
|
||||
replace(preview, selection_mode="full_compact_experiment"),
|
||||
routed,
|
||||
frozenset({"photo_editor"}),
|
||||
extra_tools=frozenset({"ask_user"}),
|
||||
)
|
||||
assert FAMILY <= set(scoped.offered)
|
||||
assert "ask_user" in scoped.offered
|
||||
assert not (CORE_NAMES & set(scoped.offered))
|
||||
|
||||
|
||||
def test_editor_agent_endpoint_reports_scoped_tools():
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routes.editor_agent_routes as eaar
|
||||
|
||||
eaar.set_editor_planner(lambda messages, owner: {"reply": "ok", "steps": [], "done": True})
|
||||
try:
|
||||
app = FastAPI()
|
||||
app.include_router(eaar.setup_editor_agent_routes())
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/editor/agent",
|
||||
json={"prompt": "hello there", "session": "s1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert PHOTO_TOOLS <= set(data["tools_offered"])
|
||||
assert "generate_image" in data["tools_offered"]
|
||||
assert not (CORE_NAMES & set(data["tools_offered"]))
|
||||
assert data["trace"][0]["type"] == "session"
|
||||
finally:
|
||||
eaar.set_editor_planner(None)
|
||||
|
||||
|
||||
async def _route_contract(monkeypatch, message, *, attachment_meta=()):
|
||||
from routes import chat_routes
|
||||
from src import tool_security
|
||||
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
|
||||
|
||||
with monkeypatch.context() as route_patch:
|
||||
endpoint = _chat_stream_endpoint(
|
||||
route_patch, "agent", {},
|
||||
session_model="odysseus-qwen3.5-tools-pre-heretic",
|
||||
attachment_meta=attachment_meta,
|
||||
)
|
||||
route_patch.setattr(
|
||||
chat_routes, "coerce_message_and_session",
|
||||
lambda *args, **kwargs: (message, "session-1"),
|
||||
)
|
||||
route_patch.setattr(
|
||||
tool_security, "owner_is_admin_or_single_user", lambda owner: True,
|
||||
)
|
||||
observed = []
|
||||
|
||||
async def capture_agent(*args, **kwargs):
|
||||
observed.append(kwargs["turn_contract"])
|
||||
yield 'data: {"delta":"ok"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
route_patch.setattr(chat_routes, "stream_agent_loop", capture_agent)
|
||||
request = _RouteRequest("agent")
|
||||
request._form.update({
|
||||
"message": message, "compare_mode": "false",
|
||||
"allow_web_search": "false", "allow_bash": "false",
|
||||
})
|
||||
response = await endpoint(request)
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
assert len(observed) == 1
|
||||
return observed[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_chat_turn_offers_no_editor_tools(monkeypatch):
|
||||
for message in ("what is the capital of France", "write a haiku"):
|
||||
contract = await _route_contract(monkeypatch, message)
|
||||
assert "photo_editor" not in contract.capabilities, message
|
||||
assert not (PHOTO_TOOLS & set(contract.offered)), message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attached_photo_edit_selects_photo_editor_family(monkeypatch):
|
||||
contract = await _route_contract(
|
||||
monkeypatch,
|
||||
"make this black and white",
|
||||
attachment_meta=[{"id": "att1", "mime": "image/png", "name": "x.png"}],
|
||||
)
|
||||
assert contract.capabilities == {"photo_editor"}
|
||||
assert set(contract.offered) == FAMILY | {"ask_user"}
|
||||
assert not (CORE_NAMES & set(contract.offered))
|
||||
|
||||
|
||||
def test_chat_context_guides_open_or_absent_editor():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "The image editor is open (session " in src
|
||||
assert "no image editor is open" in src
|
||||
assert "the attached image in the editor (Quick Edit)" in src
|
||||
|
||||
|
||||
def test_generative_intent_classification():
|
||||
from routes.editor_agent_routes import _generative_kind
|
||||
|
||||
assert _generative_kind("remove the person on the left") == "remove"
|
||||
assert _generative_kind("erase the sign in the background") == "remove"
|
||||
assert _generative_kind("add a full moon in the sky") == "add"
|
||||
assert _generative_kind("put a bird on the fence") == "add"
|
||||
assert _generative_kind("replace the sky with a sunset") == "replace"
|
||||
assert _generative_kind("change the background to night") == "replace"
|
||||
# Non-generative edits must stay deterministic.
|
||||
assert _generative_kind("make it black and white") is None
|
||||
assert _generative_kind("make it brighter") is None
|
||||
assert _generative_kind("add a vignette") is None
|
||||
assert _generative_kind("remove the background") is None
|
||||
assert _generative_kind("sharpen the image") is None
|
||||
|
||||
|
||||
def test_generative_step_uses_ai_content_tools():
|
||||
from routes.editor_agent_routes import _generative_step
|
||||
|
||||
assert _generative_step("remove", "remove the person")["op"] == "ai_edit_image"
|
||||
assert _generative_step("replace", "replace the sky")["op"] == "ai_edit_image"
|
||||
# Adding content to an existing photo is an instruction edit, not generate_image.
|
||||
assert _generative_step("add", "add a moon")["op"] == "ai_edit_image"
|
||||
assert _generative_step("add", "add a moon")["params"]["mode"] == "instruction"
|
||||
# generate_image only for a brand-new image.
|
||||
assert _generative_step("add", "create a new image of a moon")["op"] == "generate_image"
|
||||
|
||||
|
||||
def test_generative_mode_instruction_vs_masked():
|
||||
from routes.editor_agent_routes import _generative_mode
|
||||
|
||||
# Masked only for an explicit regional removal of a clearly identified object.
|
||||
assert _generative_mode("remove", "remove the person on the left") == "masked"
|
||||
# Diffuse cleanup, replacement, season/style and additions use instruction mode.
|
||||
assert _generative_mode("remove", "remove the distractions") == "instruction"
|
||||
assert _generative_mode("replace", "make the background nicer") == "instruction"
|
||||
assert _generative_mode("add", "add a full moon in the sky") == "instruction"
|
||||
|
||||
|
||||
def test_ensure_generative_replaces_deterministic_approximations():
|
||||
from routes.editor_agent_routes import _ensure_generative
|
||||
|
||||
bad = [{"op": "selection.rectangle", "params": {}}, {"op": "adjustments.apply", "params": {}}]
|
||||
removal = _ensure_generative("remove the person on the left", list(bad))
|
||||
# A mask selection step now precedes the masked AI edit.
|
||||
assert removal[-1]["op"] == "ai_edit_image"
|
||||
assert removal[-1]["params"]["mode"] == "masked"
|
||||
assert all(s["op"].startswith("selection.") for s in removal[:-1])
|
||||
# Adding an object to an existing photo is an instruction edit (no selection).
|
||||
addition = _ensure_generative("add a full moon in the sky", list(bad))
|
||||
assert addition[-1]["op"] == "ai_edit_image"
|
||||
assert addition[-1]["params"]["mode"] == "instruction"
|
||||
# Non-generative plans are left untouched.
|
||||
assert _ensure_generative("make it black and white", list(bad)) == bad
|
||||
|
||||
|
||||
def test_fallback_plan_routes_generative_intents():
|
||||
import routes.editor_agent_routes as eaar
|
||||
|
||||
removal = eaar._fallback_plan("remove the person on the left")
|
||||
assert removal["steps"][-1]["op"] == "ai_edit_image"
|
||||
assert removal["steps"][-1]["params"]["mode"] == "masked"
|
||||
addition = eaar._fallback_plan("add a full moon in the sky")
|
||||
assert addition["steps"][-1]["op"] == "ai_edit_image"
|
||||
assert addition["steps"][-1]["params"]["mode"] == "instruction"
|
||||
plain = eaar._fallback_plan("make it black and white")
|
||||
assert plain["steps"][0]["op"] == "adjustments.apply"
|
||||
|
||||
|
||||
def test_provider_message_is_actionable():
|
||||
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
|
||||
assert "image generation provider" in IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
assert "Settings > Models" in IMAGE_PROVIDER_REQUIRED_MESSAGE
|
||||
|
||||
|
||||
def test_image_provider_respects_disabled_setting(monkeypatch):
|
||||
import src.settings as settings
|
||||
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": False})
|
||||
from src.ai_interaction import image_editing_provider_available
|
||||
|
||||
assert image_editing_provider_available(None) is False
|
||||
|
||||
|
||||
def _agent_client():
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routes.editor_agent_routes as eaar
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(eaar.setup_editor_agent_routes())
|
||||
return eaar, TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
["remove the person on the left", "add a full moon in the sky"],
|
||||
)
|
||||
def test_editor_agent_blocks_generative_without_provider(monkeypatch, prompt):
|
||||
"""No image provider -> zero canvas changes + a plain provider message."""
|
||||
eaar, client = _agent_client()
|
||||
# Planner proposes an unrelated deterministic edit, as the buggy run did.
|
||||
eaar.set_editor_planner(
|
||||
lambda messages, owner: {"reply": "done", "steps": [
|
||||
{"op": "selection.rectangle", "params": {}},
|
||||
{"op": "adjustments.apply", "params": {}},
|
||||
]}
|
||||
)
|
||||
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/editor/agent", json={"prompt": prompt, "session": "s1"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["needs_provider"] is True
|
||||
assert data["blocked"] is True
|
||||
assert "image generation provider" in data["message"]
|
||||
assert data["image_provider"] is False
|
||||
assert data["results"] == []
|
||||
# Nothing was executed through the bridge (zero canvas changes).
|
||||
assert not any(t.get("type") == "step" for t in data["trace"])
|
||||
assert any(t.get("reason") == "image_provider_unavailable" for t in data["trace"])
|
||||
# The proposed deterministic plan was replaced by the correct AI op.
|
||||
ops = [s["op"] for s in data["steps"]]
|
||||
assert ops[-1] in ("ai_edit_image", "generate_image")
|
||||
finally:
|
||||
eaar.set_editor_planner(None)
|
||||
|
||||
|
||||
def test_editor_agent_prefers_ai_op_over_deterministic_when_provider_present(monkeypatch):
|
||||
eaar, client = _agent_client()
|
||||
eaar.set_editor_planner(
|
||||
lambda messages, owner: {"reply": "done", "steps": [
|
||||
{"op": "selection.rectangle", "params": {}},
|
||||
{"op": "adjustments.apply", "params": {}},
|
||||
]}
|
||||
)
|
||||
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
||||
|
||||
async def _instant_wait_for(awaitable, *args, **kwargs):
|
||||
# The plan now starts with a mask-selection bridge step; resolve it
|
||||
# immediately instead of waiting for a (non-existent) editor subscriber.
|
||||
try:
|
||||
awaitable.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "result": {"width": 100, "height": 100}}
|
||||
|
||||
monkeypatch.setattr(eaar.asyncio, "wait_for", _instant_wait_for)
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeAiEdit:
|
||||
async def execute(self, content, ctx):
|
||||
calls.append(content)
|
||||
return "ai_edit_image: fake", {"output": "ok", "exit_code": 0}
|
||||
|
||||
class _FakeGen:
|
||||
async def execute(self, content, ctx):
|
||||
calls.append(content)
|
||||
return "generate_image: fake", {"output": "ok", "exit_code": 0}
|
||||
|
||||
import src.agent_tools.editor_tools as et
|
||||
monkeypatch.setattr(et, "AiEditImageTool", _FakeAiEdit)
|
||||
monkeypatch.setattr(et, "GenerateImageLayerTool", _FakeGen)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/editor/agent",
|
||||
json={"prompt": "remove the person on the left", "session": "s1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
ops = [s["op"] for s in data["steps"]]
|
||||
assert ops[-1] == "ai_edit_image"
|
||||
assert any(r.get("op") == "ai_edit_image" for r in data["results"])
|
||||
assert calls, "the AI edit tool should have been invoked"
|
||||
assert "adjustments.apply" not in ops
|
||||
finally:
|
||||
eaar.set_editor_planner(None)
|
||||
|
||||
|
||||
def test_editor_prompts_advertise_canonical_shapes():
|
||||
import routes.editor_agent_routes as eaar
|
||||
|
||||
canonical = eaar._CANONICAL_GUIDANCE
|
||||
assert "brightness-contrast" in canonical and "render-clouds" in canonical
|
||||
assert canonical in eaar._SYSTEM_PROMPT
|
||||
assert canonical in eaar._CHECK_SYSTEM_PROMPT
|
||||
assert "_CANONICAL_GUIDANCE" in inspect.getsource(eaar.setup_editor_agent_routes)
|
||||
|
||||
|
||||
def test_run_editor_command_schema_advertises_canonical_shapes():
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
|
||||
schema = next(s for s in FUNCTION_TOOL_SCHEMAS
|
||||
if s["function"]["name"] == "run_editor_command")
|
||||
desc = schema["function"]["description"]
|
||||
assert "brightness-contrast" in desc and "render-clouds" in desc
|
||||
assert "vignette" in desc # names the banned invented ids
|
||||
|
||||
|
||||
def test_photo_editor_chat_without_canvas_asks_to_open_editor():
|
||||
from src import clean_agent_preview
|
||||
|
||||
source = inspect.getsource(clean_agent_preview.stream_preview)
|
||||
assert "not open on the editor canvas" in source
|
||||
assert "'photo_editor' in (getattr(turn_contract, 'active_capabilities'" in source
|
||||
|
||||
|
||||
def test_chat_photo_edit_without_open_editor_short_circuits(monkeypatch):
|
||||
"""A photo-edit chat turn with no open editor must not run the tool loop.
|
||||
|
||||
Regression: offering the editor tools with no canvas made the model loop
|
||||
on plan_edit/run_editor_command until the round cap and end with an empty
|
||||
answer. The runtime now answers deterministically and asks the user to
|
||||
open the image in the editor.
|
||||
"""
|
||||
import asyncio
|
||||
import json as _json
|
||||
from dataclasses import replace
|
||||
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
contract = replace(
|
||||
resolve_full_inventory_contract(schemas=FUNCTION_TOOL_SCHEMAS, policy=ToolPolicy()),
|
||||
active_capabilities=frozenset({"photo_editor"}),
|
||||
)
|
||||
provider_calls = []
|
||||
|
||||
class _Client:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
def stream(self, *args, **kwargs):
|
||||
provider_calls.append(kwargs.get("json"))
|
||||
raise AssertionError("provider must not be called when no editor is open")
|
||||
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", _Client)
|
||||
|
||||
async def _run():
|
||||
return [
|
||||
chunk
|
||||
async for chunk in module.stream_preview(
|
||||
endpoint_url="http://test",
|
||||
model="test",
|
||||
headers={},
|
||||
messages=[{"role": "user", "content": "make this black and white"}],
|
||||
turn_contract=contract,
|
||||
session_id="test",
|
||||
owner="test",
|
||||
disabled_tools=set(),
|
||||
tool_policy=ToolPolicy(),
|
||||
client_runtime_context={"web_ui_state": {"photo_editor_open": False}},
|
||||
)
|
||||
]
|
||||
|
||||
chunks = asyncio.run(_run())
|
||||
events = [
|
||||
_json.loads(chunk[6:])
|
||||
for chunk in chunks
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
|
||||
]
|
||||
|
||||
assert provider_calls == []
|
||||
contract_event = next(e for e in events if e.get("type") == "turn_contract")
|
||||
assert "photo_editor" in contract_event["active_capabilities"]
|
||||
final = next(e for e in events if e.get("type") == "final_response")
|
||||
lowered = final["content"].lower()
|
||||
assert "editor" in lowered and "open" in lowered
|
||||
# One-click affordance contract for the chat UI.
|
||||
assert final["photo_editor_action"] == {
|
||||
"kind": "open_and_apply",
|
||||
"prompt": "make this black and white",
|
||||
}
|
||||
assert not any(e.get("type") == "tool_start" for e in events)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Unit tests for the shared photo-editor style guide and its server wiring.
|
||||
|
||||
The guide (``src/editor_style_guide.py``) is the single source of truth for
|
||||
intent -> ops routing, calibrated strengths and the low-key / temperature /
|
||||
portrait / ambiguity rules. It feeds both the server planner and (via
|
||||
``GET /api/editor/style-guide``) the browser fallback.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routes.editor_agent_routes as eaar
|
||||
from src import editor_style_guide as guide
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_planner():
|
||||
eaar.set_editor_planner(None)
|
||||
yield
|
||||
eaar.set_editor_planner(None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(eaar.setup_editor_agent_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _ops(steps):
|
||||
return [s["op"] for s in steps]
|
||||
|
||||
|
||||
def test_new_ops_are_in_the_server_op_catalog():
|
||||
# If a new retouch/selection op is missing from OP_CATALOG, _parse_plan
|
||||
# filters a valid plan step out and the edit silently disappears.
|
||||
for name in guide.NEW_OP_NAMES:
|
||||
assert name in eaar.OP_CATALOG
|
||||
for name in ("photo.autoTone", "retouch.skin", "retouch.teeth",
|
||||
"selection.face", "selection.background"):
|
||||
assert name in eaar._OP_SET
|
||||
|
||||
|
||||
def test_portrait_skin_intent_is_a_multi_step_plan():
|
||||
plan = guide.fallback_plan("can you fix the skin a bit, look my best")
|
||||
assert plan is not None
|
||||
# No leading whole-image auto tone: it washes out the face. Go straight to a
|
||||
# gentle face retouch, then clear the selection.
|
||||
assert _ops(plan["steps"]) == [
|
||||
"selection.face", "retouch.skin", "selection.deselect",
|
||||
]
|
||||
skin = plan["steps"][1]["params"]
|
||||
assert 0 < skin["smooth"] <= 14
|
||||
assert 0 < skin["opacity"] <= 0.45
|
||||
# No global lift anywhere in the portrait chain (client never brightens skin).
|
||||
assert not any("autoTone" in op or "brightness" in op or "exposure" in op
|
||||
for op in _ops(plan["steps"]))
|
||||
|
||||
|
||||
def test_portrait_intent_does_not_lead_with_auto_tone():
|
||||
for prompt in ("make me look better", "fix the skin", "fix my teeth",
|
||||
"smooth my complexion", "portrait touch up"):
|
||||
plan = guide.fallback_plan(prompt)
|
||||
assert plan is not None, prompt
|
||||
assert "photo.autoTone" not in _ops(plan["steps"]), prompt
|
||||
|
||||
|
||||
def test_teeth_intent_selects_face_then_whitens():
|
||||
plan = guide.fallback_plan("fix my teeth")
|
||||
assert _ops(plan["steps"]) == ["selection.face", "retouch.teeth"]
|
||||
assert plan["steps"][1]["params"]["desat"] > 0
|
||||
|
||||
|
||||
def test_background_intent_is_selection_then_masked_blur():
|
||||
plan = guide.fallback_plan("blur the background / make the background nicer")
|
||||
ops = _ops(plan["steps"])
|
||||
assert ops == ["selection.background", "filters.applyMasked"]
|
||||
assert plan["steps"][1]["params"]["id"] == "blur-gaussian"
|
||||
|
||||
|
||||
def test_temperature_sign_is_cool_negative_warm_positive():
|
||||
cool = guide.fallback_plan("make it moodier")
|
||||
assert cool["steps"][0]["params"] == {
|
||||
"type": "white-balance", "params": {"temperature": -25}}
|
||||
warm = guide.fallback_plan("make it warmer, golden summer feel")
|
||||
assert warm["steps"][0]["params"]["params"]["temperature"] == 25
|
||||
|
||||
|
||||
def test_lighting_intent_uses_auto_tone_then_single_shadow_lift():
|
||||
plan = guide.fallback_plan("fix the lighting, it's too dark")
|
||||
assert _ops(plan["steps"]) == ["photo.autoTone", "adjustments.apply"]
|
||||
inner = plan["steps"][1]["params"]
|
||||
assert inner["type"] == "shadows-highlights"
|
||||
assert inner["params"]["highlights"] <= 0
|
||||
# The sanctioned low-key pair, never a stack of unspecified lifts.
|
||||
assert len(plan["steps"]) == 2
|
||||
|
||||
|
||||
def test_ambiguous_prompt_asks_the_user_with_concrete_options():
|
||||
plan = guide.fallback_plan("clean this up")
|
||||
assert plan["steps"] == []
|
||||
ask = plan["ask_user"]
|
||||
assert ask["question"]
|
||||
assert 2 <= len(ask["options"]) <= 3
|
||||
assert all(o.get("label") for o in ask["options"])
|
||||
assert guide.is_ambiguous("clean this up") is True
|
||||
# A concrete defect is not ambiguous.
|
||||
assert guide.is_ambiguous("fix the lighting") is False
|
||||
|
||||
|
||||
def test_collapse_global_lifts_replaces_a_stack():
|
||||
# With an auto tone present, extra brightness/exposure lifts are dropped
|
||||
# (the auto tone is the single sanctioned lift).
|
||||
with_auto = [
|
||||
{"op": "photo.autoTone", "params": {}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
||||
]
|
||||
out = guide.collapse_global_lifts(with_auto)
|
||||
assert len(out) == 1
|
||||
assert out[0]["op"] == "photo.autoTone"
|
||||
# Without an auto tone, a stack collapses to one safe shadows-highlights.
|
||||
no_auto = [
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
||||
]
|
||||
out2 = guide.collapse_global_lifts(no_auto)
|
||||
assert len(out2) == 1
|
||||
assert out2[0]["params"]["type"] == "shadows-highlights"
|
||||
assert out2[0]["params"]["params"]["highlights"] <= 0
|
||||
# The sanctioned low-key pair (auto tone + one shadows-highlights) is kept.
|
||||
pair = [
|
||||
{"op": "photo.autoTone", "params": {}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "shadows-highlights",
|
||||
"params": {"shadows": 30, "highlights": -15}}},
|
||||
]
|
||||
assert guide.collapse_global_lifts(pair) == pair
|
||||
|
||||
|
||||
def test_collapse_global_lifts_clamps_a_single_brightness_lift():
|
||||
steps = [{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 80}}}]
|
||||
out = guide.collapse_global_lifts(steps)
|
||||
assert out[0]["params"]["params"]["brightness"] <= 25
|
||||
|
||||
|
||||
def test_llm_plan_parse_collapses_lifts_and_keeps_new_ops():
|
||||
raw = (
|
||||
'{"reply": "x", "steps": ['
|
||||
'{"op": "retouch.skin", "params": {"smooth": 20}},'
|
||||
'{"op": "photo.autoTone", "params": {}},'
|
||||
'{"op": "adjustments.apply", "params": {"type": "exposure", "params": {"exposure": 25}}}'
|
||||
']}'
|
||||
)
|
||||
parsed = eaar._parse_plan(raw)
|
||||
ops = _ops(parsed["steps"])
|
||||
assert "retouch.skin" in ops
|
||||
# Every global lift that trails retouch.skin is dropped (the client guard
|
||||
# "never brighten skin"), so nothing but the retouch step remains.
|
||||
assert ops == ["retouch.skin"]
|
||||
|
||||
|
||||
def test_editor_agent_returns_ask_user_for_ambiguous_prompt(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: '{"reply": "ok", "steps": []}')
|
||||
resp = client.post("/api/editor/agent", json={"prompt": "clean this up"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body.get("ask_user_pending") is True
|
||||
assert body["steps"] == []
|
||||
assert 2 <= len(body["ask_user"]["options"]) <= 3
|
||||
|
||||
|
||||
def test_editor_plan_returns_ask_user_for_ambiguous_prompt(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: None)
|
||||
resp = client.post("/api/editor/plan", json={"prompt": "clean this up"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["steps"] == []
|
||||
assert body.get("ask_user")
|
||||
|
||||
|
||||
def test_style_guide_endpoint_serves_the_shared_source(client):
|
||||
resp = client.get("/api/editor/style-guide")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["version"] == guide.STYLE_GUIDE_VERSION
|
||||
assert {o["op"] for o in data["new_ops"]} == set(guide.NEW_OP_NAMES)
|
||||
assert data["intents"]
|
||||
assert "temperature" in data["rules"]["temperature_sign"].lower() or \
|
||||
"negative" in data["rules"]["temperature_sign"]
|
||||
# The prompt guidance states the sign rule explicitly.
|
||||
assert "NEGATIVE" in data["guidance"] and "POSITIVE" in data["guidance"]
|
||||
|
||||
|
||||
def test_prompts_embed_the_style_guidance():
|
||||
assert guide.guidance() in eaar._SYSTEM_PROMPT
|
||||
assert guide.guidance() in eaar._CHECK_SYSTEM_PROMPT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", [
|
||||
"make it pretty", "make it pop", "professional", "film look",
|
||||
])
|
||||
def test_generic_look_intent_is_a_decisive_multi_step_plan(prompt):
|
||||
plan = guide.fallback_plan(prompt)
|
||||
assert plan is not None
|
||||
ops = _ops(plan["steps"])
|
||||
assert ops[0] == "photo.autoTone"
|
||||
assert len(ops) >= 3
|
||||
# A visible vibrance lift and a local-contrast filter are present.
|
||||
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "vibrance"
|
||||
for s in plan["steps"])
|
||||
assert any(s["op"] == "filters.apply" and s["params"].get("id") == "clarity"
|
||||
for s in plan["steps"])
|
||||
|
||||
|
||||
def test_restore_intent_never_large_exposure_lift():
|
||||
plan = guide.fallback_plan("restore this old photo")
|
||||
assert plan is not None
|
||||
ops = _ops(plan["steps"])
|
||||
assert ops[0] == "photo.autoTone"
|
||||
assert "filters.apply" in ops # sharpen
|
||||
# Colour recovery present.
|
||||
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "white-balance"
|
||||
for s in plan["steps"])
|
||||
# No exposure lift at all, and the contrast lift keeps brightness neutral.
|
||||
assert not any(s["params"].get("type") == "exposure" for s in plan["steps"])
|
||||
for s in plan["steps"]:
|
||||
if s["params"].get("type") == "brightness-contrast":
|
||||
assert s["params"]["params"].get("brightness", 0) == 0
|
||||
|
||||
|
||||
def test_strengthen_promotes_a_timid_plan_to_the_recipe():
|
||||
timid = [{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}]
|
||||
promoted = guide.strengthen("make it pop", timid)
|
||||
assert promoted != timid
|
||||
assert _ops(promoted)[0] == "photo.autoTone"
|
||||
assert len(promoted) >= 3
|
||||
# A prompt with no recipe leaves the plan untouched.
|
||||
assert guide.strengthen("nonsense prompt zzz", timid) == timid
|
||||
|
||||
|
||||
def test_enforce_guards_removes_trailing_lift_after_skin():
|
||||
steps = [
|
||||
{"op": "selection.face", "params": {}},
|
||||
{"op": "retouch.skin", "params": {"smooth": 24}},
|
||||
{"op": "photo.autoTone", "params": {}},
|
||||
]
|
||||
guarded = guide.enforce_guards(steps)
|
||||
assert _ops(guarded) == ["selection.face", "retouch.skin"]
|
||||
stacked = [
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
||||
]
|
||||
collapsed = guide.enforce_guards(stacked)
|
||||
assert len(collapsed) == 1
|
||||
assert collapsed[0]["params"]["type"] == "shadows-highlights"
|
||||
|
||||
|
||||
def test_plan_route_follows_the_recipe_for_generic_look(client):
|
||||
import json as _json
|
||||
eaar.set_editor_planner(lambda messages, owner: _json.dumps({
|
||||
"reply": "ok",
|
||||
"steps": [{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}],
|
||||
}))
|
||||
resp = client.post("/api/editor/plan", json={"prompt": "make it pop"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["steps"][0]["op"] == "photo.autoTone"
|
||||
assert len(data["steps"]) >= 3
|
||||
assert data["planner"].endswith("+guide")
|
||||
|
||||
|
||||
def test_guidance_documents_look_and_restore_recipes():
|
||||
text = guide.guidance()
|
||||
low = text.lower()
|
||||
assert "clarity" in low and "vignette" in low
|
||||
assert "vibrance" in low
|
||||
assert "restore" in low and "exposure" in low
|
||||
assert "autoTone" in text
|
||||
|
||||
|
||||
def test_plan_special_format_crops_and_resizes():
|
||||
special = eaar._plan_special(
|
||||
"make a 1080x1350 Instagram post from this", {"width": 900, "height": 600}, False)
|
||||
assert special is not None
|
||||
steps, reply, note = special
|
||||
assert _ops(steps) == ["document.crop", "document.resizeCanvas"]
|
||||
assert "1080" in reply and "1350" in reply
|
||||
assert set(steps[0]["params"]) >= {"x", "y", "width", "height"}
|
||||
assert steps[1]["params"]["width"] == 1080 and steps[1]["params"]["height"] == 1350
|
||||
|
||||
|
||||
def test_plan_special_text_add_with_shadow_is_not_generative():
|
||||
assert eaar._generative_kind("add a title TOKYO with a drop shadow") is None
|
||||
special = eaar._plan_special("add a title TOKYO with a drop shadow", {}, False)
|
||||
assert special is not None
|
||||
steps, reply, note = special
|
||||
assert _ops(steps) == ["text.add", "layers.addStyle"]
|
||||
assert steps[0]["params"]["text"] == "TOKYO"
|
||||
assert steps[1]["params"]["type"] == "drop-shadow"
|
||||
assert "drop shadow" in reply.lower()
|
||||
|
||||
|
||||
def test_plan_special_extend_resizes_without_provider_generative_with():
|
||||
steps, reply, note = eaar._plan_special(
|
||||
"extend the canvas to 16:9", {"width": 900, "height": 600}, False)
|
||||
assert _ops(steps) == ["document.resizeCanvas"]
|
||||
assert "provider" in note.lower()
|
||||
steps2, _, note2 = eaar._plan_special(
|
||||
"extend the canvas to 16:9", {"width": 900, "height": 600}, True)
|
||||
assert _ops(steps2) == ["ai.generativeExpand"]
|
||||
assert note2 == ""
|
||||
|
||||
|
||||
def test_plan_special_psd_export():
|
||||
special = eaar._plan_special("save as PSD with layers", {}, False)
|
||||
assert special is not None and _ops(special[0]) == ["document.savePsd"]
|
||||
|
||||
|
||||
def test_background_replacement_is_generative_not_blur():
|
||||
assert eaar._generative_kind("put her on a beach") == "replace"
|
||||
assert eaar._generative_kind("remove the background and put her on a beach") == "replace"
|
||||
|
||||
|
||||
def test_verified_reply_corrects_a_false_size_claim():
|
||||
corrected = eaar._verified_reply(
|
||||
"make a 1080x1350 Instagram post",
|
||||
[{"op": "history.mark", "params": {}}],
|
||||
[{"ok": True}],
|
||||
{"width": 600, "height": 600},
|
||||
"Exported your 900x600 as a 1080x1350 Instagram post.",
|
||||
)
|
||||
assert "600" in corrected
|
||||
assert "still" in corrected.lower() or "didn't apply" in corrected.lower()
|
||||
passed = eaar._verified_reply(
|
||||
"make a 1080x1350 Instagram post",
|
||||
[{"op": "document.crop", "params": {}}, {"op": "document.resizeCanvas", "params": {}}],
|
||||
[{"ok": True}, {"ok": True}],
|
||||
{"width": 1080, "height": 1350},
|
||||
"Setting the canvas to 1080\u00d71350.",
|
||||
)
|
||||
assert "1080" in passed and "1350" in passed
|
||||
|
||||
|
||||
def test_plan_endpoint_returns_deterministic_format_steps(client, monkeypatch):
|
||||
eaar.set_editor_planner(lambda messages, owner: {"reply": "Exporting.", "steps": []})
|
||||
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
|
||||
resp = client.post("/api/editor/plan", json={
|
||||
"prompt": "make a 1080x1350 Instagram post",
|
||||
"state": {"width": 900, "height": 600},
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["planner"] == "deterministic"
|
||||
assert _ops(data["steps"])[:2] == ["document.crop", "document.resizeCanvas"]
|
||||
|
||||
|
||||
def test_style_guide_serves_format_text_psd_rules():
|
||||
rules = guide.as_json()["rules"]
|
||||
assert "format" in rules and "text" in rules and "psd" in rules
|
||||
assert guide.STYLE_GUIDE_VERSION >= 3
|
||||
|
||||
|
||||
# ── Quick Edit coverage: structural ops (server planner gaps) ──────────
|
||||
# Every prompt that failed the live Quick Edit coverage audit because the
|
||||
# server planner emitted a generic adjustments/transform step instead of the
|
||||
# exact structural op the browser supports.
|
||||
_STRUCTURAL_PROMPTS = [
|
||||
("fill the white area with red", "fill.flood"), # 1
|
||||
("flood fill the sky with blue", "fill.flood"), # 2
|
||||
("make the text bigger", "text.edit"), # 3
|
||||
("make the title red", "text.edit"), # 4
|
||||
("add a colour overlay", "layers.addStyle"), # 8
|
||||
("align the logo to the centre", "layers.align"), # 9
|
||||
("align the layer to the left", "layers.align"), # 10
|
||||
("rotate only this layer 90 degrees", "layer.rotate"), # 11
|
||||
("flip this layer horizontally", "layer.flip"), # 12
|
||||
("rotate the whole image 90 degrees", "document.rotate"), # 13
|
||||
("flip the canvas vertically", "document.flip"), # 14
|
||||
("copy the selection", "selection.copy"), # 19
|
||||
("paste", "selection.paste"), # 20
|
||||
("flatten the image", "layers.flatten"), # 40
|
||||
("merge the layer down", "layers.mergeDown"), # 41
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
|
||||
def test_structural_plan_routes_to_the_exact_op(prompt, expected_op):
|
||||
plan = guide.structural_plan(prompt)
|
||||
assert plan is not None, prompt
|
||||
assert plan["planner"] == "deterministic"
|
||||
assert expected_op in _ops(plan["steps"]), (prompt, _ops(plan["steps"]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
|
||||
def test_plan_special_emits_structural_ops(prompt, expected_op):
|
||||
# The server override path (_plan_special) must return the structural step,
|
||||
# otherwise a wrong model plan suppresses the browser fallback.
|
||||
special = eaar._plan_special(prompt, {"width": 900, "height": 600}, True)
|
||||
assert special is not None, prompt
|
||||
steps, reply, _ = special
|
||||
assert expected_op in _ops(steps), (prompt, _ops(steps))
|
||||
assert reply
|
||||
|
||||
|
||||
def test_structural_ops_are_in_the_server_op_catalog():
|
||||
for name in ("fill.flood", "selection.fill", "text.edit", "layers.align",
|
||||
"layer.rotate", "layer.flip", "document.rotate", "document.flip",
|
||||
"selection.copy", "selection.paste", "layers.flatten",
|
||||
"layers.mergeDown"):
|
||||
assert name in eaar._OP_SET, name
|
||||
|
||||
|
||||
def test_structural_plan_ignores_non_structural_intents():
|
||||
for prompt in ("make it brighter", "blur the background", "make it punchy",
|
||||
"restore this old photo", "smooth the skin", "sharpen it",
|
||||
"add the title \"Summer\"", "duplicate this layer"):
|
||||
assert guide.structural_plan(prompt) is None, prompt
|
||||
|
||||
|
||||
def test_structural_params_match_the_client():
|
||||
# Canonical params must match static/js/editor/galleryEditor.js control ops.
|
||||
fill = guide.structural_plan("fill the white area with red")["steps"][0]
|
||||
assert fill["op"] == "fill.flood" and fill["params"]["color"] == "#ff0000"
|
||||
sel = guide.structural_plan("fill the selection with blue")["steps"][0]
|
||||
assert sel["op"] == "selection.fill" and sel["params"]["color"] == "#0000ff"
|
||||
align = guide.structural_plan("align the layer to the left")["steps"][0]
|
||||
assert align["params"]["mode"] == "left"
|
||||
rot = guide.structural_plan("rotate only this layer 90 degrees")["steps"][0]
|
||||
assert rot["params"]["degrees"] == 90
|
||||
flip = guide.structural_plan("flip the canvas vertically")["steps"][0]
|
||||
assert flip["op"] == "document.flip" and flip["params"]["axis"] == "v"
|
||||
overlay = guide.structural_plan("add a colour overlay")["steps"][0]
|
||||
assert overlay["params"]["type"] == "color-overlay"
|
||||
edit = guide.structural_plan("make the text bigger")["steps"][0]
|
||||
assert edit["op"] == "text.edit" and edit["params"]["size"] > 0
|
||||
|
||||
|
||||
def test_plan_route_returns_structural_steps(client):
|
||||
resp = client.post("/api/editor/plan", json={
|
||||
"prompt": "flatten the image",
|
||||
"state": {"width": 900, "height": 600},
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["planner"] == "deterministic"
|
||||
assert "layers.flatten" in _ops(data["steps"])
|
||||
|
||||
|
||||
def test_style_guide_serves_structural_rule():
|
||||
rules = guide.as_json()["rules"]
|
||||
assert "structural" in rules
|
||||
assert guide.STYLE_GUIDE_VERSION >= 5
|
||||
|
||||
|
||||
# ── Targeted recolour + named reference looks ──────────────────────────
|
||||
|
||||
_RECOLOR_CASES = [
|
||||
("make only the red car blue", "red"),
|
||||
("recolour the red rock to teal", "red"),
|
||||
("change the yellow car to red", "yellow"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt,source", _RECOLOR_CASES)
|
||||
def test_targeted_recolor_selects_object_then_masked_hue(prompt, source):
|
||||
plan = guide.recolor_plan(prompt)
|
||||
assert plan is not None
|
||||
assert _ops(plan["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
||||
sel, adj = plan["steps"]
|
||||
assert sel["params"]["text"] # object description for the mask
|
||||
assert sel["params"]["fallbackColor"] == guide.color_for_name(source)
|
||||
assert adj["params"]["mode"] == "hue-saturation"
|
||||
assert "hue" in adj["params"] and isinstance(adj["params"]["hue"], int)
|
||||
|
||||
|
||||
def test_recolor_never_emits_selection_fill_or_a_global_tint():
|
||||
for prompt, _ in _RECOLOR_CASES:
|
||||
ops = _ops(guide.recolor_plan(prompt)["steps"])
|
||||
# A flat fill destroys shading; a whole-image tint is not targeted.
|
||||
assert "selection.fill" not in ops
|
||||
assert "adjustments.apply" not in ops # exact op, not applyMasked
|
||||
|
||||
|
||||
def test_targeted_recolor_hue_delta_is_source_to_target():
|
||||
# red (hue 0) -> blue (hue 240): shortest rotation is -120 degrees.
|
||||
plan = guide.recolor_plan("make only the red car blue")
|
||||
assert plan["steps"][1]["params"]["hue"] == -120
|
||||
|
||||
|
||||
def test_recolor_masked_hue_carries_source_hue():
|
||||
# red has hue 0; the client uses sourceHue as the rotation center so
|
||||
# neighbouring colours (chrome, windows, highlights) keep their own hue.
|
||||
plan = guide.recolor_plan("make only the red car blue")
|
||||
assert plan["steps"][1]["params"]["sourceHue"] == 0
|
||||
|
||||
|
||||
def test_enforce_guards_drops_global_lift_after_masked_recolor():
|
||||
# A recolour keeps shading; a later whole-image tone lift would tint the
|
||||
# rest of the photo, so enforce_guards must drop global lifts after it.
|
||||
steps = [
|
||||
{"op": "selection.samText", "params": {"text": "red car"}},
|
||||
{"op": "adjustments.applyMasked",
|
||||
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
|
||||
{"op": "photo.autoTone", "params": {}},
|
||||
]
|
||||
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
|
||||
|
||||
|
||||
def test_enforce_guards_drops_whole_image_grade_after_masked_recolor():
|
||||
# Not just tone "lifts": ANY whole-image adjustment (vibrance/saturation/
|
||||
# autoTone) after a masked recolour re-grades the photo and undoes the
|
||||
# "only this object" intent, so enforce_guards must drop them all.
|
||||
steps = [
|
||||
{"op": "selection.samText", "params": {"text": "red car"}},
|
||||
{"op": "adjustments.applyMasked",
|
||||
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "vibrance", "params": {"vibrance": 20}}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "hue-saturation", "params": {"saturation": 12}}},
|
||||
{"op": "photo.autoTone", "params": {}},
|
||||
]
|
||||
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
|
||||
|
||||
|
||||
def test_enforce_guards_keeps_grades_when_there_is_no_recolor():
|
||||
# A recipe/look (no masked recolour) still keeps its whole-image grades.
|
||||
steps = [
|
||||
{"op": "photo.autoTone", "params": {}},
|
||||
{"op": "adjustments.apply", "params": {"type": "vibrance", "params": {"vibrance": 18}}},
|
||||
{"op": "adjustments.apply",
|
||||
"params": {"type": "brightness-contrast", "params": {"brightness": 6, "contrast": -10}}},
|
||||
]
|
||||
assert _ops(guide.enforce_guards(steps)) == ["photo.autoTone", "adjustments.apply"]
|
||||
|
||||
|
||||
def test_recolor_to_white_uses_the_image_model_not_a_hue_shift():
|
||||
# A white/black/grey target cannot be reached by a hue rotation.
|
||||
plan = guide.recolor_plan("make the red car white")
|
||||
assert plan is not None
|
||||
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
|
||||
assert plan["steps"][1]["params"]["mode"] == "masked"
|
||||
|
||||
|
||||
def test_recolor_with_single_colour_uses_masked_image_model():
|
||||
plan = guide.recolor_plan("make the flowers bright pink")
|
||||
assert plan is not None
|
||||
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
|
||||
assert plan["steps"][0]["params"]["text"] == "flowers"
|
||||
params = plan["steps"][1]["params"]
|
||||
assert params["mode"] == "masked"
|
||||
assert params["steps"] == 8
|
||||
|
||||
|
||||
def test_recolor_does_not_hijack_unrelated_colour_prompts():
|
||||
for prompt in ("make it blue", "fill the white area with red",
|
||||
"make the title red", "make this black and white",
|
||||
"blur the background"):
|
||||
assert guide.recolor_plan(prompt) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", [
|
||||
"make it look like a Wes Anderson film",
|
||||
"make it look like a film noir",
|
||||
"make it look like vintage Kodachrome",
|
||||
])
|
||||
def test_named_recipe_looks_are_adjustment_stacks(prompt):
|
||||
plan = guide.look_plan(prompt)
|
||||
assert plan is not None
|
||||
ops = _ops(plan["steps"])
|
||||
assert ops and "ai_edit_image" not in ops
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", [
|
||||
"make it look like cyberpunk",
|
||||
"make it look like a Studio Ghibli film",
|
||||
])
|
||||
def test_strong_named_looks_use_instruction_mode(prompt):
|
||||
plan = guide.look_plan(prompt)
|
||||
assert plan is not None
|
||||
assert _ops(plan["steps"]) == ["ai_edit_image"]
|
||||
assert plan["steps"][0]["params"]["mode"] == "instruction"
|
||||
assert plan["steps"][0]["params"]["steps"] == 8
|
||||
|
||||
|
||||
def test_structural_plan_dispatches_looks_and_recolor():
|
||||
wes = guide.structural_plan("make it look like a Wes Anderson film")
|
||||
assert "adjustments.apply" in _ops(wes["steps"])
|
||||
recolor = guide.structural_plan("make only the red car blue")
|
||||
assert _ops(recolor["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
||||
|
||||
|
||||
def test_named_looks_do_not_hijack_generative_lookalikes():
|
||||
for prompt in ("make it look snowy", "make it look like summer"):
|
||||
assert guide.look_plan(prompt) is None
|
||||
|
||||
|
||||
def test_plan_route_returns_targeted_recolor(client):
|
||||
resp = client.post("/api/editor/plan", json={
|
||||
"prompt": "make only the red car blue",
|
||||
"state": {"width": 900, "height": 600},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["planner"] == "deterministic"
|
||||
assert _ops(data["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
||||
assert "selection.fill" not in _ops(data["steps"])
|
||||
|
||||
|
||||
def test_plan_route_returns_named_look(client):
|
||||
resp = client.post("/api/editor/plan", json={
|
||||
"prompt": "make it look like a Wes Anderson film",
|
||||
"state": {"width": 900, "height": 600},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["planner"] == "deterministic"
|
||||
assert "adjustments.apply" in _ops(data["steps"])
|
||||
|
||||
|
||||
def test_style_guide_serves_recolor_and_looks_rules():
|
||||
body = guide.as_json()
|
||||
assert "recolor" in body["rules"]
|
||||
assert "looks" in body["rules"]
|
||||
assert {lk["id"] for lk in body["looks"]} >= {
|
||||
"wes_anderson", "film_noir", "cyberpunk", "vintage_kodachrome",
|
||||
"studio_ghibli",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Graceful-shutdown signalling tests (HARNESS-owned).
|
||||
|
||||
SSE / streaming responses never complete on their own, so uvicorn's graceful
|
||||
shutdown would wait for an open browser stream up to the whole
|
||||
``timeout_graceful_shutdown`` window. src.shutdown is a process-wide flag that
|
||||
the SIGTERM/SIGINT handler flips and every streaming generator checks, so a
|
||||
restart ends open streams promptly while ordinary in-flight requests still
|
||||
finish.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from src import shutdown
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_request_shutdown_is_sticky_and_resettable():
|
||||
shutdown.reset()
|
||||
try:
|
||||
assert shutdown.is_shutting_down() is False
|
||||
shutdown.request_shutdown()
|
||||
assert shutdown.is_shutting_down() is True
|
||||
# idempotent
|
||||
shutdown.request_shutdown()
|
||||
assert shutdown.is_shutting_down() is True
|
||||
finally:
|
||||
shutdown.reset()
|
||||
assert shutdown.is_shutting_down() is False
|
||||
|
||||
|
||||
def test_app_installs_its_own_shutdown_handler():
|
||||
src = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
# This uvicorn version has no install_signal_handlers kwarg, so app.py wraps
|
||||
# server.handle_exit (which capture_signals wires SIGINT/SIGTERM to).
|
||||
assert "install_signal_handlers=False" not in src
|
||||
assert "server.handle_exit = _handle_shutdown_signal" in src
|
||||
assert "_shutdown.request_shutdown()" in src
|
||||
assert "server.run()" in src
|
||||
|
||||
|
||||
def test_streaming_generators_check_the_shutdown_flag():
|
||||
# editor bridge SSE + chat stream/resume + research + email events + shell.
|
||||
for rel in (
|
||||
"routes/editor_agent_routes.py",
|
||||
"routes/chat_routes.py",
|
||||
"routes/research/research_routes.py",
|
||||
"routes/email/email_routes.py",
|
||||
"routes/shell_routes.py",
|
||||
):
|
||||
src = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert "_shutdown.is_shutting_down()" in src, rel
|
||||
Reference in New Issue
Block a user