Files
odysseus/tests/test_photo_editor_style_guide.py
T
pewdiepie-archdaemonandClaude Opus 5.5 eb940f24a3 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>
2026-10-15 11:57:00 +00:00

643 lines
26 KiB
Python

"""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"]))