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
176 lines
7.2 KiB
Python
176 lines
7.2 KiB
Python
"""Tests for ReportGenerator helper methods.
|
|
|
|
Covers _truncate_at_sentence_boundary and _build_previous_context — two
|
|
pure-logic helpers with zero prior test coverage that are critical for
|
|
report quality and repetition avoidance.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.report_generator import IntegratedReportGenerator
|
|
|
|
|
|
@pytest.fixture
|
|
def generator():
|
|
"""Create an IntegratedReportGenerator with mocked dependencies."""
|
|
mock_llm = MagicMock()
|
|
mock_search = MagicMock()
|
|
gen = IntegratedReportGenerator.__new__(IntegratedReportGenerator)
|
|
gen.llm = mock_llm
|
|
gen.search_system = mock_search
|
|
gen.max_context_sections = 3
|
|
gen.max_context_chars = 4000
|
|
return gen
|
|
|
|
|
|
# ── _truncate_at_sentence_boundary ──
|
|
|
|
|
|
class TestTruncateAtSentenceBoundary:
|
|
"""Tests for _truncate_at_sentence_boundary."""
|
|
|
|
def test_text_shorter_than_limit_returned_unchanged(self, generator):
|
|
text = "Short text."
|
|
assert generator._truncate_at_sentence_boundary(text, 100) == text
|
|
|
|
def test_text_exactly_at_limit_returned_unchanged(self, generator):
|
|
text = "Exact." + "x" * 94 # 100 chars
|
|
assert generator._truncate_at_sentence_boundary(text, 100) == text
|
|
|
|
def test_truncates_at_period_followed_by_space(self, generator):
|
|
text = "First sentence. Second sentence. Third sentence that goes on."
|
|
result = generator._truncate_at_sentence_boundary(text, 35)
|
|
assert result.startswith("First sentence. Second sentence.")
|
|
assert result.endswith("\n[...truncated]")
|
|
|
|
def test_truncates_at_exclamation_mark(self, generator):
|
|
text = "Wow! This is amazing! More content follows here."
|
|
result = generator._truncate_at_sentence_boundary(text, 25)
|
|
assert "Wow! This is amazing!" in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_truncates_at_question_mark(self, generator):
|
|
text = "Is this working? Yes it is working perfectly fine here."
|
|
result = generator._truncate_at_sentence_boundary(text, 20)
|
|
assert "Is this working?" in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_boundary_at_end_of_truncated_text(self, generator):
|
|
# Period at exactly the last position of truncated text
|
|
text = "Hello." + "x" * 100
|
|
result = generator._truncate_at_sentence_boundary(text, 6)
|
|
# "Hello." is 6 chars, truncated[:6] = "Hello."
|
|
# last_sentence_end = 6, min_acceptable = int(6*0.8)=4, 6 > 4 → use boundary
|
|
assert "Hello." in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_no_sentence_boundary_falls_back_to_hard_truncation(
|
|
self, generator
|
|
):
|
|
text = "a" * 200
|
|
result = generator._truncate_at_sentence_boundary(text, 100)
|
|
assert result == "a" * 100 + "\n[...truncated]"
|
|
|
|
def test_sentence_boundary_too_early_falls_back(self, generator):
|
|
# Period at position 5 out of 100 → below 80% threshold
|
|
text = "Hi. " + "x" * 200
|
|
result = generator._truncate_at_sentence_boundary(text, 100)
|
|
# min_acceptable = 80, last_sentence_end = 4, 4 < 80 → hard truncation
|
|
assert result == text[:100] + "\n[...truncated]"
|
|
|
|
def test_sentence_boundary_at_80_percent_threshold(self, generator):
|
|
# Exactly at 80% boundary
|
|
# max_chars=100, min_acceptable=80
|
|
text = "x" * 80 + ". " + "y" * 50
|
|
result = generator._truncate_at_sentence_boundary(text, 100)
|
|
# last_sentence_end=81, min_acceptable=80, 81 > 80 → use boundary
|
|
assert result.endswith("\n[...truncated]")
|
|
assert result.startswith("x" * 80 + ".")
|
|
|
|
def test_period_followed_by_newline(self, generator):
|
|
text = "First sentence.\nSecond sentence continues for a while here."
|
|
result = generator._truncate_at_sentence_boundary(text, 20)
|
|
assert "First sentence." in result
|
|
assert "[...truncated]" in result
|
|
|
|
def test_period_not_followed_by_space_or_newline_ignored(self, generator):
|
|
# "3.14" has a period but it's followed by a digit, not space
|
|
text = "The value is 3.14 and more text follows after that point."
|
|
result = generator._truncate_at_sentence_boundary(text, 20)
|
|
# Only sentence boundaries followed by space/newline are valid
|
|
# In "The value is 3.14 a", the period at index 14 is followed by '1', not space
|
|
# So falls back to hard truncation
|
|
assert result == text[:20] + "\n[...truncated]"
|
|
|
|
def test_empty_text(self, generator):
|
|
assert generator._truncate_at_sentence_boundary("", 100) == ""
|
|
|
|
def test_single_character(self, generator):
|
|
assert generator._truncate_at_sentence_boundary("a", 100) == "a"
|
|
|
|
def test_multiple_sentence_endings_uses_last_valid(self, generator):
|
|
text = "One. Two. Three. Four. Five. Six. Seven. Eight."
|
|
result = generator._truncate_at_sentence_boundary(text, 30)
|
|
# Should find the last boundary within the first 30 chars
|
|
# "One. Two. Three. Four. Five. " is 29 chars
|
|
assert "[...truncated]" in result
|
|
|
|
|
|
# ── _build_previous_context ──
|
|
|
|
|
|
class TestBuildPreviousContext:
|
|
"""Tests for _build_previous_context."""
|
|
|
|
def test_empty_list_returns_empty_string(self, generator):
|
|
assert generator._build_previous_context([]) == ""
|
|
|
|
def test_single_finding_included(self, generator):
|
|
result = generator._build_previous_context(["Finding 1"])
|
|
assert "Finding 1" in result
|
|
assert "DO NOT REPEAT" in result
|
|
assert "CONTENT ALREADY WRITTEN" in result
|
|
|
|
def test_respects_max_context_sections_limit(self, generator):
|
|
generator.max_context_sections = 2
|
|
findings = ["Finding 1", "Finding 2", "Finding 3", "Finding 4"]
|
|
result = generator._build_previous_context(findings)
|
|
# Should only include last 2 findings
|
|
assert "Finding 3" in result
|
|
assert "Finding 4" in result
|
|
assert "Finding 1" not in result
|
|
assert "Finding 2" not in result
|
|
|
|
def test_joins_with_separator(self, generator):
|
|
result = generator._build_previous_context(["A", "B"])
|
|
assert "\n\n---\n\n" in result
|
|
|
|
def test_truncates_long_context(self, generator):
|
|
generator.max_context_chars = 50
|
|
long_finding = "x" * 100
|
|
result = generator._build_previous_context([long_finding])
|
|
# The content should be truncated
|
|
assert "[...truncated]" in result
|
|
|
|
def test_formatting_markers_present(self, generator):
|
|
result = generator._build_previous_context(["Test content"])
|
|
assert "=== CONTENT ALREADY WRITTEN (DO NOT REPEAT) ===" in result
|
|
assert "=== END OF PREVIOUS CONTENT ===" in result
|
|
assert "CRITICAL:" in result
|
|
|
|
def test_context_within_char_limit_not_truncated(self, generator):
|
|
generator.max_context_chars = 10000
|
|
result = generator._build_previous_context(["Short finding."])
|
|
assert "[...truncated]" not in result
|
|
|
|
def test_uses_last_n_sections(self, generator):
|
|
generator.max_context_sections = 3
|
|
findings = [f"Finding {i}" for i in range(10)]
|
|
result = generator._build_previous_context(findings)
|
|
assert "Finding 7" in result
|
|
assert "Finding 8" in result
|
|
assert "Finding 9" in result
|
|
assert "Finding 0" not in result
|