mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat(cli): 添加 --rule 选项支持模板自动发现
重构 ccw cli 模板系统: - 新增 template-discovery.ts 模块,支持扁平化模板自动发现 - 添加 --rule <template> 选项,自动加载 protocol 和 template - 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt) - 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式 - 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt 其他更新: - Dashboard: 添加 Claude Manager 和 Issue Manager 页面 - Codex-lens: 增强 chain_search 和 clustering 模块 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
282
codex-lens/tests/api/test_references.py
Normal file
282
codex-lens/tests/api/test_references.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Tests for codexlens.api.references module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from codexlens.api.references import (
|
||||
find_references,
|
||||
_read_line_from_file,
|
||||
_proximity_score,
|
||||
_group_references_by_definition,
|
||||
_transform_to_reference_result,
|
||||
)
|
||||
from codexlens.api.models import (
|
||||
DefinitionResult,
|
||||
ReferenceResult,
|
||||
GroupedReferences,
|
||||
)
|
||||
|
||||
|
||||
class TestReadLineFromFile:
|
||||
"""Tests for _read_line_from_file helper."""
|
||||
|
||||
def test_read_existing_line(self, tmp_path):
|
||||
"""Test reading an existing line from a file."""
|
||||
test_file = tmp_path / "test.py"
|
||||
test_file.write_text("line 1\nline 2\nline 3\n")
|
||||
|
||||
assert _read_line_from_file(str(test_file), 1) == "line 1"
|
||||
assert _read_line_from_file(str(test_file), 2) == "line 2"
|
||||
assert _read_line_from_file(str(test_file), 3) == "line 3"
|
||||
|
||||
def test_read_nonexistent_line(self, tmp_path):
|
||||
"""Test reading a line that doesn't exist."""
|
||||
test_file = tmp_path / "test.py"
|
||||
test_file.write_text("line 1\nline 2\n")
|
||||
|
||||
assert _read_line_from_file(str(test_file), 10) == ""
|
||||
|
||||
def test_read_nonexistent_file(self):
|
||||
"""Test reading from a file that doesn't exist."""
|
||||
assert _read_line_from_file("/nonexistent/path/file.py", 1) == ""
|
||||
|
||||
def test_strips_trailing_whitespace(self, tmp_path):
|
||||
"""Test that trailing whitespace is stripped."""
|
||||
test_file = tmp_path / "test.py"
|
||||
test_file.write_text("line with spaces \n")
|
||||
|
||||
assert _read_line_from_file(str(test_file), 1) == "line with spaces"
|
||||
|
||||
|
||||
class TestProximityScore:
|
||||
"""Tests for _proximity_score helper."""
|
||||
|
||||
def test_same_file(self):
|
||||
"""Same file should return highest score."""
|
||||
score = _proximity_score("/a/b/c.py", "/a/b/c.py")
|
||||
assert score == 1000
|
||||
|
||||
def test_same_directory(self):
|
||||
"""Same directory should return 100."""
|
||||
score = _proximity_score("/a/b/x.py", "/a/b/y.py")
|
||||
assert score == 100
|
||||
|
||||
def test_different_directories(self):
|
||||
"""Different directories should return common prefix length."""
|
||||
score = _proximity_score("/a/b/c/x.py", "/a/b/d/y.py")
|
||||
# Common path is /a/b
|
||||
assert score > 0
|
||||
|
||||
def test_empty_paths(self):
|
||||
"""Empty paths should return 0."""
|
||||
assert _proximity_score("", "/a/b/c.py") == 0
|
||||
assert _proximity_score("/a/b/c.py", "") == 0
|
||||
assert _proximity_score("", "") == 0
|
||||
|
||||
|
||||
class TestGroupReferencesByDefinition:
|
||||
"""Tests for _group_references_by_definition helper."""
|
||||
|
||||
def test_single_definition(self):
|
||||
"""Single definition should have all references."""
|
||||
definition = DefinitionResult(
|
||||
name="foo",
|
||||
kind="function",
|
||||
file_path="/a/b/c.py",
|
||||
line=10,
|
||||
end_line=20,
|
||||
)
|
||||
references = [
|
||||
ReferenceResult(
|
||||
file_path="/a/b/d.py",
|
||||
line=5,
|
||||
column=0,
|
||||
context_line="foo()",
|
||||
relationship="call",
|
||||
),
|
||||
ReferenceResult(
|
||||
file_path="/a/x/y.py",
|
||||
line=10,
|
||||
column=0,
|
||||
context_line="foo()",
|
||||
relationship="call",
|
||||
),
|
||||
]
|
||||
|
||||
result = _group_references_by_definition([definition], references)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].definition == definition
|
||||
assert len(result[0].references) == 2
|
||||
|
||||
def test_multiple_definitions(self):
|
||||
"""Multiple definitions should group by proximity."""
|
||||
def1 = DefinitionResult(
|
||||
name="foo",
|
||||
kind="function",
|
||||
file_path="/a/b/c.py",
|
||||
line=10,
|
||||
end_line=20,
|
||||
)
|
||||
def2 = DefinitionResult(
|
||||
name="foo",
|
||||
kind="function",
|
||||
file_path="/x/y/z.py",
|
||||
line=10,
|
||||
end_line=20,
|
||||
)
|
||||
|
||||
# Reference closer to def1
|
||||
ref1 = ReferenceResult(
|
||||
file_path="/a/b/d.py",
|
||||
line=5,
|
||||
column=0,
|
||||
context_line="foo()",
|
||||
relationship="call",
|
||||
)
|
||||
# Reference closer to def2
|
||||
ref2 = ReferenceResult(
|
||||
file_path="/x/y/w.py",
|
||||
line=10,
|
||||
column=0,
|
||||
context_line="foo()",
|
||||
relationship="call",
|
||||
)
|
||||
|
||||
result = _group_references_by_definition(
|
||||
[def1, def2], [ref1, ref2], include_definition=True
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
# Each definition should have the closer reference
|
||||
def1_refs = [g for g in result if g.definition == def1][0].references
|
||||
def2_refs = [g for g in result if g.definition == def2][0].references
|
||||
|
||||
assert any(r.file_path == "/a/b/d.py" for r in def1_refs)
|
||||
assert any(r.file_path == "/x/y/w.py" for r in def2_refs)
|
||||
|
||||
def test_empty_definitions(self):
|
||||
"""Empty definitions should return empty result."""
|
||||
result = _group_references_by_definition([], [])
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestTransformToReferenceResult:
|
||||
"""Tests for _transform_to_reference_result helper."""
|
||||
|
||||
def test_normalizes_relationship_type(self, tmp_path):
|
||||
"""Test that relationship type is normalized."""
|
||||
test_file = tmp_path / "test.py"
|
||||
test_file.write_text("def foo(): pass\n")
|
||||
|
||||
# Create a mock raw reference
|
||||
raw_ref = MagicMock()
|
||||
raw_ref.file_path = str(test_file)
|
||||
raw_ref.line = 1
|
||||
raw_ref.column = 0
|
||||
raw_ref.relationship_type = "calls" # Plural form
|
||||
|
||||
result = _transform_to_reference_result(raw_ref)
|
||||
|
||||
assert result.relationship == "call" # Normalized form
|
||||
assert result.context_line == "def foo(): pass"
|
||||
|
||||
|
||||
class TestFindReferences:
|
||||
"""Tests for find_references API function."""
|
||||
|
||||
def test_raises_for_invalid_project_root(self):
|
||||
"""Test that ValueError is raised for invalid project root."""
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
find_references("/nonexistent/path", "some_symbol")
|
||||
|
||||
@patch("codexlens.search.chain_search.ChainSearchEngine")
|
||||
@patch("codexlens.storage.registry.RegistryStore")
|
||||
@patch("codexlens.storage.path_mapper.PathMapper")
|
||||
@patch("codexlens.config.Config")
|
||||
def test_returns_grouped_references(
|
||||
self, mock_config, mock_mapper, mock_registry, mock_engine_class, tmp_path
|
||||
):
|
||||
"""Test that find_references returns GroupedReferences."""
|
||||
# Setup mocks
|
||||
mock_engine = MagicMock()
|
||||
mock_engine_class.return_value = mock_engine
|
||||
|
||||
# Mock symbol search (for definitions)
|
||||
mock_symbol = MagicMock()
|
||||
mock_symbol.name = "test_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = str(tmp_path / "test.py")
|
||||
mock_symbol.range = (10, 20)
|
||||
mock_engine.search_symbols.return_value = [mock_symbol]
|
||||
|
||||
# Mock reference search
|
||||
mock_ref = MagicMock()
|
||||
mock_ref.file_path = str(tmp_path / "caller.py")
|
||||
mock_ref.line = 5
|
||||
mock_ref.column = 0
|
||||
mock_ref.relationship_type = "call"
|
||||
mock_engine.search_references.return_value = [mock_ref]
|
||||
|
||||
# Create test files
|
||||
test_file = tmp_path / "test.py"
|
||||
test_file.write_text("def test_func():\n pass\n")
|
||||
caller_file = tmp_path / "caller.py"
|
||||
caller_file.write_text("test_func()\n")
|
||||
|
||||
# Call find_references
|
||||
result = find_references(str(tmp_path), "test_func")
|
||||
|
||||
# Verify result structure
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], GroupedReferences)
|
||||
assert result[0].definition.name == "test_func"
|
||||
assert len(result[0].references) == 1
|
||||
|
||||
@patch("codexlens.search.chain_search.ChainSearchEngine")
|
||||
@patch("codexlens.storage.registry.RegistryStore")
|
||||
@patch("codexlens.storage.path_mapper.PathMapper")
|
||||
@patch("codexlens.config.Config")
|
||||
def test_respects_include_definition_false(
|
||||
self, mock_config, mock_mapper, mock_registry, mock_engine_class, tmp_path
|
||||
):
|
||||
"""Test include_definition=False behavior."""
|
||||
mock_engine = MagicMock()
|
||||
mock_engine_class.return_value = mock_engine
|
||||
mock_engine.search_symbols.return_value = []
|
||||
mock_engine.search_references.return_value = []
|
||||
|
||||
result = find_references(
|
||||
str(tmp_path), "test_func", include_definition=False
|
||||
)
|
||||
|
||||
# Should still return a result with placeholder definition
|
||||
assert len(result) == 1
|
||||
assert result[0].definition.name == "test_func"
|
||||
|
||||
|
||||
class TestImports:
|
||||
"""Tests for module imports and exports."""
|
||||
|
||||
def test_find_references_exported_from_api(self):
|
||||
"""Test that find_references is exported from codexlens.api."""
|
||||
from codexlens.api import find_references as api_find_references
|
||||
|
||||
assert callable(api_find_references)
|
||||
|
||||
def test_models_exported_from_api(self):
|
||||
"""Test that result models are exported from codexlens.api."""
|
||||
from codexlens.api import (
|
||||
GroupedReferences,
|
||||
ReferenceResult,
|
||||
DefinitionResult,
|
||||
)
|
||||
|
||||
assert GroupedReferences is not None
|
||||
assert ReferenceResult is not None
|
||||
assert DefinitionResult is not None
|
||||
528
codex-lens/tests/api/test_semantic_search.py
Normal file
528
codex-lens/tests/api/test_semantic_search.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""Tests for semantic_search API."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from codexlens.api import SemanticResult
|
||||
from codexlens.api.semantic import (
|
||||
semantic_search,
|
||||
_build_search_options,
|
||||
_generate_match_reason,
|
||||
_split_camel_case,
|
||||
_transform_results,
|
||||
)
|
||||
|
||||
|
||||
class TestSemanticSearchFunctionSignature:
|
||||
"""Test that semantic_search has the correct function signature."""
|
||||
|
||||
def test_function_accepts_all_parameters(self):
|
||||
"""Verify function signature matches spec."""
|
||||
import inspect
|
||||
sig = inspect.signature(semantic_search)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
expected_params = [
|
||||
"project_root",
|
||||
"query",
|
||||
"mode",
|
||||
"vector_weight",
|
||||
"structural_weight",
|
||||
"keyword_weight",
|
||||
"fusion_strategy",
|
||||
"kind_filter",
|
||||
"limit",
|
||||
"include_match_reason",
|
||||
]
|
||||
|
||||
assert params == expected_params
|
||||
|
||||
def test_default_parameter_values(self):
|
||||
"""Verify default parameter values match spec."""
|
||||
import inspect
|
||||
sig = inspect.signature(semantic_search)
|
||||
|
||||
assert sig.parameters["mode"].default == "fusion"
|
||||
assert sig.parameters["vector_weight"].default == 0.5
|
||||
assert sig.parameters["structural_weight"].default == 0.3
|
||||
assert sig.parameters["keyword_weight"].default == 0.2
|
||||
assert sig.parameters["fusion_strategy"].default == "rrf"
|
||||
assert sig.parameters["kind_filter"].default is None
|
||||
assert sig.parameters["limit"].default == 20
|
||||
assert sig.parameters["include_match_reason"].default is False
|
||||
|
||||
|
||||
class TestBuildSearchOptions:
|
||||
"""Test _build_search_options helper function."""
|
||||
|
||||
def test_vector_mode_options(self):
|
||||
"""Test options for pure vector mode."""
|
||||
options = _build_search_options(
|
||||
mode="vector",
|
||||
vector_weight=1.0,
|
||||
structural_weight=0.0,
|
||||
keyword_weight=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert options.hybrid_mode is True
|
||||
assert options.enable_vector is True
|
||||
assert options.pure_vector is True
|
||||
assert options.enable_fuzzy is False
|
||||
|
||||
def test_structural_mode_options(self):
|
||||
"""Test options for structural mode."""
|
||||
options = _build_search_options(
|
||||
mode="structural",
|
||||
vector_weight=0.0,
|
||||
structural_weight=1.0,
|
||||
keyword_weight=0.0,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert options.hybrid_mode is True
|
||||
assert options.enable_vector is False
|
||||
assert options.enable_fuzzy is True
|
||||
assert options.include_symbols is True
|
||||
|
||||
def test_fusion_mode_options(self):
|
||||
"""Test options for fusion mode (default)."""
|
||||
options = _build_search_options(
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert options.hybrid_mode is True
|
||||
assert options.enable_vector is True # vector_weight > 0
|
||||
assert options.enable_fuzzy is True # keyword_weight > 0
|
||||
assert options.include_symbols is True # structural_weight > 0
|
||||
|
||||
|
||||
class TestTransformResults:
|
||||
"""Test _transform_results helper function."""
|
||||
|
||||
def test_transforms_basic_result(self):
|
||||
"""Test basic result transformation."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.path = "/project/src/auth.py"
|
||||
mock_result.score = 0.85
|
||||
mock_result.excerpt = "def authenticate():"
|
||||
mock_result.symbol_name = "authenticate"
|
||||
mock_result.symbol_kind = "function"
|
||||
mock_result.start_line = 10
|
||||
mock_result.symbol = None
|
||||
mock_result.metadata = {}
|
||||
|
||||
results = _transform_results(
|
||||
results=[mock_result],
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
kind_filter=None,
|
||||
include_match_reason=False,
|
||||
query="auth",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].symbol_name == "authenticate"
|
||||
assert results[0].kind == "function"
|
||||
assert results[0].file_path == "/project/src/auth.py"
|
||||
assert results[0].line == 10
|
||||
assert results[0].fusion_score == 0.85
|
||||
|
||||
def test_kind_filter_excludes_non_matching(self):
|
||||
"""Test that kind_filter excludes non-matching results."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.path = "/project/src/auth.py"
|
||||
mock_result.score = 0.85
|
||||
mock_result.excerpt = "AUTH_TOKEN = 'secret'"
|
||||
mock_result.symbol_name = "AUTH_TOKEN"
|
||||
mock_result.symbol_kind = "variable"
|
||||
mock_result.start_line = 5
|
||||
mock_result.symbol = None
|
||||
mock_result.metadata = {}
|
||||
|
||||
results = _transform_results(
|
||||
results=[mock_result],
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
kind_filter=["function", "class"], # Exclude variable
|
||||
include_match_reason=False,
|
||||
query="auth",
|
||||
)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
def test_kind_filter_includes_matching(self):
|
||||
"""Test that kind_filter includes matching results."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.path = "/project/src/auth.py"
|
||||
mock_result.score = 0.85
|
||||
mock_result.excerpt = "class AuthManager:"
|
||||
mock_result.symbol_name = "AuthManager"
|
||||
mock_result.symbol_kind = "class"
|
||||
mock_result.start_line = 1
|
||||
mock_result.symbol = None
|
||||
mock_result.metadata = {}
|
||||
|
||||
results = _transform_results(
|
||||
results=[mock_result],
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
kind_filter=["function", "class"], # Include class
|
||||
include_match_reason=False,
|
||||
query="auth",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].symbol_name == "AuthManager"
|
||||
|
||||
def test_include_match_reason_generates_reason(self):
|
||||
"""Test that include_match_reason generates match reasons."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.path = "/project/src/auth.py"
|
||||
mock_result.score = 0.85
|
||||
mock_result.excerpt = "def authenticate(user, password):"
|
||||
mock_result.symbol_name = "authenticate"
|
||||
mock_result.symbol_kind = "function"
|
||||
mock_result.start_line = 10
|
||||
mock_result.symbol = None
|
||||
mock_result.metadata = {}
|
||||
|
||||
results = _transform_results(
|
||||
results=[mock_result],
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
kind_filter=None,
|
||||
include_match_reason=True,
|
||||
query="authenticate",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].match_reason is not None
|
||||
assert "authenticate" in results[0].match_reason.lower()
|
||||
|
||||
|
||||
class TestGenerateMatchReason:
|
||||
"""Test _generate_match_reason helper function."""
|
||||
|
||||
def test_direct_name_match(self):
|
||||
"""Test match reason for direct name match."""
|
||||
reason = _generate_match_reason(
|
||||
query="authenticate",
|
||||
symbol_name="authenticate",
|
||||
symbol_kind="function",
|
||||
snippet="def authenticate(user): pass",
|
||||
vector_score=0.8,
|
||||
structural_score=None,
|
||||
)
|
||||
|
||||
assert "authenticate" in reason.lower()
|
||||
|
||||
def test_keyword_match(self):
|
||||
"""Test match reason for keyword match in snippet."""
|
||||
reason = _generate_match_reason(
|
||||
query="password validation",
|
||||
symbol_name="verify_user",
|
||||
symbol_kind="function",
|
||||
snippet="def verify_user(password): validate(password)",
|
||||
vector_score=0.6,
|
||||
structural_score=None,
|
||||
)
|
||||
|
||||
assert "password" in reason.lower() or "validation" in reason.lower()
|
||||
|
||||
def test_high_semantic_similarity(self):
|
||||
"""Test match reason mentions semantic similarity for high vector score."""
|
||||
reason = _generate_match_reason(
|
||||
query="authentication",
|
||||
symbol_name="login_handler",
|
||||
symbol_kind="function",
|
||||
snippet="def login_handler(): pass",
|
||||
vector_score=0.85,
|
||||
structural_score=None,
|
||||
)
|
||||
|
||||
assert "semantic" in reason.lower()
|
||||
|
||||
def test_returns_string_even_with_no_matches(self):
|
||||
"""Test that a reason string is always returned."""
|
||||
reason = _generate_match_reason(
|
||||
query="xyz123",
|
||||
symbol_name="abc456",
|
||||
symbol_kind="function",
|
||||
snippet="completely unrelated code",
|
||||
vector_score=0.3,
|
||||
structural_score=None,
|
||||
)
|
||||
|
||||
assert isinstance(reason, str)
|
||||
assert len(reason) > 0
|
||||
|
||||
|
||||
class TestSplitCamelCase:
|
||||
"""Test _split_camel_case helper function."""
|
||||
|
||||
def test_camel_case(self):
|
||||
"""Test splitting camelCase."""
|
||||
result = _split_camel_case("authenticateUser")
|
||||
assert "authenticate" in result.lower()
|
||||
assert "user" in result.lower()
|
||||
|
||||
def test_pascal_case(self):
|
||||
"""Test splitting PascalCase."""
|
||||
result = _split_camel_case("AuthManager")
|
||||
assert "auth" in result.lower()
|
||||
assert "manager" in result.lower()
|
||||
|
||||
def test_snake_case(self):
|
||||
"""Test splitting snake_case."""
|
||||
result = _split_camel_case("auth_manager")
|
||||
assert "auth" in result.lower()
|
||||
assert "manager" in result.lower()
|
||||
|
||||
def test_mixed_case(self):
|
||||
"""Test splitting mixed case."""
|
||||
result = _split_camel_case("HTTPRequestHandler")
|
||||
# Should handle acronyms
|
||||
assert "http" in result.lower() or "request" in result.lower()
|
||||
|
||||
|
||||
class TestSemanticResultDataclass:
|
||||
"""Test SemanticResult dataclass structure."""
|
||||
|
||||
def test_semantic_result_fields(self):
|
||||
"""Test SemanticResult has all required fields."""
|
||||
result = SemanticResult(
|
||||
symbol_name="test",
|
||||
kind="function",
|
||||
file_path="/test.py",
|
||||
line=1,
|
||||
vector_score=0.8,
|
||||
structural_score=0.6,
|
||||
fusion_score=0.7,
|
||||
snippet="def test(): pass",
|
||||
match_reason="Test match",
|
||||
)
|
||||
|
||||
assert result.symbol_name == "test"
|
||||
assert result.kind == "function"
|
||||
assert result.file_path == "/test.py"
|
||||
assert result.line == 1
|
||||
assert result.vector_score == 0.8
|
||||
assert result.structural_score == 0.6
|
||||
assert result.fusion_score == 0.7
|
||||
assert result.snippet == "def test(): pass"
|
||||
assert result.match_reason == "Test match"
|
||||
|
||||
def test_semantic_result_optional_fields(self):
|
||||
"""Test SemanticResult with optional None fields."""
|
||||
result = SemanticResult(
|
||||
symbol_name="test",
|
||||
kind="function",
|
||||
file_path="/test.py",
|
||||
line=1,
|
||||
vector_score=None, # Degraded - no vector index
|
||||
structural_score=None, # Degraded - no relationships
|
||||
fusion_score=0.5,
|
||||
snippet="def test(): pass",
|
||||
match_reason=None, # Not requested
|
||||
)
|
||||
|
||||
assert result.vector_score is None
|
||||
assert result.structural_score is None
|
||||
assert result.match_reason is None
|
||||
|
||||
def test_semantic_result_to_dict(self):
|
||||
"""Test SemanticResult.to_dict() filters None values."""
|
||||
result = SemanticResult(
|
||||
symbol_name="test",
|
||||
kind="function",
|
||||
file_path="/test.py",
|
||||
line=1,
|
||||
vector_score=None,
|
||||
structural_score=0.6,
|
||||
fusion_score=0.7,
|
||||
snippet="def test(): pass",
|
||||
match_reason=None,
|
||||
)
|
||||
|
||||
d = result.to_dict()
|
||||
|
||||
assert "symbol_name" in d
|
||||
assert "vector_score" not in d # None values filtered
|
||||
assert "structural_score" in d
|
||||
assert "match_reason" not in d # None values filtered
|
||||
|
||||
|
||||
class TestFusionStrategyMapping:
|
||||
"""Test fusion_strategy parameter mapping via _execute_search."""
|
||||
|
||||
def test_rrf_strategy_calls_search(self):
|
||||
"""Test that rrf strategy maps to standard search."""
|
||||
from codexlens.api.semantic import _execute_search
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.search.return_value = MagicMock(results=[])
|
||||
mock_options = MagicMock()
|
||||
|
||||
_execute_search(
|
||||
engine=mock_engine,
|
||||
query="test query",
|
||||
source_path=Path("/test"),
|
||||
fusion_strategy="rrf",
|
||||
options=mock_options,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
mock_engine.search.assert_called_once()
|
||||
|
||||
def test_staged_strategy_calls_staged_cascade_search(self):
|
||||
"""Test that staged strategy maps to staged_cascade_search."""
|
||||
from codexlens.api.semantic import _execute_search
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.staged_cascade_search.return_value = MagicMock(results=[])
|
||||
mock_options = MagicMock()
|
||||
|
||||
_execute_search(
|
||||
engine=mock_engine,
|
||||
query="test query",
|
||||
source_path=Path("/test"),
|
||||
fusion_strategy="staged",
|
||||
options=mock_options,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
mock_engine.staged_cascade_search.assert_called_once()
|
||||
|
||||
def test_binary_strategy_calls_binary_cascade_search(self):
|
||||
"""Test that binary strategy maps to binary_cascade_search."""
|
||||
from codexlens.api.semantic import _execute_search
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.binary_cascade_search.return_value = MagicMock(results=[])
|
||||
mock_options = MagicMock()
|
||||
|
||||
_execute_search(
|
||||
engine=mock_engine,
|
||||
query="test query",
|
||||
source_path=Path("/test"),
|
||||
fusion_strategy="binary",
|
||||
options=mock_options,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
mock_engine.binary_cascade_search.assert_called_once()
|
||||
|
||||
def test_hybrid_strategy_calls_hybrid_cascade_search(self):
|
||||
"""Test that hybrid strategy maps to hybrid_cascade_search."""
|
||||
from codexlens.api.semantic import _execute_search
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.hybrid_cascade_search.return_value = MagicMock(results=[])
|
||||
mock_options = MagicMock()
|
||||
|
||||
_execute_search(
|
||||
engine=mock_engine,
|
||||
query="test query",
|
||||
source_path=Path("/test"),
|
||||
fusion_strategy="hybrid",
|
||||
options=mock_options,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
mock_engine.hybrid_cascade_search.assert_called_once()
|
||||
|
||||
def test_unknown_strategy_defaults_to_rrf(self):
|
||||
"""Test that unknown strategy defaults to standard search (rrf)."""
|
||||
from codexlens.api.semantic import _execute_search
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.search.return_value = MagicMock(results=[])
|
||||
mock_options = MagicMock()
|
||||
|
||||
_execute_search(
|
||||
engine=mock_engine,
|
||||
query="test query",
|
||||
source_path=Path("/test"),
|
||||
fusion_strategy="unknown_strategy",
|
||||
options=mock_options,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
mock_engine.search.assert_called_once()
|
||||
|
||||
|
||||
class TestGracefulDegradation:
|
||||
"""Test graceful degradation behavior."""
|
||||
|
||||
def test_vector_score_none_when_no_vector_index(self):
|
||||
"""Test vector_score=None when vector index unavailable."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.path = "/project/src/auth.py"
|
||||
mock_result.score = 0.5
|
||||
mock_result.excerpt = "def auth(): pass"
|
||||
mock_result.symbol_name = "auth"
|
||||
mock_result.symbol_kind = "function"
|
||||
mock_result.start_line = 1
|
||||
mock_result.symbol = None
|
||||
mock_result.metadata = {} # No vector score in metadata
|
||||
|
||||
results = _transform_results(
|
||||
results=[mock_result],
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
kind_filter=None,
|
||||
include_match_reason=False,
|
||||
query="auth",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
# When no source_scores in metadata, vector_score should be None
|
||||
assert results[0].vector_score is None
|
||||
|
||||
def test_structural_score_extracted_from_fts(self):
|
||||
"""Test structural_score extracted from FTS scores."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.path = "/project/src/auth.py"
|
||||
mock_result.score = 0.8
|
||||
mock_result.excerpt = "def auth(): pass"
|
||||
mock_result.symbol_name = "auth"
|
||||
mock_result.symbol_kind = "function"
|
||||
mock_result.start_line = 1
|
||||
mock_result.symbol = None
|
||||
mock_result.metadata = {
|
||||
"source_scores": {
|
||||
"exact": 0.9,
|
||||
"fuzzy": 0.7,
|
||||
}
|
||||
}
|
||||
|
||||
results = _transform_results(
|
||||
results=[mock_result],
|
||||
mode="fusion",
|
||||
vector_weight=0.5,
|
||||
structural_weight=0.3,
|
||||
keyword_weight=0.2,
|
||||
kind_filter=None,
|
||||
include_match_reason=False,
|
||||
query="auth",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].structural_score == 0.9 # max of exact/fuzzy
|
||||
Reference in New Issue
Block a user