mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-30 23:00:30 +00:00
e9e8c8d218
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""
|
|
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_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
|
|
|
|
|
|
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_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_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_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 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_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
|