diff --git a/tests/conftest.py b/tests/conftest.py index dc7fa22..71a2071 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ _mock_adapter.initialize_all_servers = AsyncMock(return_value=None) _mock_adapter.get_all_tools = MagicMock(return_value=[]) _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) # Inject before any test imports coding_agent so the module-level diff --git a/tests/test_chat_manager.py b/tests/test_chat_manager.py index 879a43d..91aaa92 100644 --- a/tests/test_chat_manager.py +++ b/tests/test_chat_manager.py @@ -27,6 +27,8 @@ def _mock_ok(content="AI reply"): # ── Initialization ──────────────────────────────────────────────────────────── class TestChatManagerInit: + """Verify that a fresh ChatManager starts in a clean, predictable state.""" + def test_chat_history_starts_empty(self): cm = ChatManager() assert cm.chat_history == [] @@ -40,6 +42,8 @@ class TestChatManagerInit: # ── History management ──────────────────────────────────────────────────────── class TestHistory: + """Tests for add_message, get_history, and clear_history.""" + @pytest.fixture def cm(self): return ChatManager() @@ -55,6 +59,7 @@ class TestHistory: assert cm.chat_history[1]["role"] == "assistant" def test_get_history_returns_copy_not_reference(self, cm): + """Mutating the returned list must not corrupt internal history.""" cm.add_message("user", "Hi") history = cm.get_history() assert history == cm.chat_history @@ -73,6 +78,8 @@ class TestHistory: # ── send_message (mocked HTTP) ──────────────────────────────────────────────── class TestSendMessage: + """Tests for send_message: history updates, HTTP payload, error handling, and auth headers.""" + @pytest.fixture def cm(self): return ChatManager() @@ -150,6 +157,7 @@ class TestSendMessage: assert headers.get("Authorization") == "Bearer test-key-123" 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" with patch("requests.post", return_value=_mock_ok()) as mock_post: cm.send_message("Hello") @@ -176,6 +184,8 @@ class TestSendMessage: # ── get_chat_display ────────────────────────────────────────────────────────── class TestGetChatDisplay: + """Tests for get_chat_display: correct shape, ordering, isolation, and role coverage.""" + @pytest.fixture def cm(self): return ChatManager() @@ -211,6 +221,8 @@ class TestGetChatDisplay: # ── Integration (skipped when API unreachable) ──────────────────────────────── class TestSendMessageIntegration: + """End-to-end tests against the live API. Skipped automatically when the API is unreachable.""" + @pytest.fixture def cm(self): return ChatManager() diff --git a/tests/test_coding_agent.py b/tests/test_coding_agent.py index 492c567..e04d3f1 100644 --- a/tests/test_coding_agent.py +++ b/tests/test_coding_agent.py @@ -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: + """Return a JSON string in the exact format the agent expects from the LLM: + {"thought": "...", "tool": "", "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: + """Tests for truncate_result(): ensures long tool outputs are capped before entering the message history.""" def test_short_result_unchanged(self): assert truncate_result("hello") == "hello" @@ -82,8 +85,11 @@ class TestTruncateResult: 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: + """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 = [ {"role": "system", "content": "sys"}, {"role": "user", "content": "original task"}, @@ -116,6 +122,8 @@ class TestTrimMessages: assert trimmed[1]["content"] == "original task" 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) trimmed = trim_messages(msgs) contents = [m["content"] for m in trimmed] @@ -123,6 +131,7 @@ class TestTrimMessages: 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): assert _strip_code_fences("hello") == "hello" @@ -144,6 +153,7 @@ class TestStripCodeFences: # ═════════════════════════════════════════════════════════════════════════════ class TestCodingAgentInit: + """Tests for CodingAgent.__init__ and start_task(): state is clean before and after task setup.""" def test_initial_state_is_clean(self): agent = CodingAgent() @@ -187,6 +197,7 @@ class TestCodingAgentInit: # ═════════════════════════════════════════════════════════════════════════════ class TestProposeNextAction: + """Tests for propose_next_action(): API is mocked so no real HTTP calls are made.""" @pytest.fixture def agent(self): @@ -263,6 +274,7 @@ class TestProposeNextAction: # ═════════════════════════════════════════════════════════════════════════════ class TestApprove: + """Tests for approve(): dispatch_tool is mocked so no filesystem or subprocess side-effects occur.""" @pytest.fixture def agent(self): @@ -271,6 +283,8 @@ class TestApprove: return a 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) agent.pending_action = { "raw": raw, @@ -318,6 +332,8 @@ class TestApprove: @pytest.mark.asyncio 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"): self._set_pending(agent, "read_file", path="nonexistent.py") await agent.approve() @@ -338,6 +354,7 @@ class TestApprove: # ═════════════════════════════════════════════════════════════════════════════ class TestReject: + """Tests for reject(): user feedback is injected into the history and the pending action is discarded.""" @pytest.fixture def agent(self): diff --git a/tests/test_debug_logger.py b/tests/test_debug_logger.py index 4df8dcc..50f7b3f 100644 --- a/tests/test_debug_logger.py +++ b/tests/test_debug_logger.py @@ -13,12 +13,15 @@ from backend.managers.debug_logger import DebugLogger @pytest.fixture def logger(): + """Return a fresh DebugLogger for each test.""" return DebugLogger() # ── Initialization ──────────────────────────────────────────────────────────── class TestInit: + """Tests that a fresh DebugLogger starts with an empty log list.""" + def test_logs_start_empty(self, logger): assert logger.logs == [] @@ -26,6 +29,8 @@ class TestInit: # ── log() ───────────────────────────────────────────────────────────────────── class TestLog: + """Tests for log(): each call appends an INFO-level entry with message and timestamp.""" + def test_log_appends_entry(self, logger): logger.log("started") assert len(logger.logs) == 1 @@ -49,6 +54,8 @@ class TestLog: # ── log_error() ─────────────────────────────────────────────────────────────── class TestLogError: + """Tests for log_error(): same shape as log() but level is ERROR, not INFO.""" + def test_log_error_appends_entry(self, logger): logger.log_error("something broke") assert len(logger.logs) == 1 @@ -71,6 +78,8 @@ class TestLogError: # ── get_logs() ──────────────────────────────────────────────────────────────── 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): logger.log("first") logger.log_error("second") @@ -86,6 +95,8 @@ class TestGetLogs: # ── clear() ─────────────────────────────────────────────────────────────────── class TestClear: + """Tests for clear(): empties the log list and leaves the logger ready for reuse.""" + def test_clear_removes_all_entries(self, logger): logger.log("first") logger.log_error("second") @@ -107,6 +118,9 @@ class TestClear: # ── format_debug_output() ───────────────────────────────────────────────────── 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): result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""}) assert "[SUCCESS]" in result diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 6628fc1..985b240 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -21,12 +21,15 @@ def mock_streamlit(): @pytest.fixture def fm(tmp_path): + """Return a FileManager whose workspace is an isolated pytest temp directory.""" return FileManager(base_path=tmp_path) # ── create_folder ───────────────────────────────────────────────────────────── class TestCreateFolder: + """Tests for create_folder(): name validation, path-traversal protection, and nested creation.""" + def test_creates_folder_successfully(self, fm, tmp_path): result = fm.create_folder("", "myfolder") assert result is True @@ -58,6 +61,8 @@ class TestCreateFolder: # ── create_file ─────────────────────────────────────────────────────────────── class TestCreateFile: + """Tests for create_file(): name validation, auto .txt extension, and path-traversal protection.""" + def test_creates_file_successfully(self, fm, tmp_path): result = fm.create_file("", "test.py") assert result is True @@ -84,6 +89,8 @@ class TestCreateFile: # ── read_file ───────────────────────────────────────────────────────────────── 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): f = tmp_path / "hello.py" f.write_text("print('hello')") @@ -98,6 +105,8 @@ class TestReadFile: assert fm.read_file(sub) == "" 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.write_text("secret") result = fm.read_file(outside) @@ -108,6 +117,8 @@ class TestReadFile: # ── save_file ───────────────────────────────────────────────────────────────── 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): f = tmp_path / "output.py" f.touch() @@ -129,6 +140,8 @@ class TestSaveFile: # ── rename_file ─────────────────────────────────────────────────────────────── 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): (tmp_path / "old.py").touch() result = fm.rename_file("old.py", "new") @@ -137,6 +150,8 @@ class TestRenameFile: assert not (tmp_path / "old.py").exists() 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() fm.rename_file("script.py", "renamed.txt") assert (tmp_path / "renamed.py").exists() @@ -155,6 +170,8 @@ class TestRenameFile: # ── delete_file ─────────────────────────────────────────────────────────────── 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): f = tmp_path / "todelete.py" f.touch() @@ -172,6 +189,8 @@ class TestDeleteFile: # ── delete_folder ───────────────────────────────────────────────────────────── class TestDeleteFolder: + """Tests for delete_folder(): recursively removes a folder and all its contents.""" + def test_deletes_folder_and_contents(self, fm, tmp_path): sub = tmp_path / "todelete" sub.mkdir() @@ -190,6 +209,8 @@ class TestDeleteFolder: # ── get_file_tree ───────────────────────────────────────────────────────────── 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): assert fm.get_file_tree() == {} diff --git a/tests/test_state.py b/tests/test_state.py index 083b276..f1a6e0e 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -39,6 +39,7 @@ class FakeSessionState: @pytest.fixture def fake_state(): + """Inject a FakeSessionState in place of st.session_state for the duration of a test.""" state = FakeSessionState() with patch("frontend.state.st.session_state", state): yield state @@ -47,6 +48,8 @@ def fake_state(): # ── Key initialisation ──────────────────────────────────────────────────────── class TestInitStateKeys: + """Tests that init_state() creates all required session keys with the correct default values.""" + EXPECTED_KEYS = [ "last_selected", "selected_folder", @@ -112,6 +115,9 @@ class TestInitStateKeys: # ── Idempotency ─────────────────────────────────────────────────────────────── 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): """init_state() must not reset state that was set by the user.""" from frontend.state import init_state @@ -205,6 +211,8 @@ class TestInitStateIdempotency: assert fake_state.agent_mode is True 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 init_state() sentinel = object() diff --git a/tests/test_system_prompter.py b/tests/test_system_prompter.py index bc0a7cb..214f3ba 100644 --- a/tests/test_system_prompter.py +++ b/tests/test_system_prompter.py @@ -6,7 +6,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) 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: @@ -30,6 +30,7 @@ class TestSystemPrompterBasePrompt: assert SystemPrompter.generate_prompt(file_context=None) == SystemPrompter.generate_prompt() 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() @@ -113,12 +114,16 @@ class TestSystemPrompterSpecialCharacters: assert "true & false" in prompt 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( file_context={"name": "template.html", "content": "
hello
"} ) assert "
hello
" in prompt 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 ( and ) that the LLM relies on for context. prompt = SystemPrompter.generate_prompt( file_context={"name": "f.py", "content": "x < y and a > b"} )