"""Unit tests for DirectorySummarizer.
Tests the directory summary generation logic with mock ContextFS and LLM.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from core.models import ContextNode, RequestContext
from index.directory_summarizer import (
DirectorySummarizer,
DirectorySummary,
is_directory_uri,
)
class MockContextFS:
"""Mock ContextFS for testing."""
def __init__(self):
self.nodes: dict[str, ContextNode] = {}
self.children: dict[str, list[str]] = {}
def add_node(self, node: ContextNode):
self.nodes[node.uri] = node
def set_children(self, parent_uri: str, child_uris: list[str]):
self.children[parent_uri] = child_uris
def read_node(self, uri: str, ctx: RequestContext) -> ContextNode:
if uri not in self.nodes:
raise FileNotFoundError(f"Node not found: {uri}")
return self.nodes[uri]
def list_children(self, uri: str, ctx: RequestContext) -> list[str]:
return self.children.get(uri, [])
class MockLLM:
"""Mock LLM for testing."""
def __init__(self, response: dict = None):
self._response = response or {
"abstract": "Test directory abstract",
"overview": "## Overview\n\nTest overview content",
}
self.call_count = 0
self.last_prompt = None
def complete_json(self, prompt: str, schema: dict) -> dict:
self.call_count += 1
self.last_prompt = prompt
return self._response
def set_response(self, response: dict):
self._response = response
class FailingMockLLM(MockLLM):
"""Mock LLM that always fails."""
def complete_json(self, prompt: str, schema: dict) -> dict:
self.call_count += 1
raise RuntimeError("LLM API error")
class TestIsDirectoryUri:
"""Tests for is_directory_uri function."""
def test_directory_uri_ends_with_slash(self):
assert is_directory_uri("ctx://acme/users/alice/memories/preferences/") is True
def test_leaf_uri_does_not_end_with_slash(self):
assert is_directory_uri("ctx://acme/users/alice/memories/profile") is False
def test_root_directory(self):
assert is_directory_uri("ctx://acme/") is True
def test_empty_string(self):
assert is_directory_uri("") is False
class TestDirectorySummary:
"""Tests for DirectorySummary dataclass."""
def test_creation(self):
summary = DirectorySummary(
abstract="Test abstract",
overview="Test overview",
child_count=5,
categories=["profile", "preference"],
)
assert summary.abstract == "Test abstract"
assert summary.overview == "Test overview"
assert summary.child_count == 5
assert summary.categories == ["profile", "preference"]
def test_default_categories(self):
summary = DirectorySummary(
abstract="Test",
overview="Overview",
child_count=1,
)
assert summary.categories == []
class TestDirectorySummarizer:
"""Tests for DirectorySummarizer class."""
@pytest.fixture
def mock_fs(self):
return MockContextFS()
@pytest.fixture
def mock_llm(self):
return MockLLM()
def test_init(self, mock_fs, mock_llm):
"""Test initialization."""
summarizer = DirectorySummarizer(mock_fs, mock_llm)
assert summarizer._fs is mock_fs
assert summarizer._llm is mock_llm
assert summarizer._max_children == 50
def test_init_with_custom_max_children(self, mock_fs, mock_llm):
"""Test initialization with custom max_children."""
summarizer = DirectorySummarizer(mock_fs, mock_llm, max_children=10)
assert summarizer._max_children == 10
class TestFallbackMethods:
"""Tests for fallback summary generation methods."""
@pytest.fixture
def summarizer(self):
fs = MockContextFS()
llm = MockLLM()
return DirectorySummarizer(fs, llm)
def test_fallback_abstract(self, summarizer):
"""Test fallback abstract generation."""
child_summaries = [
{"uri": "uri1", "abstract": "Abstract 1", "category": "preference"},
{"uri": "uri2", "abstract": "Abstract 2", "category": "event"},
]
abstract = summarizer._fallback_abstract(child_summaries)
assert "2" in abstract
assert "preference" in abstract or "event" in abstract
def test_fallback_overview(self, summarizer):
"""Test fallback overview generation."""
child_summaries = [
{"uri": "uri1", "abstract": "Abstract 1", "category": "preference"},
{"uri": "uri2", "abstract": "Abstract 2", "category": "preference"},
]
overview = summarizer._fallback_overview(child_summaries)
assert "# Directory Overview" in overview
assert "preference" in overview
def test_fallback_overview_multiple_categories(self, summarizer):
"""Test fallback overview with multiple categories."""
child_summaries = [
{"uri": "uri1", "abstract": "Abstract 1", "category": "preference"},
{"uri": "uri2", "abstract": "Abstract 2", "category": "event"},
{"uri": "uri3", "abstract": "Abstract 3", "category": "event"},
]
overview = summarizer._fallback_overview(child_summaries)
assert "## preference" in overview
assert "## event" in overview
class TestGenerateSummary:
"""Tests for _generate_summary method."""
def test_generates_summary_with_llm(self):
"""Test that summary is generated using LLM."""
fs = MockContextFS()
llm = MockLLM({
"abstract": "Custom abstract",
"overview": "Custom overview",
})
summarizer = DirectorySummarizer(fs, llm)
child_summaries = [
{"uri": "uri1", "abstract": "Abstract 1", "category": "preference"},
]
summary = summarizer._generate_summary("test_directory/", child_summaries)
assert summary.abstract == "Custom abstract"
assert summary.overview == "Custom overview"
assert summary.child_count == 1
def test_abstract_is_truncated(self):
"""Test that abstract is truncated to 100 chars."""
fs = MockContextFS()
llm = MockLLM({
"abstract": "A" * 300,
"overview": "Overview",
})
summarizer = DirectorySummarizer(fs, llm)
child_summaries = [
{"uri": "uri1", "abstract": "Abstract 1", "category": "preference"},
]
summary = summarizer._generate_summary("test_directory/", child_summaries)
assert len(summary.abstract) == 100
def test_categories_are_deduplicated(self):
"""Test that categories are deduplicated."""
fs = MockContextFS()
llm = MockLLM({
"abstract": "Abstract",
"overview": "Overview",
})
summarizer = DirectorySummarizer(fs, llm)
child_summaries = [
{"uri": "uri1", "abstract": "A1", "category": "preference"},
{"uri": "uri2", "abstract": "A2", "category": "preference"},
{"uri": "uri3", "abstract": "A3", "category": "event"},
]
summary = summarizer._generate_summary("test_directory/", child_summaries)
assert set(summary.categories) == {"preference", "event"}
assert len(summary.categories) == 2
def test_llm_failure_uses_fallback(self):
"""Test that LLM failure triggers fallback summary."""
fs = MockContextFS()
failing_llm = FailingMockLLM()
summarizer = DirectorySummarizer(fs, failing_llm)
child_summaries = [
{"uri": "uri1", "abstract": "Style preference abstract", "category": "preference"},
]
summary = summarizer._generate_summary("test_directory/", child_summaries)
assert summary is not None
assert summary.child_count == 1
assert "preference" in summary.abstract