Skip to content

Commit f083e11

Browse files
committed
refactor: Introduce new module-specific unit tests for various components and remove outdated general test files.
1 parent e9b4a24 commit f083e11

22 files changed

Lines changed: 621 additions & 54 deletions
File renamed without changes.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Unit tests for FastAPI endpoints using TestClient."""
2+
3+
from fastapi import FastAPI
4+
from fastapi.testclient import TestClient
5+
6+
from knowcode.api import api
7+
from knowcode.models import CodeChunk
8+
9+
10+
class DummySearchEngine:
11+
def search(self, _query, limit=5, expand_deps=True):
12+
return [CodeChunk(id="c1", entity_id="e1", content="hi", tokens=["hi"])]
13+
14+
15+
class DummyService:
16+
def __init__(self) -> None:
17+
self.reload_called = False
18+
19+
def get_stats(self):
20+
return {"total_entities": 1}
21+
22+
def search(self, _pattern):
23+
return [
24+
{
25+
"id": "e1",
26+
"kind": "function",
27+
"name": "foo",
28+
"qualified_name": "foo",
29+
"file": "file.py",
30+
"line": 1,
31+
}
32+
]
33+
34+
def get_context(self, _target, max_tokens=2000):
35+
return {
36+
"entity_id": "e1",
37+
"context_text": "ctx",
38+
"total_tokens": 1,
39+
"truncated": False,
40+
"included_entities": ["e1"],
41+
}
42+
43+
def get_entity_details(self, _entity_id):
44+
return {"id": "e1", "source_code": "pass", "location": {"file_path": "file.py"}}
45+
46+
def get_callers(self, _entity_id):
47+
return []
48+
49+
def get_callees(self, _entity_id):
50+
return []
51+
52+
def get_search_engine(self, _index_path=None):
53+
return DummySearchEngine()
54+
55+
def reload(self):
56+
self.reload_called = True
57+
58+
59+
def _make_client():
60+
app = FastAPI()
61+
app.include_router(api.router)
62+
return TestClient(app)
63+
64+
65+
def test_health_and_stats_endpoints():
66+
api._service = DummyService()
67+
client = _make_client()
68+
69+
assert client.get("/api/v1/health").json() == {"status": "ok"}
70+
assert client.get("/api/v1/stats").json()["total_entities"] == 1
71+
72+
73+
def test_search_and_context_endpoints():
74+
api._service = DummyService()
75+
client = _make_client()
76+
77+
search_resp = client.get("/api/v1/search", params={"q": "foo"})
78+
assert search_resp.status_code == 200
79+
assert search_resp.json()[0]["id"] == "e1"
80+
81+
context_resp = client.get("/api/v1/context", params={"target": "e1"})
82+
assert context_resp.status_code == 200
83+
assert context_resp.json()["context_text"] == "ctx"
84+
85+
86+
def test_query_and_entity_endpoints():
87+
api._service = DummyService()
88+
client = _make_client()
89+
90+
query_resp = client.post("/api/v1/context/query", json={"query": "hi", "limit": 1})
91+
assert query_resp.status_code == 200
92+
assert query_resp.json()["chunks"][0]["id"] == "c1"
93+
94+
entity_resp = client.get("/api/v1/entities/e1")
95+
assert entity_resp.status_code == 200
96+
assert entity_resp.json()["id"] == "e1"
97+
98+
99+
def test_reload_endpoint():
100+
service = DummyService()
101+
api._service = service
102+
client = _make_client()
103+
104+
resp = client.post("/api/v1/reload")
105+
assert resp.status_code == 200
106+
assert resp.json()["status"] == "reloaded"
107+
assert service.reload_called is True

tests/unit/cli/test_cli.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Unit tests for CLI commands."""
2+
3+
import json
4+
5+
from click.testing import CliRunner
6+
7+
from knowcode.cli import cli
8+
9+
10+
def test_cli_analyze_query_stats_context(tmp_path):
11+
"""Basic CLI commands should run against a temporary project."""
12+
(tmp_path / "sample.py").write_text("def foo():\n return 1\n", encoding="utf-8")
13+
14+
runner = CliRunner()
15+
analyze = runner.invoke(cli, ["analyze", str(tmp_path), "--output", str(tmp_path)])
16+
assert analyze.exit_code == 0
17+
18+
store_path = tmp_path / "knowcode_knowledge.json"
19+
data = json.loads(store_path.read_text(encoding="utf-8"))
20+
entity_id = next(iter(data["entities"].keys()))
21+
22+
query = runner.invoke(cli, ["query", "search", "foo", "--store", str(tmp_path)])
23+
assert query.exit_code == 0
24+
assert "foo" in query.output
25+
26+
stats = runner.invoke(cli, ["stats", "--store", str(tmp_path)])
27+
assert stats.exit_code == 0
28+
assert "Total Entities" in stats.output
29+
30+
context = runner.invoke(cli, ["context", entity_id, "--store", str(tmp_path), "--max-tokens", "200"])
31+
assert context.exit_code == 0
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Unit tests for background indexing."""
2+
3+
import time
4+
from pathlib import Path
5+
6+
from knowcode.indexing.background_indexer import BackgroundIndexer
7+
8+
9+
class DummyIndexer:
10+
def __init__(self) -> None:
11+
self.calls: list[Path] = []
12+
13+
def index_file(self, path: Path) -> int:
14+
self.calls.append(path)
15+
return 1
16+
17+
18+
def test_background_indexer_processes_queue(tmp_path: Path) -> None:
19+
"""Queued files should be processed by the worker thread."""
20+
indexer = DummyIndexer()
21+
bg = BackgroundIndexer(indexer)
22+
bg.start()
23+
24+
target = tmp_path / "file.py"
25+
target.write_text("print('hi')", encoding="utf-8")
26+
bg.queue_file(target)
27+
28+
for _ in range(20):
29+
if indexer.calls:
30+
break
31+
time.sleep(0.05)
32+
33+
bg.stop()
34+
35+
assert indexer.calls == [target]
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Unit tests for the chunker."""
2+
3+
from pathlib import Path
4+
5+
from knowcode.indexing.chunker import Chunker
6+
from knowcode.models import ChunkingConfig, Entity, EntityKind, Location, ParseResult
7+
8+
9+
def test_chunker_module_extraction() -> None:
10+
"""Module header and imports should be extracted."""
11+
chunker = Chunker()
12+
source = '"""Module docstring."""\nimport os\n\ndef foo(): pass'
13+
14+
header = chunker._extract_module_header(source)
15+
assert '"""Module docstring."""' in header
16+
17+
imports = chunker._extract_imports(source)
18+
assert "import os" in imports
19+
20+
21+
def test_chunker_metadata_has_docstring_and_last_modified(tmp_path: Path) -> None:
22+
"""Chunk metadata should include docstring and timestamp flags."""
23+
source = '"""Module docstring."""\n\ndef foo():\n """Doc."""\n return 1\n'
24+
file_path = tmp_path / "mod.py"
25+
file_path.write_text(source, encoding="utf-8")
26+
27+
module_entity = Entity(
28+
id=f"{file_path}::mod",
29+
kind=EntityKind.MODULE,
30+
name="mod",
31+
qualified_name="mod",
32+
location=Location(str(file_path), 1, 5),
33+
source_code=source,
34+
)
35+
func_entity = Entity(
36+
id=f"{file_path}::foo",
37+
kind=EntityKind.FUNCTION,
38+
name="foo",
39+
qualified_name="foo",
40+
location=Location(str(file_path), 3, 5),
41+
docstring="Doc.",
42+
signature="def foo()",
43+
source_code="def foo():\n return 1\n",
44+
)
45+
46+
result = ParseResult(
47+
file_path=str(file_path),
48+
entities=[module_entity, func_entity],
49+
relationships=[],
50+
)
51+
chunker = Chunker()
52+
chunks = chunker.process_parse_result(result)
53+
54+
func_chunks = [c for c in chunks if c.entity_id == func_entity.id]
55+
assert func_chunks
56+
assert func_chunks[0].metadata["has_docstring"] == "true"
57+
assert "last_modified" in func_chunks[0].metadata
58+
59+
60+
def test_chunker_overlap_chunking() -> None:
61+
"""Large entities should be split into overlapping chunks."""
62+
content = "a" * 120
63+
entity = Entity(
64+
id="file.py::big",
65+
kind=EntityKind.FUNCTION,
66+
name="big",
67+
qualified_name="big",
68+
location=Location("file.py", 1, 10),
69+
source_code=content,
70+
)
71+
result = ParseResult(file_path="file.py", entities=[entity], relationships=[])
72+
73+
chunker = Chunker(ChunkingConfig(max_chunk_size=50, overlap=10))
74+
chunks = chunker.process_parse_result(result)
75+
76+
assert len(chunks) > 1
77+
assert chunks[0].metadata["chunk_index"] == "0"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Unit tests for graph builder reference resolution."""
2+
3+
from knowcode.indexing.graph_builder import GraphBuilder
4+
from knowcode.models import Entity, EntityKind, Location, Relationship, RelationshipKind
5+
6+
7+
def test_reference_resolution() -> None:
8+
"""ref:: targets should resolve to known entities when possible."""
9+
builder = GraphBuilder()
10+
entity = Entity(
11+
id="file.py::Foo",
12+
kind=EntityKind.CLASS,
13+
name="Foo",
14+
qualified_name="Foo",
15+
location=Location("file.py", 1, 5),
16+
)
17+
builder.entities = {entity.id: entity}
18+
builder.relationships = [
19+
Relationship(
20+
source_id="file.py::Caller",
21+
target_id="ref::Foo",
22+
kind=RelationshipKind.REFERENCES,
23+
)
24+
]
25+
26+
builder._resolve_references()
27+
28+
assert builder.relationships[0].target_id == entity.id
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Unit tests for the file scanner."""
2+
3+
from pathlib import Path
4+
5+
from knowcode.indexing.scanner import Scanner
6+
7+
8+
def test_scanner_respects_gitignore_and_extensions(tmp_path: Path) -> None:
9+
"""Scanner should respect ignore rules and extensions."""
10+
(tmp_path / ".gitignore").write_text("ignored.py\n", encoding="utf-8")
11+
(tmp_path / "ignored.py").write_text("print('ignore')", encoding="utf-8")
12+
(tmp_path / "skip.py").write_text("print('skip')", encoding="utf-8")
13+
(tmp_path / "keep.py").write_text("print('keep')", encoding="utf-8")
14+
(tmp_path / "note.txt").write_text("text", encoding="utf-8")
15+
16+
scanner = Scanner(tmp_path, additional_ignores=["skip.py"])
17+
files = scanner.scan_all()
18+
paths = {f.relative_path for f in files}
19+
20+
assert "keep.py" in paths
21+
assert "ignored.py" not in paths
22+
assert "skip.py" not in paths
23+
assert "note.txt" not in paths

0 commit comments

Comments
 (0)