tts-voice-creator-clone-and.../tests/test_character_image_isolation.py
mARTin-B78 084a99af2c fix(characters): isolate image generation per character
Bind the consistent-image Idempotency-Key to the character request so a reused
key cannot return another character's job; add back-to-back and concurrent
two-character regression tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:34:07 +02:00

169 lines
8.1 KiB
Python

"""Per-character isolation of the consistent-image (profile + reference sheet) generation path.
Two deliberately dissimilar characters are generated back to back and concurrently; each ComfyUI
request must carry only its own prompt, seed family, identity image and result.
"""
from __future__ import annotations
import os
import tempfile
import threading
import time
import unittest
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-charisol-test-config-"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.conversation as conv
DWARF = {
"name": "Borin", "book": "Isolation Book",
"sheet": {"race_species": "Zwerg", "gender": "male", "age": "60", "physical": "stocky dwarf with braided red beard",
"clothing": "heavy forged-iron mail", "items": ["war hammer"]},
}
ELF = {
"name": "Lirael", "book": "Isolation Book",
"sheet": {"race_species": "Elf", "gender": "female", "age": "30", "physical": "tall slender elf with silver hair",
"clothing": "light green linen robe", "items": ["longbow"]},
}
def _payload(char: dict) -> dict:
sheet = char["sheet"]
return {
"name": char["name"], "book": char["book"], "sheet": sheet, "visual_style": "cinematic",
"book_profile": {},
"profile_prompt": f"Profile portrait of {char['name']}: {sheet['physical']}",
"reference_prompt": f"Reference sheet of {char['name']}: {sheet['physical']}",
}
def _uri(tag: str) -> str:
return "data:image/png;base64,UE5H" + tag.encode().hex()
class ConsistentImageIsolationTests(unittest.TestCase):
def setUp(self):
self.identity_calls: list[dict] = []
self.sheet_calls: list[dict] = []
self.lock = threading.Lock()
self.delay = 0.0
conv._consistent_image_jobs.clear()
conv._consistent_image_idempotency.clear()
def fake_identity(url, workflow, node, field, out, prompt, seed_key="", progress=None):
time.sleep(self.delay)
with self.lock:
self.identity_calls.append({"prompt": prompt, "seed_key": seed_key})
return _uri("identity:" + seed_key.split("|")[3])
def fake_sheet(url, workflow, identity_image, profile_prompt, front, side, back, scene, props,
style_key="", progress=None, face_transfer=True):
time.sleep(self.delay)
with self.lock:
self.sheet_calls.append({"identity": identity_image, "profile": profile_prompt, "front": front,
"side": side, "back": back, "scene": scene, "props": list(props),
"style_key": style_key})
owner = style_key.split("|")[3]
return {"profile": _uri("profile:" + owner), "reference": _uri("reference:" + owner), "_panels": {}}
patches = [
mock.patch.object(conv, "_load_settings", return_value={"comfyui_url": "http://comfy.invalid",
"image_check_enabled": False}),
mock.patch.object(conv, "_comfyui_run", side_effect=fake_identity),
mock.patch.object(conv, "_comfyui_run_consistent_images", side_effect=fake_sheet),
mock.patch.object(conv, "_persist_consistent_image_artifacts", side_effect=lambda job, result: dict(result)),
mock.patch.object(conv, "background_job_create"),
mock.patch.object(conv, "background_job_update"),
mock.patch.object(conv, "background_job_get", return_value=None),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
app = FastAPI()
app.include_router(conv.router)
self.client = TestClient(app)
def _run(self, char: dict, headers: dict | None = None) -> dict:
resp = self.client.post("/api/character-generate-consistent-images", json=_payload(char), headers=headers or {})
self.assertEqual(resp.status_code, 200, resp.text)
return self._wait(resp.json()["job_id"])
def _wait(self, job_id: str) -> dict:
for _ in range(200):
status = self.client.get(f"/api/character-generate-consistent-images/status/{job_id}").json()
if status.get("done"):
self.assertIsNone(status.get("error"), status)
return status
time.sleep(0.05)
self.fail("job did not finish")
def _assert_separate(self, dwarf_result: dict, elf_result: dict) -> None:
self.assertEqual(len(self.sheet_calls), 2)
by_owner = {c["style_key"].split("|")[3]: c for c in self.sheet_calls}
dwarf_call, elf_call = by_owner["Borin"], by_owner["Lirael"]
# prompts: each request holds only its own character's traits
for key in ("profile", "front", "side", "back", "scene"):
self.assertNotIn("Lirael", dwarf_call[key]); self.assertNotIn("elf", dwarf_call[key].lower().replace("self", ""))
self.assertNotIn("Borin", elf_call[key]); self.assertNotIn("dwarf", elf_call[key].lower())
self.assertNotIn("longbow", " ".join(dwarf_call["props"]).lower())
self.assertNotIn("hammer", " ".join(elf_call["props"]).lower())
# identity reference image and seed family are per-character
self.assertIn("identity:Borin".encode().hex(), dwarf_call["identity"])
self.assertIn("identity:Lirael".encode().hex(), elf_call["identity"])
self.assertNotEqual(dwarf_call["style_key"], elf_call["style_key"])
self.assertNotEqual(dwarf_call["identity"], elf_call["identity"])
for call in self.identity_calls:
owner = call["seed_key"].split("|")[3]
other = "Lirael" if owner == "Borin" else "Borin"
self.assertNotIn(other, call["prompt"])
# results
self.assertEqual(dwarf_result["result"]["profile"], _uri("profile:Borin"))
self.assertEqual(dwarf_result["result"]["reference"], _uri("reference:Borin"))
self.assertEqual(elf_result["result"]["profile"], _uri("profile:Lirael"))
self.assertEqual(elf_result["result"]["reference"], _uri("reference:Lirael"))
self.assertNotEqual(dwarf_result["result"]["identity_fingerprint"], elf_result["result"]["identity_fingerprint"])
def test_back_to_back_runs_do_not_share_prompts_refs_or_results(self):
first = self._run(DWARF)
second = self._run(ELF)
self._assert_separate(first, second)
def test_concurrent_runs_do_not_share_prompts_refs_or_results(self):
self.delay = 0.15
results: dict[str, str] = {}
def submit(char):
r = self.client.post("/api/character-generate-consistent-images", json=_payload(char))
results[char["name"]] = r.json()["job_id"]
threads = [threading.Thread(target=submit, args=(c,)) for c in (DWARF, ELF)]
[t.start() for t in threads]; [t.join() for t in threads]
self.assertNotEqual(results["Borin"], results["Lirael"])
self._assert_separate(self._wait(results["Borin"]), self._wait(results["Lirael"]))
def test_source_sheet_and_payload_are_not_mutated(self):
payload = _payload(DWARF)
before = repr(payload)
self.client.post("/api/character-generate-consistent-images", json=payload)
self.assertEqual(repr(payload), before)
self.assertEqual(repr(DWARF["sheet"]), repr(_payload(DWARF)["sheet"]))
def test_idempotency_key_never_returns_another_characters_job(self):
headers = {"Idempotency-Key": "reused-key"}
first = self.client.post("/api/character-generate-consistent-images", json=_payload(DWARF), headers=headers)
job_id = first.json()["job_id"]
same = self.client.post("/api/character-generate-consistent-images", json=_payload(DWARF), headers=headers)
self.assertEqual(same.json(), {"job_id": job_id, "deduplicated": True})
other = self.client.post("/api/character-generate-consistent-images", json=_payload(ELF), headers=headers)
self.assertEqual(other.status_code, 409)
self._wait(job_id)
self.assertEqual({c["style_key"].split("|")[3] for c in self.sheet_calls}, {"Borin"})
if __name__ == "__main__":
unittest.main()