Adding Comments and Docstrings to testFiles for Documentation
This commit is contained in:
parent
ee64b8c706
commit
2a471988cc
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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": "<name>", "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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() == {}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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": "<div>hello</div>"}
|
||||
)
|
||||
assert "<div>hello</div>" 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 (<file ...> and <code>) that the LLM relies on for context.
|
||||
prompt = SystemPrompter.generate_prompt(
|
||||
file_context={"name": "f.py", "content": "x < y and a > b"}
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user