Skip to content

Commit 48d81c5

Browse files
committed
test(analyze): cover code subcommand internals
Add unit tests around the `robotcode analyze code` building blocks: - ResultCollector counts, ExitCodeMask.parse, _format_duration - CodeAnalyzer.collect_documents path filtering (incl. the sibling-prefix regression from the previous fix) and the run() yield contract (Pass 1 skips empty reports, Pass 2 always yields for file counting) - _print_diagnostics output formatting: document/folder prefix, dot marker for folder-level diagnostics, related-information always carries its line:column - RobotFrameworkLanguageProvider only registers the unused-keyword/ unused-variable collectors when `--collect-unused` is set Drop the obsolete `collect_unused=True` stub in the existing unused variable diagnostics test (the guarded early-return it was feeding has been removed).
1 parent b0b8820 commit 48d81c5

7 files changed

Lines changed: 636 additions & 1 deletion

File tree

tests/robotcode/analyze/__init__.py

Whitespace-only changes.

tests/robotcode/analyze/code/__init__.py

Whitespace-only changes.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
from contextlib import contextmanager
2+
from pathlib import Path
3+
from typing import Any, Dict, Iterable, List, cast
4+
5+
import pytest
6+
from pytest_mock import MockerFixture
7+
8+
from robotcode.analyze.code.code_analyzer import (
9+
CodeAnalyzer,
10+
DocumentDiagnosticReport,
11+
FolderDiagnosticReport,
12+
)
13+
from robotcode.core.lsp.types import Diagnostic, DiagnosticSeverity, Position, Range
14+
15+
16+
@pytest.fixture
17+
def file_tree(tmp_path: Path) -> Dict[str, Path]:
18+
"""
19+
Build:
20+
<tmp>/tests/api/foo.robot
21+
<tmp>/tests/api_v2/bar.robot
22+
<tmp>/tests/other/baz.robot
23+
"""
24+
api = tmp_path / "tests" / "api"
25+
api_v2 = tmp_path / "tests" / "api_v2"
26+
other = tmp_path / "tests" / "other"
27+
for d in (api, api_v2, other):
28+
d.mkdir(parents=True)
29+
30+
foo = api / "foo.robot"
31+
bar = api_v2 / "bar.robot"
32+
baz = other / "baz.robot"
33+
for f in (foo, bar, baz):
34+
f.write_text("*** Test Cases ***\nT\n Log x\n")
35+
36+
return {"root": tmp_path, "api": api, "api_v2": api_v2, "foo": foo, "bar": bar, "baz": baz}
37+
38+
39+
def _build_analyzer(mocker: MockerFixture, root: Path, files: List[Path]) -> CodeAnalyzer:
40+
"""Build a minimal CodeAnalyzer that only has what collect_documents needs."""
41+
analyzer: Any = object.__new__(CodeAnalyzer)
42+
analyzer.app = mocker.Mock()
43+
44+
def make_doc(path: Path) -> Any:
45+
doc = mocker.Mock()
46+
doc.uri.to_path.return_value = path
47+
return doc
48+
49+
workspace = mocker.Mock()
50+
workspace.documents.get_or_open_document.side_effect = make_doc
51+
analyzer._workspace = workspace
52+
53+
handler = mocker.Mock()
54+
handler.collect_workspace_folder_files.return_value = files
55+
analyzer.language_handlers = [handler]
56+
57+
return cast(CodeAnalyzer, analyzer)
58+
59+
60+
def _doc_paths(documents: List[Any]) -> List[Path]:
61+
return [d.uri.to_path() for d in documents]
62+
63+
64+
class TestCollectDocumentsPathFilter:
65+
def test_no_paths_returns_all_files(self, mocker: MockerFixture, file_tree: Dict[str, Path]) -> None:
66+
analyzer = _build_analyzer(mocker, file_tree["root"], [file_tree["foo"], file_tree["bar"], file_tree["baz"]])
67+
folder = mocker.Mock()
68+
folder.uri.to_path.return_value = file_tree["root"]
69+
70+
docs = analyzer.collect_documents(folder)
71+
72+
assert sorted(_doc_paths(docs)) == sorted([file_tree["foo"], file_tree["bar"], file_tree["baz"]])
73+
74+
def test_directory_filter_excludes_sibling_with_shared_prefix(
75+
self, mocker: MockerFixture, file_tree: Dict[str, Path]
76+
) -> None:
77+
# Regression test: passing `tests/api` must not pull in `tests/api_v2/bar.robot`.
78+
analyzer = _build_analyzer(mocker, file_tree["root"], [file_tree["foo"], file_tree["bar"], file_tree["baz"]])
79+
folder = mocker.Mock()
80+
folder.uri.to_path.return_value = file_tree["root"]
81+
82+
docs = analyzer.collect_documents(folder, paths=[file_tree["api"]])
83+
84+
assert _doc_paths(docs) == [file_tree["foo"]]
85+
86+
def test_multiple_path_filters_are_unioned(self, mocker: MockerFixture, file_tree: Dict[str, Path]) -> None:
87+
analyzer = _build_analyzer(mocker, file_tree["root"], [file_tree["foo"], file_tree["bar"], file_tree["baz"]])
88+
folder = mocker.Mock()
89+
folder.uri.to_path.return_value = file_tree["root"]
90+
91+
docs = analyzer.collect_documents(folder, paths=[file_tree["api"], file_tree["api_v2"]])
92+
93+
assert sorted(_doc_paths(docs)) == sorted([file_tree["foo"], file_tree["bar"]])
94+
95+
def test_single_file_filter_matches_only_that_file(self, mocker: MockerFixture, file_tree: Dict[str, Path]) -> None:
96+
analyzer = _build_analyzer(mocker, file_tree["root"], [file_tree["foo"], file_tree["bar"], file_tree["baz"]])
97+
folder = mocker.Mock()
98+
folder.uri.to_path.return_value = file_tree["root"]
99+
100+
docs = analyzer.collect_documents(folder, paths=[file_tree["foo"]])
101+
102+
assert _doc_paths(docs) == [file_tree["foo"]]
103+
104+
105+
def _diag(severity: DiagnosticSeverity = DiagnosticSeverity.ERROR, message: str = "x") -> Diagnostic:
106+
return Diagnostic(
107+
range=Range(start=Position(line=0, character=0), end=Position(line=0, character=0)),
108+
message=message,
109+
severity=severity,
110+
)
111+
112+
113+
@contextmanager
114+
def _passthrough_progressbar(items: Iterable[Any], label: str = "") -> Any:
115+
yield iter(list(items))
116+
117+
118+
def _build_run_analyzer(
119+
mocker: MockerFixture,
120+
documents: List[Any],
121+
folder_result: Any = None,
122+
analyze_result: Any = None,
123+
collect_result: Any = None,
124+
) -> CodeAnalyzer:
125+
"""
126+
Build a CodeAnalyzer wired up just enough for `run()`.
127+
128+
*_result values follow the DiagnosticHandlers contract: a list whose entries
129+
are List[Diagnostic], BaseException, or None; or None to skip the phase.
130+
"""
131+
analyzer: Any = object.__new__(CodeAnalyzer)
132+
133+
app = mocker.Mock()
134+
app.progressbar.side_effect = _passthrough_progressbar
135+
analyzer.app = app
136+
137+
folder = mocker.Mock()
138+
folder.uri.to_path.return_value = Path("/ws")
139+
140+
workspace = mocker.Mock()
141+
workspace.workspace_folders = [folder]
142+
analyzer._workspace = workspace
143+
144+
diagnostics_handlers = mocker.Mock()
145+
diagnostics_handlers.analyze_folder.return_value = folder_result
146+
diagnostics_handlers.analyze_document.return_value = analyze_result
147+
diagnostics_handlers.collect_diagnostics.return_value = collect_result
148+
analyzer._dispatcher = diagnostics_handlers
149+
150+
mocker.patch.object(CodeAnalyzer, "collect_documents", return_value=documents)
151+
152+
return cast(CodeAnalyzer, analyzer)
153+
154+
155+
class TestRunYields:
156+
def test_pass1_skips_empty_document_reports(self, mocker: MockerFixture) -> None:
157+
# analyze_document returns [None] (no diagnostics) -> Pass 1 must not yield.
158+
# collect_diagnostics also returns [None] -> Pass 2 still yields (empty), for file counting.
159+
doc = mocker.Mock()
160+
analyzer = _build_run_analyzer(
161+
mocker,
162+
documents=[doc],
163+
analyze_result=[None],
164+
collect_result=[None],
165+
)
166+
167+
reports = list(analyzer.run())
168+
169+
doc_reports = [r for r in reports if isinstance(r, DocumentDiagnosticReport)]
170+
assert len(doc_reports) == 1
171+
assert doc_reports[0].items == []
172+
173+
def test_pass1_yields_when_provider_returns_diagnostics(self, mocker: MockerFixture) -> None:
174+
# Pass 1 returns diagnostics -> it must yield. Pass 2 still yields (empty).
175+
doc = mocker.Mock()
176+
d = _diag(DiagnosticSeverity.WARNING)
177+
analyzer = _build_run_analyzer(
178+
mocker,
179+
documents=[doc],
180+
analyze_result=[[d]],
181+
collect_result=[None],
182+
)
183+
184+
doc_reports = [r for r in analyzer.run() if isinstance(r, DocumentDiagnosticReport)]
185+
# Two reports: one from Pass 1 (with the warning), one from Pass 2 (empty for counting).
186+
assert len(doc_reports) == 2
187+
assert doc_reports[0].items == [d]
188+
assert doc_reports[1].items == []
189+
190+
def test_both_passes_yield_when_both_return_diagnostics(self, mocker: MockerFixture) -> None:
191+
doc = mocker.Mock()
192+
warn = _diag(DiagnosticSeverity.WARNING, message="w")
193+
err = _diag(DiagnosticSeverity.ERROR, message="e")
194+
analyzer = _build_run_analyzer(
195+
mocker,
196+
documents=[doc],
197+
analyze_result=[[warn]],
198+
collect_result=[[err]],
199+
)
200+
201+
doc_reports = [r for r in analyzer.run() if isinstance(r, DocumentDiagnosticReport)]
202+
assert len(doc_reports) == 2
203+
assert doc_reports[0].items == [warn]
204+
assert doc_reports[1].items == [err]
205+
206+
def test_pass2_always_yields_for_file_counting(self, mocker: MockerFixture) -> None:
207+
# Pass 2 must yield even when there are no diagnostics, otherwise the
208+
# `Files: N` statistic underreports.
209+
doc = mocker.Mock()
210+
analyzer = _build_run_analyzer(
211+
mocker,
212+
documents=[doc],
213+
analyze_result=None,
214+
collect_result=[None],
215+
)
216+
217+
doc_reports = [r for r in analyzer.run() if isinstance(r, DocumentDiagnosticReport)]
218+
assert len(doc_reports) == 1
219+
assert doc_reports[0].document is doc
220+
221+
def test_folder_diagnostics_yielded_as_folder_report(self, mocker: MockerFixture) -> None:
222+
d = _diag()
223+
analyzer = _build_run_analyzer(
224+
mocker,
225+
documents=[],
226+
folder_result=[[d]],
227+
)
228+
229+
reports = list(analyzer.run())
230+
231+
folder_reports = [r for r in reports if isinstance(r, FolderDiagnosticReport)]
232+
assert len(folder_reports) == 1
233+
assert folder_reports[0].items == [d]
234+
235+
def test_folder_analyzer_exception_routes_to_app_error(self, mocker: MockerFixture) -> None:
236+
boom = RuntimeError("kaboom")
237+
analyzer = _build_run_analyzer(
238+
mocker,
239+
documents=[],
240+
folder_result=[boom],
241+
)
242+
243+
reports = list(analyzer.run())
244+
245+
app_error = cast(Any, analyzer.app).error
246+
app_error.assert_called_once()
247+
assert "kaboom" in app_error.call_args[0][0]
248+
# Still yields the folder report (with no items, since the exception was swallowed).
249+
folder_reports = [r for r in reports if isinstance(r, FolderDiagnosticReport)]
250+
assert len(folder_reports) == 1
251+
assert folder_reports[0].items == []
252+
253+
def test_folder_analyzer_returns_none_skips_folder_report(self, mocker: MockerFixture) -> None:
254+
analyzer = _build_run_analyzer(
255+
mocker,
256+
documents=[],
257+
folder_result=None,
258+
)
259+
260+
reports = list(analyzer.run())
261+
262+
assert not any(isinstance(r, FolderDiagnosticReport) for r in reports)

0 commit comments

Comments
 (0)