"""Tests for the shared LLM JSON envelope recovery used by every AI route.""" from __future__ import annotations import pytest from services.llm_json import ( LLMJsonError, coerce_dict_list, collect_dict_objects, extract_dict_list, extract_json_blocks, extract_json_list, extract_json_object, extract_json_value, loads_jsonish, normalize_candidate, strip_reasoning, ) class TestStripReasoning: def test_removes_think_blocks(self) -> None: assert strip_reasoning("hmm{\"a\": 1}") == '{"a": 1}' def test_removes_unpaired_closing_tag(self) -> None: assert strip_reasoning('{"a": 1}') == '{"a": 1}' def test_removes_reasoning_preamble(self) -> None: assert strip_reasoning('Thinking process: {"a": 1}') == '{"a": 1}' def test_plain_text_is_unchanged(self) -> None: assert strip_reasoning(" plain ") == "plain" class TestExtractJsonBlocks: def test_finds_outer_and_nested_blocks(self) -> None: # The scanner starts at every opener; callers take the first usable block. blocks = extract_json_blocks('pre {"a": {"b": 1}} post') assert blocks[0] == '{"a": {"b": 1}}' assert '{"b": 1}' in blocks def test_ignores_brackets_inside_strings(self) -> None: assert extract_json_blocks('{"a": "}"}') == ['{"a": "}"}'] def test_finds_array(self) -> None: assert extract_json_blocks("see [1, 2] now") == ["[1, 2]"] def test_returns_empty_for_no_json(self) -> None: assert extract_json_blocks("no json here") == [] class TestNormalizeCandidate: def test_replaces_smart_quotes(self) -> None: assert normalize_candidate('{\u201ca\u201d: 1}') == '{"a": 1}' def test_drops_trailing_comma(self) -> None: assert normalize_candidate('{"a": 1,}') == '{"a": 1}' def test_strips_bom(self) -> None: assert normalize_candidate("\ufeff{}") == "{}" class TestLoadsJsonish: def test_parses_valid_json(self) -> None: assert loads_jsonish('{"a": 1}') == {"a": 1} def test_parses_fenced_json(self) -> None: assert loads_jsonish('```json\n{"a": 1}\n```') == {"a": 1} def test_parses_python_literals(self) -> None: assert loads_jsonish("{'a': True, 'b': None}") == {"a": True, "b": None} def test_returns_none_for_garbage(self) -> None: assert loads_jsonish("not json at all") is None class TestExtractJsonValue: def test_prefers_first_parseable_object(self) -> None: assert extract_json_value('{"a": 1} trailing {"b": 2}', preferred=dict) == {"a": 1} def test_recovers_from_prose_wrapper(self) -> None: prose = 'Sure! Here is the payload:\n```json\n{"a": 1}\n```\nHope that helps.' assert extract_json_value(prose, preferred=dict) == {"a": 1} def test_recovers_from_thinking_block(self) -> None: assert extract_json_value('...\n[{"q": 1}]', preferred=list) == [{"q": 1}] def test_raises_when_nothing_parses(self) -> None: with pytest.raises(LLMJsonError): extract_json_value("no json here") def test_raises_when_type_not_preferred(self) -> None: with pytest.raises(LLMJsonError): extract_json_value("[1, 2]", preferred=dict) class TestTypedHelpers: def test_extract_json_object_returns_none_on_array(self) -> None: assert extract_json_object("[1]") is None def test_extract_json_list_returns_none_on_object(self) -> None: assert extract_json_list('{"a": 1}') is None def test_extract_json_object_parses(self) -> None: assert extract_json_object('{"a": 1}') == {"a": 1} class TestDictListExtraction: def test_coerce_filters_non_dicts(self) -> None: assert coerce_dict_list([{"a": 1}, "x", 2, None]) == [{"a": 1}] def test_coerce_rejects_non_list(self) -> None: assert coerce_dict_list({"a": 1}) == [] def test_extract_dict_list_from_bare_array(self) -> None: assert extract_dict_list([{"a": 1}]) == [{"a": 1}] def test_extract_dict_list_unwraps_questions_key(self) -> None: assert extract_dict_list({"questions": [{"q": 1}]}) == [{"q": 1}] def test_extract_dict_list_unwraps_practice_questions_key(self) -> None: assert extract_dict_list({"practice_questions": [{"q": 1}]}) == [{"q": 1}] def test_extract_dict_list_prefers_questions_over_data(self) -> None: payload = {"data": [{"d": 1}], "questions": [{"q": 1}]} assert extract_dict_list(payload) == [{"q": 1}] def test_extract_dict_list_returns_empty_for_unknown_envelope(self) -> None: assert extract_dict_list({"unknown": [{"q": 1}]}) == [] class TestCollectDictObjects: def test_salvages_multiple_objects_from_truncated_output(self) -> None: truncated = '{"question": "a", "correct": "A"} {"question": "b", "correct": "B"}' assert collect_dict_objects(truncated) == [ {"question": "a", "correct": "A"}, {"question": "b", "correct": "B"}, ] def test_ignores_arrays(self) -> None: assert collect_dict_objects("[1, 2]") == []