"""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) self.assertEqual(_character_design_variation("Alrik", sheet), _character_design_variation("Alrik", sheet)) 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-PANEL ORTHOGRAPHIC CHARACTER TURNAROUND", triptych) self.assertIn("LEFT THIRD", triptych) self.assertIn("CENTER THIRD", triptych) self.assertIn("RIGHT THIRD", triptych) for unused in (side, back): self.assertIn("Unused", unused) 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_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_an_established_scar_is_stated_as_healed(self): portrait = self.prompts({"physical": "Eine fast verblaßte Narbe zieht sich über die Stirn."})[0] self.assertIn("fully healed", portrait) def test_a_character_without_a_scar_gets_no_scar_wording(self): self.assertNotIn("scar", self.prompts({"physical": "Blondes Haar."})[0].lower()) def test_an_armed_character_is_told_how_to_hold_the_weapon(self): portrait = self.prompts(self.KARYLA, "Karyla")[0] self.assertIn("gripped low by its hilt", portrait) def test_an_unarmed_character_gets_no_weapon_wording(self): self.assertNotIn("hilt", self.prompts({"clothing": "Schlichtes Leinenkleid."})[0]) if __name__ == "__main__": unittest.main()