mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
Add unit tests for LspGraphBuilder class
- Implement comprehensive unit tests for the LspGraphBuilder class to validate its functionality in building code association graphs. - Tests cover various scenarios including single level graph expansion, max nodes and depth boundaries, concurrent expansion limits, document symbol caching, error handling during node expansion, and edge cases such as empty seed lists and self-referencing nodes. - Utilize pytest and asyncio for asynchronous testing and mocking of LspBridge methods.
This commit is contained in:
1
codex-lens/tests/unit/__init__.py
Normal file
1
codex-lens/tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Unit tests package
|
||||
1
codex-lens/tests/unit/lsp/__init__.py
Normal file
1
codex-lens/tests/unit/lsp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# LSP unit tests package
|
||||
879
codex-lens/tests/unit/lsp/test_lsp_bridge.py
Normal file
879
codex-lens/tests/unit/lsp/test_lsp_bridge.py
Normal file
@@ -0,0 +1,879 @@
|
||||
"""Unit tests for LspBridge service (VSCode Bridge HTTP mode).
|
||||
|
||||
This module provides comprehensive tests for the LspBridge class when used
|
||||
in VSCode Bridge HTTP mode (use_vscode_bridge=True). These tests mock
|
||||
aiohttp HTTP communication with the VSCode Bridge extension.
|
||||
|
||||
Test coverage:
|
||||
- P0 (Critical): Success/failure scenarios for core methods
|
||||
- P1 (Important): Cache hit/miss and invalidation logic
|
||||
- P2 (Supplementary): Edge cases and error handling
|
||||
|
||||
Note: For standalone mode tests (direct language server communication),
|
||||
see tests/real/ directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip all tests if aiohttp is not available
|
||||
pytest.importorskip("aiohttp")
|
||||
|
||||
import aiohttp
|
||||
|
||||
from codexlens.hybrid_search.data_structures import (
|
||||
CallHierarchyItem,
|
||||
CodeSymbolNode,
|
||||
Range,
|
||||
)
|
||||
from codexlens.lsp.lsp_bridge import (
|
||||
CacheEntry,
|
||||
Location,
|
||||
LspBridge,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_symbol() -> CodeSymbolNode:
|
||||
"""Create a sample CodeSymbolNode for testing.
|
||||
|
||||
Returns:
|
||||
CodeSymbolNode with typical function symbol data.
|
||||
"""
|
||||
return CodeSymbolNode(
|
||||
id="test.py:test_func:10",
|
||||
name="test_func",
|
||||
kind="function",
|
||||
file_path="/path/to/test.py",
|
||||
range=Range(
|
||||
start_line=10,
|
||||
start_character=1,
|
||||
end_line=20,
|
||||
end_character=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response() -> AsyncMock:
|
||||
"""Create a mock aiohttp response with configurable attributes.
|
||||
|
||||
Returns:
|
||||
AsyncMock configured as aiohttp ClientResponse.
|
||||
"""
|
||||
response = AsyncMock()
|
||||
response.status = 200
|
||||
response.json = AsyncMock(return_value={"success": True, "result": []})
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(mock_response: AsyncMock) -> AsyncMock:
|
||||
"""Create a mock aiohttp ClientSession.
|
||||
|
||||
Args:
|
||||
mock_response: The mock response to return from post().
|
||||
|
||||
Returns:
|
||||
AsyncMock configured as aiohttp ClientSession with async context manager.
|
||||
"""
|
||||
session = AsyncMock(spec=aiohttp.ClientSession)
|
||||
|
||||
# Configure post() to return context manager with response
|
||||
post_cm = AsyncMock()
|
||||
post_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
post_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
session.post = MagicMock(return_value=post_cm)
|
||||
session.closed = False
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lsp_bridge() -> LspBridge:
|
||||
"""Create a fresh LspBridge instance for testing in VSCode Bridge mode.
|
||||
|
||||
Returns:
|
||||
LspBridge with use_vscode_bridge=True for HTTP-based tests.
|
||||
"""
|
||||
return LspBridge(use_vscode_bridge=True)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Location Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocation:
|
||||
"""Tests for the Location dataclass."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Location.to_dict() returns correct dictionary format."""
|
||||
loc = Location(file_path="/test/file.py", line=10, character=5)
|
||||
result = loc.to_dict()
|
||||
|
||||
assert result == {
|
||||
"file_path": "/test/file.py",
|
||||
"line": 10,
|
||||
"character": 5,
|
||||
}
|
||||
|
||||
def test_from_lsp_response_with_range(self):
|
||||
"""Location.from_lsp_response() parses LSP range format correctly."""
|
||||
data = {
|
||||
"uri": "file:///test/file.py",
|
||||
"range": {
|
||||
"start": {"line": 9, "character": 4}, # 0-based
|
||||
"end": {"line": 15, "character": 0},
|
||||
},
|
||||
}
|
||||
loc = Location.from_lsp_response(data)
|
||||
|
||||
assert loc.file_path == "/test/file.py"
|
||||
assert loc.line == 10 # Converted to 1-based
|
||||
assert loc.character == 5 # Converted to 1-based
|
||||
|
||||
def test_from_lsp_response_direct_fields(self):
|
||||
"""Location.from_lsp_response() handles direct line/character fields."""
|
||||
data = {
|
||||
"file_path": "/direct/path.py",
|
||||
"line": 25,
|
||||
"character": 8,
|
||||
}
|
||||
loc = Location.from_lsp_response(data)
|
||||
|
||||
assert loc.file_path == "/direct/path.py"
|
||||
assert loc.line == 25
|
||||
assert loc.character == 8
|
||||
|
||||
|
||||
class TestLocationFromVscodeUri:
|
||||
"""Tests for parsing VSCode URI formats (P2 test case)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uri,expected_path",
|
||||
[
|
||||
# Unix-style paths
|
||||
("file:///home/user/project/file.py", "/home/user/project/file.py"),
|
||||
("file:///usr/local/lib.py", "/usr/local/lib.py"),
|
||||
# Windows-style paths
|
||||
("file:///C:/Users/dev/project/file.py", "C:/Users/dev/project/file.py"),
|
||||
("file:///D:/code/test.ts", "D:/code/test.ts"),
|
||||
# Already plain path
|
||||
("/plain/path/file.py", "/plain/path/file.py"),
|
||||
# Edge case: file:// without third slash
|
||||
("file://shared/network/file.py", "shared/network/file.py"),
|
||||
],
|
||||
)
|
||||
def test_location_from_vscode_uri(self, uri: str, expected_path: str):
|
||||
"""Test correct parsing of various VSCode URI formats to OS paths.
|
||||
|
||||
Verifies that file:///C:/path format on Windows and file:///path
|
||||
format on Unix are correctly converted to native OS paths.
|
||||
"""
|
||||
data = {
|
||||
"uri": uri,
|
||||
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
|
||||
}
|
||||
loc = Location.from_lsp_response(data)
|
||||
|
||||
assert loc.file_path == expected_path
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# P0 Critical Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetReferencesSuccess:
|
||||
"""P0: Test successful get_references scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_references_success(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test get_references returns Location list and caches result.
|
||||
|
||||
Mock session returns 200 OK with valid LSP location list.
|
||||
Verifies:
|
||||
- Returns list of Location objects
|
||||
- Results are stored in cache
|
||||
"""
|
||||
# Setup mock response with valid locations
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{
|
||||
"uri": "file:///ref1.py",
|
||||
"range": {"start": {"line": 5, "character": 0}, "end": {"line": 5, "character": 10}},
|
||||
},
|
||||
{
|
||||
"uri": "file:///ref2.py",
|
||||
"range": {"start": {"line": 15, "character": 4}, "end": {"line": 15, "character": 14}},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
# Inject mock session
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
# Execute
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
refs = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Verify results
|
||||
assert len(refs) == 2
|
||||
assert isinstance(refs[0], Location)
|
||||
assert refs[0].file_path == "/ref1.py"
|
||||
assert refs[0].line == 6 # 0-based to 1-based
|
||||
assert refs[1].file_path == "/ref2.py"
|
||||
assert refs[1].line == 16
|
||||
|
||||
# Verify cached
|
||||
cache_key = f"refs:{sample_symbol.id}"
|
||||
assert cache_key in lsp_bridge.cache
|
||||
assert lsp_bridge.cache[cache_key].data == refs
|
||||
|
||||
|
||||
class TestGetReferencesBridgeNotRunning:
|
||||
"""P0: Test get_references when bridge is not running."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_references_bridge_not_running(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
):
|
||||
"""Test get_references returns empty list on ClientConnectorError.
|
||||
|
||||
When VSCode Bridge is not running, aiohttp raises ClientConnectorError.
|
||||
Verifies:
|
||||
- Returns empty list []
|
||||
- No cache entry is created
|
||||
"""
|
||||
# Setup mock session that raises connection error
|
||||
mock_session = AsyncMock(spec=aiohttp.ClientSession)
|
||||
mock_session.closed = False
|
||||
mock_session.post = MagicMock(side_effect=aiohttp.ClientConnectorError(
|
||||
connection_key=MagicMock(),
|
||||
os_error=OSError("Connection refused"),
|
||||
))
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
# Execute
|
||||
refs = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Verify
|
||||
assert refs == []
|
||||
cache_key = f"refs:{sample_symbol.id}"
|
||||
assert cache_key not in lsp_bridge.cache
|
||||
|
||||
|
||||
class TestGetReferencesTimeout:
|
||||
"""P0: Test get_references timeout handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_references_timeout(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
):
|
||||
"""Test get_references returns empty list on asyncio.TimeoutError.
|
||||
|
||||
When request times out, should gracefully return empty list.
|
||||
"""
|
||||
# Setup mock session that raises timeout
|
||||
mock_session = AsyncMock(spec=aiohttp.ClientSession)
|
||||
mock_session.closed = False
|
||||
|
||||
async def raise_timeout(*args, **kwargs):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
post_cm = AsyncMock()
|
||||
post_cm.__aenter__ = raise_timeout
|
||||
post_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.post = MagicMock(return_value=post_cm)
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
# Execute
|
||||
refs = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Verify
|
||||
assert refs == []
|
||||
|
||||
|
||||
class TestCallHierarchyFallback:
|
||||
"""P0: Test call_hierarchy fallback to references."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_hierarchy_fallback_to_references(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
):
|
||||
"""Test get_call_hierarchy falls back to get_references when not supported.
|
||||
|
||||
When call_hierarchy request returns None (not supported by language server),
|
||||
verifies:
|
||||
- Falls back to calling get_references
|
||||
- Returns converted CallHierarchyItem list
|
||||
"""
|
||||
call_count = 0
|
||||
|
||||
async def mock_json():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# First call is get_call_hierarchy - return failure
|
||||
return {"success": False}
|
||||
else:
|
||||
# Second call is get_references - return valid refs
|
||||
return {
|
||||
"success": True,
|
||||
"result": [
|
||||
{
|
||||
"uri": "file:///caller.py",
|
||||
"range": {"start": {"line": 10, "character": 5}, "end": {"line": 10, "character": 15}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Setup mock response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = mock_json
|
||||
|
||||
post_cm = AsyncMock()
|
||||
post_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
post_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.post = MagicMock(return_value=post_cm)
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
# Execute
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
items = await lsp_bridge.get_call_hierarchy(sample_symbol)
|
||||
|
||||
# Verify fallback occurred and returned CallHierarchyItem
|
||||
assert len(items) == 1
|
||||
assert isinstance(items[0], CallHierarchyItem)
|
||||
assert items[0].file_path == "/caller.py"
|
||||
assert items[0].kind == "reference"
|
||||
assert "Inferred from reference" in items[0].detail
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# P1 Important Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheHit:
|
||||
"""P1: Test cache hit behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test that same symbol called twice only makes one request.
|
||||
|
||||
Verifies:
|
||||
- _request is only called once
|
||||
- Second call returns cached result
|
||||
"""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{"uri": "file:///ref.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
|
||||
],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
# First call - should make request
|
||||
refs1 = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Second call - should use cache
|
||||
refs2 = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Verify only one HTTP call was made
|
||||
assert mock_session.post.call_count == 1
|
||||
|
||||
# Verify both calls return same data
|
||||
assert refs1 == refs2
|
||||
|
||||
|
||||
class TestCacheInvalidationTtl:
|
||||
"""P1: Test cache TTL invalidation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_invalidation_ttl(
|
||||
self,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test cache entry expires after TTL.
|
||||
|
||||
Sets extremely short TTL and verifies:
|
||||
- Cache entry expires
|
||||
- New request is made after TTL expires
|
||||
"""
|
||||
# Create bridge with very short TTL (VSCode Bridge mode for HTTP tests)
|
||||
bridge = LspBridge(cache_ttl=1, use_vscode_bridge=True) # 1 second TTL
|
||||
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{"uri": "file:///ref.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
|
||||
],
|
||||
})
|
||||
|
||||
bridge._session = mock_session
|
||||
|
||||
with patch.object(bridge, "_get_file_mtime", return_value=1000.0):
|
||||
# First call
|
||||
await bridge.get_references(sample_symbol)
|
||||
assert mock_session.post.call_count == 1
|
||||
|
||||
# Wait for TTL to expire
|
||||
await asyncio.sleep(1.1)
|
||||
|
||||
# Second call - should make new request
|
||||
await bridge.get_references(sample_symbol)
|
||||
assert mock_session.post.call_count == 2
|
||||
|
||||
await bridge.close()
|
||||
|
||||
|
||||
class TestCacheInvalidationFileModified:
|
||||
"""P1: Test cache invalidation on file modification."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_invalidation_file_modified(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test cache entry invalidates when file mtime changes.
|
||||
|
||||
Verifies:
|
||||
- mtime change triggers cache invalidation
|
||||
- New request is made after file modification
|
||||
"""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{"uri": "file:///ref.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
|
||||
],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
# Mock mtime: first call returns 1000.0, subsequent calls return 2000.0
|
||||
# This simulates file being modified between cache store and cache check
|
||||
call_count = [0]
|
||||
|
||||
def get_mtime(path: str) -> float:
|
||||
call_count[0] += 1
|
||||
# First call during _cache() stores mtime 1000.0
|
||||
# Second call during _is_cached() should see different mtime
|
||||
if call_count[0] <= 1:
|
||||
return 1000.0
|
||||
return 2000.0 # File modified
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", side_effect=get_mtime):
|
||||
# First call - should make request and cache with mtime 1000.0
|
||||
await lsp_bridge.get_references(sample_symbol)
|
||||
assert mock_session.post.call_count == 1
|
||||
|
||||
# Second call - mtime check returns 2000.0 (different from cached 1000.0)
|
||||
# Should invalidate cache and make new request
|
||||
await lsp_bridge.get_references(sample_symbol)
|
||||
assert mock_session.post.call_count == 2
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# P2 Supplementary Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResponseParsingInvalidJson:
|
||||
"""P2: Test handling of malformed JSON responses."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_parsing_invalid_json(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
):
|
||||
"""Test graceful handling of malformed JSON response.
|
||||
|
||||
Verifies:
|
||||
- Returns empty list when JSON parsing fails
|
||||
- Does not raise exception
|
||||
"""
|
||||
# Setup mock to raise JSONDecodeError
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(side_effect=Exception("Invalid JSON"))
|
||||
|
||||
post_cm = AsyncMock()
|
||||
post_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
post_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.post = MagicMock(return_value=post_cm)
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
# Execute - should not raise
|
||||
refs = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Verify graceful handling
|
||||
assert refs == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_with_malformed_location_items(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test handling of partially malformed location items.
|
||||
|
||||
The source code catches KeyError and TypeError when parsing items.
|
||||
Tests that items causing these specific exceptions are skipped while
|
||||
valid items are returned.
|
||||
"""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
# Valid item
|
||||
{"uri": "file:///valid.py", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}},
|
||||
# Another valid item
|
||||
{"uri": "file:///valid2.py", "range": {"start": {"line": 5, "character": 0}, "end": {"line": 5, "character": 0}}},
|
||||
],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
refs = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
# Should return both valid items
|
||||
assert len(refs) == 2
|
||||
assert refs[0].file_path == "/valid.py"
|
||||
assert refs[1].file_path == "/valid2.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_with_empty_result_list(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test handling of empty result list."""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
refs = await lsp_bridge.get_references(sample_symbol)
|
||||
|
||||
assert refs == []
|
||||
|
||||
|
||||
class TestLspBridgeContextManager:
|
||||
"""Test async context manager functionality (VSCode Bridge mode)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_closes_session(self):
|
||||
"""Test that async context manager properly closes session in VSCode Bridge mode."""
|
||||
async with LspBridge(use_vscode_bridge=True) as bridge:
|
||||
# Create a session
|
||||
session = await bridge._get_session()
|
||||
assert session is not None
|
||||
assert not session.closed
|
||||
|
||||
# After context, session should be closed
|
||||
assert bridge._session is None or bridge._session.closed
|
||||
|
||||
|
||||
class TestCacheEntry:
|
||||
"""Test CacheEntry dataclass."""
|
||||
|
||||
def test_cache_entry_fields(self):
|
||||
"""CacheEntry stores all required fields."""
|
||||
entry = CacheEntry(
|
||||
data=["some", "data"],
|
||||
file_mtime=12345.0,
|
||||
cached_at=time.time(),
|
||||
)
|
||||
|
||||
assert entry.data == ["some", "data"]
|
||||
assert entry.file_mtime == 12345.0
|
||||
assert entry.cached_at > 0
|
||||
|
||||
|
||||
class TestLspBridgeCacheLru:
|
||||
"""Test LRU cache behavior."""
|
||||
|
||||
def test_cache_lru_eviction(self):
|
||||
"""Test that oldest entries are evicted when at max capacity."""
|
||||
bridge = LspBridge(max_cache_size=3)
|
||||
|
||||
# Add entries
|
||||
bridge._cache("key1", "/file1.py", "data1")
|
||||
bridge._cache("key2", "/file2.py", "data2")
|
||||
bridge._cache("key3", "/file3.py", "data3")
|
||||
|
||||
assert len(bridge.cache) == 3
|
||||
|
||||
# Add one more - should evict oldest (key1)
|
||||
bridge._cache("key4", "/file4.py", "data4")
|
||||
|
||||
assert len(bridge.cache) == 3
|
||||
assert "key1" not in bridge.cache
|
||||
assert "key4" in bridge.cache
|
||||
|
||||
def test_cache_access_moves_to_end(self):
|
||||
"""Test that accessing cached item moves it to end (LRU behavior)."""
|
||||
bridge = LspBridge(max_cache_size=3)
|
||||
|
||||
with patch.object(bridge, "_get_file_mtime", return_value=1000.0):
|
||||
bridge._cache("key1", "/file.py", "data1")
|
||||
bridge._cache("key2", "/file.py", "data2")
|
||||
bridge._cache("key3", "/file.py", "data3")
|
||||
|
||||
# Access key1 - should move it to end
|
||||
bridge._is_cached("key1", "/file.py")
|
||||
|
||||
# Add key4 - should evict key2 (now oldest)
|
||||
bridge._cache("key4", "/file.py", "data4")
|
||||
|
||||
assert "key1" in bridge.cache
|
||||
assert "key2" not in bridge.cache
|
||||
|
||||
|
||||
class TestGetHover:
|
||||
"""Test get_hover method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hover_returns_string(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test get_hover returns hover documentation string."""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": {
|
||||
"contents": "Function documentation here",
|
||||
},
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
hover = await lsp_bridge.get_hover(sample_symbol)
|
||||
|
||||
assert hover == "Function documentation here"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hover_handles_marked_string_list(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test get_hover handles MarkedString list format."""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{"value": "```python\ndef func():\n```"},
|
||||
{"value": "Documentation text"},
|
||||
],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
hover = await lsp_bridge.get_hover(sample_symbol)
|
||||
|
||||
assert "def func()" in hover
|
||||
assert "Documentation text" in hover
|
||||
|
||||
|
||||
class TestGetDefinition:
|
||||
"""Test get_definition method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_definition_returns_location(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test get_definition returns Location for found definition."""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{
|
||||
"uri": "file:///definition.py",
|
||||
"range": {"start": {"line": 99, "character": 0}, "end": {"line": 110, "character": 0}},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
definition = await lsp_bridge.get_definition(sample_symbol)
|
||||
|
||||
assert definition is not None
|
||||
assert definition.file_path == "/definition.py"
|
||||
assert definition.line == 100 # 0-based to 1-based
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_definition_returns_none_on_failure(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
sample_symbol: CodeSymbolNode,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test get_definition returns None when not found."""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": False,
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
definition = await lsp_bridge.get_definition(sample_symbol)
|
||||
|
||||
assert definition is None
|
||||
|
||||
|
||||
class TestGetDocumentSymbols:
|
||||
"""Test get_document_symbols method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_symbols_flattens_hierarchy(
|
||||
self,
|
||||
lsp_bridge: LspBridge,
|
||||
mock_session: AsyncMock,
|
||||
mock_response: AsyncMock,
|
||||
):
|
||||
"""Test get_document_symbols flattens nested symbol hierarchy."""
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={
|
||||
"success": True,
|
||||
"result": [
|
||||
{
|
||||
"name": "MyClass",
|
||||
"kind": 5, # Class
|
||||
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 20, "character": 0}},
|
||||
"children": [
|
||||
{
|
||||
"name": "my_method",
|
||||
"kind": 6, # Method
|
||||
"range": {"start": {"line": 5, "character": 4}, "end": {"line": 10, "character": 4}},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
lsp_bridge._session = mock_session
|
||||
|
||||
with patch.object(lsp_bridge, "_get_file_mtime", return_value=1000.0):
|
||||
symbols = await lsp_bridge.get_document_symbols("/test/file.py")
|
||||
|
||||
# Should have both class and method
|
||||
assert len(symbols) == 2
|
||||
assert symbols[0]["name"] == "MyClass"
|
||||
assert symbols[0]["kind"] == "class"
|
||||
assert symbols[1]["name"] == "my_method"
|
||||
assert symbols[1]["kind"] == "method"
|
||||
assert symbols[1]["parent"] == "MyClass"
|
||||
|
||||
|
||||
class TestSymbolKindConversion:
|
||||
"""Test symbol kind integer to string conversion."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind_int,expected_str",
|
||||
[
|
||||
(1, "file"),
|
||||
(5, "class"),
|
||||
(6, "method"),
|
||||
(12, "function"),
|
||||
(13, "variable"),
|
||||
(999, "unknown"), # Unknown kind
|
||||
],
|
||||
)
|
||||
def test_symbol_kind_to_string(self, kind_int: int, expected_str: str):
|
||||
"""Test _symbol_kind_to_string converts LSP SymbolKind correctly."""
|
||||
bridge = LspBridge()
|
||||
result = bridge._symbol_kind_to_string(kind_int)
|
||||
assert result == expected_str
|
||||
|
||||
|
||||
class TestClearCache:
|
||||
"""Test cache clearing functionality."""
|
||||
|
||||
def test_clear_cache(self, lsp_bridge: LspBridge):
|
||||
"""Test clear_cache removes all entries."""
|
||||
# Add some cache entries
|
||||
lsp_bridge._cache("key1", "/file.py", "data1")
|
||||
lsp_bridge._cache("key2", "/file.py", "data2")
|
||||
|
||||
assert len(lsp_bridge.cache) == 2
|
||||
|
||||
# Clear
|
||||
lsp_bridge.clear_cache()
|
||||
|
||||
assert len(lsp_bridge.cache) == 0
|
||||
777
codex-lens/tests/unit/lsp/test_lsp_edge_cases.py
Normal file
777
codex-lens/tests/unit/lsp/test_lsp_edge_cases.py
Normal file
@@ -0,0 +1,777 @@
|
||||
"""Edge case and exception tests for LSP Bridge and Graph Builder.
|
||||
|
||||
This module tests boundary conditions, error handling, and exceptional
|
||||
scenarios in the LSP communication and graph building components.
|
||||
|
||||
Test Categories:
|
||||
- P1 (Critical): Empty responses, HTTP errors
|
||||
- P2 (Important): Edge inputs, deep structures, special characters
|
||||
- P3 (Nice-to-have): Cache eviction, concurrent access, circular refs
|
||||
|
||||
Note: Tests for HTTP-based communication use use_vscode_bridge=True mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from codexlens.hybrid_search.data_structures import (
|
||||
CodeAssociationGraph,
|
||||
CodeSymbolNode,
|
||||
Range,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def valid_range() -> Range:
|
||||
"""Create a valid Range for test symbols."""
|
||||
return Range(
|
||||
start_line=10,
|
||||
start_character=0,
|
||||
end_line=20,
|
||||
end_character=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_symbol(valid_range: Range) -> CodeSymbolNode:
|
||||
"""Create a sample CodeSymbolNode for testing."""
|
||||
return CodeSymbolNode(
|
||||
id="test/file.py:test_func:10",
|
||||
name="test_func",
|
||||
kind="function",
|
||||
file_path="test/file.py",
|
||||
range=valid_range,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def symbol_with_empty_path() -> CodeSymbolNode:
|
||||
"""Create a CodeSymbolNode with empty file_path.
|
||||
|
||||
Note: CodeSymbolNode.__post_init__ validates that file_path cannot be empty,
|
||||
so this fixture tests the case where validation is bypassed or data comes
|
||||
from external sources that might have empty paths.
|
||||
"""
|
||||
# We need to bypass validation for this edge case test
|
||||
node = object.__new__(CodeSymbolNode)
|
||||
node.id = "::0"
|
||||
node.name = "empty"
|
||||
node.kind = "unknown"
|
||||
node.file_path = "" # Empty path - edge case
|
||||
node.range = Range(start_line=0, start_character=0, end_line=0, end_character=0)
|
||||
node.embedding = None
|
||||
node.raw_code = ""
|
||||
node.docstring = ""
|
||||
node.score = 0.0
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_aiohttp_session():
|
||||
"""Create a mock aiohttp ClientSession."""
|
||||
session = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_error_response():
|
||||
"""Create a mock aiohttp response with HTTP 500 error."""
|
||||
response = AsyncMock()
|
||||
response.status = 500
|
||||
response.json = AsyncMock(return_value={"error": "Internal Server Error"})
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_empty_response():
|
||||
"""Create a mock aiohttp response returning empty list."""
|
||||
response = AsyncMock()
|
||||
response.status = 200
|
||||
response.json = AsyncMock(return_value={"success": True, "result": []})
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P1 Tests - Critical Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLspReturnsEmptyList:
|
||||
"""Test handling when LSP returns empty results.
|
||||
|
||||
Module: LspGraphBuilder._expand_node
|
||||
Mock: LspBridge methods return []
|
||||
Assert: Node marked as visited, no new nodes/edges added, returns []
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_node_with_empty_references(self, sample_symbol: CodeSymbolNode):
|
||||
"""When LSP returns empty references, node should be visited but no expansion."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
# Create mock LspBridge that returns empty results
|
||||
mock_bridge = AsyncMock()
|
||||
mock_bridge.get_references = AsyncMock(return_value=[])
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
|
||||
builder = LspGraphBuilder(max_depth=2, max_nodes=100)
|
||||
graph = CodeAssociationGraph()
|
||||
graph.add_node(sample_symbol)
|
||||
visited = set()
|
||||
semaphore = asyncio.Semaphore(10)
|
||||
|
||||
# Expand the node
|
||||
result = await builder._expand_node(
|
||||
sample_symbol,
|
||||
depth=0,
|
||||
graph=graph,
|
||||
lsp_bridge=mock_bridge,
|
||||
visited=visited,
|
||||
semaphore=semaphore,
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert sample_symbol.id in visited # Node should be marked as visited
|
||||
assert result == [] # No new nodes to process
|
||||
assert len(graph.nodes) == 1 # Only the original seed node
|
||||
assert len(graph.edges) == 0 # No edges added
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_from_seeds_with_empty_lsp_results(self, sample_symbol: CodeSymbolNode):
|
||||
"""When LSP returns empty for all queries, graph should contain only seeds."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
mock_bridge.get_references = AsyncMock(return_value=[])
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
mock_bridge.get_document_symbols = AsyncMock(return_value=[])
|
||||
|
||||
builder = LspGraphBuilder(max_depth=2, max_nodes=100)
|
||||
|
||||
# Build graph from seed
|
||||
graph = await builder.build_from_seeds([sample_symbol], mock_bridge)
|
||||
|
||||
# Should only have the seed node
|
||||
assert len(graph.nodes) == 1
|
||||
assert sample_symbol.id in graph.nodes
|
||||
assert len(graph.edges) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_visited_node_returns_empty(self, sample_symbol: CodeSymbolNode):
|
||||
"""Attempting to expand an already-visited node should return empty immediately."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
# These should not be called since node is already visited
|
||||
mock_bridge.get_references = AsyncMock(return_value=[])
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
|
||||
builder = LspGraphBuilder()
|
||||
graph = CodeAssociationGraph()
|
||||
graph.add_node(sample_symbol)
|
||||
visited = {sample_symbol.id} # Already visited
|
||||
semaphore = asyncio.Semaphore(10)
|
||||
|
||||
result = await builder._expand_node(
|
||||
sample_symbol,
|
||||
depth=0,
|
||||
graph=graph,
|
||||
lsp_bridge=mock_bridge,
|
||||
visited=visited,
|
||||
semaphore=semaphore,
|
||||
)
|
||||
|
||||
assert result == []
|
||||
# Bridge methods should not have been called
|
||||
mock_bridge.get_references.assert_not_called()
|
||||
mock_bridge.get_call_hierarchy.assert_not_called()
|
||||
|
||||
|
||||
class TestLspHttpError500:
|
||||
"""Test handling of HTTP 500 errors from LSP bridge (VSCode Bridge mode).
|
||||
|
||||
Module: LspBridge._request_vscode_bridge
|
||||
Mock: aiohttp response status=500
|
||||
Assert: Returns None, caller handles as failure
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_returns_none_on_500(self):
|
||||
"""HTTP 500 response should result in None return value."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
# Create bridge in VSCode Bridge mode with mocked session
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
# Mock the session to return 500 error
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 500
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.object(bridge, '_get_session', return_value=mock_session):
|
||||
result = await bridge._request_vscode_bridge("get_references", {"file_path": "test.py"})
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_references_returns_empty_on_500(self, sample_symbol: CodeSymbolNode):
|
||||
"""get_references should return empty list on HTTP 500."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
# Mock _request_vscode_bridge to return None (simulating HTTP error)
|
||||
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
|
||||
result = await bridge.get_references(sample_symbol)
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_definition_returns_none_on_500(self, sample_symbol: CodeSymbolNode):
|
||||
"""get_definition should return None on HTTP 500."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
|
||||
result = await bridge.get_definition(sample_symbol)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hover_returns_none_on_500(self, sample_symbol: CodeSymbolNode):
|
||||
"""get_hover should return None on HTTP 500."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
|
||||
result = await bridge.get_hover(sample_symbol)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_builder_handles_lsp_errors_gracefully(self, sample_symbol: CodeSymbolNode):
|
||||
"""Graph builder should handle LSP errors without crashing."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
# Simulate exceptions from LSP
|
||||
mock_bridge.get_references = AsyncMock(side_effect=Exception("LSP Error"))
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(side_effect=Exception("LSP Error"))
|
||||
|
||||
builder = LspGraphBuilder()
|
||||
|
||||
# Should not raise, should return graph with just the seed
|
||||
graph = await builder.build_from_seeds([sample_symbol], mock_bridge)
|
||||
|
||||
assert len(graph.nodes) == 1
|
||||
assert sample_symbol.id in graph.nodes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P2 Tests - Important Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSymbolWithEmptyFilePath:
|
||||
"""Test handling of symbols with empty file_path (VSCode Bridge mode).
|
||||
|
||||
Module: LspBridge.get_references
|
||||
Input: CodeSymbolNode with file_path=""
|
||||
Assert: Does not send request, returns [] early
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_references_with_empty_path_symbol(self, symbol_with_empty_path: CodeSymbolNode):
|
||||
"""get_references with empty file_path should handle gracefully."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
# Mock _request_vscode_bridge - it should still work but with empty path
|
||||
mock_result = []
|
||||
with patch.object(bridge, '_request_vscode_bridge', return_value=mock_result) as mock_req:
|
||||
result = await bridge.get_references(symbol_with_empty_path)
|
||||
|
||||
# Should return empty list
|
||||
assert result == []
|
||||
# The request was still made (current implementation doesn't pre-validate)
|
||||
# This documents current behavior - might want to add validation
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_with_empty_path_symbol(self, symbol_with_empty_path: CodeSymbolNode):
|
||||
"""Cache operations with empty file_path should not crash."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge()
|
||||
|
||||
# Cache should handle empty path (mtime check returns 0.0)
|
||||
cache_key = f"refs:{symbol_with_empty_path.id}"
|
||||
bridge._cache(cache_key, "", []) # Empty path
|
||||
|
||||
# Should be able to check cache without crashing
|
||||
is_cached = bridge._is_cached(cache_key, "")
|
||||
# Note: May or may not be cached depending on mtime behavior
|
||||
assert isinstance(is_cached, bool)
|
||||
|
||||
|
||||
class TestVeryDeepGraphStructure:
|
||||
"""Test graph building with very deep reference chains.
|
||||
|
||||
Module: LspGraphBuilder.build_from_seeds
|
||||
Input: max_depth=10
|
||||
Mock: LspBridge produces long chain of references
|
||||
Assert: Expansion stops cleanly at max_depth
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expansion_stops_at_max_depth(self, valid_range: Range):
|
||||
"""Graph expansion should stop at max_depth."""
|
||||
from codexlens.lsp.lsp_bridge import Location
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
# Create a chain of symbols: seed -> ref1 -> ref2 -> ... -> refN
|
||||
max_depth = 3 # Use small depth for testing
|
||||
|
||||
def create_mock_refs(symbol: CodeSymbolNode) -> List[Location]:
|
||||
"""Create a single reference pointing to next in chain."""
|
||||
depth = int(symbol.id.split(":")[-1]) # Extract depth from ID
|
||||
if depth >= max_depth + 5: # Chain goes deeper than max_depth
|
||||
return []
|
||||
next_depth = depth + 1
|
||||
return [Location(
|
||||
file_path=f"test/file_{next_depth}.py",
|
||||
line=1,
|
||||
character=0,
|
||||
)]
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
mock_bridge.get_references = AsyncMock(side_effect=lambda s: create_mock_refs(s))
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
mock_bridge.get_document_symbols = AsyncMock(return_value=[])
|
||||
|
||||
# Seed at depth 0
|
||||
seed = CodeSymbolNode(
|
||||
id="test/file_0.py:seed:0",
|
||||
name="seed",
|
||||
kind="function",
|
||||
file_path="test/file_0.py",
|
||||
range=valid_range,
|
||||
)
|
||||
|
||||
builder = LspGraphBuilder(max_depth=max_depth, max_nodes=100)
|
||||
graph = await builder.build_from_seeds([seed], mock_bridge)
|
||||
|
||||
# Graph should not exceed max_depth + 1 nodes (seed + max_depth levels)
|
||||
# Actual count depends on how references are resolved
|
||||
assert len(graph.nodes) <= max_depth + 2 # Some tolerance for edge cases
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expansion_stops_at_max_nodes(self, valid_range: Range):
|
||||
"""Graph expansion should stop when max_nodes is reached."""
|
||||
from codexlens.lsp.lsp_bridge import Location
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
call_count = [0]
|
||||
|
||||
def create_many_refs(symbol: CodeSymbolNode) -> List[Location]:
|
||||
"""Create multiple references to generate many nodes."""
|
||||
call_count[0] += 1
|
||||
# Return multiple refs to rapidly grow the graph
|
||||
return [
|
||||
Location(file_path=f"test/ref_{call_count[0]}_{i}.py", line=1, character=0)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
mock_bridge.get_references = AsyncMock(side_effect=create_many_refs)
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
mock_bridge.get_document_symbols = AsyncMock(return_value=[])
|
||||
|
||||
seed = CodeSymbolNode(
|
||||
id="test/seed.py:seed:0",
|
||||
name="seed",
|
||||
kind="function",
|
||||
file_path="test/seed.py",
|
||||
range=valid_range,
|
||||
)
|
||||
|
||||
max_nodes = 10
|
||||
builder = LspGraphBuilder(max_depth=100, max_nodes=max_nodes) # High depth, low nodes
|
||||
graph = await builder.build_from_seeds([seed], mock_bridge)
|
||||
|
||||
# Graph should not exceed max_nodes
|
||||
assert len(graph.nodes) <= max_nodes
|
||||
|
||||
|
||||
class TestNodeIdWithSpecialCharacters:
|
||||
"""Test node ID creation with special characters.
|
||||
|
||||
Module: LspGraphBuilder._create_node_id
|
||||
Input: file_path="a/b/c", name="<init>", line=10
|
||||
Assert: ID successfully created as "a/b/c:<init>:10"
|
||||
"""
|
||||
|
||||
def test_create_node_id_with_special_name(self):
|
||||
"""Node ID should handle special characters in name."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
builder = LspGraphBuilder()
|
||||
|
||||
# Test with angle brackets (common in Java/Kotlin constructors)
|
||||
node_id = builder._create_node_id("a/b/c", "<init>", 10)
|
||||
assert node_id == "a/b/c:<init>:10"
|
||||
|
||||
# Test with other special characters
|
||||
node_id = builder._create_node_id("src/file.py", "__init__", 1)
|
||||
assert node_id == "src/file.py:__init__:1"
|
||||
|
||||
# Test with spaces (should preserve as-is)
|
||||
node_id = builder._create_node_id("my path/file.ts", "my func", 5)
|
||||
assert node_id == "my path/file.ts:my func:5"
|
||||
|
||||
def test_create_node_id_with_windows_path(self):
|
||||
"""Node ID should handle Windows-style paths."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
builder = LspGraphBuilder()
|
||||
|
||||
# Windows path with backslashes
|
||||
node_id = builder._create_node_id("C:\\Users\\test\\file.py", "main", 1)
|
||||
assert "main" in node_id
|
||||
assert "1" in node_id
|
||||
|
||||
def test_create_node_id_with_unicode(self):
|
||||
"""Node ID should handle unicode characters."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
builder = LspGraphBuilder()
|
||||
|
||||
# Unicode in name
|
||||
node_id = builder._create_node_id("src/file.py", "func_name", 10)
|
||||
assert node_id == "src/file.py:func_name:10"
|
||||
|
||||
def test_code_symbol_node_id_format(self):
|
||||
"""CodeSymbolNode.create_id should match LspGraphBuilder format."""
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
builder = LspGraphBuilder()
|
||||
|
||||
# Both should produce the same format
|
||||
builder_id = builder._create_node_id("path/file.py", "func", 10)
|
||||
symbol_id = CodeSymbolNode.create_id("path/file.py", "func", 10)
|
||||
|
||||
assert builder_id == symbol_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P3 Tests - Additional Edge Cases (if time allows)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheLruEviction:
|
||||
"""Test LRU cache eviction behavior.
|
||||
|
||||
Module: LspBridge._cache
|
||||
Input: max_cache_size=3, add 5 entries
|
||||
Assert: Only most recent 3 entries remain
|
||||
"""
|
||||
|
||||
def test_cache_evicts_oldest_entries(self):
|
||||
"""Cache should evict oldest entries when at capacity."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(max_cache_size=3)
|
||||
|
||||
# Add 5 entries (exceeds max of 3)
|
||||
for i in range(5):
|
||||
bridge._cache(f"key_{i}", "test.py", f"data_{i}")
|
||||
|
||||
# Should only have 3 entries
|
||||
assert len(bridge.cache) == 3
|
||||
|
||||
# Oldest entries (key_0, key_1) should be evicted
|
||||
assert "key_0" not in bridge.cache
|
||||
assert "key_1" not in bridge.cache
|
||||
|
||||
# Newest entries should remain
|
||||
assert "key_2" in bridge.cache
|
||||
assert "key_3" in bridge.cache
|
||||
assert "key_4" in bridge.cache
|
||||
|
||||
def test_cache_moves_accessed_entry_to_end(self):
|
||||
"""Accessing a cached entry should move it to end (LRU behavior)."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(max_cache_size=3)
|
||||
|
||||
# Add 3 entries
|
||||
bridge._cache("key_0", "test.py", "data_0")
|
||||
bridge._cache("key_1", "test.py", "data_1")
|
||||
bridge._cache("key_2", "test.py", "data_2")
|
||||
|
||||
# Access key_0 (should move to end)
|
||||
with patch.object(bridge, '_get_file_mtime', return_value=0.0):
|
||||
bridge._is_cached("key_0", "test.py")
|
||||
|
||||
# Add new entry - key_1 should be evicted (was least recently used)
|
||||
bridge._cache("key_3", "test.py", "data_3")
|
||||
|
||||
assert len(bridge.cache) == 3
|
||||
assert "key_0" in bridge.cache # Was accessed, moved to end
|
||||
assert "key_1" not in bridge.cache # Was evicted
|
||||
assert "key_2" in bridge.cache
|
||||
assert "key_3" in bridge.cache
|
||||
|
||||
|
||||
class TestConcurrentCacheAccess:
|
||||
"""Test thread-safety of cache operations.
|
||||
|
||||
Module: LspBridge
|
||||
Test: Multiple concurrent requests access/update cache
|
||||
Assert: No race conditions, cache remains consistent
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_cache_operations(self, valid_range: Range):
|
||||
"""Multiple concurrent requests should not corrupt cache."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(max_cache_size=100)
|
||||
|
||||
async def cache_operation(i: int) -> None:
|
||||
"""Simulate a cache read/write operation."""
|
||||
key = f"key_{i % 10}" # Reuse keys to create contention
|
||||
file_path = f"file_{i}.py"
|
||||
|
||||
# Check cache
|
||||
bridge._is_cached(key, file_path)
|
||||
|
||||
# Small delay to increase contention likelihood
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
# Write to cache
|
||||
bridge._cache(key, file_path, f"data_{i}")
|
||||
|
||||
# Run many concurrent operations
|
||||
tasks = [cache_operation(i) for i in range(50)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# Cache should be in consistent state
|
||||
assert len(bridge.cache) <= bridge.max_cache_size
|
||||
|
||||
# All entries should be valid CacheEntry objects
|
||||
for key, entry in bridge.cache.items():
|
||||
assert hasattr(entry, 'data')
|
||||
assert hasattr(entry, 'cached_at')
|
||||
assert hasattr(entry, 'file_mtime')
|
||||
|
||||
|
||||
class TestGraphWithCircularReferences:
|
||||
"""Test graph handling of circular reference patterns.
|
||||
|
||||
Module: LspGraphBuilder
|
||||
Mock: A -> B -> C -> A circular reference
|
||||
Assert: visited set prevents infinite loop
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_circular_references_do_not_loop_infinitely(self, valid_range: Range):
|
||||
"""Circular references should not cause infinite loops."""
|
||||
from codexlens.lsp.lsp_bridge import Location
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
# Create circular reference pattern: A -> B -> C -> A
|
||||
symbol_a = CodeSymbolNode(
|
||||
id="file.py:A:1", name="A", kind="function",
|
||||
file_path="file.py", range=valid_range,
|
||||
)
|
||||
symbol_b = CodeSymbolNode(
|
||||
id="file.py:B:10", name="B", kind="function",
|
||||
file_path="file.py", range=valid_range,
|
||||
)
|
||||
symbol_c = CodeSymbolNode(
|
||||
id="file.py:C:20", name="C", kind="function",
|
||||
file_path="file.py", range=valid_range,
|
||||
)
|
||||
|
||||
ref_map = {
|
||||
"file.py:A:1": [Location(file_path="file.py", line=10, character=0)], # A -> B
|
||||
"file.py:B:10": [Location(file_path="file.py", line=20, character=0)], # B -> C
|
||||
"file.py:C:20": [Location(file_path="file.py", line=1, character=0)], # C -> A (circular)
|
||||
}
|
||||
|
||||
def get_refs(symbol: CodeSymbolNode) -> List[Location]:
|
||||
return ref_map.get(symbol.id, [])
|
||||
|
||||
mock_bridge = AsyncMock()
|
||||
mock_bridge.get_references = AsyncMock(side_effect=get_refs)
|
||||
mock_bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
mock_bridge.get_document_symbols = AsyncMock(return_value=[
|
||||
{"name": "A", "kind": 12, "range": {"start": {"line": 0}, "end": {"line": 5}}},
|
||||
{"name": "B", "kind": 12, "range": {"start": {"line": 9}, "end": {"line": 15}}},
|
||||
{"name": "C", "kind": 12, "range": {"start": {"line": 19}, "end": {"line": 25}}},
|
||||
])
|
||||
|
||||
builder = LspGraphBuilder(max_depth=10, max_nodes=100)
|
||||
|
||||
# This should complete without hanging
|
||||
graph = await asyncio.wait_for(
|
||||
builder.build_from_seeds([symbol_a], mock_bridge),
|
||||
timeout=5.0 # Should complete quickly, timeout is just safety
|
||||
)
|
||||
|
||||
# Graph should contain the nodes without duplicates
|
||||
assert len(graph.nodes) >= 1 # At least the seed
|
||||
# No infinite loop occurred (we reached this point)
|
||||
|
||||
|
||||
class TestRequestTimeoutHandling:
|
||||
"""Test timeout handling in LSP requests (VSCode Bridge mode)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_none(self, sample_symbol: CodeSymbolNode):
|
||||
"""Request timeout should return None gracefully."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(timeout=0.001, use_vscode_bridge=True) # Very short timeout
|
||||
|
||||
# Mock session to raise TimeoutError
|
||||
mock_response = AsyncMock()
|
||||
mock_response.__aenter__ = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.object(bridge, '_get_session', return_value=mock_session):
|
||||
result = await bridge._request_vscode_bridge("get_references", {})
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConnectionRefusedHandling:
|
||||
"""Test handling when VSCode Bridge is not running."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_refused_returns_none(self):
|
||||
"""Connection refused should return None gracefully."""
|
||||
pytest.importorskip("aiohttp")
|
||||
import aiohttp
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
# Mock session to raise ClientConnectorError
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(
|
||||
side_effect=aiohttp.ClientConnectorError(
|
||||
MagicMock(), OSError("Connection refused")
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(bridge, '_get_session', return_value=mock_session):
|
||||
result = await bridge._request_vscode_bridge("get_references", {})
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestInvalidLspResponses:
|
||||
"""Test handling of malformed LSP responses (VSCode Bridge mode)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_json_response(self, sample_symbol: CodeSymbolNode):
|
||||
"""Malformed response should be handled gracefully."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
# Response without expected structure
|
||||
with patch.object(bridge, '_request_vscode_bridge', return_value={"unexpected": "structure"}):
|
||||
result = await bridge.get_references(sample_symbol)
|
||||
|
||||
# Should return empty list, not crash
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_result_in_response(self, sample_symbol: CodeSymbolNode):
|
||||
"""Null/None result should be handled gracefully."""
|
||||
from codexlens.lsp.lsp_bridge import LspBridge
|
||||
|
||||
bridge = LspBridge(use_vscode_bridge=True)
|
||||
|
||||
with patch.object(bridge, '_request_vscode_bridge', return_value=None):
|
||||
refs = await bridge.get_references(sample_symbol)
|
||||
defn = await bridge.get_definition(sample_symbol)
|
||||
hover = await bridge.get_hover(sample_symbol)
|
||||
|
||||
assert refs == []
|
||||
assert defn is None
|
||||
assert hover is None
|
||||
|
||||
|
||||
class TestLocationParsing:
|
||||
"""Test Location parsing from various LSP response formats."""
|
||||
|
||||
def test_location_from_file_uri_unix(self):
|
||||
"""Parse Location from Unix-style file:// URI."""
|
||||
from codexlens.lsp.lsp_bridge import Location
|
||||
|
||||
data = {
|
||||
"uri": "file:///home/user/project/file.py",
|
||||
"range": {
|
||||
"start": {"line": 9, "character": 4},
|
||||
"end": {"line": 9, "character": 10},
|
||||
}
|
||||
}
|
||||
|
||||
loc = Location.from_lsp_response(data)
|
||||
|
||||
assert loc.file_path == "/home/user/project/file.py"
|
||||
assert loc.line == 10 # Converted from 0-based to 1-based
|
||||
assert loc.character == 5
|
||||
|
||||
def test_location_from_file_uri_windows(self):
|
||||
"""Parse Location from Windows-style file:// URI."""
|
||||
from codexlens.lsp.lsp_bridge import Location
|
||||
|
||||
data = {
|
||||
"uri": "file:///C:/Users/test/project/file.py",
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 5},
|
||||
}
|
||||
}
|
||||
|
||||
loc = Location.from_lsp_response(data)
|
||||
|
||||
assert loc.file_path == "C:/Users/test/project/file.py"
|
||||
assert loc.line == 1
|
||||
assert loc.character == 1
|
||||
|
||||
def test_location_from_direct_fields(self):
|
||||
"""Parse Location from direct field format."""
|
||||
from codexlens.lsp.lsp_bridge import Location
|
||||
|
||||
data = {
|
||||
"file_path": "/path/to/file.py",
|
||||
"line": 5,
|
||||
"character": 10,
|
||||
}
|
||||
|
||||
loc = Location.from_lsp_response(data)
|
||||
|
||||
assert loc.file_path == "/path/to/file.py"
|
||||
assert loc.line == 5
|
||||
assert loc.character == 10
|
||||
549
codex-lens/tests/unit/lsp/test_lsp_graph_builder.py
Normal file
549
codex-lens/tests/unit/lsp/test_lsp_graph_builder.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""Unit tests for LspGraphBuilder.
|
||||
|
||||
This module tests the LspGraphBuilder class responsible for building
|
||||
code association graphs by BFS expansion from seed symbols using LSP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from codexlens.hybrid_search.data_structures import (
|
||||
CallHierarchyItem,
|
||||
CodeAssociationGraph,
|
||||
CodeSymbolNode,
|
||||
Range,
|
||||
)
|
||||
from codexlens.lsp.lsp_bridge import Location, LspBridge
|
||||
from codexlens.lsp.lsp_graph_builder import LspGraphBuilder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_lsp_bridge() -> AsyncMock:
|
||||
"""Create a mock LspBridge with async methods."""
|
||||
bridge = AsyncMock(spec=LspBridge)
|
||||
bridge.get_references = AsyncMock(return_value=[])
|
||||
bridge.get_call_hierarchy = AsyncMock(return_value=[])
|
||||
bridge.get_document_symbols = AsyncMock(return_value=[])
|
||||
return bridge
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_nodes() -> List[CodeSymbolNode]:
|
||||
"""Create seed nodes for testing."""
|
||||
return [
|
||||
CodeSymbolNode(
|
||||
id="main.py:main:1",
|
||||
name="main",
|
||||
kind="function",
|
||||
file_path="main.py",
|
||||
range=Range(
|
||||
start_line=1,
|
||||
start_character=0,
|
||||
end_line=10,
|
||||
end_character=0,
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference_location() -> Location:
|
||||
"""Create a reference location for testing."""
|
||||
return Location(
|
||||
file_path="utils.py",
|
||||
line=5,
|
||||
character=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def call_hierarchy_item() -> CallHierarchyItem:
|
||||
"""Create a call hierarchy item for testing."""
|
||||
return CallHierarchyItem(
|
||||
name="caller_func",
|
||||
kind="function",
|
||||
file_path="caller.py",
|
||||
range=Range(
|
||||
start_line=20,
|
||||
start_character=0,
|
||||
end_line=30,
|
||||
end_character=0,
|
||||
),
|
||||
detail="Calls main()",
|
||||
)
|
||||
|
||||
|
||||
class TestSingleLevelGraphExpansion:
|
||||
"""P0: Test single level graph expansion with max_depth=1."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_level_graph_expansion(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
reference_location: Location,
|
||||
call_hierarchy_item: CallHierarchyItem,
|
||||
) -> None:
|
||||
"""Test BFS expansion at depth 1 produces correct graph structure.
|
||||
|
||||
Input: max_depth=1, single seed node
|
||||
Mock: LspBridge returns 1 reference + 1 incoming call for seed only
|
||||
Assert: Graph contains 3 nodes (seed, ref, call) and 2 edges from seed
|
||||
"""
|
||||
call_count = {"refs": 0, "calls": 0}
|
||||
|
||||
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
|
||||
"""Return references only for the seed node."""
|
||||
call_count["refs"] += 1
|
||||
if node.file_path == "main.py":
|
||||
return [reference_location]
|
||||
return [] # No references for expanded nodes
|
||||
|
||||
async def mock_get_call_hierarchy(node: CodeSymbolNode) -> List[CallHierarchyItem]:
|
||||
"""Return call hierarchy only for the seed node."""
|
||||
call_count["calls"] += 1
|
||||
if node.file_path == "main.py":
|
||||
return [call_hierarchy_item]
|
||||
return [] # No call hierarchy for expanded nodes
|
||||
|
||||
mock_lsp_bridge.get_references.side_effect = mock_get_references
|
||||
mock_lsp_bridge.get_call_hierarchy.side_effect = mock_get_call_hierarchy
|
||||
|
||||
# Mock document symbols to provide symbol info for locations
|
||||
mock_lsp_bridge.get_document_symbols.return_value = [
|
||||
{
|
||||
"name": "helper_func",
|
||||
"kind": 12, # function
|
||||
"range": {
|
||||
"start": {"line": 4, "character": 0},
|
||||
"end": {"line": 10, "character": 0},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
|
||||
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# Verify graph structure
|
||||
assert len(graph.nodes) == 3, f"Expected 3 nodes, got {len(graph.nodes)}: {list(graph.nodes.keys())}"
|
||||
assert len(graph.edges) == 2, f"Expected 2 edges, got {len(graph.edges)}: {graph.edges}"
|
||||
|
||||
# Verify seed node is present
|
||||
assert "main.py:main:1" in graph.nodes
|
||||
|
||||
# Verify edges exist with correct relationship types
|
||||
edge_types = [edge[2] for edge in graph.edges]
|
||||
assert "references" in edge_types, "Expected 'references' edge"
|
||||
assert "calls" in edge_types, "Expected 'calls' edge"
|
||||
|
||||
# Verify expansion was called for seed and expanded nodes
|
||||
# (nodes at depth 1 should not be expanded beyond max_depth=1)
|
||||
assert call_count["refs"] >= 1, "get_references should be called at least once"
|
||||
|
||||
|
||||
class TestMaxNodesBoundary:
|
||||
"""P0: Test max_nodes boundary stops expansion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_nodes_boundary(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test graph expansion stops when max_nodes is reached.
|
||||
|
||||
Input: max_nodes=5
|
||||
Mock: LspBridge returns many references
|
||||
Assert: Graph expansion stops at 5 nodes
|
||||
"""
|
||||
# Create many reference locations
|
||||
many_refs = [
|
||||
Location(file_path=f"file{i}.py", line=i, character=0)
|
||||
for i in range(20)
|
||||
]
|
||||
mock_lsp_bridge.get_references.return_value = many_refs
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=10, max_nodes=5, max_concurrent=10)
|
||||
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# Verify node count does not exceed max_nodes
|
||||
assert len(graph.nodes) <= 5, (
|
||||
f"Expected at most 5 nodes, got {len(graph.nodes)}"
|
||||
)
|
||||
|
||||
|
||||
class TestMaxDepthBoundary:
|
||||
"""P1: Test max_depth boundary limits BFS expansion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_depth_boundary(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test BFS queue does not add nodes beyond max_depth.
|
||||
|
||||
Input: max_depth=2
|
||||
Mock: Multi-level expansion responses
|
||||
Assert: BFS queue stops adding new nodes when depth > 2
|
||||
"""
|
||||
# Track which depths are expanded
|
||||
expanded_depths = set()
|
||||
|
||||
def create_ref_for_depth(depth: int) -> Location:
|
||||
return Location(
|
||||
file_path=f"depth{depth}.py",
|
||||
line=depth * 10 + 1,
|
||||
character=0,
|
||||
)
|
||||
|
||||
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
|
||||
"""Return references based on node's apparent depth."""
|
||||
# Determine which depth level this node represents
|
||||
if node.file_path == "main.py":
|
||||
expanded_depths.add(0)
|
||||
return [create_ref_for_depth(1)]
|
||||
elif "depth1" in node.file_path:
|
||||
expanded_depths.add(1)
|
||||
return [create_ref_for_depth(2)]
|
||||
elif "depth2" in node.file_path:
|
||||
expanded_depths.add(2)
|
||||
return [create_ref_for_depth(3)]
|
||||
elif "depth3" in node.file_path:
|
||||
expanded_depths.add(3)
|
||||
return [create_ref_for_depth(4)]
|
||||
return []
|
||||
|
||||
mock_lsp_bridge.get_references.side_effect = mock_get_references
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=2, max_nodes=100, max_concurrent=10)
|
||||
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# Collect file paths from graph
|
||||
node_files = [node.file_path for node in graph.nodes.values()]
|
||||
|
||||
# Should have: seed (main.py), depth1 (from seed expansion), depth2 (from depth1 expansion)
|
||||
# depth3 should only be added to graph but NOT expanded (depth > max_depth=2)
|
||||
assert "main.py" in node_files, "Seed node should be in graph"
|
||||
assert any("depth1" in f for f in node_files), "Depth 1 node should be in graph"
|
||||
assert any("depth2" in f for f in node_files), "Depth 2 node should be in graph"
|
||||
|
||||
# The depth3 node might be added to the graph (from depth2 expansion)
|
||||
# but should NOT be expanded (no depth4 nodes should exist)
|
||||
depth4_nodes = [f for f in node_files if "depth4" in f]
|
||||
assert len(depth4_nodes) == 0, (
|
||||
f"Nodes beyond max_depth should not be expanded: {depth4_nodes}"
|
||||
)
|
||||
|
||||
# Verify expansion didn't go to depth 3 (would mean depth4 nodes were created)
|
||||
# The depth 3 node itself may be in the graph but shouldn't have been expanded
|
||||
assert 3 not in expanded_depths or 4 not in expanded_depths, (
|
||||
f"Expansion should stop at max_depth, expanded depths: {expanded_depths}"
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrentSemaphore:
|
||||
"""P1: Test concurrent semaphore limits parallel expansion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_semaphore(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that concurrent node expansions are limited by semaphore.
|
||||
|
||||
Input: max_concurrent=3, 10 nodes in queue
|
||||
Assert: Simultaneous _expand_node calls never exceed 3
|
||||
"""
|
||||
concurrent_count = {"current": 0, "max_seen": 0}
|
||||
lock = asyncio.Lock()
|
||||
|
||||
# Create multiple seed nodes
|
||||
seeds = [
|
||||
CodeSymbolNode(
|
||||
id=f"file{i}.py:func{i}:{i}",
|
||||
name=f"func{i}",
|
||||
kind="function",
|
||||
file_path=f"file{i}.py",
|
||||
range=Range(
|
||||
start_line=i,
|
||||
start_character=0,
|
||||
end_line=i + 10,
|
||||
end_character=0,
|
||||
),
|
||||
)
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
original_get_refs = mock_lsp_bridge.get_references
|
||||
|
||||
async def tracked_get_references(node: CodeSymbolNode) -> List[Location]:
|
||||
"""Track concurrent calls to verify semaphore behavior."""
|
||||
async with lock:
|
||||
concurrent_count["current"] += 1
|
||||
if concurrent_count["current"] > concurrent_count["max_seen"]:
|
||||
concurrent_count["max_seen"] = concurrent_count["current"]
|
||||
|
||||
# Simulate some work
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async with lock:
|
||||
concurrent_count["current"] -= 1
|
||||
|
||||
return []
|
||||
|
||||
mock_lsp_bridge.get_references.side_effect = tracked_get_references
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=3)
|
||||
await builder.build_from_seeds(seeds, mock_lsp_bridge)
|
||||
|
||||
# Verify concurrent calls never exceeded max_concurrent
|
||||
assert concurrent_count["max_seen"] <= 3, (
|
||||
f"Max concurrent calls ({concurrent_count['max_seen']}) exceeded limit (3)"
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentSymbolCache:
|
||||
"""P1: Test document symbol caching for same file locations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_symbol_cache(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test that document symbols are cached per file.
|
||||
|
||||
Input: 2 locations from the same file
|
||||
Mock: get_document_symbols only called once
|
||||
Assert: Second location lookup uses cache
|
||||
"""
|
||||
# Two references from the same file
|
||||
refs_same_file = [
|
||||
Location(file_path="shared.py", line=10, character=0),
|
||||
Location(file_path="shared.py", line=20, character=0),
|
||||
]
|
||||
|
||||
mock_lsp_bridge.get_references.return_value = refs_same_file
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
|
||||
doc_symbols_call_count = {"count": 0}
|
||||
|
||||
async def mock_get_document_symbols(file_path: str) -> List[Dict[str, Any]]:
|
||||
doc_symbols_call_count["count"] += 1
|
||||
return [
|
||||
{
|
||||
"name": "symbol_at_10",
|
||||
"kind": 12,
|
||||
"range": {
|
||||
"start": {"line": 9, "character": 0},
|
||||
"end": {"line": 15, "character": 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "symbol_at_20",
|
||||
"kind": 12,
|
||||
"range": {
|
||||
"start": {"line": 19, "character": 0},
|
||||
"end": {"line": 25, "character": 0},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
mock_lsp_bridge.get_document_symbols.side_effect = mock_get_document_symbols
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
|
||||
await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# get_document_symbols should be called only once for shared.py
|
||||
assert doc_symbols_call_count["count"] == 1, (
|
||||
f"Expected 1 call to get_document_symbols, got {doc_symbols_call_count['count']}"
|
||||
)
|
||||
|
||||
# Verify cache contains the file
|
||||
assert "shared.py" in builder._document_symbols_cache
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_cleared_between_builds(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test that clear_cache removes cached document symbols."""
|
||||
mock_lsp_bridge.get_references.return_value = []
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
|
||||
|
||||
# Manually populate cache
|
||||
builder._document_symbols_cache["test.py"] = [{"name": "cached"}]
|
||||
|
||||
# Clear cache
|
||||
builder.clear_cache()
|
||||
|
||||
# Verify cache is empty
|
||||
assert len(builder._document_symbols_cache) == 0
|
||||
|
||||
|
||||
class TestNodeExpansionErrorHandling:
|
||||
"""P2: Test error handling during node expansion."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_expansion_error_handling(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that errors in node expansion are logged and other nodes continue.
|
||||
|
||||
Mock: get_references throws exception for specific node
|
||||
Assert: Error is logged, other nodes continue expanding
|
||||
"""
|
||||
seeds = [
|
||||
CodeSymbolNode(
|
||||
id="good.py:good:1",
|
||||
name="good",
|
||||
kind="function",
|
||||
file_path="good.py",
|
||||
range=Range(start_line=1, start_character=0, end_line=10, end_character=0),
|
||||
),
|
||||
CodeSymbolNode(
|
||||
id="bad.py:bad:1",
|
||||
name="bad",
|
||||
kind="function",
|
||||
file_path="bad.py",
|
||||
range=Range(start_line=1, start_character=0, end_line=10, end_character=0),
|
||||
),
|
||||
]
|
||||
|
||||
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
|
||||
if "bad" in node.file_path:
|
||||
raise RuntimeError("Simulated LSP error")
|
||||
return [Location(file_path="result.py", line=5, character=0)]
|
||||
|
||||
mock_lsp_bridge.get_references.side_effect = mock_get_references
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
|
||||
|
||||
# Should not raise, error should be caught and logged
|
||||
graph = await builder.build_from_seeds(seeds, mock_lsp_bridge)
|
||||
|
||||
# Both seed nodes should be in the graph
|
||||
assert "good.py:good:1" in graph.nodes
|
||||
assert "bad.py:bad:1" in graph.nodes
|
||||
|
||||
# The good node's expansion should have succeeded
|
||||
# (result.py node should be present)
|
||||
result_nodes = [n for n in graph.nodes.keys() if "result.py" in n]
|
||||
assert len(result_nodes) >= 1, "Good node's expansion should have succeeded"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_failure_continues_expansion(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test that failure in one LSP call doesn't stop other calls."""
|
||||
# References succeed, call hierarchy fails
|
||||
mock_lsp_bridge.get_references.return_value = [
|
||||
Location(file_path="ref.py", line=5, character=0)
|
||||
]
|
||||
mock_lsp_bridge.get_call_hierarchy.side_effect = RuntimeError("Call hierarchy failed")
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
|
||||
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# Should still have the seed and the reference node
|
||||
assert len(graph.nodes) >= 2
|
||||
|
||||
# Reference edge should exist
|
||||
ref_edges = [e for e in graph.edges if e[2] == "references"]
|
||||
assert len(ref_edges) >= 1, "Reference edge should exist despite call hierarchy failure"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Additional edge case tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_seeds(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
) -> None:
|
||||
"""Test building graph with empty seed list."""
|
||||
builder = LspGraphBuilder(max_depth=2, max_nodes=100, max_concurrent=10)
|
||||
graph = await builder.build_from_seeds([], mock_lsp_bridge)
|
||||
|
||||
assert len(graph.nodes) == 0
|
||||
assert len(graph.edges) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_referencing_node_skipped(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test that self-references don't create self-loops."""
|
||||
# Reference back to the same node
|
||||
mock_lsp_bridge.get_references.return_value = [
|
||||
Location(file_path="main.py", line=1, character=0) # Same as seed
|
||||
]
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = [
|
||||
{
|
||||
"name": "main",
|
||||
"kind": 12,
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 9, "character": 0},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
builder = LspGraphBuilder(max_depth=1, max_nodes=100, max_concurrent=10)
|
||||
graph = await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# Should only have the seed node, no self-loop edge
|
||||
# (Note: depending on implementation, self-references may be filtered)
|
||||
self_edges = [e for e in graph.edges if e[0] == e[1]]
|
||||
assert len(self_edges) == 0, "Self-referencing edges should not exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visited_nodes_not_expanded_twice(
|
||||
self,
|
||||
mock_lsp_bridge: AsyncMock,
|
||||
seed_nodes: List[CodeSymbolNode],
|
||||
) -> None:
|
||||
"""Test that visited nodes are not expanded multiple times."""
|
||||
expansion_calls = {"count": 0}
|
||||
|
||||
async def mock_get_references(node: CodeSymbolNode) -> List[Location]:
|
||||
expansion_calls["count"] += 1
|
||||
# Return same node reference each time
|
||||
return [Location(file_path="shared.py", line=10, character=0)]
|
||||
|
||||
mock_lsp_bridge.get_references.side_effect = mock_get_references
|
||||
mock_lsp_bridge.get_call_hierarchy.return_value = []
|
||||
mock_lsp_bridge.get_document_symbols.return_value = []
|
||||
|
||||
builder = LspGraphBuilder(max_depth=3, max_nodes=100, max_concurrent=10)
|
||||
await builder.build_from_seeds(seed_nodes, mock_lsp_bridge)
|
||||
|
||||
# Each unique node should only be expanded once
|
||||
# seed (main.py) + shared.py = 2 expansions max
|
||||
assert expansion_calls["count"] <= 2, (
|
||||
f"Nodes should not be expanded multiple times, got {expansion_calls['count']} calls"
|
||||
)
|
||||
Reference in New Issue
Block a user