"""Character visual identity: fingerprint parity with the browser, and the form-aware pair review.""" from __future__ import annotations import base64 import json import os import re import subprocess import tempfile import unittest from pathlib import Path from unittest import mock os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-charid-test-config-")) from fastapi import FastAPI from fastapi.testclient import TestClient import routes.characters as characters from routes.conversation import ( _CHARACTER_IDENTITY_FIELDS, _CHARACTER_IDENTITY_OVERRIDE_FIELDS, _CHARACTER_VISUAL_PROMPT_VERSION, _character_identity_fingerprint, ) ROOT = Path(__file__).resolve().parent.parent JS = (ROOT / "static/js/library-characters.js").read_text(encoding="utf-8") def _js_list(name: str) -> list[str]: m = re.search(rf"const {name} = \[(.*?)\];", JS, re.S) return re.findall(r"'([^']+)'", m.group(1)) def _js_fingerprint(sheets: list[dict]) -> list[str]: """Run the browser's own fingerprint code in Node.""" def grab(pattern: str) -> str: return re.search(pattern, JS, re.S).group(0) src = "\n".join([ grab(r"function _libStr\(v\) \{.*?\n\}"), grab(r"const LIB_IDENTITY_FIELDS = \[.*?\];"), grab(r"const LIB_IDENTITY_OVERRIDE_FIELDS = \[.*?\];"), grab(r"function _libIdentityFingerprint\(sheet\) \{.*?\n\}"), f"console.log(JSON.stringify({json.dumps(sheets)}.map(_libIdentityFingerprint)));", ]) out = subprocess.run(["node", "-e", src], capture_output=True, text=True, check=True, timeout=30) return json.loads(out.stdout) def _legacy_fingerprint(sheet: dict) -> str: """The algorithm before override fields existed, for backward-compat checks.""" raw = "|".join(f"{k}:{sheet.get(k) if sheet.get(k) is not None else ''}" for k in _CHARACTER_IDENTITY_FIELDS) value = 0 for ch in raw: value = ((value << 5) - value + ord(ch)) & 0xFFFFFFFF return format(value, "x") class FingerprintTests(unittest.TestCase): def test_browser_and_server_use_the_same_field_lists(self): self.assertEqual(_js_list("LIB_IDENTITY_FIELDS"), list(_CHARACTER_IDENTITY_FIELDS)) self.assertEqual(_js_list("LIB_IDENTITY_OVERRIDE_FIELDS"), list(_CHARACTER_IDENTITY_OVERRIDE_FIELDS)) def test_browser_and_server_compute_identical_fingerprints(self): sheets = [ {}, {"physical": "tall, white hair", "clothing": "grey coat", "gender": "female", "age_estimate": "40"}, {"physical": "x", "visual_appearance": "copper hair, green eyes", "visual_costume": "blue wool coat"}, {"physical": "ä ö ü ß – »Zitat« 😀", "marks": ["scar", "tattoo"], "visual_species": "Elf"}, ] self.assertEqual(_js_fingerprint(sheets), [_character_identity_fingerprint(s) for s in sheets]) def test_a_pair_made_before_overrides_existed_keeps_a_valid_fingerprint(self): sheet = {"physical": "tall, white hair", "clothing": "grey coat", "gender": "female"} self.assertEqual(_character_identity_fingerprint(sheet), _legacy_fingerprint(sheet)) def test_editing_an_override_marks_the_pair_stale(self): base = {"physical": "tall", "clothing": "coat"} before = _character_identity_fingerprint(base) for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS: with self.subTest(key=key): self.assertNotEqual(_character_identity_fingerprint({**base, key: "changed"}), before) def test_active_form_changes_the_fingerprint_but_not_the_base(self): sheet = {"physical": "human", "forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}]} base = _character_identity_fingerprint({**sheet, "active_form_id": ""}) self.assertNotEqual(_character_identity_fingerprint({**sheet, "active_form_id": "wolf"}), base) _PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") def _img(n: int) -> str: """A distinct valid data URI per n (digests differ).""" return "data:image/png;base64," + base64.b64encode(_PNG + bytes([n])).decode() def _pair_meta(sheet: dict) -> dict: return { "consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION, "consistent_identity_fingerprint": _character_identity_fingerprint(sheet), "identity_consistent": True, } class VisualPairReviewTests(unittest.TestCase): def setUp(self): app = FastAPI() app.include_router(characters.router) self.client = TestClient(app) def review(self, records): with mock.patch.object(characters, "char_get_all", return_value=records): r = self.client.get("/api/characters/visual-pair-review") self.assertEqual(r.status_code, 200) return r.json() def test_a_healthy_base_pair_and_a_healthy_form_pair_pass(self): base = {"physical": "human", "clothing": "coat"} form = {"id": "wolf", "name": "Wolf", "physical": "grey fur"} form_fp = _character_identity_fingerprint({**base, "forms": [form], "active_form_id": "wolf"}) rec = {"id": "c1", "name": "Zerwas", "image": _img(1), "sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [{**form, "image": _img(3), "concept_art_image": _img(4), "consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION, "consistent_identity_fingerprint": form_fp, "identity_consistent": True}], "active_form_id": "wolf"}} self.assertEqual(self.review([rec])["issues"], []) def test_a_stale_form_pair_is_reported_under_the_form_name(self): base = {"physical": "human"} form = {"id": "wolf", "name": "Wolf", "physical": "grey fur", "image": _img(3), "concept_art_image": _img(4), "consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION, "consistent_identity_fingerprint": "deadbeef", "identity_consistent": True} rec = {"id": "c1", "name": "Zerwas", "image": _img(1), "sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [form]}} issues = self.review([rec])["issues"] self.assertEqual(len(issues), 1) self.assertEqual(issues[0]["form_id"], "wolf") self.assertEqual(issues[0]["form_name"], "Wolf") self.assertIn("appearance_changed_since_generation", issues[0]["issues"]) def test_an_active_form_no_longer_makes_the_base_pair_look_unfingerprinted(self): base = {"physical": "human"} rec = {"id": "c1", "name": "Zerwas", "image": _img(1), "sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}], "active_form_id": "wolf"}} self.assertEqual(self.review([rec])["issues"], []) def test_a_form_without_images_is_not_reviewed(self): base = {"physical": "human"} rec = {"id": "c1", "name": "A", "image": _img(1), "sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [{"id": "f", "name": "F", "physical": "x"}]}} self.assertEqual(self.review([rec])["issues"], []) def test_the_same_character_reusing_its_picture_in_another_book_is_not_flagged(self): s = {"physical": "a"} recs = [{"id": f"b{i}::darrag", "book": f"Book {i}", "name": "Darrag", "image": _img(9), "sheet": {**s, **_pair_meta(s), "concept_art_image": _img(20 + i)}} for i in range(2)] recs[1]["name"] = " darrag " self.assertEqual(self.review(recs)["issues"], []) def test_the_same_asset_on_two_characters_is_flagged(self): s = {"physical": "a"} recs = [{"id": f"c{i}", "name": f"N{i}", "image": _img(9), "sheet": {**s, **_pair_meta(s), "concept_art_image": _img(10 + i)}} for i in range(2)] issues = self.review(recs)["issues"] self.assertEqual({i["id"] for i in issues}, {"c0", "c1"}) self.assertTrue(all("asset_reused_across_characters" in i["issues"] for i in issues)) class PromptHygieneTests(unittest.TestCase): """Facts that reach an image prompt must be period-safe, uninjured, and not identical between characters.""" def facts(self, **sheet): from routes.conversation import _character_visual_facts return _character_visual_facts("Test", sheet) def test_modern_wardrobe_words_become_period_garments(self): text = self.facts(clothing="Offiziersuniform mit Weste, Hut und Sporen. Uniformjacke, Schal.") for modern in ("uniform", "weste", "hut", "sporen", "jacke", "schal"): self.assertNotIn(modern, text.lower(), modern) self.assertIn("surcoat", text) def test_injury_clauses_are_dropped_but_the_rest_of_the_sentence_stays(self): text = self.facts(physical="Grüne Augen, blutverschmierte Stirn und schwarzes Haar. Sein Arm ist verbunden.") self.assertIn("Grüne Augen", text) self.assertIn("schwarzes Haar", text) self.assertNotIn("blut", text.lower()) self.assertNotIn("verbunden", text.lower()) def test_a_stored_renaissance_book_profile_is_reworded(self): from routes.conversation import _book_visual_context text = _book_visual_context("B", {"era": "Frühren aissance".replace(" ", ""), "setting": "early-Renaissance fantasy world, approximately 1420–1500"}) self.assertNotIn("enaissance", text) self.assertNotIn("1420", text) self.assertIn("medieval", text) def test_a_tricorn_hat_becomes_a_helm(self): self.assertNotIn("dreispitz", self.facts(clothing="Rüstung mit Dreispitz").lower()) def test_soot_and_dirt_are_not_painted_on(self): text = self.facts(physical="Wallendes Haar, rußverschmierte Backen.") self.assertIn("Wallendes Haar", text) self.assertNotIn("ruß", text.lower()) def test_injured_clothing_is_cleaned_too(self): text = self.facts(clothing="Graue Robe, an der Seite zerrissen und blutig.") self.assertIn("Graue Robe", text) self.assertNotIn("blutig", text.lower()) def test_soldiers_get_different_cuts_and_colours(self): from routes.conversation import _character_design_variation sheet = {"clothing": "Rüstung und Schwert"} designs = {_character_design_variation(n, sheet) for n in ("Alrik", "Baron", "Coran", "Darrag", "Elda", "Fenn", "Garbag", "Himgi")} self.assertGreaterEqual(len(designs), 5) first = _character_design_variation("Alrik", sheet) second = _character_design_variation("Alrik", sheet) self.assertEqual(first, second) def test_colours_the_sheet_already_names_are_kept(self): from routes.conversation import _character_design_variation self.assertIn("colours already described", _character_design_variation("Alrik", {"clothing": "rote Rüstung"})) class TurnaroundIdentityTests(unittest.TestCase): def prompts(self, **sheet): from routes.conversation import _build_compact_character_render_prompts return _build_compact_character_render_prompts("Test", "Buch", sheet, {}, "cinematic", "", "") def test_the_three_views_are_generated_as_one_triptych(self): """Per-view edit renders returned three front views and copied the pose guide into the costume.""" _portrait, triptych, side, back, *_rest = self.prompts(clothing="Wollrock") self.assertIn("three full-body views of the same one", triptych) self.assertIn("left a front view, middle a side profile, right a back view", triptych) for unused in (side, back): self.assertIn("Unused", unused) def test_the_turnaround_prompt_is_short_and_framing_first(self): """Measured: framing rules buried in a ~3 000-character prompt were ignored (feet cut off 5/5, scenery backdrop); the short framing-first prompt passed 10/10.""" triptych = self.prompts(clothing="Wollrock", physical="Kurzes Haar.")[1] self.assertLess(len(triptych), 1500) self.assertTrue(triptych.startswith("Character turnaround reference sheet")) framing = triptych.index("top of the head to the soles of the shoes") self.assertLess(framing, triptych.index("The adult person:")) self.assertIn("Plain empty white seamless studio background", triptych) self.assertNotIn("..", triptych) def test_a_long_saved_subject_is_trimmed_to_keep_the_framing_on_top(self): triptych = self.prompts(physical="Sehr lange Beschreibung. " * 80)[1] self.assertLess(len(triptych), 1500) def test_the_portrait_look_is_carried_into_the_turnaround_and_the_scene(self): from routes.conversation import _build_compact_character_render_prompts out = _build_compact_character_render_prompts("Darrag", "Buch", {"physical": "Kräftig."}, {}, "cinematic", "", "", "hair: shoulder-length brown hair") self.assertIn("exactly as in the portrait: hair: shoulder-length brown hair", out[1]) self.assertIn("exactly as in the portrait: hair: shoulder-length brown hair", out[4]) self.assertLess(out[1].index("head to the soles"), out[1].index("exactly as in the portrait")) def test_turnaround_keeps_the_rules_that_matter(self): leather = self.prompts(clothing="Abgenutzte Lederrüstung.")[1] self.assertIn("Leather and cloth armour only", leather) elf = self.prompts(race_species="Elfe")[1] self.assertIn("Pointed elf-shaped ears", elf) child = self.prompts(physical="Ein Kind von acht Jahren.")[1] self.assertIn("same one child", child) def test_prop_prompts_are_object_only(self): *_, props = self.prompts(clothing="Rüstung", inventory="Schwert") self.assertTrue(props) for prompt in props: for person_word in ("scalp", "expression", "CLOTHING LOCK", "hairstyle"): self.assertNotIn(person_word, prompt) def test_accessory_prompts_are_isolated_props_without_body_parts(self): *_, props = self.prompts(clothing="Rüstung", inventory="Armbrust, Schlüssel, Lederhandschuhe") self.assertTrue(props) for prompt in props: self.assertIn("concept art prop sheet featuring isolated character accessories:", prompt) self.assertIn("plain, neutral studio background", prompt) self.assertIn("Strictly inanimate objects only", prompt) self.assertIn("no people, no hands, no faces, and no body parts of any kind", prompt) self.assertNotIn("oak table", prompt) joined = " ".join(props).lower() self.assertIn("armbrust", joined) self.assertIn("schlüssel", joined) def test_item_negative_prompt_reaches_the_item_node_only(self): from routes.conversation import _SIGNATURE_ITEM_PANEL_NEGATIVE, _LOWER_PANEL_NEGATIVE self.assertIn("hands", _SIGNATURE_ITEM_PANEL_NEGATIVE) self.assertNotIn("boots", _SIGNATURE_ITEM_PANEL_NEGATIVE) self.assertNotIn(_SIGNATURE_ITEM_PANEL_NEGATIVE, _LOWER_PANEL_NEGATIVE) def test_the_book_period_is_high_fantasy_medieval_not_renaissance(self): portrait = self.prompts(clothing="Wollrock")[0] self.assertNotIn("Renaissance", portrait) self.assertIn("The Dark Eye", portrait) class RenderDefectTests(unittest.TestCase): """Each test pins one defect seen in a real render.""" KARYLA = {"gender": "female", "race_species": "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)", "physical": "Kurzes blondes Haar; drahtige Statur einer Kriegerin.", "clothing": "Robuste, abgenutzte Lederrüstung mit Waffengürtel und Schwert."} def prompts(self, sheet, name="Test"): from routes.conversation import _build_compact_character_render_prompts return _build_compact_character_render_prompts(name, "Buch", sheet, {}, "cinematic", "", "") def test_a_hedged_species_does_not_give_a_human_elf_ears(self): from routes.conversation import _character_ear_shape, _character_visual_facts self.assertEqual(_character_ear_shape(self.KARYLA), "ordinary rounded human ears") self.assertNotIn("Halbelfe", _character_visual_facts("Karyla", self.KARYLA)) def test_a_real_elf_keeps_pointed_ears(self): from routes.conversation import _character_ear_shape self.assertEqual(_character_ear_shape({"race_species": "Elfe"}), "pointed elf-shaped ears") def test_the_scene_panel_restates_gender_and_species(self): scene = self.prompts(self.KARYLA, "Karyla")[4] self.assertIn("one adult woman named Karyla", scene) def test_a_child_is_not_described_as_an_adult_in_the_scene(self): scene = self.prompts({"gender": "male", "physical": "Ein Kind von acht Jahren."}, "Jorin")[4] self.assertIn("boy named Jorin", scene) def test_scars_are_left_out_of_image_prompts(self): """Any scar wording -- even "fully healed, pale" -- rendered as fresh red gashes (Eolan).""" prompts = self.prompts({"physical": "Weißhaarig. Eine lange Narbe zieht sich über die Wange.", "marks": "scar"}) for prompt in (prompts[0], prompts[1]): self.assertIn("Weißhaarig", prompt) self.assertNotIn("Narbe", prompt) self.assertNotIn("scar", prompt.lower()) def test_a_gesture_of_the_moment_is_not_drawn(self): """"Zunge streicht über die Lippen" rendered as a tongue sticking out (Eolan).""" triptych = self.prompts({"physical": "Asketisches Gesicht. Zunge streicht über die Lippen."})[1] self.assertIn("Asketisches Gesicht", triptych) self.assertNotIn("Zunge", triptych) def test_an_armed_character_is_told_how_to_hold_the_weapon(self): scene = self.prompts(self.KARYLA, "Karyla")[4] self.assertIn("gripped low by its hilt", scene) self.assertIn("both hands empty", self.prompts(self.KARYLA, "Karyla")[0]) def test_an_unarmed_character_gets_no_weapon_wording(self): self.assertNotIn("hilt", self.prompts({"clothing": "Schlichtes Leinenkleid."})[4]) class LowerPanelPromptTests(unittest.TestCase): """The story moment and the signature items under the turnaround. Marcian (a knight in plate and a red cloak, Das Jahr des Greifen) got a modern man with headphones and a camera in a garden, captioned with pseudo-text, and a stock crossbow on a white sweep: the scene prompt was ~5 400 characters with the period rules at the end, opened with "ears ... clearly visible" and asked for studio photography by a photographer. """ BOOK = "Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm" MARCIAN = { "gender": "male", "race_species": "Mensch", "title": "Ritter", "profession": "Inquisitor, Agent der KGIA", "visual_appearance": "Ein markanter Mann mittleren Alters mit dunklem, leicht ergrautem Haar.", "visual_costume": "Prächtige Plattenrüstung mit einem flammend roten Umhang.", "visual_scene": "Marcian gibt den Verteidigern von Greifenfurt einen präzisen Befehl.", "visual_props": ["Armbrust"], } def prompts(self, style="cinematic", look="", book_profile=None): from routes.conversation import _build_compact_character_render_prompts return _build_compact_character_render_prompts("Marcian", self.BOOK, self.MARCIAN, book_profile or {}, style, "", "", look) def test_the_scene_is_the_same_character_in_the_books_world(self): scene = self.prompts(look="hair: shoulder-length dark brown hair")[4] self.assertIn("medieval high-fantasy world of The Dark Eye", scene) self.assertIn("900-1300 AD", scene) self.assertIn("one adult man named Marcian", scene) self.assertIn("exactly the character of the turnaround", scene) self.assertIn("dunklem, leicht ergrautem Haar", scene) self.assertIn("Plattenrüstung mit einem flammend roten Umhang", scene) self.assertIn("exactly as in the portrait: hair: shoulder-length dark brown hair", scene) self.assertIn("Greifenfurt", scene) def test_the_scene_opens_with_the_world_and_stays_short(self): scene = self.prompts()[4] self.assertTrue(scene.startswith("A cinematic film still from a medieval high-fantasy epic")) self.assertLess(scene.index("The Dark Eye"), scene.index("Marcian")) self.assertLess(len(scene), 3200) long_sheet = dict(self.MARCIAN, visual_scene="Er spricht sehr lange. " * 80) from routes.conversation import _build_compact_character_render_prompts self.assertLess(len(_build_compact_character_render_prompts( "Marcian", self.BOOK, long_sheet, {}, "cinematic", "", "")[4]), 3200) def test_the_lower_panels_state_a_hand_made_unmarked_world(self): _p, _t, _s, _b, scene, props = self.prompts() for prompt in [scene] + props: self.assertIn("hand-made and pre-industrial", prompt) self.assertIn("All surfaces are plain and unmarked", prompt) self.assertIn("one single seamless picture", prompt) def test_the_lower_panels_carry_no_modern_or_text_cue(self): """The encoder draws what it reads, even after "no": nothing to avoid is named positively.""" _p, _t, _s, _b, scene, props = self.prompts() for prompt in [scene] + props: # the accessory template itself asks for a "neutral studio background" lower = prompt.lower().replace("neutral studio background", "") for cue in ("photograph", "studio", "actor", "camera", "headphone", "label", "caption", "lettered", "overlay", "output", "museum", "catalog", "head continuity", "evidence", "lock:", "der sturm"): self.assertNotIn(cue, lower, f"{cue!r} in {prompt[:80]}…") def test_the_concept_style_is_painted(self): _p, _t, _s, _b, scene, props = self.prompts(style="concept") self.assertTrue(scene.startswith("A semi-realistic hand-painted medieval high-fantasy illustration")) for prompt in props: self.assertIn("A semi-realistic hand-painted medieval high-fantasy illustration", prompt) def test_the_book_profile_setting_reaches_the_world(self): scene = self.prompts(book_profile={"setting": "Greifenfurt, eine belagerte Stadt", "era": "Frührenaissance"})[4] self.assertIn("Greifenfurt, eine belagerte Stadt", scene) self.assertNotIn("renaissance", scene.lower()) def test_the_prop_is_the_characters_own_item_in_context(self): props = self.prompts()[5] self.assertEqual(len(props), 1) prop = props[0] self.assertIn("one complete medieval crossbow", prop) self.assertIn("personal, well-used possession of Marcian", prop) self.assertIn("Plattenrüstung mit einem flammend roten Umhang", prop) self.assertIn("plain, neutral studio background", prop) self.assertIn("The Dark Eye", prop) def test_a_still_life_item_rests_on_the_table_not_the_sweep(self): from routes.conversation import _build_compact_character_render_prompts sheet = dict(self.MARCIAN, visual_props=["Amulett"]) prop = _build_compact_character_render_prompts("Marcian", self.BOOK, sheet, {}, "cinematic", "", "")[5][0] self.assertIn("coiled flat on a plain surface", prop) self.assertNotIn("sweep", prop) def test_the_negative_prompt_names_text_and_modern_objects(self): from routes.conversation import _LOWER_PANEL_NEGATIVE for word in ("text", "caption", "label", "watermark", "logo", "headphones", "camera", "modern clothing", "modern building", "suburban garden"): self.assertIn(word, _LOWER_PANEL_NEGATIVE) def test_the_scene_and_prop_negatives_reach_the_workflow(self): import requests from fastapi import HTTPException import routes.conversation as conversation sent = {} def fake_post(url, json=None, **_kw): sent["graph"] = json["prompt"] raise requests.exceptions.ConnectionError("stop here") with mock.patch.object(conversation, "_comfyui_upload_data_uri", return_value="x.png"), \ mock.patch.object(conversation.requests, "post", side_effect=fake_post): with self.assertRaises(HTTPException): conversation._comfyui_run_consistent_images( "http://comfy", conversation._bundled_consistent_comfyui_workflow(), "data:image/png;base64,", "p", "front", "side", "back", "scene", ["prop"], face_transfer=False) graph = sent["graph"] for node in ("71", "24"): text = graph[node]["inputs"]["text"] self.assertTrue(text.startswith("low quality")) # the workflow's own list is kept self.assertIn("headphones", text) self.assertIn("caption", text) self.assertEqual(graph["70"]["inputs"]["text"], "scene") self.assertEqual(graph["23"]["inputs"]["text"], "prop") if __name__ == "__main__": unittest.main()