"""Character info is translated to English before image prompts are built. All LLM calls are mocked; no image generation, no network. """ from __future__ import annotations import json import os import tempfile import unittest from unittest import mock os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-translation-test-config-")) import routes.conversation as conv from core import prompt_translation as pt TRANSLATIONS = { "Mensch (oder Zwerg, je nach Kontext 'Amboßzwerg', hier als junger Krieger beschrieben)": "Human (or dwarf, depending on context 'Anvil dwarf', described here as a young warrior)", "Klein, dünn, schwach; hat dünne Arme.": "Small, thin, weak; has thin arms.", "Ein roter Umhang aus Wolle.": "A red cloak made of wool.", "Zwerg (Amboßzwerg)": "Dwarf (anvil dwarf)", "Breit gebaut, mit langem rotem Bart.": "Broadly built, with a long red beard.", "Ein Kettenhemd über einer Wolltunika.": "A chainmail shirt over a wool tunic.", "Kind, etwa 10 Jahre": "Child, about 10 years old", "Er trägt einen Hut": "He wears a hat", } class FakeChat: def __init__(self, table=None, raw=None, exc=None): self.table, self.raw, self.exc, self.calls = table or TRANSLATIONS, raw, exc, [] def __call__(self, messages, max_tokens): self.calls.append(messages) if self.exc: raise self.exc if self.raw is not None: return self.raw batch = json.loads(messages[-1]["content"]) out = {} for key, value in batch.items(): out[key] = [self.table.get(v, v) for v in value] if isinstance(value, list) else self.table.get(value, value) return json.dumps(out, ensure_ascii=False) def run(fields, chat, cache=None, names=None, enabled=True): return pt.translate_fields(fields, chat=chat, model="m1", cache=cache if cache is not None else pt.MemoryCache(), names=names, enabled=enabled) class DetectionTests(unittest.TestCase): def test_german_and_english(self): self.assertTrue(pt.needs_translation("hat dünne Arme")) self.assertTrue(pt.needs_translation("Mensch (oder Zwerg)")) self.assertFalse(pt.needs_translation("A tall man with a long grey beard, wearing a wool cloak.")) self.assertFalse(pt.needs_translation("Human (or dwarf, depending on context)")) self.assertFalse(pt.needs_translation("")) self.assertFalse(pt.needs_translation("42")) self.assertFalse(pt.needs_translation(None)) class TranslateFieldsTests(unittest.TestCase): def test_batch_is_one_call_and_cache_hit_miss_invalidate(self): cache, chat = pt.MemoryCache(), FakeChat() fields = {"physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle.", "x": "already English text of the thing"} first = run(fields, chat, cache) self.assertEqual(len(chat.calls), 1) self.assertEqual(first.values["physical"], "Small, thin, weak; has thin arms.") self.assertEqual(first.skipped, ["x"]) second = run(fields, chat, cache) self.assertEqual(len(chat.calls), 1, "cache hit must not call the LLM") self.assertEqual(second.cache_hits, 2) changed = dict(fields, physical="Klein, dünn, schwach; hat dünne Arme.", clothing="Ein roter Umhang aus Wolle.") chat.table = dict(TRANSLATIONS, **{"Ein grüner Umhang aus Wolle.": "A green cloak made of wool."}) changed["clothing"] = "Ein grüner Umhang aus Wolle." third = run(changed, chat, cache) self.assertEqual(len(chat.calls), 2, "changed source text must miss") self.assertEqual(third.values["clothing"], "A green cloak made of wool.") self.assertEqual(third.cache_hits, 1) # a different translator model is a different key run(fields, chat, cache) and pt.translate_fields(fields, chat=chat, model="other", cache=cache) self.assertEqual(len(chat.calls), 3) def test_list_fields(self): chat = FakeChat(table={"Ein Schwert": "A sword", "Ein Dolch": "A dagger"}) res = run({"inventory": ["Ein Schwert", "Ein Dolch"]}, chat) self.assertEqual(res.values["inventory"], ["A sword", "A dagger"]) def test_invalid_json_falls_back(self): res = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, FakeChat(raw="Sorry, no JSON")) self.assertEqual(res.values["physical"], "Klein, dünn, schwach; hat dünne Arme.") self.assertTrue(res.used_untranslated) self.assertTrue(res.error) def test_validation_failures_fall_back_per_field(self): raw = json.dumps({"physical": "", "clothing": "I'm sorry, I cannot translate this text."}) res = run({"physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle."}, FakeChat(raw=raw)) self.assertEqual(sorted(res.untranslated), ["clothing", "physical"]) self.assertEqual(res.values["clothing"], "Ein roter Umhang aus Wolle.") def test_echo_and_length_rejected(self): src = "Klein, dünn, schwach; hat dünne Arme." self.assertIsNotNone(pt.validate_translation(src, src)) self.assertIsNotNone(pt.validate_translation(src, "x" * 500)) self.assertIsNone(pt.validate_translation(src, "Small, thin, weak; has thin arms.")) def test_names_preserved(self): src = "Marrad trägt einen roten Umhang." self.assertIsNone(pt.validate_translation(src, "Marrad wears a red cloak.", ["Marrad"])) self.assertIn("name not preserved", pt.validate_translation(src, "Marat wears a red cloak.", ["Marrad"])) chat = FakeChat(table={src: "Marat wears a red cloak."}) res = run({"physical": src}, chat, names=["Marrad"]) self.assertEqual(res.values["physical"], src) def test_english_only_never_calls_llm(self): chat = FakeChat() res = run({"physical": "A thin boy with short hair."}, chat) self.assertEqual(chat.calls, []) self.assertFalse(res.used_untranslated) def test_flag_off_and_llm_down(self): chat = FakeChat() off = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, chat, enabled=False) self.assertEqual(chat.calls, []) self.assertTrue(off.used_untranslated) down = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, FakeChat(exc=ConnectionError("down"))) self.assertEqual(down.values["physical"], "Klein, dünn, schwach; hat dünne Arme.") self.assertTrue(down.used_untranslated) none = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, None) self.assertTrue(none.used_untranslated) def test_env_flag(self): with mock.patch.dict(os.environ, {"PROMPT_TRANSLATION": "off"}): self.assertFalse(pt.translation_enabled({})) with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop("PROMPT_TRANSLATION", None) self.assertTrue(pt.translation_enabled({})) self.assertFalse(pt.translation_enabled({"prompt_translation": False})) BOY = { "race_species": "Mensch (oder Zwerg, je nach Kontext 'Amboßzwerg', hier als junger Krieger beschrieben)", "gender": "male", "physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle.", "age_estimate": "Kind, etwa 10 Jahre", } def prompts(name, sheet): portrait = conv._build_character_portrait_prompt(name, "Book", sheet, {}, "cinematic") turnaround = conv._build_character_sheet_prompt(name, "Book", sheet, {}, "cinematic") out = conv._build_compact_character_render_prompts(name, "Book", sheet, {}, "cinematic", portrait, turnaround) return " ".join([portrait, turnaround, *out[:5], *out[5]]) class ConversationWiringTests(unittest.TestCase): def english(self, sheet, chat=None, **kw): return conv._english_prompt_inputs("Marrad", sheet, {}, None, {"llm_url": ""}, chat=chat or FakeChat(), cache=pt.MemoryCache(), **kw) def test_hat_duenne_arme_regression(self): sheet_en, _, _, info = self.english(BOY) self.assertEqual(sheet_en["physical"], "Small, thin, weak; has thin arms.") self.assertFalse(info["used_untranslated_text"]) self.assertNotIn("hood", prompts("Marrad", sheet_en)) # fallback path with the German text must not turn "hat" into a hood either self.assertNotIn("hood", conv._period_wording("Klein, dünn, schwach; hat dünne Arme.")) self.assertNotIn("hood", conv._period_wording("hat dünne Arme")) self.assertIn("hood", conv._period_wording("He wears a hat")) def test_mixed_species_note_is_not_a_dwarf_with_and_without_translation(self): self.assertFalse(conv._character_is_dwarf(BOY)) sheet_en, *_ = self.english(BOY) self.assertEqual(sheet_en["race_species"].split("(")[0].strip(), "Human") self.assertFalse(conv._character_is_dwarf(sheet_en)) self.assertNotIn("DWARF", prompts("Marrad", sheet_en).upper()) def test_real_dwarf_still_dwarf_both_ways(self): dwarf = {"race_species": "Zwerg (Amboßzwerg)", "gender": "male", "physical": "Breit gebaut, mit langem rotem Bart.", "clothing": "Ein Kettenhemd über einer Wolltunika."} self.assertTrue(conv._character_is_dwarf(dwarf)) sheet_en, *_ = self.english(dwarf) self.assertEqual(sheet_en["race_species"], "Dwarf (anvil dwarf)") self.assertTrue(conv._character_is_dwarf(sheet_en)) def test_age_estimate_is_read(self): self.assertEqual(conv._character_age_category({"age_estimate": "Kind, etwa 10 Jahre"}), "child") self.assertEqual(conv._character_age_category({"age_estimate": "about 15 years old"}), "teen") self.assertEqual(conv._character_age_category({"age_estimate": "Mitte 40"}), "adult") self.assertEqual(conv._character_age_category({"age": "9 Jahre"}), "child") sheet_en, *_ = self.english(BOY) self.assertEqual(conv._age_body_lock(sheet_en)[0], "EXACTLY ONE CHILD.") def test_record_not_modified(self): original = json.loads(json.dumps(BOY)) self.english(BOY) self.assertEqual(BOY, original) def test_llm_down_falls_back_and_still_builds(self): sheet_en, _, _, info = self.english(BOY, chat=FakeChat(exc=ConnectionError("down"))) self.assertEqual(sheet_en["physical"], BOY["physical"]) self.assertTrue(info["used_untranslated_text"]) self.assertFalse(conv._character_is_dwarf(sheet_en)) self.assertTrue(prompts("Marrad", sheet_en)) def test_flag_off(self): chat = FakeChat() with mock.patch.dict(os.environ, {"PROMPT_TRANSLATION": "0"}): sheet_en, _, _, info = self.english(BOY, chat=chat) self.assertEqual(chat.calls, []) self.assertFalse(info["enabled"]) self.assertTrue(info["used_untranslated_text"]) self.assertEqual(sheet_en["physical"], BOY["physical"]) def test_scene_evidence_and_book_profile_translated(self): table = {"Der Zwerg hob die Axt und rief laut.": "The dwarf raised the axe and shouted loudly.", "Ein dunkler Wald im Norden": "A dark forest in the north"} sheet = {"sources": [{"quote": "Der Zwerg hob die Axt und rief laut.", "line_hint": "action"}]} sheet_en, book_en, _, _ = conv._english_prompt_inputs( "Kolon", sheet, {"setting": "Ein dunkler Wald im Norden"}, None, {"llm_url": ""}, chat=FakeChat(table=table), cache=pt.MemoryCache()) self.assertEqual(sheet_en["visual_scene"], "The dwarf raised the axe and shouted loudly.") self.assertEqual(book_en["setting"], "A dark forest in the north") if __name__ == "__main__": unittest.main()