Adding Comments and Docstrings to testFiles for Documentation

This commit is contained in:
Livio Meuli 2026-05-21 11:37:05 +02:00
parent ee64b8c706
commit 2a471988cc
7 changed files with 79 additions and 1 deletions

View File

@ -13,6 +13,7 @@ _mock_adapter.initialize_all_servers = AsyncMock(return_value=None)
_mock_adapter.get_all_tools = MagicMock(return_value=[]) _mock_adapter.get_all_tools = MagicMock(return_value=[])
_mock_adapter.call_tool = AsyncMock(return_value=MagicMock(isError=False, content=[])) _mock_adapter.call_tool = AsyncMock(return_value=MagicMock(isError=False, content=[]))
# Wrap as a class mock: calling MCPToolAdapter() returns _mock_adapter.
_mock_adapter_cls = MagicMock(return_value=_mock_adapter) _mock_adapter_cls = MagicMock(return_value=_mock_adapter)
# Inject before any test imports coding_agent so the module-level # Inject before any test imports coding_agent so the module-level

View File

@ -27,6 +27,8 @@ def _mock_ok(content="AI reply"):
# ── Initialization ──────────────────────────────────────────────────────────── # ── Initialization ────────────────────────────────────────────────────────────
class TestChatManagerInit: class TestChatManagerInit:
"""Verify that a fresh ChatManager starts in a clean, predictable state."""
def test_chat_history_starts_empty(self): def test_chat_history_starts_empty(self):
cm = ChatManager() cm = ChatManager()
assert cm.chat_history == [] assert cm.chat_history == []
@ -40,6 +42,8 @@ class TestChatManagerInit:
# ── History management ──────────────────────────────────────────────────────── # ── History management ────────────────────────────────────────────────────────
class TestHistory: class TestHistory:
"""Tests for add_message, get_history, and clear_history."""
@pytest.fixture @pytest.fixture
def cm(self): def cm(self):
return ChatManager() return ChatManager()
@ -55,6 +59,7 @@ class TestHistory:
assert cm.chat_history[1]["role"] == "assistant" assert cm.chat_history[1]["role"] == "assistant"
def test_get_history_returns_copy_not_reference(self, cm): def test_get_history_returns_copy_not_reference(self, cm):
"""Mutating the returned list must not corrupt internal history."""
cm.add_message("user", "Hi") cm.add_message("user", "Hi")
history = cm.get_history() history = cm.get_history()
assert history == cm.chat_history assert history == cm.chat_history
@ -73,6 +78,8 @@ class TestHistory:
# ── send_message (mocked HTTP) ──────────────────────────────────────────────── # ── send_message (mocked HTTP) ────────────────────────────────────────────────
class TestSendMessage: class TestSendMessage:
"""Tests for send_message: history updates, HTTP payload, error handling, and auth headers."""
@pytest.fixture @pytest.fixture
def cm(self): def cm(self):
return ChatManager() return ChatManager()
@ -150,6 +157,7 @@ class TestSendMessage:
assert headers.get("Authorization") == "Bearer test-key-123" assert headers.get("Authorization") == "Bearer test-key-123"
def test_api_key_excluded_from_header_when_empty_sentinel(self, cm): def test_api_key_excluded_from_header_when_empty_sentinel(self, cm):
# "EMPTY" is the sentinel string the UI writes when the user leaves the key field blank.
cm.api_key = "EMPTY" cm.api_key = "EMPTY"
with patch("requests.post", return_value=_mock_ok()) as mock_post: with patch("requests.post", return_value=_mock_ok()) as mock_post:
cm.send_message("Hello") cm.send_message("Hello")
@ -176,6 +184,8 @@ class TestSendMessage:
# ── get_chat_display ────────────────────────────────────────────────────────── # ── get_chat_display ──────────────────────────────────────────────────────────
class TestGetChatDisplay: class TestGetChatDisplay:
"""Tests for get_chat_display: correct shape, ordering, isolation, and role coverage."""
@pytest.fixture @pytest.fixture
def cm(self): def cm(self):
return ChatManager() return ChatManager()
@ -211,6 +221,8 @@ class TestGetChatDisplay:
# ── Integration (skipped when API unreachable) ──────────────────────────────── # ── Integration (skipped when API unreachable) ────────────────────────────────
class TestSendMessageIntegration: class TestSendMessageIntegration:
"""End-to-end tests against the live API. Skipped automatically when the API is unreachable."""
@pytest.fixture @pytest.fixture
def cm(self): def cm(self):
return ChatManager() return ChatManager()

View File

@ -52,6 +52,8 @@ def _make_api_response(content: str, status_code: int = 200):
def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) -> str: def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) -> str:
"""Return a JSON string in the exact format the agent expects from the LLM:
{"thought": "...", "tool": "<name>", "arguments": {...}}."""
return json.dumps({"thought": thought, "tool": tool, "arguments": arguments}) return json.dumps({"thought": thought, "tool": tool, "arguments": arguments})
@ -60,6 +62,7 @@ def _agent_action_json(tool: str, thought: str = "thinking...", **arguments) ->
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestTruncateResult: class TestTruncateResult:
"""Tests for truncate_result(): ensures long tool outputs are capped before entering the message history."""
def test_short_result_unchanged(self): def test_short_result_unchanged(self):
assert truncate_result("hello") == "hello" assert truncate_result("hello") == "hello"
@ -82,8 +85,11 @@ class TestTruncateResult:
class TestTrimMessages: class TestTrimMessages:
"""Tests for trim_messages(): keeps system + original task, drops old turns when history grows too large."""
def _make_messages(self, n_extra: int, chars_each: int = 100) -> list: def _make_messages(self, n_extra: int, chars_each: int = 100) -> list:
"""Build a message list with a fixed system + user header followed by
n_extra assistant/user pairs, each pair consuming 2*chars_each characters."""
msgs = [ msgs = [
{"role": "system", "content": "sys"}, {"role": "system", "content": "sys"},
{"role": "user", "content": "original task"}, {"role": "user", "content": "original task"},
@ -116,6 +122,8 @@ class TestTrimMessages:
assert trimmed[1]["content"] == "original task" assert trimmed[1]["content"] == "original task"
def test_reminder_injected_when_trimmed(self): def test_reminder_injected_when_trimmed(self):
# trim_messages inserts a "system_note" message so the agent knows that
# earlier turns were dropped and it should not reference missing context.
msgs = self._make_messages(n_extra=500, chars_each=200) msgs = self._make_messages(n_extra=500, chars_each=200)
trimmed = trim_messages(msgs) trimmed = trim_messages(msgs)
contents = [m["content"] for m in trimmed] contents = [m["content"] for m in trimmed]
@ -123,6 +131,7 @@ class TestTrimMessages:
class TestStripCodeFences: class TestStripCodeFences:
"""Tests for _strip_code_fences(): the LLM sometimes wraps its JSON in markdown fences — this strips them."""
def test_plain_text_unchanged(self): def test_plain_text_unchanged(self):
assert _strip_code_fences("hello") == "hello" assert _strip_code_fences("hello") == "hello"
@ -144,6 +153,7 @@ class TestStripCodeFences:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestCodingAgentInit: class TestCodingAgentInit:
"""Tests for CodingAgent.__init__ and start_task(): state is clean before and after task setup."""
def test_initial_state_is_clean(self): def test_initial_state_is_clean(self):
agent = CodingAgent() agent = CodingAgent()
@ -187,6 +197,7 @@ class TestCodingAgentInit:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestProposeNextAction: class TestProposeNextAction:
"""Tests for propose_next_action(): API is mocked so no real HTTP calls are made."""
@pytest.fixture @pytest.fixture
def agent(self): def agent(self):
@ -263,6 +274,7 @@ class TestProposeNextAction:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestApprove: class TestApprove:
"""Tests for approve(): dispatch_tool is mocked so no filesystem or subprocess side-effects occur."""
@pytest.fixture @pytest.fixture
def agent(self): def agent(self):
@ -271,6 +283,8 @@ class TestApprove:
return a return a
def _set_pending(self, agent, tool: str, **arguments): def _set_pending(self, agent, tool: str, **arguments):
"""Inject a pending_action into the agent as if propose_next_action() had just run.
'raw' holds the original JSON string; 'action' holds the parsed dict."""
raw = _agent_action_json(tool, "thought", **arguments) raw = _agent_action_json(tool, "thought", **arguments)
agent.pending_action = { agent.pending_action = {
"raw": raw, "raw": raw,
@ -318,6 +332,8 @@ class TestApprove:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_approve_error_result_adds_replan_tag(self, agent): async def test_approve_error_result_adds_replan_tag(self, agent):
# When a tool returns an error, approve() adds a "replan" tag to the message
# so the LLM knows the last action failed and must choose a different approach.
with patch("backend.agent.coding_agent.dispatch_tool", return_value="ERROR: file not found"): with patch("backend.agent.coding_agent.dispatch_tool", return_value="ERROR: file not found"):
self._set_pending(agent, "read_file", path="nonexistent.py") self._set_pending(agent, "read_file", path="nonexistent.py")
await agent.approve() await agent.approve()
@ -338,6 +354,7 @@ class TestApprove:
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
class TestReject: class TestReject:
"""Tests for reject(): user feedback is injected into the history and the pending action is discarded."""
@pytest.fixture @pytest.fixture
def agent(self): def agent(self):

View File

@ -13,12 +13,15 @@ from backend.managers.debug_logger import DebugLogger
@pytest.fixture @pytest.fixture
def logger(): def logger():
"""Return a fresh DebugLogger for each test."""
return DebugLogger() return DebugLogger()
# ── Initialization ──────────────────────────────────────────────────────────── # ── Initialization ────────────────────────────────────────────────────────────
class TestInit: class TestInit:
"""Tests that a fresh DebugLogger starts with an empty log list."""
def test_logs_start_empty(self, logger): def test_logs_start_empty(self, logger):
assert logger.logs == [] assert logger.logs == []
@ -26,6 +29,8 @@ class TestInit:
# ── log() ───────────────────────────────────────────────────────────────────── # ── log() ─────────────────────────────────────────────────────────────────────
class TestLog: class TestLog:
"""Tests for log(): each call appends an INFO-level entry with message and timestamp."""
def test_log_appends_entry(self, logger): def test_log_appends_entry(self, logger):
logger.log("started") logger.log("started")
assert len(logger.logs) == 1 assert len(logger.logs) == 1
@ -49,6 +54,8 @@ class TestLog:
# ── log_error() ─────────────────────────────────────────────────────────────── # ── log_error() ───────────────────────────────────────────────────────────────
class TestLogError: class TestLogError:
"""Tests for log_error(): same shape as log() but level is ERROR, not INFO."""
def test_log_error_appends_entry(self, logger): def test_log_error_appends_entry(self, logger):
logger.log_error("something broke") logger.log_error("something broke")
assert len(logger.logs) == 1 assert len(logger.logs) == 1
@ -71,6 +78,8 @@ class TestLogError:
# ── get_logs() ──────────────────────────────────────────────────────────────── # ── get_logs() ────────────────────────────────────────────────────────────────
class TestGetLogs: class TestGetLogs:
"""Tests for get_logs(): returns all entries and a copy, not a reference to the internal list."""
def test_get_logs_returns_all_entries(self, logger): def test_get_logs_returns_all_entries(self, logger):
logger.log("first") logger.log("first")
logger.log_error("second") logger.log_error("second")
@ -86,6 +95,8 @@ class TestGetLogs:
# ── clear() ─────────────────────────────────────────────────────────────────── # ── clear() ───────────────────────────────────────────────────────────────────
class TestClear: class TestClear:
"""Tests for clear(): empties the log list and leaves the logger ready for reuse."""
def test_clear_removes_all_entries(self, logger): def test_clear_removes_all_entries(self, logger):
logger.log("first") logger.log("first")
logger.log_error("second") logger.log_error("second")
@ -107,6 +118,9 @@ class TestClear:
# ── format_debug_output() ───────────────────────────────────────────────────── # ── format_debug_output() ─────────────────────────────────────────────────────
class TestFormatDebugOutput: class TestFormatDebugOutput:
"""Tests for format_debug_output(): renders a dict of {rc, stdout, stderr} plus
accumulated log entries into a human-readable string for the UI."""
def test_success_exit_code_shows_success(self, logger): def test_success_exit_code_shows_success(self, logger):
result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""}) result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""})
assert "[SUCCESS]" in result assert "[SUCCESS]" in result

View File

@ -21,12 +21,15 @@ def mock_streamlit():
@pytest.fixture @pytest.fixture
def fm(tmp_path): def fm(tmp_path):
"""Return a FileManager whose workspace is an isolated pytest temp directory."""
return FileManager(base_path=tmp_path) return FileManager(base_path=tmp_path)
# ── create_folder ───────────────────────────────────────────────────────────── # ── create_folder ─────────────────────────────────────────────────────────────
class TestCreateFolder: class TestCreateFolder:
"""Tests for create_folder(): name validation, path-traversal protection, and nested creation."""
def test_creates_folder_successfully(self, fm, tmp_path): def test_creates_folder_successfully(self, fm, tmp_path):
result = fm.create_folder("", "myfolder") result = fm.create_folder("", "myfolder")
assert result is True assert result is True
@ -58,6 +61,8 @@ class TestCreateFolder:
# ── create_file ─────────────────────────────────────────────────────────────── # ── create_file ───────────────────────────────────────────────────────────────
class TestCreateFile: class TestCreateFile:
"""Tests for create_file(): name validation, auto .txt extension, and path-traversal protection."""
def test_creates_file_successfully(self, fm, tmp_path): def test_creates_file_successfully(self, fm, tmp_path):
result = fm.create_file("", "test.py") result = fm.create_file("", "test.py")
assert result is True assert result is True
@ -84,6 +89,8 @@ class TestCreateFile:
# ── read_file ───────────────────────────────────────────────────────────────── # ── read_file ─────────────────────────────────────────────────────────────────
class TestReadFile: class TestReadFile:
"""Tests for read_file(): accepts an absolute Path, validates workspace boundary, returns content or ""."""
def test_reads_file_content(self, fm, tmp_path): def test_reads_file_content(self, fm, tmp_path):
f = tmp_path / "hello.py" f = tmp_path / "hello.py"
f.write_text("print('hello')") f.write_text("print('hello')")
@ -98,6 +105,8 @@ class TestReadFile:
assert fm.read_file(sub) == "" assert fm.read_file(sub) == ""
def test_file_outside_workspace_returns_empty_string(self, fm, tmp_path): def test_file_outside_workspace_returns_empty_string(self, fm, tmp_path):
# tmp_path.parent is a shared pytest temp dir, so we clean up to avoid
# leaving stray files that could affect other tests.
outside = tmp_path.parent / "outside.py" outside = tmp_path.parent / "outside.py"
outside.write_text("secret") outside.write_text("secret")
result = fm.read_file(outside) result = fm.read_file(outside)
@ -108,6 +117,8 @@ class TestReadFile:
# ── save_file ───────────────────────────────────────────────────────────────── # ── save_file ─────────────────────────────────────────────────────────────────
class TestSaveFile: class TestSaveFile:
"""Tests for save_file(): accepts an absolute path string, overwrites content, and blocks path traversal."""
def test_saves_content_to_file(self, fm, tmp_path): def test_saves_content_to_file(self, fm, tmp_path):
f = tmp_path / "output.py" f = tmp_path / "output.py"
f.touch() f.touch()
@ -129,6 +140,8 @@ class TestSaveFile:
# ── rename_file ─────────────────────────────────────────────────────────────── # ── rename_file ───────────────────────────────────────────────────────────────
class TestRenameFile: class TestRenameFile:
"""Tests for rename_file(): renames by stem only — the original extension is always preserved."""
def test_renames_file_successfully(self, fm, tmp_path): def test_renames_file_successfully(self, fm, tmp_path):
(tmp_path / "old.py").touch() (tmp_path / "old.py").touch()
result = fm.rename_file("old.py", "new") result = fm.rename_file("old.py", "new")
@ -137,6 +150,8 @@ class TestRenameFile:
assert not (tmp_path / "old.py").exists() assert not (tmp_path / "old.py").exists()
def test_preserves_original_extension(self, fm, tmp_path): def test_preserves_original_extension(self, fm, tmp_path):
# Even if the caller passes a different extension (.txt), rename_file
# silently replaces it with the original (.py) to prevent accidental type changes.
(tmp_path / "script.py").touch() (tmp_path / "script.py").touch()
fm.rename_file("script.py", "renamed.txt") fm.rename_file("script.py", "renamed.txt")
assert (tmp_path / "renamed.py").exists() assert (tmp_path / "renamed.py").exists()
@ -155,6 +170,8 @@ class TestRenameFile:
# ── delete_file ─────────────────────────────────────────────────────────────── # ── delete_file ───────────────────────────────────────────────────────────────
class TestDeleteFile: class TestDeleteFile:
"""Tests for delete_file(): accepts a relative path, validates workspace boundary, removes the file."""
def test_deletes_file_successfully(self, fm, tmp_path): def test_deletes_file_successfully(self, fm, tmp_path):
f = tmp_path / "todelete.py" f = tmp_path / "todelete.py"
f.touch() f.touch()
@ -172,6 +189,8 @@ class TestDeleteFile:
# ── delete_folder ───────────────────────────────────────────────────────────── # ── delete_folder ─────────────────────────────────────────────────────────────
class TestDeleteFolder: class TestDeleteFolder:
"""Tests for delete_folder(): recursively removes a folder and all its contents."""
def test_deletes_folder_and_contents(self, fm, tmp_path): def test_deletes_folder_and_contents(self, fm, tmp_path):
sub = tmp_path / "todelete" sub = tmp_path / "todelete"
sub.mkdir() sub.mkdir()
@ -190,6 +209,8 @@ class TestDeleteFolder:
# ── get_file_tree ───────────────────────────────────────────────────────────── # ── get_file_tree ─────────────────────────────────────────────────────────────
class TestGetFileTree: class TestGetFileTree:
"""Tests for get_file_tree(): returns a nested dict where files map to None and dirs map to dicts."""
def test_empty_workspace_returns_empty_dict(self, fm): def test_empty_workspace_returns_empty_dict(self, fm):
assert fm.get_file_tree() == {} assert fm.get_file_tree() == {}

View File

@ -39,6 +39,7 @@ class FakeSessionState:
@pytest.fixture @pytest.fixture
def fake_state(): def fake_state():
"""Inject a FakeSessionState in place of st.session_state for the duration of a test."""
state = FakeSessionState() state = FakeSessionState()
with patch("frontend.state.st.session_state", state): with patch("frontend.state.st.session_state", state):
yield state yield state
@ -47,6 +48,8 @@ def fake_state():
# ── Key initialisation ──────────────────────────────────────────────────────── # ── Key initialisation ────────────────────────────────────────────────────────
class TestInitStateKeys: class TestInitStateKeys:
"""Tests that init_state() creates all required session keys with the correct default values."""
EXPECTED_KEYS = [ EXPECTED_KEYS = [
"last_selected", "last_selected",
"selected_folder", "selected_folder",
@ -112,6 +115,9 @@ class TestInitStateKeys:
# ── Idempotency ─────────────────────────────────────────────────────────────── # ── Idempotency ───────────────────────────────────────────────────────────────
class TestInitStateIdempotency: class TestInitStateIdempotency:
"""Tests that calling init_state() a second time never resets keys that were already set.
This mirrors Streamlit's own pattern: initialise only when the key is absent."""
def test_second_call_does_not_overwrite_open_files(self, fake_state): def test_second_call_does_not_overwrite_open_files(self, fake_state):
"""init_state() must not reset state that was set by the user.""" """init_state() must not reset state that was set by the user."""
from frontend.state import init_state from frontend.state import init_state
@ -205,6 +211,8 @@ class TestInitStateIdempotency:
assert fake_state.agent_mode is True assert fake_state.agent_mode is True
def test_second_call_does_not_overwrite_coding_agent(self, fake_state): def test_second_call_does_not_overwrite_coding_agent(self, fake_state):
# object() is used as a sentinel: a unique instance that cannot accidentally
# equal any value init_state() might write, so identity check is unambiguous.
from frontend.state import init_state from frontend.state import init_state
init_state() init_state()
sentinel = object() sentinel = object()

View File

@ -6,7 +6,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest import pytest
from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS from backend.managers.system_prompter import SystemPrompter, MAX_FILE_CHARS # MAX_FILE_CHARS: character limit before content is truncated
class TestSystemPrompterBasePrompt: class TestSystemPrompterBasePrompt:
@ -30,6 +30,7 @@ class TestSystemPrompterBasePrompt:
assert SystemPrompter.generate_prompt(file_context=None) == SystemPrompter.generate_prompt() assert SystemPrompter.generate_prompt(file_context=None) == SystemPrompter.generate_prompt()
def test_empty_dict_behaves_like_none(self): def test_empty_dict_behaves_like_none(self):
# {} is falsy in Python, so the implementation treats it the same as None (no file context).
assert SystemPrompter.generate_prompt(file_context={}) == SystemPrompter.generate_prompt() assert SystemPrompter.generate_prompt(file_context={}) == SystemPrompter.generate_prompt()
@ -113,12 +114,16 @@ class TestSystemPrompterSpecialCharacters:
assert "true & false" in prompt assert "true & false" in prompt
def test_xml_tags_in_content_are_preserved_literally(self): def test_xml_tags_in_content_are_preserved_literally(self):
# User code often contains HTML or XML. The prompt builder must embed it
# verbatim — escaping or stripping tags would corrupt the file content.
prompt = SystemPrompter.generate_prompt( prompt = SystemPrompter.generate_prompt(
file_context={"name": "template.html", "content": "<div>hello</div>"} file_context={"name": "template.html", "content": "<div>hello</div>"}
) )
assert "<div>hello</div>" in prompt assert "<div>hello</div>" in prompt
def test_prompt_still_contains_file_xml_tag_with_special_content(self): def test_prompt_still_contains_file_xml_tag_with_special_content(self):
# Verifies that special characters in the content don't corrupt the surrounding
# structural tags (<file ...> and <code>) that the LLM relies on for context.
prompt = SystemPrompter.generate_prompt( prompt = SystemPrompter.generate_prompt(
file_context={"name": "f.py", "content": "x < y and a > b"} file_context={"name": "f.py", "content": "x < y and a > b"}
) )