modified: .gitlab-ci.yml modified: bench/qa_questions.txt modified: bench/qa_sweep.py modified: bench/run.sh modified: docs/TICKETS.md modified: docs/_source/README.md modified: docs/_source/_ext/makefile_targets.py modified: docs/_source/api/cli.rst modified: docs/_source/api/distill.rst modified: docs/_source/api/mesh.rst modified: docs/_source/api/qa.rst modified: docs/_source/api/retrieval.rst modified: docs/_source/api/storage.rst modified: docs/_source/api/substrate.rst modified: docs/_source/concepts.rst modified: docs/_source/conf.py modified: docs/_source/cookbook.rst modified: docs/_source/index.rst modified: docs/_source/license.rst modified: docs/_source/quickstart.rst modified: docs/bench-maxing.md modified: docs/benchmarks.md modified: docs/cti-architecture.md modified: docs/diagrams/aborist-modules.dot modified: docs/diagrams/aborist-modules.svg modified: docs/diagrams/mesh-data-flow.dot modified: docs/diagrams/mesh-epoch-lifecycle.dot modified: docs/diagrams/mesh-epoch-lifecycle.svg modified: docs/diagrams/mesh-group-decisions.dot modified: docs/diagrams/mesh-group-decisions.svg modified: docs/diagrams/mesh-identity-stack.dot modified: docs/diagrams/mesh-secret-envelope.dot modified: docs/mesh.md modified: docs/qa-modes-bench.md modified: docs/seven-point-program.md modified: docs/tickets/ticket-000001-retrieval-keywords-audit-gap.md modified: docs/tickets/ticket-000002-reference-frame-polarity-contract.md modified: docs/tickets/ticket-000003-anchor-class-warrant.md modified: docs/tickets/ticket-000005-label-ladder-migration.md modified: docs/tickets/ticket-000006-bench-emergent-findings.md modified: docs/tickets/ticket-000007-query-layer-hyphen-fold.md modified: docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md modified: docs/tickets/ticket-000009-quantifier-preflight-dag-binding.md modified: docs/tickets/ticket-000010-metacognition-preflight-guard.md modified: docs/tickets/ticket-000011-soft-preflight-hint-sidecar.md modified: scripts/backfill_concepts.py modified: scripts/bench_emergent.py modified: tests/crawler/test_async_web_fetcher.py modified: tests/crawler/test_bridge.py modified: tests/crawler/test_web_fetch.py modified: tests/test_bench_qa_sweep.py modified: tests/test_burn.py modified: tests/test_burn_doc.py modified: tests/test_claim_lattice.py modified: tests/test_cli_render.py modified: tests/test_compress.py modified: tests/test_concepts.py modified: tests/test_dag.py modified: tests/test_directives.py modified: tests/test_distill.py modified: tests/test_distill_recursive.py modified: tests/test_evict.py modified: tests/test_frame.py modified: tests/test_grok_source.py modified: tests/test_html_source.py modified: tests/test_ingest.py modified: tests/test_inspect.py modified: tests/test_journal.py modified: tests/test_keys.py modified: tests/test_llm_context_base.py modified: tests/test_merkle.py modified: tests/test_mesh.py modified: tests/test_mesh_aead.py modified: tests/test_mesh_chain.py modified: tests/test_mesh_cli.py modified: tests/test_mesh_cli_pull.py modified: tests/test_mesh_wire.py modified: tests/test_mesh_wire_e2e.py modified: tests/test_metacognition.py modified: tests/test_migration_audit_mode.py modified: tests/test_providence_source.py modified: tests/test_qa.py modified: tests/test_qa_quality_live.py modified: tests/test_quantifier_caps.py modified: tests/test_quantifier_classifier.py modified: tests/test_quantifier_phase4.py modified: tests/test_quantifier_reminder.py modified: tests/test_query.py modified: tests/test_reclassify.py modified: tests/test_repair.py modified: tests/test_resume.py modified: tests/test_snapshot.py modified: tests/test_soft_preflight.py modified: tests/test_tfidf.py modified: tests/test_vcs_source.py modified: tests/test_verify.py modified: tests/test_verify_json.py modified: tests/test_versioned_ingest.py modified: tests/test_warrant.py modified: tests/test_wikipedia_old.py modified: tests/test_wikipedia_xml.py modified: tests/test_wikitext.py
343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""
|
|
Tests for core.web_fetch module.
|
|
|
|
Tests URL extraction, sources footer building, and web fetch helpers.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, AsyncMock, patch
|
|
from arborist.sources.crawler.web_fetch import (
|
|
extract_url_from_content,
|
|
is_web_fetch_request,
|
|
build_sources_footer,
|
|
detect_fresh_fetch_intent,
|
|
extract_domain,
|
|
format_progress_message,
|
|
format_answering_message,
|
|
generate_keyword_stems,
|
|
)
|
|
|
|
|
|
class TestURLExtraction:
|
|
"""Test URL extraction from message content."""
|
|
|
|
def test_extract_url_with_protocol(self):
|
|
"""Test extracting URL with http:// or https://"""
|
|
content = "Check out https://example.com for more info"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://example.com"
|
|
|
|
def test_extract_url_http_protocol(self):
|
|
"""Test extracting URL with http:// protocol"""
|
|
content = "Visit http://insecure.example.com"
|
|
url = extract_url_from_content(content)
|
|
assert url == "http://insecure.example.com"
|
|
|
|
def test_extract_url_without_protocol(self):
|
|
"""Test extracting URL without protocol (adds https://)"""
|
|
content = "Go to example.com"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://example.com"
|
|
|
|
def test_extract_subdomain_url(self):
|
|
"""Test extracting URL with subdomain"""
|
|
content = "Check media.unturf.com/test"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://media.unturf.com/test"
|
|
|
|
def test_extract_url_with_path(self):
|
|
"""Test extracting URL with path"""
|
|
content = "Read https://docs.example.com/api/v1/reference"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://docs.example.com/api/v1/reference"
|
|
|
|
def test_extract_url_with_query(self):
|
|
"""Test extracting URL with query parameters"""
|
|
content = "See https://example.com/search?q=test&lang=en"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://example.com/search?q=test&lang=en"
|
|
|
|
def test_no_url_in_content(self):
|
|
"""Test when no URL is present"""
|
|
content = "This is just a regular message without any URLs"
|
|
url = extract_url_from_content(content)
|
|
assert url is None
|
|
|
|
def test_extract_first_url_when_multiple(self):
|
|
"""Test that first URL is extracted when multiple are present"""
|
|
content = "Check https://first.com and https://second.com"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://first.com"
|
|
|
|
def test_extract_url_with_www(self):
|
|
"""Test extracting URL with www prefix"""
|
|
content = "Visit www.example.com"
|
|
url = extract_url_from_content(content)
|
|
assert url == "https://www.example.com"
|
|
|
|
def test_extract_url_ignores_discord_embeds(self):
|
|
"""Test that angle brackets don't break URL extraction"""
|
|
content = "Check out <https://example.com>"
|
|
url = extract_url_from_content(content)
|
|
# Angle brackets should be part of the URL match
|
|
assert "example.com" in url
|
|
|
|
|
|
@pytest.mark.skip(
|
|
reason="chat-bot fetch triggers (has_web_fetch_trigger) were "
|
|
"dropped during the arborist lift — arborist has no chat surface; "
|
|
"see arborist/sources/crawler/__init__.py for rationale."
|
|
)
|
|
class TestWebFetchDetection:
|
|
"""Test web fetch request detection."""
|
|
|
|
def test_is_web_fetch_request_with_url(self):
|
|
"""Test detection when URL is present"""
|
|
assert is_web_fetch_request("Fetch https://example.com") is True
|
|
|
|
def test_is_web_fetch_request_without_url(self):
|
|
"""Test detection when no URL is present"""
|
|
assert is_web_fetch_request("Tell me about Python") is False
|
|
|
|
def test_is_web_fetch_request_domain_only(self):
|
|
"""Test detection with domain-only URL"""
|
|
assert is_web_fetch_request("What's on example.com?") is True
|
|
|
|
|
|
@pytest.mark.skip(
|
|
reason="chat-bot fresh-fetch triggers (has_fresh_fetch_trigger) were "
|
|
"dropped during the arborist lift — arborist has no chat surface; "
|
|
"see arborist/sources/crawler/__init__.py for rationale."
|
|
)
|
|
class TestFreshFetchDetection:
|
|
"""Test fresh fetch intent detection."""
|
|
|
|
def test_detect_fresh_with_keyword(self):
|
|
"""Test detection with 'fresh' keyword"""
|
|
assert detect_fresh_fetch_intent("Get fresh data from example.com") is True
|
|
|
|
def test_detect_refresh_with_keyword(self):
|
|
"""Test detection with 'refresh' keyword"""
|
|
assert detect_fresh_fetch_intent("Refresh the content") is True
|
|
|
|
def test_detect_reload_with_keyword(self):
|
|
"""Test detection with 'reload' keyword"""
|
|
assert detect_fresh_fetch_intent("Reload example.com") is True
|
|
|
|
def test_detect_latest_with_keyword(self):
|
|
"""Test detection with 'latest' keyword"""
|
|
assert detect_fresh_fetch_intent("Get the latest from example.com") is True
|
|
|
|
def test_no_fresh_intent(self):
|
|
"""Test when no fresh fetch keywords present"""
|
|
assert detect_fresh_fetch_intent("Fetch example.com") is False
|
|
|
|
def test_fresh_detection_case_insensitive(self):
|
|
"""Test that fresh detection is case insensitive"""
|
|
assert detect_fresh_fetch_intent("FRESH data please") is True
|
|
|
|
|
|
class TestSourcesFooter:
|
|
"""Test sources footer building."""
|
|
|
|
def test_build_sources_footer_single_source(self):
|
|
"""Test building footer with single source"""
|
|
sources = [{'url': 'https://example.com', 'title': 'Example Page'}]
|
|
footer = build_sources_footer(sources)
|
|
assert 'Sources:' in footer
|
|
assert 'Example Page' in footer
|
|
assert '<https://example.com>' in footer
|
|
|
|
def test_build_sources_footer_multiple_sources(self):
|
|
"""Test building footer with multiple sources"""
|
|
sources = [
|
|
{'url': 'https://example.com', 'title': 'Example Page'},
|
|
{'url': 'https://test.com', 'title': 'Test Page'}
|
|
]
|
|
footer = build_sources_footer(sources)
|
|
assert 'Example Page' in footer
|
|
assert 'Test Page' in footer
|
|
assert footer.count('<') == 2 # Two URLs with angle brackets
|
|
|
|
def test_build_sources_footer_empty_list(self):
|
|
"""Test building footer with empty source list"""
|
|
footer = build_sources_footer([])
|
|
assert footer == ""
|
|
|
|
def test_build_sources_footer_missing_title(self):
|
|
"""Test building footer when title is missing"""
|
|
sources = [{'url': 'https://example.com'}]
|
|
footer = build_sources_footer(sources)
|
|
assert 'Untitled' in footer
|
|
assert '<https://example.com>' in footer
|
|
|
|
def test_build_sources_footer_missing_url(self):
|
|
"""Test building footer when URL is missing"""
|
|
sources = [{'title': 'Example Page'}]
|
|
footer = build_sources_footer(sources)
|
|
# Should not include entry if URL is missing
|
|
assert footer == '**Sources:**\n'
|
|
|
|
def test_build_sources_footer_angle_brackets_suppress_embeds(self):
|
|
"""Test that angle brackets are used to suppress Discord embeds"""
|
|
sources = [{'url': 'https://example.com', 'title': 'Test'}]
|
|
footer = build_sources_footer(sources)
|
|
# URLs should be in angle brackets
|
|
assert '<https://example.com>' in footer
|
|
assert 'https://example.com>' not in footer.replace('<https://example.com>', '')
|
|
|
|
|
|
class TestDomainExtraction:
|
|
"""Test domain extraction from URLs."""
|
|
|
|
def test_extract_domain_basic(self):
|
|
"""Test extracting domain from basic URL"""
|
|
domain = extract_domain("https://example.com/path")
|
|
assert domain == "example.com"
|
|
|
|
def test_extract_domain_with_subdomain(self):
|
|
"""Test extracting domain with subdomain"""
|
|
domain = extract_domain("https://api.example.com")
|
|
assert domain == "api.example.com"
|
|
|
|
def test_extract_domain_with_port(self):
|
|
"""Test extracting domain with port"""
|
|
domain = extract_domain("https://example.com:8080/path")
|
|
assert domain == "example.com:8080"
|
|
|
|
def test_extract_domain_invalid_url(self):
|
|
"""Test extracting domain from invalid URL"""
|
|
domain = extract_domain("not a url")
|
|
# Should return None or handle gracefully
|
|
assert domain is None or isinstance(domain, str)
|
|
|
|
|
|
class TestProgressFormatting:
|
|
"""Test progress message formatting."""
|
|
|
|
def test_format_progress_message_basic(self):
|
|
"""Test basic progress message formatting"""
|
|
msg = format_progress_message(
|
|
url="https://example.com",
|
|
task="Find pricing",
|
|
keywords=["pricing", "cost"],
|
|
depth=2
|
|
)
|
|
assert "example.com" in msg
|
|
assert "pricing" in msg
|
|
assert "cost" in msg
|
|
assert "**Depth:** 2" in msg
|
|
|
|
def test_format_progress_message_with_crawl_delay(self):
|
|
"""Test progress message with crawl delay"""
|
|
msg = format_progress_message(
|
|
url="https://example.com",
|
|
crawl_delay=5.0
|
|
)
|
|
assert "**Crawl delay:** 5.0s" in msg
|
|
|
|
def test_format_progress_message_no_crawl_delay_if_default(self):
|
|
"""Test that default crawl delay is not shown"""
|
|
msg = format_progress_message(
|
|
url="https://example.com",
|
|
crawl_delay=2.0
|
|
)
|
|
assert "Crawl delay" not in msg
|
|
|
|
def test_format_progress_message_fresh_mode(self):
|
|
"""Test progress message with fresh mode"""
|
|
msg = format_progress_message(
|
|
url="https://example.com",
|
|
cache_mode="fresh"
|
|
)
|
|
assert "**Mode:** fresh" in msg
|
|
|
|
def test_format_answering_message(self):
|
|
"""Test answering phase message formatting"""
|
|
msg = format_answering_message(5)
|
|
assert "5 page(s)" in msg
|
|
assert "Analyzing" in msg
|
|
|
|
|
|
class TestKeywordStemming:
|
|
"""Test keyword stemming functionality."""
|
|
|
|
def test_generate_stems_basic(self):
|
|
"""Test generating stems from keywords"""
|
|
stems = generate_keyword_stems(["running", "tests"], [])
|
|
assert "run" in stems or "test" in stems
|
|
|
|
def test_generate_stems_no_duplicates(self):
|
|
"""Test that stems don't duplicate keywords"""
|
|
stems = generate_keyword_stems(["run"], ["running"])
|
|
# "run" is already in keywords, so stem shouldn't be added
|
|
assert "run" not in stems
|
|
|
|
def test_generate_stems_variations(self):
|
|
"""Test stem generation from variations"""
|
|
stems = generate_keyword_stems([], ["pricing", "prices"])
|
|
# Should generate stems if they're different from variations
|
|
assert isinstance(stems, list)
|
|
|
|
def test_generate_stems_no_stemming_needed(self):
|
|
"""Test when no stemming is needed"""
|
|
stems = generate_keyword_stems(["api", "cli"], [])
|
|
# Short words shouldn't be stemmed
|
|
assert len(stems) == 0
|
|
|
|
def test_generate_stems_phrase(self):
|
|
"""Test stem generation from phrase"""
|
|
stems = generate_keyword_stems(["pull requests"], [])
|
|
# Should split phrase and stem individual words
|
|
assert isinstance(stems, list)
|
|
|
|
|
|
class TestWebFetchIntegration:
|
|
"""Test web fetch integration with mock web cache manager."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_and_cache_success(self):
|
|
"""Test successful fetch and cache operation"""
|
|
# This would require mocking web_cache_manager
|
|
# For now, just verify the function exists and has correct signature
|
|
from arborist.sources.crawler.web_fetch import fetch_and_cache
|
|
import inspect
|
|
sig = inspect.signature(fetch_and_cache)
|
|
assert 'url' in sig.parameters
|
|
assert 'web_cache_manager' in sig.parameters
|
|
assert 'keywords' in sig.parameters
|
|
assert 'depth' in sig.parameters
|
|
assert 'fresh' in sig.parameters
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_and_cache_with_progress_callback(self):
|
|
"""Test fetch with progress callback"""
|
|
from arborist.sources.crawler.web_fetch import fetch_and_cache
|
|
|
|
# Mock web cache manager
|
|
mock_manager = Mock()
|
|
mock_manager.get_or_fetch = AsyncMock(return_value={
|
|
'url': 'https://example.com',
|
|
'title': 'Test',
|
|
'extracted_text': 'Content',
|
|
'source_urls': []
|
|
})
|
|
|
|
# Mock progress callback
|
|
progress_calls = []
|
|
async def progress_callback(msg):
|
|
progress_calls.append(msg)
|
|
|
|
result = await fetch_and_cache(
|
|
url='https://example.com',
|
|
web_cache_manager=mock_manager,
|
|
progress_callback=progress_callback
|
|
)
|
|
|
|
assert result is not None
|
|
assert result['url'] == 'https://example.com'
|
|
mock_manager.get_or_fetch.assert_called_once()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
pytest.main([__file__, '-v'])
|