"""Automatic character image check: request shape, parsing, judging, and the route.""" from __future__ import annotations import os import tempfile import unittest from unittest import mock os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-imgcheck-test-config-")) from fastapi import FastAPI from fastapi.testclient import TestClient from core.character_image_check import build_messages, expected_from_sheet, judge, parse_observation import routes.conversation as conversation PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" WOMAN = expected_from_sheet({"gender": "female"}, is_elf=False, age_category="adult") GOOD_TURNAROUND = { "figure_count": 3, "views_left_to_right": ["front", "side", "back"], "all_feet_visible": True, "all_heads_visible": True, "same_person_in_all": True, "apparent_gender": "female", "ears": "round", "wounds_or_blood": False, "background": "plain", } class RequestTests(unittest.TestCase): def test_the_question_asks_for_observations_not_for_agreement(self): text = build_messages("turnaround", [PNG])[0]["content"][0]["text"] self.assertIn("views_left_to_right", text) self.assertIn("Report only what is actually visible", text) self.assertNotIn("female", text.split("keys:")[0]) # the expected gender is never revealed def test_same_person_sends_both_images(self): parts = build_messages("same_person", [PNG, PNG])[0]["content"] self.assertEqual(sum(1 for p in parts if p["type"] == "image_url"), 2) def test_wrong_image_counts_are_rejected(self): with self.assertRaises(ValueError): build_messages("same_person", [PNG]) with self.assertRaises(ValueError): build_messages("portrait", [PNG, PNG]) class ParseTests(unittest.TestCase): def test_reads_fenced_json_and_drops_unknown_keys(self): raw = 'hmm```json\n{"apparent_gender":"male","extra":1}\n```' self.assertEqual(parse_observation(raw, "portrait"), {"apparent_gender": "male"}) def test_garbage_gives_an_empty_observation(self): self.assertEqual(parse_observation("I cannot tell.", "portrait"), {}) class JudgeTests(unittest.TestCase): def test_a_correct_turnaround_passes(self): self.assertTrue(judge("turnaround", GOOD_TURNAROUND, WOMAN)["passed"]) def test_three_front_views_fail(self): out = judge("turnaround", dict(GOOD_TURNAROUND, views_left_to_right=["front", "front", "front"]), WOMAN) self.assertFalse(out["passed"]) self.assertTrue(any("views" in f for f in out["failures"])) def test_cut_off_feet_fail(self): out = judge("turnaround", dict(GOOD_TURNAROUND, all_feet_visible=False), WOMAN) self.assertIn("feet cut off", out["failures"]) def test_a_man_where_the_sheet_says_woman_fails(self): out = judge("scene", {"main_person_gender": "male", "ears": "round", "wounds_or_blood": False}, WOMAN) self.assertEqual(out["failures"], ["gender: shows male, sheet says female"]) def test_elf_ears_on_a_human_fail_but_an_elf_needs_them(self): self.assertFalse(judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "pointed", "wounds_or_blood": False, "background": "plain"}, WOMAN)["passed"]) elf = expected_from_sheet({"gender": "female"}, is_elf=True, age_category="adult") self.assertTrue(judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "pointed", "wounds_or_blood": False, "background": "plain"}, elf)["passed"]) def test_hidden_ears_are_not_held_against_the_image(self): self.assertTrue(judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "hidden", "wounds_or_blood": False, "background": "plain"}, WOMAN)["passed"]) def test_scenery_behind_a_studio_portrait_fails(self): out = judge("portrait", {"person_count": 1, "apparent_gender": "female", "ears": "round", "wounds_or_blood": False, "background": "scenery"}, WOMAN) self.assertFalse(out["passed"]) def test_wounds_fail(self): out = judge("turnaround", dict(GOOD_TURNAROUND, wounds_or_blood=True), WOMAN) self.assertIn("wounds or blood visible", out["failures"]) def test_an_observation_the_model_did_not_give_is_a_failure(self): self.assertFalse(judge("turnaround", {}, WOMAN)["passed"]) def test_no_gender_on_the_sheet_means_gender_is_not_judged(self): unknown = expected_from_sheet({}, is_elf=False, age_category="adult") out = judge("scene", {"main_person_gender": "male", "ears": "round", "wounds_or_blood": False}, unknown) self.assertTrue(out["passed"]) def test_a_different_person_from_the_profile_fails(self): self.assertFalse(judge("same_person", {"same_person": False}, WOMAN)["passed"]) class RouteTests(unittest.TestCase): def setUp(self): app = FastAPI() app.include_router(conversation.router) self.client = TestClient(app) def answer(self, content, status=200): r = mock.Mock(); r.status_code = status r.json.return_value = {"choices": [{"message": {"content": content}}]} r.text = content return r def test_judges_the_models_observation_against_the_sheet(self): with mock.patch.object(conversation, "_post_llm_chat_completion", return_value=self.answer('{"main_person_gender":"male","ears":"round","wounds_or_blood":false}')): out = self.client.post("/api/character-image-check", json={"kind": "scene", "images": [PNG], "sheet": {"gender": "female"}}).json() self.assertFalse(out["passed"]) self.assertEqual(out["failures"], ["gender: shows male, sheet says female"]) self.assertEqual(out["expected"]["gender"], "female") def test_a_hedged_species_is_judged_as_human(self): sheet = {"gender": "female", "race_species": "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)"} with mock.patch.object(conversation, "_post_llm_chat_completion", return_value=self.answer('{"main_person_gender":"female","ears":"pointed","wounds_or_blood":false}')): out = self.client.post("/api/character-image-check", json={"kind": "scene", "images": [PNG], "sheet": sheet}).json() self.assertEqual(out["expected"]["ears"], "round") self.assertIn("ears: pointed, but the character is not an elf", out["failures"]) def test_thinking_is_switched_off_and_dropped_if_the_backend_refuses_it(self): good = self.answer('{"main_person_gender":"female","ears":"round","wounds_or_blood":false}') with mock.patch.object(conversation, "_post_llm_chat_completion", side_effect=[self.answer("unsupported", status=400), good]) as llm: out = self.client.post("/api/character-image-check", json={"kind": "scene", "images": [PNG], "sheet": {"gender": "female"}}).json() self.assertTrue(out["passed"]) self.assertEqual(llm.call_args_list[0].args[1]["chat_template_kwargs"], {"enable_thinking": False}) self.assertNotIn("chat_template_kwargs", llm.call_args_list[1].args[1]) def test_an_unreachable_checker_is_a_failure_not_a_pass(self): with mock.patch.object(conversation, "_post_llm_chat_completion", side_effect=RuntimeError("down")): out = self.client.post("/api/character-image-check", json={"kind": "portrait", "images": [PNG], "sheet": {}}).json() self.assertFalse(out["passed"]) self.assertTrue(out["failures"][0].startswith("checker unavailable")) def test_bad_requests_are_rejected_before_any_model_call(self): with mock.patch.object(conversation, "_post_llm_chat_completion") as llm: for body in ({"kind": "nope", "images": [PNG]}, {"kind": "portrait", "images": ["http://evil/x.png"]}, {"kind": "portrait", "images": []}, {"kind": "same_person", "images": [PNG]}): with self.subTest(body=str(body)[:50]): self.assertEqual(self.client.post("/api/character-image-check", json=body).status_code, 400) llm.assert_not_called() if __name__ == "__main__": unittest.main()