399 lines
22 KiB
Python
399 lines
22 KiB
Python
"""Distinct, deterministic face profiles (core/face_profile.py) and their place in the prompts.
|
|
|
|
The fixture roster is shaped like the real book-02 sheets: German text, most characters with no
|
|
facial trait at all, a few with hair or eye colour, hedged species notes, a raven and a narrator.
|
|
No image is generated and no LLM is called.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-face-test-config-"))
|
|
|
|
import routes.conversation as conv
|
|
from core import face_profile as fp
|
|
|
|
BOOK = "Testbuch 02"
|
|
|
|
|
|
def _s(gender="", age="", species="Mensch", physical="", rel="", **extra):
|
|
return {"gender": gender, "age_estimate": age, "race_species": species, "physical": physical,
|
|
"relationships": rel, **extra}
|
|
|
|
|
|
ROSTER = [
|
|
("Cindira", _s("female", "Junge Erwachsene", physical="Weiblich, Tränen in den Augen, zerrüttet.")),
|
|
("Marcian", _s("male", "Jung", physical="Jung, schlank genug, um sich auf einem Schemel unbehaglich zu fühlen.")),
|
|
("Marrad", _s("male", "Jung (Fast acht Sommer)", "Mensch (oder Zwerg, je nach Kontext)", "Klein, dünn, schwach.",
|
|
"Vater: Darrag (Schmied)")),
|
|
("Lysandra", _s("female", "jung bis mittelalterlich", "Mensch (Amazone)", "Schwarzhaarig.")),
|
|
("Garbaz", _s("male", "mittleres Alter", "Zwerg (Amboßzwerg)", "Breit gebaut.")),
|
|
("Tschubax", _s("male", "Mittleres Alter", "Erzzwerg", "Trägt einen Bart, wirkt wie ein Gelehrter.")),
|
|
("Himgi", _s("male", "mittleres Alter", "Mensch", "Zwergengröße, sträubende Haare bei Anspannung.")),
|
|
("Nyrilla", _s("female", "unbestimmt", "Elfe", "Schlank.")),
|
|
("Eolan", _s("male", "alt", "Auelfe (Mensch-Elfen-Mischling)", "")),
|
|
("Kolon", _s("male", "Mittleres Alter", "Mensch (oder Ork-Verbündeter)", "Kleiner Mann mit einem weißen Bart.")),
|
|
("Darrag", _s("male", "Erwachsener Mann", "Mensch", "Groß, stark; gerötete Augen, unrasiertes Gesicht.",
|
|
"Vater von Marrad (tot)")),
|
|
("Prinz", _s("male", "junges Erwachsenenalter", "Mensch",
|
|
"Jung, schulterlanges dunkelblondes Haar, braune Augen, trägt ein Amulett.")),
|
|
("Ludara", _s("female", "junges Erwachsenenalter", "Mensch", "Blonde Haare, blaue Augen.")),
|
|
("Gernot", _s("male", "junges Erwachsenenalter", "Mensch", "Junges Aussehen, schulterlanges dunkelblondes Haar, braune Augen.")),
|
|
("Marina", _s("female", "Jungfrau (junges Mädchen)", "Mensch (wird Vampir)", "Schön. Langes, blondes Haar.")),
|
|
("Zerwas", _s("male", "alter Mann (im Aussehen)", "Vampir", "Groß und muskulös.")),
|
|
("Promos", _s("male", "Alter Mann (beugt sich, grauhaarig)", "Mensch", "Alter Mann, taumelt benommen.")),
|
|
("Graf", _s("male", "alt", "Mensch", "Alt, boshafte Gesichtszüge.")),
|
|
("Ordbert", _s("male", "mittleres Alter", "Mensch", "Hat gezwirbelte Schnauzbartspitzen.")),
|
|
("Leonardo", _s("male", "Mittleres Alter", "Mensch", "Bart, unsicherer Blick.")),
|
|
("Rialla", _s("female", "jung bis mittelalterlich", "Mensch", "groß, blonde Haare, Kriegerin.")),
|
|
("Olda", _s("female", "", "Mensch", "Schwarzhaarige")),
|
|
("Sartassa", _s("female", "jung", "Mensch", "")),
|
|
("junges Mädchen", _s("female", "Junges Mädchen", "Mensch", "Schlank, junges Mädchen.")),
|
|
("Orkschamane", _s("", "", "", "")),
|
|
("Orgrim", _s("male", "mittleres Alter", "Ork", "")),
|
|
("Bote", _s("male", "jung", "Mensch", "")),
|
|
("Wächter", _s("", "", "Mensch", "")),
|
|
("Kroah", _s("male", "unbekannt", "Rabe (magisch)", "Rabe, fliegt enge Kreise.")),
|
|
("Narrator", _s("", "", "", "")),
|
|
]
|
|
|
|
|
|
def _inputs(roster=ROSTER, cast=None):
|
|
names = [n for n, _ in roster]
|
|
return [fp.face_input_from_sheet(n, sh, conv._character_age_category(sh), names,
|
|
conv._character_species_text(sh, 100)) for n, sh in roster]
|
|
|
|
|
|
def _profiles(roster=ROSTER):
|
|
return fp.assign_profiles(BOOK, _inputs(roster))
|
|
|
|
|
|
class DeterminismTests(unittest.TestCase):
|
|
def test_same_roster_gives_same_profiles(self):
|
|
a, b = _profiles(), _profiles()
|
|
self.assertEqual({n: p.values for n, p in a.items()}, {n: p.values for n, p in b.items()})
|
|
|
|
def test_other_book_gives_other_choices(self):
|
|
other = fp.assign_profiles("Anderes Buch", _inputs())
|
|
mine = _profiles()
|
|
self.assertNotEqual({n: p.values for n, p in mine.items()}, {n: p.values for n, p in other.items()})
|
|
|
|
def test_existing_characters_keep_their_profile_when_the_cast_grows(self):
|
|
before = _profiles()
|
|
extra = [("Neu Eins", _s("female", "jung", "Mensch", "")), ("Neu Zwei", _s("male", "alt", "Zwerg", ""))]
|
|
after = _profiles(ROSTER + extra)
|
|
for name, profile in before.items():
|
|
self.assertEqual(profile.values, after[name].values, name)
|
|
self.assertIn("Neu Eins", after)
|
|
|
|
def test_non_humanoids_and_the_narrator_get_no_profile(self):
|
|
profiles = _profiles()
|
|
self.assertNotIn("Kroah", profiles)
|
|
self.assertNotIn("Narrator", profiles)
|
|
self.assertIn("Orkschamane", profiles) # species taken from the name when the sheet has none
|
|
self.assertEqual(profiles["Orkschamane"].species, "o")
|
|
|
|
|
|
class DistinctnessTests(unittest.TestCase):
|
|
def test_distinctness_beats_the_old_baseline(self):
|
|
inputs = _inputs()
|
|
profiles = fp.assign_profiles(BOOK, inputs)
|
|
report = fp.similarity_report(inputs, profiles)
|
|
self.assertLess(report["mean_distance_before"], 0.25) # the model default is shared
|
|
self.assertGreater(report["mean_distance_after"], 0.8)
|
|
self.assertGreater(report["mean_distance_after"], report["mean_distance_before"] + 0.55)
|
|
self.assertGreater(report["characters_without_any_face_trait"], 10)
|
|
|
|
def test_no_two_unrelated_characters_look_alike(self):
|
|
profiles = _profiles()
|
|
names = list(profiles)
|
|
kin = {frozenset(("Marrad", "Darrag")), frozenset(("Himgi", "Darrag"))}
|
|
worst = 0.0
|
|
for i, a in enumerate(names):
|
|
for b in names[i + 1:]:
|
|
if frozenset((a, b)) in kin:
|
|
continue
|
|
worst = max(worst, 1 - fp.distance(profiles[a].values, profiles[b].values))
|
|
self.assertLess(worst, 0.55, f"most similar unrelated pair has similarity {worst:.2f}")
|
|
|
|
def test_greedy_beats_picking_by_hash_alone(self):
|
|
inputs = _inputs()
|
|
greedy = fp.mean_pairwise([p.values for p in fp.assign_profiles(BOOK, inputs).values()])
|
|
solo = fp.mean_pairwise([fp.assign_profiles(BOOK, [i]).get(i.name).values
|
|
for i in inputs if fp.assign_profiles(BOOK, [i])])
|
|
self.assertGreater(greedy, solo)
|
|
|
|
|
|
class ConstraintTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.profiles = _profiles()
|
|
|
|
def test_no_facial_hair_for_women_children_or_unknown_gender(self):
|
|
for name in ("Cindira", "Lysandra", "Nyrilla", "Ludara", "Marina", "Rialla", "Olda", "Sartassa",
|
|
"junges Mädchen", "Marrad", "Orkschamane", "Wächter"):
|
|
self.assertNotIn("facial_hair", self.profiles[name].values, name)
|
|
self.assertNotIn("beard", self.profiles[name].clause(), name)
|
|
|
|
def test_no_beard_for_a_young_woman_even_when_a_dwarf(self):
|
|
sheet = _s("female", "jung", "Zwerg", "")
|
|
prof = fp.assign_profiles(BOOK, [fp.face_input_from_sheet("Dwarfa", sheet, "adult", [], "Zwerg")])["Dwarfa"]
|
|
self.assertNotIn("facial_hair", prof.values)
|
|
|
|
def test_elves_have_no_beard_and_no_wrinkles_and_no_orc_jaw(self):
|
|
for name in ("Nyrilla", "Eolan"):
|
|
prof = self.profiles[name]
|
|
self.assertNotIn("facial_hair", prof.values)
|
|
self.assertNotIn(prof.values.get("age_signs", "elf_ageless")[:3], ("old", "mid"))
|
|
self.assertNotIn("tusk_jaw", prof.values.values())
|
|
|
|
def test_human_clauses_never_mention_elf_or_orc_traits(self):
|
|
for name, profile in self.profiles.items():
|
|
if profile.species == "h":
|
|
text = profile.clause().lower()
|
|
for word in ("pointed ear", "elf", "tusk", "protruding lower jaw", "ageless"):
|
|
self.assertNotIn(word, text, f"{word!r} in {name}")
|
|
|
|
def test_age_appropriate_hair_and_skin(self):
|
|
for name in ("Promos", "Graf", "Zerwas"):
|
|
self.assertIn(self.profiles[name].values["hair_colour"], ("grey", "white", "grey_streaked"), name)
|
|
self.assertIn(self.profiles[name].values["age_signs"], ("old_wrinkled", "old_sagging", "old_lines"), name)
|
|
for name in ("Marrad", "junges Mädchen"):
|
|
self.assertTrue(self.profiles[name].values["age_signs"].startswith(("child", "teen")), name)
|
|
self.assertNotIn(self.profiles[name].values["hair_colour"], ("grey", "white", "grey_streaked"))
|
|
for name in ("Ludara", "Prinz", "Gernot"):
|
|
self.assertNotIn(self.profiles[name].values["hair_colour"], ("grey", "white", "grey_streaked"))
|
|
|
|
def test_white_beard_in_the_sheet_means_light_hair(self):
|
|
# Kolon: "weißen Bart" (middle-aged): hair follows the beard, the beard itself is kept
|
|
self.assertEqual(self.profiles["Kolon"].values["facial_hair"], "sheet_beard:white")
|
|
self.assertIn(self.profiles["Kolon"].values["hair_colour"], ("grey", "white", "grey_streaked"))
|
|
self.assertIn("a white beard", self.profiles["Kolon"].clause())
|
|
|
|
def test_vampire_skin_stays_pale(self):
|
|
self.assertIn(self.profiles["Zerwas"].values["skin"], ("pale", "fair", "sallow", "olive"))
|
|
self.assertNotIn("freckles", self.profiles["Zerwas"].values.get("mark", ""))
|
|
|
|
def test_orc_and_dwarf_pools(self):
|
|
self.assertIn(self.profiles["Orgrim"].values["skin"], ("olive", "dark_olive", "ash_brown", "warm_brown"))
|
|
self.assertIn(self.profiles["Garbaz"].values["facial_hair"], ("stubble", "moustache", "full_beard", "long_beard",
|
|
"braided_beard", "forked_beard", "square_beard"))
|
|
|
|
def test_culture_named_in_the_sheet_limits_skin(self):
|
|
north = _s("male", "jung", "Mensch", "", nationality_background="Thorwaler")
|
|
inp = fp.face_input_from_sheet("Thor", north, "adult", [])
|
|
self.assertEqual(inp.culture, "N")
|
|
self.assertTrue(all(o.id not in ("warm_brown", "deep_brown") for o in fp.candidates("skin", inp)))
|
|
south = _s("male", "jung", "Mensch", "", nationality_background="Tulamide aus Mhanadistan")
|
|
inp = fp.face_input_from_sheet("Tula", south, "adult", [])
|
|
self.assertTrue(all(o.id not in ("pale", "fair_freckled", "ruddy") for o in fp.candidates("skin", inp)))
|
|
|
|
def test_alternatives_in_the_species_note_do_not_change_the_species(self):
|
|
self.assertEqual(fp.species_code("Mensch (oder Zwerg, je nach Kontext)"), "h")
|
|
self.assertEqual(fp.species_code("Mensch oder Ork"), "h")
|
|
self.assertEqual(fp.species_code("Halbelf"), "he")
|
|
self.assertEqual(fp.species_code("Rabe (magisch)"), "")
|
|
self.assertEqual(fp.species_code("", "Elfriede"), "h")
|
|
|
|
def test_every_species_age_gender_combination_gets_every_applicable_slot(self):
|
|
for species in ("h", "e", "d", "o", "v", "he"):
|
|
for stage in "ctymo":
|
|
for gender in ("m", "f", ""):
|
|
inp = fp.FaceInput("X", species, stage, gender, "", "")
|
|
prof = fp.build_profile(inp, "b", [])
|
|
missing = [s for s in fp.SLOTS if s not in prof.values and fp._slot_applicable(s, inp)]
|
|
self.assertEqual(missing, [], (species, stage, gender))
|
|
if species != "e" or stage in "ct":
|
|
continue
|
|
self.assertNotIn(prof.values.get("age_signs", ""), ("old_wrinkled", "mid_lines"))
|
|
|
|
def test_alter_the_noun_is_not_old(self):
|
|
self.assertEqual(fp.age_stage("Alter unklar, erscheint als junger Mann"), "y")
|
|
self.assertEqual(fp.age_stage("Alter Mann (beugt sich)"), "o")
|
|
|
|
|
|
class SheetTraitTests(unittest.TestCase):
|
|
def _one(self, physical, **kw):
|
|
sheet = _s(kw.pop("gender", "male"), kw.pop("age", "jung"), kw.pop("species", "Mensch"), physical)
|
|
return fp.assign_profiles(BOOK, [fp.face_input_from_sheet("X", sheet, "adult", [])])["X"]
|
|
|
|
def test_sheet_traits_are_kept_and_marked(self):
|
|
prof = self._one("Schwarzhaarig, blaue Augen, Sommersprossen, hohe Wangenknochen, Hakennase.")
|
|
self.assertEqual((prof.values["hair_colour"], prof.values["eye_colour"], prof.values["mark"],
|
|
prof.values["cheeks"], prof.values["nose"]), ("black", "blue", "freckles", "high", "aquiline"))
|
|
for slot in ("hair_colour", "eye_colour", "mark", "cheeks", "nose"):
|
|
self.assertEqual(prof.sources[slot], "sheet")
|
|
self.assertIn("black hair", prof.clause())
|
|
|
|
def test_english_sheets_work_too(self):
|
|
prof = self._one("Short red hair, green eyes, a broad nose and a square jaw.")
|
|
self.assertEqual((prof.values["hair_colour"], prof.values["eye_colour"], prof.values["nose"], prof.values["jaw"]),
|
|
("red", "green", "broad", "square_jaw"))
|
|
self.assertEqual(prof.values["hair_style"], "s:short")
|
|
|
|
def test_sheet_never_contradicted(self):
|
|
cases = [("Kurzes dunkles Haar, grüne Augen", {"hair_colour": "dark_brown", "eye_colour": "green"}),
|
|
("Bartlos, rundes Gesicht", {"facial_hair": "clean", "face_shape": "round"}),
|
|
("Dunkelhäutig, volle Lippen", {"skin": "deep_brown", "mouth": "full"}),
|
|
("Hair is white, eyes are grey", {"hair_colour": "white", "eye_colour": "grey"}),
|
|
("dark eyes and brown hair", {"hair_colour": "brown", "eye_colour": "dark_brown"})]
|
|
for text, expected in cases:
|
|
prof = self._one(text)
|
|
for slot, oid in expected.items():
|
|
self.assertEqual(prof.values[slot], oid, f"{text!r}: {slot}")
|
|
|
|
def test_bald_sheet_gets_no_hair_colour_or_style(self):
|
|
prof = self._one("Kahlköpfig.")
|
|
self.assertEqual(prof.values["hair_style"], "bald")
|
|
self.assertNotIn("hair_colour", prof.values)
|
|
self.assertIn("a bald head", prof.clause())
|
|
|
|
def test_scars_are_not_added_to_the_prompt_text(self):
|
|
prof = self._one("Eine lange Narbe über der Wange.")
|
|
self.assertEqual(prof.values["mark"], "sheet_mark")
|
|
self.assertNotIn("scar", prof.clause().lower())
|
|
|
|
def test_unspecified_unrasiert_is_stubble_not_clean(self):
|
|
self.assertEqual(self._one("Unrasiert.").values["facial_hair"], "stubble")
|
|
|
|
def test_owner_override_replaces_the_clause_and_still_counts(self):
|
|
sheet = _s("male", "jung", "Mensch", "Mager.", visual_face="narrow face, hooked nose, one blind white eye, black hair")
|
|
inp = fp.face_input_from_sheet("X", sheet, "adult", [])
|
|
prof = fp.assign_profiles(BOOK, [inp])["X"]
|
|
self.assertEqual(prof.clause(), "face: narrow face, hooked nose, one blind white eye, black hair")
|
|
self.assertEqual(prof.values["hair_colour"], "black") # parsed, so later characters avoid it
|
|
self.assertEqual(prof.sources["hair_colour"], "override")
|
|
|
|
|
|
class KinTests(unittest.TestCase):
|
|
def test_kin_detection(self):
|
|
names = ["Marrad", "Darrag", "Himgi"]
|
|
self.assertEqual(fp.find_kin("Marrad", "Vater: Darrag (Schmied, trinkt viel); Himgi (Freund)", names), ("Darrag",))
|
|
self.assertEqual(fp.find_kin("Darrag", "Vater von Marrad (tot)", names), ("Marrad",))
|
|
self.assertEqual(fp.find_kin("Himgi", "Arbeitskollege von Darrag", names), ())
|
|
|
|
def test_relatives_share_family_traits_strangers_do_not(self):
|
|
shared_kin = shared_other = 0
|
|
total = 12
|
|
for i in range(total):
|
|
roster = [(f"Anna{i}", _s("female", "jung", "Mensch", "")), (f"Bert{i}", _s("male", "jung", "Mensch", "",
|
|
rel=f"Schwester: Anna{i}")), (f"Cord{i}", _s("male", "jung", "Mensch", ""))]
|
|
profiles = fp.assign_profiles(BOOK, _inputs(roster))
|
|
fam = lambda a, b: sum(profiles[a].values.get(s) == profiles[b].values.get(s) for s in fp.FAMILY_SLOTS)
|
|
shared_kin += fam(f"Anna{i}", f"Bert{i}")
|
|
shared_other += fam(f"Anna{i}", f"Cord{i}")
|
|
self.assertGreater(shared_kin, shared_other)
|
|
self.assertGreaterEqual(shared_kin / total, 1.0) # at least one inherited slot on average
|
|
|
|
def test_relatives_still_differ_in_the_other_slots(self):
|
|
roster = [("Anna", _s("female", "jung", "Mensch", "")), ("Bert", _s("male", "jung", "Mensch", "", rel="Bruder: Anna"))]
|
|
profiles = fp.assign_profiles(BOOK, _inputs(roster))
|
|
self.assertGreater(fp.distance(profiles["Anna"].values, profiles["Bert"].values), 0.3)
|
|
|
|
|
|
class PromptTests(unittest.TestCase):
|
|
SHEET = _s("female", "Junge Erwachsene", "Mensch", "Blonde Haare, blaue Augen.",
|
|
clothing="Ein einfaches Leinenkleid.")
|
|
|
|
def _attached(self, name="Ludara", sheet=None):
|
|
sheet = sheet or self.SHEET
|
|
roster = [{"name": n, "sheet": sh} for n, sh in ROSTER]
|
|
with mock.patch("core.database.char_face_roster", return_value=roster):
|
|
return conv._attach_face_profile(name, BOOK, sheet, dict(sheet))
|
|
|
|
def test_face_clause_is_in_every_prompt_and_survives_cleaning(self):
|
|
sheet = self._attached()
|
|
clause = sheet["_face_clause"]
|
|
self.assertTrue(clause.startswith("face: "))
|
|
self.assertIn("blonde hair", clause)
|
|
portrait = conv._build_character_portrait_prompt("Ludara", BOOK, sheet, {}, "cinematic")
|
|
turnaround = conv._build_character_sheet_prompt("Ludara", BOOK, sheet, {}, "cinematic")
|
|
self.assertIn(clause.removeprefix("face: "), portrait)
|
|
self.assertIn(clause.removeprefix("face: "), turnaround)
|
|
profile, front, _, _, scene, _ = conv._build_compact_character_render_prompts(
|
|
"Ludara", BOOK, sheet, {}, "cinematic", portrait, turnaround)
|
|
for text in (profile, front, scene):
|
|
self.assertIn(clause, text) # the injury/negation filters leave it untouched
|
|
|
|
def test_hand_edited_subject_still_gets_the_face(self):
|
|
sheet = self._attached()
|
|
edited = "Task: x. Subject: a weathered shepherd girl in a brown dress. Context: y."
|
|
profile, front, *_ = conv._build_compact_character_render_prompts(
|
|
"Ludara", BOOK, sheet, {}, "cinematic", edited, edited)
|
|
self.assertIn(sheet["_face_clause"], profile)
|
|
self.assertIn(sheet["_face_clause"], front)
|
|
|
|
def test_homogenising_boilerplate_is_gone(self):
|
|
sheet = self._attached()
|
|
text = " ".join((
|
|
conv._build_character_portrait_prompt("Ludara", BOOK, sheet, {}, "concept"),
|
|
conv._build_character_sheet_prompt("Ludara", BOOK, sheet, {}, "concept"),
|
|
*conv._build_compact_character_render_prompts("Ludara", BOOK, sheet, {}, "concept", "", "")[:2],
|
|
)).lower()
|
|
self.assertNotIn("consistent facial construction", text)
|
|
self.assertNotIn("reaching at least to the ears or collar", text)
|
|
self.assertNotIn("past the shoulders", text)
|
|
|
|
def test_dwarf_beard_comes_from_the_face_clause_only(self):
|
|
dwarf = _s("male", "mittleres Alter", "Zwerg", "Breit gebaut.", clothing="Kettenhemd")
|
|
sheet = self._attached("Garbaz", dwarf)
|
|
self.assertIn("beard", sheet["_face_clause"]) if sheet["_face_beard"] else None
|
|
lock = conv._age_body_lock(dwarf)[2]
|
|
self.assertNotIn("beard", lock)
|
|
woman = self._attached("Dwarfa", _s("female", "jung", "Zwerg", "", clothing="Kleid"))
|
|
text = " ".join(conv._build_compact_character_render_prompts("Dwarfa", BOOK, woman, {}, "cinematic", "", "")[:2]).lower()
|
|
self.assertNotIn("beard", text)
|
|
|
|
def test_german_to_english_path_still_feeds_the_profile(self):
|
|
sheet = {**self.SHEET, "physical": "Schwarzhaarig, blaue Augen."}
|
|
|
|
def chat(messages, max_tokens):
|
|
import json
|
|
payload = json.loads(messages[-1]["content"]) if messages[-1]["content"].lstrip().startswith("{") else {}
|
|
return json.dumps({k: str(v).replace("Schwarzhaarig", "Black-haired") for k, v in payload.items()})
|
|
|
|
with mock.patch.object(conv, "_load_settings", return_value={}):
|
|
en, _, _, info = conv._english_prompt_inputs("Ludara", sheet, {}, None, {}, chat=chat, cache={})
|
|
roster = [{"name": n, "sheet": sh} for n, sh in ROSTER]
|
|
with mock.patch("core.database.char_face_roster", return_value=roster):
|
|
prompt_sheet = conv._attach_face_profile("Ludara", BOOK, sheet, en)
|
|
self.assertIn("black hair", prompt_sheet["_face_clause"])
|
|
self.assertIn("blue eyes", prompt_sheet["_face_clause"])
|
|
self.assertTrue(conv._character_visual_facts("Ludara", prompt_sheet).count("face: ") == 1)
|
|
|
|
def test_cast_unreadable_falls_back_to_the_character_alone(self):
|
|
with mock.patch("core.database.char_face_roster", side_effect=RuntimeError("db")):
|
|
sheet = conv._attach_face_profile("Ludara", BOOK, self.SHEET, dict(self.SHEET))
|
|
self.assertTrue(sheet["_face_clause"].startswith("face: "))
|
|
|
|
def test_facts_without_cast_context_are_still_deterministic(self):
|
|
a = conv._character_visual_facts("Ludara", self.SHEET)
|
|
b = conv._character_visual_facts("Ludara", dict(self.SHEET))
|
|
self.assertEqual(a, b)
|
|
self.assertIn("face: ", a)
|
|
|
|
def test_seeds_stay_per_prompt(self):
|
|
# the image seed is derived from the prompt text / generation key, never shared by the cast
|
|
import inspect
|
|
source = inspect.getsource(conv._comfyui_run)
|
|
self.assertIn("seed_key or prompt", source)
|
|
|
|
|
|
class PreviewTests(unittest.TestCase):
|
|
def test_preview_lists_profiles_and_a_before_after_score(self):
|
|
roster = [{"name": n, "sheet": sh} for n, sh in ROSTER]
|
|
result = conv._face_preview_for_roster(BOOK, roster)
|
|
self.assertEqual(result["report"]["characters"], len(result["characters"]))
|
|
self.assertGreater(result["report"]["mean_distance_after"], result["report"]["mean_distance_before"])
|
|
self.assertEqual(len(result["similarity_matrix_after"]), len(result["order"]))
|
|
self.assertIn("Kroah", result["skipped_without_humanoid_face"])
|
|
first = result["characters"][0]
|
|
self.assertIn("slots", first)
|
|
self.assertIn("source", next(iter(first["slots"].values())))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|