"""Gateway RPC tests for pet generation (pet.generate / pet.hatch). Image generation is mocked, so these assert the RPC contract + staging behavior (draft tokens, data-URI previews, expiry, activation) without any API calls. """ from __future__ import annotations import pytest pytest.importorskip("PIL") from PIL import Image # noqa: E402 from tui_gateway import server # noqa: E402 def _png(path): Image.new("RGBA", (64, 64), (200, 80, 80, 255)).save(path) def test_pet_generate_requires_prompt(): resp = server._methods["pet.generate"]("r1", {"prompt": " "}) assert "error" in resp def test_pet_generate_rejects_invalid_reference_image(): resp = server._methods["pet.generate"]( "r_invalid_ref", {"referenceImage": "data:image/svg+xml;base64,PHN2Zy8+"}, ) assert "error" in resp assert "unsupported reference image type" in resp["error"]["message"] def test_pet_generate_rejects_oversized_reference_image(monkeypatch): import base64 monkeypatch.setattr(server, "_PET_REFERENCE_MAX_BYTES", 8) payload = base64.b64encode(b"0123456789").decode("ascii") resp = server._methods["pet.generate"]( "r_big_ref", {"referenceImage": f"data:image/png;base64,{payload}"}, ) assert "error" in resp assert "too large" in resp["error"]["message"].lower() def test_pet_generate_returns_token_and_previews(monkeypatch, tmp_path): import agent.pet.generate as gen def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None): paths = [] for i in range(n): p = tmp_path / f"d{i}.png" _png(p) paths.append(p) if on_draft is not None: on_draft(i, p) return paths monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts) resp = server._methods["pet.generate"]("r2", {"prompt": "a robot fox", "count": 4}) result = resp["result"] assert result["ok"] assert len(result["drafts"]) == 4 assert all(d["dataUri"].startswith("data:image/png;base64,") for d in result["drafts"]) # Drafts are staged on disk under the returned token. staged = server._pet_gen_root() / result["token"] / "draft-0.png" assert staged.is_file() def test_pet_cancel_unknown_token_is_noop(): resp = server._methods["pet.cancel"]("c0", {"token": "missing"}) assert resp["result"]["ok"] is True def test_pet_generate_cancel_stops_run(monkeypatch, tmp_path): import agent.pet.generate as gen seen: dict = {} def cap_emit(event, sid, payload=None): # Capture the token from the up-front init event so we can cancel it. if event == "pet.generate.progress" and payload and payload.get("token") and not payload.get("dataUri"): seen["token"] = payload["token"] monkeypatch.setattr(server, "_emit", cap_emit) def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None): # Simulate a Stop landing mid-run: the cooperative flag must read True. server._pet_cancel_request(seen["token"]) assert is_cancelled() is True return [] # bailed before producing anything monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts) resp = server._methods["pet.generate"]("rc", {"prompt": "x", "count": 4}) assert "error" in resp assert "cancel" in resp["error"]["message"].lower() # The flag is released after the run so reusing the token isn't pre-cancelled. assert server._pet_is_cancelled(seen["token"]) is False def test_pet_hatch_validates_params(): assert "error" in server._methods["pet.hatch"]("r1", {"name": "x"}) # missing token assert "error" in server._methods["pet.hatch"]("r2", {"token": "abc"}) # missing name def test_pet_hatch_expired_draft(): resp = server._methods["pet.hatch"]("r3", {"token": "nope", "index": 0, "name": "Ghost"}) assert "error" in resp assert "expired" in resp["error"]["message"] def _fake_drafts_factory(tmp_path): def fake_drafts(prompt, *, n=4, style="auto", reference_images=None, provider=None, on_draft=None, is_cancelled=None): paths = [] for i in range(n): p = tmp_path / f"d{i}.png" _png(p) paths.append(p) if on_draft is not None: on_draft(i, p) return paths return fake_drafts def _fake_hatch_factory(captured): """A hatch that registers a real local pet (so the preview payload populates).""" import agent.pet.generate as gen from agent.pet import store def fake_hatch(*, base_image, slug, display_name="", description="", concept="", style="auto", on_progress=None, provider=None, is_cancelled=None): captured["base_image"] = str(base_image) captured["slug"] = slug pet = store.register_local_pet( Image.new("RGBA", (192, 208), (10, 20, 30, 255)), slug=slug, display_name=display_name, description=description, ) return gen.HatchResult( slug=pet.slug, display_name=display_name or pet.display_name, spritesheet=pet.spritesheet, states=["idle", "wave"], validation={"ok": True, "warnings": ["state 'jump' has no frames"]}, ) return fake_hatch def test_pet_generate_then_hatch_previews_without_activating(monkeypatch, tmp_path): import agent.pet.generate as gen from agent.pet import store captured = {} monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path)) monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured)) token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"] resp = server._methods["pet.hatch"]( "r2", {"token": token, "index": 1, "name": "My Fox", "description": "vulpine"}, ) result = resp["result"] assert result["ok"] assert result["slug"] == "my-fox" assert result["displayName"] == "My Fox" assert result["warnings"] == ["state 'jump' has no frames"] # Hatched from the chosen draft index. assert captured["base_image"].endswith("draft-1.png") # The pet is installed on disk and the preview payload carries the sheet, # but hatch must NOT activate it — adoption is a separate step. assert store.load_pet("my-fox") is not None assert result["pet"]["slug"] == "my-fox" assert result["pet"]["spritesheetBase64"] assert server._methods["pet.info"]("r3", {}).get("result", {}).get("enabled") in (False, None) def test_pet_hatch_then_adopt_activates(monkeypatch, tmp_path): import agent.pet.generate as gen captured = {} monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path)) monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured)) activated = {} monkeypatch.setattr("hermes_cli.pets._set_active", lambda slug: activated.setdefault("slug", slug)) token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"] hatched = server._methods["pet.hatch"]("r2", {"token": token, "index": 0, "name": "My Fox"})["result"] # Adoption is the existing pet.select path, against the now-installed slug. adopt = server._methods["pet.select"]("r3", {"slug": hatched["slug"]})["result"] assert adopt["ok"] assert activated["slug"] == "my-fox" def test_pet_sprite_payload_includes_concrete_row_counts(): from agent.pet import constants, store cols, rows = 8, 9 sheet = Image.new("RGBA", (constants.FRAME_W * cols, constants.FRAME_H * rows), (0, 0, 0, 0)) # Current Codex rows can have more/fewer frames than Hermes' generic # FRAMES_PER_STATE. The desktop preview needs the concrete row count. real = {0: 6, 1: 8, 3: 4, 4: 5, 7: 6} for row, count in real.items(): for col in range(count): block = Image.new("RGBA", (constants.FRAME_W, constants.FRAME_H), (80, 120, 220, 255)) sheet.paste(block, (col * constants.FRAME_W, row * constants.FRAME_H)) pet = store.register_local_pet(sheet, slug="row-counts", display_name="Row Counts") payload = server._pet_sprite_payload(pet, scale=0.7) assert payload["framesByRow"]["running-right"] == 8 assert payload["framesByRow"]["waving"] == 4 assert payload["framesByRow"]["jumping"] == 5 assert payload["framesByState"]["run"] == 6 def test_pet_info_meta_avoids_full_payload(monkeypatch): import hermes_cli.config as cli_config from agent.pet import constants, store sheet = Image.new("RGBA", (constants.FRAME_W * 8, constants.FRAME_H * 9), (80, 120, 220, 255)) pet = store.register_local_pet(sheet, slug="meta-pet", display_name="Meta Pet") monkeypatch.setattr( cli_config, "load_config", lambda: {"display": {"pet": {"enabled": True, "slug": pet.slug, "scale": 0.7}}}, ) resp = server._methods["pet.info.meta"]("r_meta", {}) result = resp["result"] assert result["enabled"] is True assert result["slug"] == pet.slug assert result["displayName"] == "Meta Pet" assert result["scale"] == 0.7 assert ":" in result["spritesheetRevision"]