""" Tests for `src.llm.json_parser`. Acceptance: - a mock LLM that delivers JSON in a code block is parsed cleanly - direct, Markdown block, curly braces, square brackets and truncated JSON are all recognised """ import unittest from src.llm.json_parser import coerce_float, parse_llm_json class TestParseLLMJson(unittest.TestCase): def test_direct_json(self): text = '{"foo": "bar", "n": 42}' result = parse_llm_json(text) self.assertEqual(result, {"foo": "bar", "n": 42}) def test_json_array_direct(self): text = '[{"a": 1}, {"b": 2}]' result = parse_llm_json(text) self.assertEqual(result, [{"a": 1}, {"b": 2}]) def test_markdown_codeblock(self): text = '''Hier ist meine Antwort: ```json {"anchor_type": "none", "confidence": 0.94} ``` ''' result = parse_llm_json(text) self.assertEqual(result["anchor_type"], "none") self.assertEqual(result["confidence"], 0.94) def test_markdown_codeblock_no_lang(self): text = '```\n{"x": 1}\n```' result = parse_llm_json(text) self.assertEqual(result, {"x": 1}) def test_with_prefix_text(self): text = 'Sicher, hier ist das JSON: {"y": 2, "z": [1, 2]}' result = parse_llm_json(text) self.assertEqual(result, {"y": 2, "z": [1, 2]}) def test_with_think_block(self): text = 'Ich überlege...\n{"answer": "ok"}' result = parse_llm_json(text) self.assertEqual(result, {"answer": "ok"}) def test_unclosed_think_block(self): # With a max_tokens cutoff during reasoning the closing tag is missing. # The result is without JSON after it — the parser must return # empty, not crash. text = 'Ich überlege noch und denke über' result = parse_llm_json(text, default={}) self.assertEqual(result, {}) def test_nested_braces(self): text = '{"outer": {"inner": {"deep": "value"}}, "x": 1}' result = parse_llm_json(text) self.assertEqual(result["outer"]["inner"]["deep"], "value") def test_braces_in_strings_dont_confuse_parser(self): # A `}` inside a string must not be taken as the end of the object. text = '{"text": "Hier ist ein } in einem String", "n": 1}' result = parse_llm_json(text) self.assertEqual(result["text"], "Hier ist ein } in einem String") self.assertEqual(result["n"], 1) def test_truncated_object_repaired(self): # Cut off at max_tokens — the parser should rescue the complete # fields. text = '{"a": 1, "b": "halbe Antwo' result = parse_llm_json(text, default={}) # At least the complete first field is in it self.assertEqual(result.get("a"), 1) def test_empty_string(self): self.assertEqual(parse_llm_json("", default={}), {}) self.assertEqual(parse_llm_json(" \n ", default={}), {}) def test_no_json_at_all(self): text = "Tut mir leid, ich kann das nicht." result = parse_llm_json(text, default={"fallback": True}) self.assertEqual(result, {"fallback": True}) def test_expected_keys_validation_passes(self): text = '{"anchor_type": "person", "confidence": 0.9, "extra": "x"}' result = parse_llm_json( text, expected_keys=["anchor_type", "confidence"], default={} ) self.assertEqual(result["anchor_type"], "person") def test_expected_keys_validation_fails(self): # `confidence` missing — the parser must return the default text = '{"anchor_type": "person"}' result = parse_llm_json( text, expected_keys=["anchor_type", "confidence"], default={} ) self.assertEqual(result, {}) def test_array_after_text(self): text = "Hier sind die Items:\n[{\"i\": 1}, {\"i\": 2}]" result = parse_llm_json(text) self.assertEqual(result, [{"i": 1}, {"i": 2}]) class TestCoerceFloat(unittest.TestCase): def test_float_passthrough(self): self.assertEqual(coerce_float(0.85), 0.85) def test_int_to_float(self): self.assertEqual(coerce_float(1), 1.0) def test_string_with_dot(self): self.assertEqual(coerce_float("0.7"), 0.7) def test_string_with_comma(self): # German spelling — tolerated self.assertEqual(coerce_float("0,7"), 0.7) def test_percent_string(self): self.assertEqual(coerce_float("85%"), 0.85) def test_percent_too_big_normalized(self): # The LLM answers "0.85" as "85" — recognised heuristically result = coerce_float("85") self.assertEqual(result, 0.85) def test_clamp_below(self): self.assertEqual(coerce_float(-0.5), 0.0) def test_clamp_above(self): self.assertEqual(coerce_float(1.5), 1.0) def test_invalid_returns_default(self): self.assertEqual(coerce_float("not a number", default=0.42), 0.42) def test_none_returns_default(self): self.assertEqual(coerce_float(None, default=0.0), 0.0) if __name__ == "__main__": unittest.main()