"""
Unit tests for changedetectionio/llm/response_parser.py
All functions are pure — no external dependencies needed.
"""
import pytest
from changedetectionio.llm.response_parser import (
_extract_json,
parse_eval_response,
parse_preview_response,
parse_setup_response,
)
class TestExtractJson:
def test_plain_json_passes_through(self):
raw = '{"important": true, "summary": "price dropped"}'
assert _extract_json(raw) == raw
def test_strips_json_code_fence(self):
raw = '```json\n{"important": false, "summary": "no match"}\n```'
result = _extract_json(raw)
assert result.startswith('{')
assert '"important"' in result
def test_strips_plain_code_fence(self):
raw = '```\n{"important": true, "summary": "ok"}\n```'
result = _extract_json(raw)
assert result.startswith('{')
def test_extracts_json_from_surrounding_text(self):
raw = 'Here is my response: {"important": true, "summary": "match"} — done.'
result = _extract_json(raw)
assert result == '{"important": true, "summary": "match"}'
def test_multiline_json(self):
raw = '{\n "important": false,\n "summary": "nothing relevant"\n}'
result = _extract_json(raw)
assert '"important"' in result
def test_strips_reasoning_think_tags(self):
raw = (
'\n'
'Let us consider if {"important": false} is right. Actually, yes.\n'
'\n'
'{"important": true, "summary": "Price fell to $300"}'
)
result = _extract_json(raw)
assert result == '{"important": true, "summary": "Price fell to $300"}'
def test_strips_reasoning_think_tags_with_code_fence(self):
raw = (
'\nThinking about the price change.\n\n'
'```json\n{"important": false, "summary": "Cosmetic change only"}\n```'
)
result = _extract_json(raw)
assert '"important"' in result
assert '' not in result
class TestReasoningBlockEdgeCases:
"""A reasoning scratchpad usually contains JSON of its own, so any leftover scratchpad
lets _extract_json return a discarded intermediate answer. Every shape below carries a
misleading `"important": false` in the scratchpad and the real verdict outside it."""
def test_closing_tag_only_is_still_stripped(self):
# Several providers/chat templates inject the opening tag themselves, so only the
# closer comes back over the wire.
raw = (
'My first read was {"important": false, "summary": "nothing"}\n'
'\n'
'{"important": true, "summary": "Price dropped"}'
)
assert _extract_json(raw) == '{"important": true, "summary": "Price dropped"}'
assert parse_eval_response(raw) == {
'important': True,
'summary': 'Price dropped',
}
def test_thinking_tag_variant_is_stripped(self):
raw = (
'weighing {"important": false, "summary": "no"}\n'
'{"important": true, "summary": "Price dropped"}'
)
assert parse_eval_response(raw)['important'] is True
def test_multiple_reasoning_blocks_are_stripped(self):
raw = (
'step one'
'{"important": false, "summary": "no"}'
'{"important": true, "summary": "Price dropped"}'
)
assert parse_eval_response(raw)['important'] is True
def test_unterminated_reasoning_block_raises(self):
# Truncated by max_tokens mid-thought: the only JSON present is the abandoned guess,
# so returning it would silently invert the verdict. Raise instead and let
# evaluator.py's handler fall back to "important" rather than dropping the change.
raw = (
'\n'
'First guess: {"important": false, "summary": "nothing"}\n'
'But actually the price dropped, so'
)
with pytest.raises(ValueError, match='unterminated reasoning block'):
_extract_json(raw)
def test_unterminated_block_propagates_out_of_parse_eval_response(self):
"""Deliberately NOT swallowed. parse_eval_response's own fallback is
important=False, which suppresses the notification - the opposite of what
evaluator.py wants on failure ("don't suppress the notification"). Letting
ValueError escape routes it to that handler instead. Do not add ValueError to
the except tuple in parse_eval_response."""
raw = 'truncated mid-thought {"important": false}'
with pytest.raises(ValueError):
parse_eval_response(raw)
def test_response_with_no_reasoning_block_is_untouched(self):
raw = '{"important": true, "summary": "plain"}'
assert _extract_json(raw) == raw
class TestParseEvalResponse:
def test_valid_important_true(self):
raw = '{"important": true, "summary": "Price dropped from $500 to $400"}'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'Price dropped from $500 to $400'
def test_valid_important_false(self):
raw = '{"important": false, "summary": "Only a date counter changed"}'
result = parse_eval_response(raw)
assert result['important'] is False
assert 'date counter' in result['summary']
def test_string_false_evaluates_to_false(self):
raw = '{"important": "false", "summary": "No relevant changes found"}'
result = parse_eval_response(raw)
assert result['important'] is False
assert result['summary'] == 'No relevant changes found'
def test_string_true_evaluates_to_true(self):
raw = '{"important": "true", "summary": "Price updated"}'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'Price updated'
def test_markdown_fenced_response(self):
raw = '```json\n{"important": true, "summary": "New job posted"}\n```'
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'New job posted'
def test_reasoning_model_response_parsed_correctly(self):
raw = (
'\n'
'1. Checking diff: {"important": false} was our initial thought.\n'
'2. However the price dropped from $100 to $80.\n'
'\n'
'{"important": true, "summary": "Price dropped by $20"}'
)
result = parse_eval_response(raw)
assert result['important'] is True
assert result['summary'] == 'Price dropped by $20'
def test_malformed_json_falls_back_to_safe_default(self):
result = parse_eval_response('this is not json at all')
assert result['important'] is False
assert result['summary'] == ''
def test_empty_string_falls_back(self):
result = parse_eval_response('')
assert result['important'] is False
def test_truthy_integer_coerced_to_bool(self):
raw = '{"important": 1, "summary": "yes"}'
result = parse_eval_response(raw)
assert result['important'] is True
def test_falsy_integer_coerced_to_bool(self):
raw = '{"important": 0, "summary": "no"}'
result = parse_eval_response(raw)
assert result['important'] is False
def test_summary_stripped_of_whitespace(self):
raw = '{"important": false, "summary": " no match "}'
result = parse_eval_response(raw)
assert result['summary'] == 'no match'
def test_missing_summary_defaults_to_empty_string(self):
raw = '{"important": true}'
result = parse_eval_response(raw)
assert result['summary'] == ''
def test_extra_keys_ignored(self):
raw = '{"important": false, "summary": "skip", "confidence": 0.3, "debug": "xyz"}'
result = parse_eval_response(raw)
assert result['important'] is False
assert result['summary'] == 'skip'
class TestParsePreviewResponse:
def test_valid_found_true(self):
raw = '{"found": true, "answer": "Price is $49.99"}'
result = parse_preview_response(raw)
assert result['found'] is True
assert result['answer'] == 'Price is $49.99'
def test_valid_found_false(self):
raw = '{"found": false, "answer": "Item not listed"}'
result = parse_preview_response(raw)
assert result['found'] is False
assert result['answer'] == 'Item not listed'
def test_string_false_in_preview(self):
raw = '{"found": "false", "answer": "Not found"}'
result = parse_preview_response(raw)
assert result['found'] is False
assert result['answer'] == 'Not found'
def test_preview_with_think_tags(self):
raw = 'Looking for price...\n{"found": true, "answer": "$19.99"}'
result = parse_preview_response(raw)
assert result['found'] is True
assert result['answer'] == '$19.99'
class TestParseSetupResponse:
def test_no_prefilter_needed(self):
raw = '{"needs_prefilter": false, "selector": null, "reason": "intent is global"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is False
assert result['selector'] is None
def test_string_false_in_setup(self):
raw = '{"needs_prefilter": "false", "selector": null, "reason": "global"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is False
def test_semantic_selector_accepted(self):
raw = (
'{"needs_prefilter": true, "selector": "footer", "reason": "intent references footer"}'
)
result = parse_setup_response(raw)
assert result['needs_prefilter'] is True
assert result['selector'] == 'footer'
def test_attribute_selector_accepted(self):
raw = '{"needs_prefilter": true, "selector": "[class*=\'price\']", "reason": "pricing section"}'
result = parse_setup_response(raw)
assert result['needs_prefilter'] is True
assert result['selector'] is not None
def test_nth_child_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "div:nth-child(3)", "reason": "third div"}'
result = parse_setup_response(raw)
assert result['selector'] is None
assert result['needs_prefilter'] is False
def test_nth_of_type_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "p:nth-of-type(2)", "reason": "second p"}'
result = parse_setup_response(raw)
assert result['selector'] is None
assert result['needs_prefilter'] is False
def test_eq_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "div:eq(0)", "reason": "first div"}'
result = parse_setup_response(raw)
assert result['selector'] is None
def test_xpath_positional_selector_rejected(self):
raw = '{"needs_prefilter": true, "selector": "//*[2]", "reason": "second element"}'
result = parse_setup_response(raw)
assert result['selector'] is None
def test_selector_forced_to_null_when_needs_prefilter_false(self):
# Even if selector is provided alongside needs_prefilter=false, selector is nulled
raw = '{"needs_prefilter": false, "selector": "main", "reason": "not needed"}'
result = parse_setup_response(raw)
assert result['selector'] is None
def test_malformed_json_safe_defaults(self):
result = parse_setup_response('garbage text')
assert result['needs_prefilter'] is False
assert result['selector'] is None
assert result['reason'] == ''
def test_empty_response_safe_defaults(self):
result = parse_setup_response('')
assert result['needs_prefilter'] is False