"""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]") == []