7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
500 lines
16 KiB
Python
500 lines
16 KiB
Python
"""Comprehensive coverage tests for json_utils and type_utils.
|
|
|
|
Tests every public and private function in:
|
|
- local_deep_research.utilities.json_utils
|
|
- local_deep_research.utilities.type_utils
|
|
|
|
Pure-function tests -- no complex mocks required.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.utilities.json_utils import (
|
|
_clean_llm_json_artifacts,
|
|
_extract_by_brackets,
|
|
_remove_think_tags,
|
|
_strip_code_fences,
|
|
extract_json,
|
|
get_llm_response_text,
|
|
)
|
|
from local_deep_research.utilities.type_utils import to_bool
|
|
|
|
MODULE_JSON = "local_deep_research.utilities.json_utils"
|
|
MODULE_TYPE = "local_deep_research.utilities.type_utils"
|
|
|
|
|
|
# ===================================================================
|
|
# get_llm_response_text
|
|
# ===================================================================
|
|
|
|
|
|
class TestGetLlmResponseText:
|
|
"""Cover .content, .text, str() fallback, think-tag removal, None."""
|
|
|
|
def test_content_attribute_string(self):
|
|
obj = MagicMock()
|
|
obj.content = "hello from content"
|
|
assert get_llm_response_text(obj) == "hello from content"
|
|
|
|
def test_text_attribute_string(self):
|
|
"""Falls to .text when .content is absent."""
|
|
obj = MagicMock(spec=[]) # spec=[] -> no .content
|
|
obj.text = "hello from text"
|
|
result = get_llm_response_text(obj)
|
|
assert result == "hello from text"
|
|
|
|
def test_plain_string_passthrough(self):
|
|
assert get_llm_response_text("raw string") == "raw string"
|
|
|
|
def test_think_tags_removed_from_content(self):
|
|
obj = MagicMock()
|
|
obj.content = "<think>internal reasoning</think>visible answer"
|
|
assert get_llm_response_text(obj) == "visible answer"
|
|
|
|
def test_none_returns_empty_string(self):
|
|
assert get_llm_response_text(None) == ""
|
|
|
|
def test_content_none_falls_to_text(self):
|
|
"""When .content is None, the .text branch is tried."""
|
|
obj = MagicMock()
|
|
obj.content = None
|
|
obj.text = "fallback text"
|
|
assert get_llm_response_text(obj) == "fallback text"
|
|
|
|
def test_text_none_falls_to_str(self):
|
|
"""When both .content and .text are None, str(response) is used."""
|
|
obj = MagicMock(spec=[])
|
|
obj.text = None
|
|
result = get_llm_response_text(obj)
|
|
assert isinstance(result, str)
|
|
|
|
def test_non_string_content_converted(self):
|
|
"""Non-string .content is converted via str()."""
|
|
obj = MagicMock()
|
|
obj.content = 12345
|
|
assert get_llm_response_text(obj) == "12345"
|
|
|
|
def test_integer_input_uses_str(self):
|
|
assert get_llm_response_text(42) == "42"
|
|
|
|
def test_multiple_think_blocks(self):
|
|
obj = MagicMock()
|
|
obj.content = "<think>a</think>X<think>b</think>Y"
|
|
assert get_llm_response_text(obj) == "XY"
|
|
|
|
|
|
# ===================================================================
|
|
# extract_json
|
|
# ===================================================================
|
|
|
|
|
|
class TestExtractJson:
|
|
"""Cover valid JSON, code-fenced JSON, surrounding text, malformed
|
|
JSON, expected_type filtering, empty/None inputs."""
|
|
|
|
# --- valid JSON -------------------------------------------------
|
|
|
|
def test_valid_dict(self):
|
|
assert extract_json('{"a": 1}') == {"a": 1}
|
|
|
|
def test_valid_list(self):
|
|
assert extract_json("[1, 2, 3]") == [1, 2, 3]
|
|
|
|
def test_valid_nested(self):
|
|
text = '{"x": [1, {"y": 2}]}'
|
|
assert extract_json(text) == {"x": [1, {"y": 2}]}
|
|
|
|
# --- JSON inside code fences ------------------------------------
|
|
|
|
def test_json_in_json_fence(self):
|
|
text = '```json\n{"key": "val"}\n```'
|
|
assert extract_json(text) == {"key": "val"}
|
|
|
|
def test_json_in_plain_fence(self):
|
|
text = '```\n{"key": "val"}\n```'
|
|
assert extract_json(text) == {"key": "val"}
|
|
|
|
# --- JSON with surrounding prose --------------------------------
|
|
|
|
def test_prose_around_dict(self):
|
|
text = 'Here is the result: {"score": 9} -- done.'
|
|
assert extract_json(text) == {"score": 9}
|
|
|
|
def test_prose_around_list(self):
|
|
text = "The indices are: [0, 3, 5] as computed."
|
|
assert extract_json(text, expected_type=list) == [0, 3, 5]
|
|
|
|
# --- malformed JSON cleaned up ----------------------------------
|
|
|
|
def test_trailing_comma_in_dict(self):
|
|
text = '{"a": 1, "b": 2,}'
|
|
assert extract_json(text) == {"a": 1, "b": 2}
|
|
|
|
def test_trailing_comma_in_list(self):
|
|
text = "[1, 2, 3,]"
|
|
assert extract_json(text) == [1, 2, 3]
|
|
|
|
def test_js_line_comments(self):
|
|
text = '{\n"a": 1, // first\n"b": 2 // second\n}'
|
|
assert extract_json(text) == {"a": 1, "b": 2}
|
|
|
|
def test_ellipsis_entry_removed(self):
|
|
text = "[1, 2, 3, ...]"
|
|
assert extract_json(text, expected_type=list) == [1, 2, 3]
|
|
|
|
# --- expected_type filtering ------------------------------------
|
|
|
|
def test_expected_dict_returns_dict(self):
|
|
assert extract_json('{"k": 1}', expected_type=dict) == {"k": 1}
|
|
|
|
def test_expected_list_returns_list(self):
|
|
assert extract_json("[1]", expected_type=list) == [1]
|
|
|
|
def test_expected_list_but_got_dict_falls_to_bracket(self):
|
|
"""Direct parse gives dict; bracket extraction for [] finds inner list."""
|
|
text = '{"items": [10, 20]}'
|
|
result = extract_json(text, expected_type=list)
|
|
assert result == [10, 20]
|
|
|
|
def test_expected_dict_but_got_list_returns_none(self):
|
|
"""Plain list when dict expected and no inner dict exists."""
|
|
assert extract_json("[1, 2]", expected_type=dict) is None
|
|
|
|
# --- empty / None -----------------------------------------------
|
|
|
|
def test_empty_string(self):
|
|
assert extract_json("") is None
|
|
|
|
def test_whitespace_only(self):
|
|
assert extract_json(" \n\t ") is None
|
|
|
|
def test_none_input(self):
|
|
assert extract_json(None) is None
|
|
|
|
# --- scalar JSON values are not returned ------------------------
|
|
|
|
def test_scalar_number(self):
|
|
assert extract_json("42") is None
|
|
|
|
def test_scalar_string(self):
|
|
assert extract_json('"hello"') is None
|
|
|
|
def test_scalar_bool(self):
|
|
assert extract_json("true") is None
|
|
|
|
def test_scalar_null(self):
|
|
assert extract_json("null") is None
|
|
|
|
# --- deepseek-style think+fence pattern -------------------------
|
|
|
|
def test_think_then_fence(self):
|
|
text = '<think>reasoning</think>\n```json\n{"answer": "yes"}\n```'
|
|
assert extract_json(text) == {"answer": "yes"}
|
|
|
|
# --- completely invalid text ------------------------------------
|
|
|
|
def test_no_json_at_all(self):
|
|
assert extract_json("Hello, this is plain text.") is None
|
|
|
|
def test_brackets_with_garbage(self):
|
|
assert extract_json("{not json at all!}") is None
|
|
|
|
|
|
# ===================================================================
|
|
# _remove_think_tags
|
|
# ===================================================================
|
|
|
|
|
|
class TestRemoveThinkTags:
|
|
"""Paired tags, orphaned tags, no tags, empty input."""
|
|
|
|
def test_paired_tags_removed(self):
|
|
assert _remove_think_tags("<think>x</think>y") == "y"
|
|
|
|
def test_multiline_content_removed(self):
|
|
text = "<think>\nline1\nline2\n</think>result"
|
|
assert _remove_think_tags(text) == "result"
|
|
|
|
def test_orphaned_closing_tag(self):
|
|
assert _remove_think_tags("before</think>after") == "beforeafter"
|
|
|
|
def test_orphaned_opening_tag(self):
|
|
assert _remove_think_tags("before<think>after") == "beforeafter"
|
|
|
|
def test_no_tags(self):
|
|
assert _remove_think_tags("no tags here") == "no tags here"
|
|
|
|
def test_empty_string(self):
|
|
assert _remove_think_tags("") == ""
|
|
|
|
def test_multiple_paired_blocks(self):
|
|
text = "<think>a</think>X<think>b</think>Y"
|
|
assert _remove_think_tags(text) == "XY"
|
|
|
|
def test_only_think_tags_returns_empty(self):
|
|
assert _remove_think_tags("<think>all gone</think>") == ""
|
|
|
|
def test_result_is_stripped(self):
|
|
assert _remove_think_tags(" <think>x</think> y ") == "y"
|
|
|
|
|
|
# ===================================================================
|
|
# _strip_code_fences
|
|
# ===================================================================
|
|
|
|
|
|
class TestStripCodeFences:
|
|
"""```json fences, plain ``` fences, no fences."""
|
|
|
|
def test_json_fence(self):
|
|
text = '```json\n{"a": 1}\n```'
|
|
assert _strip_code_fences(text) == '{"a": 1}'
|
|
|
|
def test_plain_fence(self):
|
|
text = '```\n{"a": 1}\n```'
|
|
assert _strip_code_fences(text) == '{"a": 1}'
|
|
|
|
def test_no_fences(self):
|
|
text = '{"a": 1}'
|
|
assert _strip_code_fences(text) == '{"a": 1}'
|
|
|
|
def test_json_fence_preferred_over_plain(self):
|
|
"""```json is checked before plain ```."""
|
|
text = '```json\n{"first": 1}\n```\n```\n{"second": 2}\n```'
|
|
result = _strip_code_fences(text)
|
|
assert '{"first": 1}' in result
|
|
|
|
def test_only_two_backtick_groups_returns_original(self):
|
|
"""Single ``` without a matching close returns original text."""
|
|
text = "some ``` text"
|
|
assert _strip_code_fences(text) == text
|
|
|
|
def test_json_fence_no_closing(self):
|
|
text = '```json\n{"key": "val"}'
|
|
result = _strip_code_fences(text)
|
|
assert '{"key": "val"}' in result
|
|
|
|
def test_json_fence_with_whitespace(self):
|
|
text = '```json\n {"a": 1} \n```'
|
|
assert _strip_code_fences(text) == '{"a": 1}'
|
|
|
|
|
|
# ===================================================================
|
|
# _extract_by_brackets
|
|
# ===================================================================
|
|
|
|
|
|
class TestExtractByBrackets:
|
|
"""Dict brackets, list brackets, nested, no brackets."""
|
|
|
|
def test_dict_brackets(self):
|
|
assert _extract_by_brackets('x{"a":1}y', "{", "}") == '{"a":1}'
|
|
|
|
def test_list_brackets(self):
|
|
assert _extract_by_brackets("x[1,2]y", "[", "]") == "[1,2]"
|
|
|
|
def test_nested_brackets(self):
|
|
text = '{"a": {"b": 1}}'
|
|
assert _extract_by_brackets(text, "{", "}") == text
|
|
|
|
def test_no_opening_bracket(self):
|
|
assert _extract_by_brackets("no brackets", "{", "}") is None
|
|
|
|
def test_no_closing_bracket(self):
|
|
assert _extract_by_brackets("{no close", "{", "}") is None
|
|
|
|
def test_close_before_open(self):
|
|
"""Closing bracket appears before opening -- end <= start."""
|
|
assert _extract_by_brackets("}text{", "{", "}") is None
|
|
|
|
def test_empty_string(self):
|
|
assert _extract_by_brackets("", "{", "}") is None
|
|
|
|
def test_adjacent_brackets(self):
|
|
assert _extract_by_brackets("prefix{}suffix", "{", "}") == "{}"
|
|
|
|
def test_multiple_opens_uses_first(self):
|
|
text = "{ extra { inner }}"
|
|
result = _extract_by_brackets(text, "{", "}")
|
|
assert result.startswith("{")
|
|
|
|
def test_multiple_closes_uses_last(self):
|
|
text = '{"a": {"b": 1}} }'
|
|
result = _extract_by_brackets(text, "{", "}")
|
|
assert result.endswith("}")
|
|
|
|
|
|
# ===================================================================
|
|
# _clean_llm_json_artifacts
|
|
# ===================================================================
|
|
|
|
|
|
class TestCleanLlmJsonArtifacts:
|
|
"""Trailing commas, JS comments, ellipsis entries."""
|
|
|
|
def test_trailing_comma_before_close_brace(self):
|
|
text = '{"a": 1,}'
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert json.loads(result) == {"a": 1}
|
|
|
|
def test_trailing_comma_before_close_bracket(self):
|
|
text = "[1, 2,]"
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert json.loads(result) == [1, 2]
|
|
|
|
def test_trailing_comma_with_whitespace(self):
|
|
text = '{"a": 1 , \n }'
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert json.loads(result) == {"a": 1}
|
|
|
|
def test_inline_comment_removed(self):
|
|
text = '{"a": 1 // comment\n}'
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert "//" not in result
|
|
assert json.loads(result) == {"a": 1}
|
|
|
|
def test_multiple_comments(self):
|
|
text = '{\n"a": 1, // first\n"b": 2 // second\n}'
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert json.loads(result) == {"a": 1, "b": 2}
|
|
|
|
def test_trailing_ellipsis_removed(self):
|
|
text = "[1, 2, ...]"
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert "..." not in result
|
|
|
|
def test_quoted_ellipsis_between_items(self):
|
|
text = '[1, "...", 2]'
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert "..." not in result
|
|
|
|
def test_ellipsis_between_items_keeps_one_comma(self):
|
|
"""The comma-preserving regex handles , ... , correctly."""
|
|
text = "[1, ..., 2]"
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert "..." not in result
|
|
|
|
def test_single_slash_not_touched(self):
|
|
"""A single / is not a comment."""
|
|
text = '{"path": "/api/v1"}'
|
|
assert _clean_llm_json_artifacts(text) == text
|
|
|
|
def test_clean_json_unchanged(self):
|
|
text = '{"a": 1, "b": [2, 3]}'
|
|
assert _clean_llm_json_artifacts(text) == text
|
|
|
|
def test_all_artifacts_combined(self):
|
|
text = "[\n1, // comment\n2,\n...,\n]"
|
|
result = _clean_llm_json_artifacts(text)
|
|
assert "//" not in result
|
|
assert "..." not in result
|
|
parsed = json.loads(result)
|
|
assert parsed == [1, 2]
|
|
|
|
|
|
# ===================================================================
|
|
# to_bool (from type_utils)
|
|
# ===================================================================
|
|
|
|
|
|
class TestToBool:
|
|
"""Cover every documented truthy/falsy string, types, defaults."""
|
|
|
|
# --- truthy strings ---------------------------------------------
|
|
|
|
@pytest.mark.parametrize("val", ["true", "True", "TRUE", "tRuE"])
|
|
def test_true_variants(self, val):
|
|
assert to_bool(val) is True
|
|
|
|
@pytest.mark.parametrize("val", ["yes", "Yes", "YES"])
|
|
def test_yes_variants(self, val):
|
|
assert to_bool(val) is True
|
|
|
|
def test_one_string(self):
|
|
assert to_bool("1") is True
|
|
|
|
@pytest.mark.parametrize("val", ["on", "On", "ON"])
|
|
def test_on_variants(self, val):
|
|
assert to_bool(val) is True
|
|
|
|
@pytest.mark.parametrize("val", ["enabled", "Enabled", "ENABLED"])
|
|
def test_enabled_variants(self, val):
|
|
assert to_bool(val) is True
|
|
|
|
# --- falsy strings ----------------------------------------------
|
|
|
|
@pytest.mark.parametrize("val", ["false", "False", "FALSE"])
|
|
def test_false_variants(self, val):
|
|
assert to_bool(val) is False
|
|
|
|
@pytest.mark.parametrize("val", ["no", "No", "NO"])
|
|
def test_no_variants(self, val):
|
|
assert to_bool(val) is False
|
|
|
|
def test_zero_string(self):
|
|
assert to_bool("0") is False
|
|
|
|
@pytest.mark.parametrize("val", ["off", "Off", "OFF"])
|
|
def test_off_variants(self, val):
|
|
assert to_bool(val) is False
|
|
|
|
@pytest.mark.parametrize("val", ["disabled", "Disabled", "DISABLED"])
|
|
def test_disabled_variants(self, val):
|
|
assert to_bool(val) is False
|
|
|
|
# --- whitespace handling ----------------------------------------
|
|
|
|
def test_leading_trailing_whitespace(self):
|
|
assert to_bool(" true ") is True
|
|
|
|
def test_whitespace_false(self):
|
|
assert to_bool(" false ") is False
|
|
|
|
# --- None uses default ------------------------------------------
|
|
|
|
def test_none_default_false(self):
|
|
assert to_bool(None) is False
|
|
|
|
def test_none_default_true(self):
|
|
assert to_bool(None, default=True) is True
|
|
|
|
# --- bool pass-through ------------------------------------------
|
|
|
|
def test_bool_true_passthrough(self):
|
|
assert to_bool(True) is True
|
|
|
|
def test_bool_false_passthrough(self):
|
|
assert to_bool(False) is False
|
|
|
|
# --- integer input ----------------------------------------------
|
|
|
|
def test_int_one(self):
|
|
assert to_bool(1) is True
|
|
|
|
def test_int_zero(self):
|
|
assert to_bool(0) is False
|
|
|
|
def test_int_negative(self):
|
|
"""Negative int is truthy via bool()."""
|
|
assert to_bool(-1) is True
|
|
|
|
# --- unknown strings use the truthy set -------------------------
|
|
|
|
def test_unknown_string_returns_false(self):
|
|
"""A string not in the truthy set returns False."""
|
|
assert to_bool("maybe") is False
|
|
|
|
def test_empty_string_returns_false(self):
|
|
assert to_bool("") is False
|
|
|
|
# --- default ignored for non-None values ------------------------
|
|
|
|
def test_default_ignored_for_string(self):
|
|
assert to_bool("false", default=True) is False
|
|
|
|
def test_default_ignored_for_bool(self):
|
|
assert to_bool(False, default=True) is False
|