diff --git a/backend/managers/debug_logger.py b/backend/managers/debug_logger.py index d43ee7b..c224c49 100644 --- a/backend/managers/debug_logger.py +++ b/backend/managers/debug_logger.py @@ -1,14 +1,66 @@ -# Simple in-memory logger used during code execution. -# Messages are collected in a list and can be retrieved for display or debugging. +from datetime import datetime + class DebugLogger: + """In-memory logger for code execution events. + + Collects timestamped INFO and ERROR entries during a single run. + Call clear() before each new execution to start fresh. + """ + def __init__(self): + self.logs: list[dict] = [] + + def log(self, message: str) -> None: + """Append a general info message.""" + self.logs.append({ + "level": "INFO", + "message": message, + "timestamp": datetime.now().strftime("%H:%M:%S"), + }) + + def log_error(self, error_message: str) -> None: + """Append an error message.""" + self.logs.append({ + "level": "ERROR", + "message": error_message, + "timestamp": datetime.now().strftime("%H:%M:%S"), + }) + + def get_logs(self) -> list[dict]: + """Return all collected log entries.""" + return self.logs + + def clear(self) -> None: + """Reset the log — call before each new execution.""" self.logs = [] - def log(self, message): - """Append a log message to the in-memory list.""" - self.logs.append(message) + def format_debug_output(self, output: dict) -> str: + """Format an ExecutionEngine result dict into a human-readable string. - def get_logs(self): - """Return all collected log messages.""" - return self.logs + Args: + output: dict with keys 'stdout', 'stderr', and 'rc'. + + Returns: + A formatted string ready for display in the UI. + """ + lines = [] + + status = "SUCCESS" if output.get("rc") == 0 else "FAILED" + lines.append(f"[{status}] Exit code: {output.get('rc')}") + + if output.get("stdout"): + lines.append("\n--- stdout ---") + lines.append(output["stdout"].rstrip()) + + if output.get("stderr"): + lines.append("\n--- stderr ---") + lines.append(output["stderr"].rstrip()) + + if not output.get("stdout") and not output.get("stderr"): + lines.append("No output produced.") + + for entry in self.logs: + lines.append(f"[{entry['timestamp']}] [{entry['level']}] {entry['message']}") + + return "\n".join(lines) diff --git a/backend/managers/file_manager.py b/backend/managers/file_manager.py index 48c9b76..a3f51f4 100644 --- a/backend/managers/file_manager.py +++ b/backend/managers/file_manager.py @@ -238,7 +238,6 @@ class FileManager: """ file_path = Path(relative_path) abs_file_path = (Path(self.base_path) / file_path).resolve() - print(f"Absolute file path resolved to: {abs_file_path}") # Debugging info if not str(abs_file_path).startswith(str(self.base_path.resolve())): st.error(f"Access denied: {relative_path}") diff --git a/frontend/editor.py b/frontend/editor.py index e8abb61..9f9c8b8 100644 --- a/frontend/editor.py +++ b/frontend/editor.py @@ -98,12 +98,16 @@ def run_active_file(): execution_engine = ExecutionEngine() debug_logger = DebugLogger() + debug_logger.clear() debug_logger.log(f"Executing code from {active_file}...") with st.spinner(f"Running {Path(active_file).name}..."): output = execution_engine.run_code(Path(active_file)) - debug_logger.log("Execution completed.") + if output["rc"] == 0: + debug_logger.log("Execution completed successfully.") + else: + debug_logger.log_error(f"Execution failed with exit code {output['rc']}.") st.session_state.code_execution_output = { "stdout": output["stdout"], diff --git a/tests/test_chat_manager.py b/tests/test_chat_manager.py index a771ec0..924dc5b 100644 --- a/tests/test_chat_manager.py +++ b/tests/test_chat_manager.py @@ -1,242 +1,224 @@ -"""Test script for ChatManager - Pytest compatible tests""" +"""Tests for ChatManager (backend/managers/chat_manager.py).""" import sys from pathlib import Path import pytest +import requests from unittest.mock import patch, MagicMock -# Add project root to Python path sys.path.insert(0, str(Path(__file__).parent.parent)) from backend.managers.chat_manager import ChatManager -class TestChatManager: - """Test suite for ChatManager functionality.""" +# ── Helpers ────────────────────────────────────────────────────────────────── +def _mock_ok(content="AI reply"): + """Return a mocked 200 response with a single assistant choice.""" + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = { + "choices": [{"message": {"role": "assistant", "content": content}}] + } + mock.text = "" + return mock + + +# ── Initialization ──────────────────────────────────────────────────────────── + +class TestChatManagerInit: + def test_chat_history_starts_empty(self): + cm = ChatManager() + assert cm.chat_history == [] + + def test_api_url_contains_endpoint(self): + cm = ChatManager() + assert isinstance(cm.api_url, str) + assert "/v1/chat/completions" in cm.api_url + + +# ── History management ──────────────────────────────────────────────────────── + +class TestHistory: @pytest.fixture - def chat_manager(self): + def cm(self): return ChatManager() - def test_initialization(self, chat_manager): - """Test that ChatManager initializes correctly.""" - assert chat_manager.api_url is not None - assert chat_manager.model is not None - assert chat_manager.chat_history == [] + def test_add_message_appends_correct_entry(self, cm): + cm.add_message("user", "Hello") + assert cm.chat_history == [{"role": "user", "content": "Hello"}] - def test_add_message(self, chat_manager): - """Test adding messages to chat history.""" - chat_manager.add_message("user", "Hello") - assert len(chat_manager.chat_history) == 1 - assert chat_manager.chat_history[0]["role"] == "user" - assert chat_manager.chat_history[0]["content"] == "Hello" + def test_add_multiple_messages_preserves_order(self, cm): + cm.add_message("user", "Hi") + cm.add_message("assistant", "Hello!") + assert cm.chat_history[0]["role"] == "user" + assert cm.chat_history[1]["role"] == "assistant" - def test_get_history(self, chat_manager): - """Test retrieving chat history.""" - chat_manager.add_message("user", "Hello") - chat_manager.add_message("assistant", "Hi there!") + def test_get_history_returns_internal_list(self, cm): + cm.add_message("user", "Hi") + assert cm.get_history() is cm.chat_history - history = chat_manager.get_history() - assert len(history) == 2 - assert history[0]["role"] == "user" - assert history[1]["role"] == "assistant" + def test_clear_history_empties_list(self, cm): + cm.add_message("user", "Hi") + cm.clear_history() + assert cm.chat_history == [] - def test_clear_history(self, chat_manager): - """Test clearing chat history.""" - chat_manager.add_message("user", "Hello") - assert len(chat_manager.chat_history) == 1 - - chat_manager.clear_history() - assert len(chat_manager.chat_history) == 0 - - def test_send_message_integration(self, chat_manager): - """ - Integration test for sending message to AI. - This test actually communicates with the API. - """ - try: - # Send a simple test message - response = chat_manager.send_message("Hello, what is 2+2?") - - # Verify response is not empty - assert isinstance(response, str) - assert len(response) > 0 - - # Verify message was added to history - assert len(chat_manager.chat_history) == 2 # user + assistant - assert chat_manager.chat_history[0]["role"] == "user" - assert chat_manager.chat_history[1]["role"] == "assistant" - - print(f"API Test Passed") - print(f"Response: {response}") - - except Exception as e: - # If API is not reachable, mark as skipped - pytest.skip(f"API not reachable: {str(e)}") - - def test_multiple_messages(self, chat_manager): - """Test sending multiple messages in a conversation.""" - try: - # Send first message - response1 = chat_manager.send_message("What is your name?") - assert len(response1) > 0 - - # Send follow-up message - response2 = chat_manager.send_message("Tell me more") - assert len(response2) > 0 - - # Verify full conversation is in history - assert len(chat_manager.chat_history) == 4 # 2 user + 2 assistant - - print(f"Conversation Test Passed") - print(f"Messages: {len(chat_manager.chat_history)}") - - except Exception as e: - pytest.skip(f"API not reachable: {str(e)}") + def test_clear_history_on_empty_is_safe(self, cm): + cm.clear_history() + assert cm.chat_history == [] -class TestChatManagerSendMessage: - """Unit tests for send_message using mocked HTTP requests.""" +# ── send_message (mocked HTTP) ──────────────────────────────────────────────── +class TestSendMessage: @pytest.fixture - def chat_manager(self): + def cm(self): return ChatManager() - def _mock_response(self, content="AI reply", status_code=200): - mock = MagicMock() - mock.status_code = status_code - mock.json.return_value = { - "choices": [{"message": {"role": "assistant", "content": content}}] - } - mock.text = "error text" - return mock + def test_user_message_added_to_history(self, cm): + with patch("requests.post", return_value=_mock_ok()): + cm.send_message("Hello") + assert cm.chat_history[0] == {"role": "user", "content": "Hello"} - def test_send_message_adds_user_message_to_history(self, chat_manager): - with patch("requests.post", return_value=self._mock_response()): - chat_manager.send_message("Hello") - assert chat_manager.chat_history[0] == {"role": "user", "content": "Hello"} + def test_assistant_reply_added_to_history(self, cm): + with patch("requests.post", return_value=_mock_ok("Hi there")): + cm.send_message("Hello") + assert cm.chat_history[1] == {"role": "assistant", "content": "Hi there"} - def test_send_message_adds_assistant_response_to_history(self, chat_manager): - with patch("requests.post", return_value=self._mock_response("Hi there")): - chat_manager.send_message("Hello") - assert chat_manager.chat_history[1] == {"role": "assistant", "content": "Hi there"} + def test_returns_assistant_content_string(self, cm): + with patch("requests.post", return_value=_mock_ok("Answer")): + result = cm.send_message("Question") + assert result == "Answer" - def test_send_message_returns_ai_content(self, chat_manager): - with patch("requests.post", return_value=self._mock_response("Answer")): - response = chat_manager.send_message("Question") - assert response == "Answer" + def test_full_history_sent_in_request_payload(self, cm): + """All prior messages must be forwarded so the model has conversation context.""" + cm.add_message("system", "You are helpful.") + with patch("requests.post", return_value=_mock_ok()) as mock_post: + cm.send_message("Hello") + payload = mock_post.call_args.kwargs["json"] + assert payload["messages"][0]["role"] == "system" + assert payload["messages"][1]["role"] == "user" - def test_send_message_history_grows_with_each_call(self, chat_manager): - with patch("requests.post", return_value=self._mock_response()): - chat_manager.send_message("First") - chat_manager.send_message("Second") - assert len(chat_manager.chat_history) == 4 # 2 user + 2 assistant + def test_history_grows_by_two_per_call(self, cm): + with patch("requests.post", return_value=_mock_ok()): + cm.send_message("First") + cm.send_message("Second") + assert len(cm.chat_history) == 4 - def test_send_message_connection_error_raises(self, chat_manager): - import requests + def test_connection_error_raises_and_adds_error_to_history(self, cm): with patch("requests.post", side_effect=requests.exceptions.ConnectionError("refused")): with pytest.raises(Exception, match="Connection Error"): - chat_manager.send_message("Hello") + cm.send_message("Hello") + assert any("Error" in msg["content"] for msg in cm.chat_history) - def test_send_message_api_error_status_raises(self, chat_manager): - mock = self._mock_response(status_code=500) + def test_api_error_status_raises(self, cm): + mock = MagicMock() + mock.status_code = 500 + mock.text = "Internal Server Error" with patch("requests.post", return_value=mock): with pytest.raises(Exception, match="API Error 500"): - chat_manager.send_message("Hello") + cm.send_message("Hello") - def test_send_message_empty_choices_raises(self, chat_manager): + def test_timeout_raises(self, cm): + with patch("requests.post", side_effect=requests.exceptions.Timeout()): + with pytest.raises(Exception): + cm.send_message("Hello") + + def test_empty_choices_raises(self, cm): mock = MagicMock() mock.status_code = 200 mock.json.return_value = {"choices": []} with patch("requests.post", return_value=mock): with pytest.raises(Exception, match="Invalid API response format"): - chat_manager.send_message("Hello") + cm.send_message("Hello") - def test_send_message_missing_choices_key_raises(self, chat_manager): + def test_missing_choices_key_raises(self, cm): mock = MagicMock() mock.status_code = 200 mock.json.return_value = {} with patch("requests.post", return_value=mock): with pytest.raises(Exception): - chat_manager.send_message("Hello") + cm.send_message("Hello") - def test_send_message_timeout_raises(self, chat_manager): - import requests - with patch("requests.post", side_effect=requests.exceptions.Timeout()): - with pytest.raises(Exception): - chat_manager.send_message("Hello") + def test_api_key_included_in_header_when_set(self, cm): + cm.api_key = "test-key-123" + with patch("requests.post", return_value=_mock_ok()) as mock_post: + cm.send_message("Hello") + headers = mock_post.call_args.kwargs["headers"] + assert headers.get("Authorization") == "Bearer test-key-123" + + def test_api_key_excluded_from_header_when_empty(self, cm): + cm.api_key = "EMPTY" + with patch("requests.post", return_value=_mock_ok()) as mock_post: + cm.send_message("Hello") + headers = mock_post.call_args.kwargs["headers"] + assert "Authorization" not in headers + + def test_json_decode_error_raises(self, cm): + import json + mock = MagicMock() + mock.status_code = 200 + mock.json.side_effect = json.JSONDecodeError("bad json", "", 0) + with patch("requests.post", return_value=mock): + with pytest.raises(Exception, match="JSON Decode Error"): + cm.send_message("Hello") -class TestChatManagerGetChatDisplay: - """Tests for get_chat_display().""" +# ── get_chat_display ────────────────────────────────────────────────────────── +class TestGetChatDisplay: @pytest.fixture - def chat_manager(self): + def cm(self): return ChatManager() - def test_empty_history_returns_empty_list(self, chat_manager): - assert chat_manager.get_chat_display() == [] + def test_empty_history_returns_empty_list(self, cm): + assert cm.get_chat_display() == [] - def test_display_contains_role_and_content_keys(self, chat_manager): - chat_manager.add_message("user", "Hello") - display = chat_manager.get_chat_display() - assert "role" in display[0] - assert "content" in display[0] + def test_display_has_role_and_content_keys(self, cm): + cm.add_message("user", "Hello") + entry = cm.get_chat_display()[0] + assert "role" in entry + assert "content" in entry - def test_display_preserves_message_order(self, chat_manager): - chat_manager.add_message("user", "First") - chat_manager.add_message("assistant", "Second") - display = chat_manager.get_chat_display() + def test_display_preserves_message_order(self, cm): + cm.add_message("user", "First") + cm.add_message("assistant", "Second") + display = cm.get_chat_display() assert display[0]["role"] == "user" assert display[1]["role"] == "assistant" - def test_display_matches_history(self, chat_manager): - chat_manager.add_message("user", "Hi") - chat_manager.add_message("assistant", "Hello!") - assert chat_manager.get_chat_display() == chat_manager.get_history() + def test_display_returns_copy_not_reference(self, cm): + """Mutating the returned list must not corrupt internal history.""" + cm.add_message("user", "Hi") + display = cm.get_chat_display() + display.clear() + assert len(cm.chat_history) == 1 - def test_system_message_included_in_display(self, chat_manager): - chat_manager.add_message("system", "You are a helper.") - display = chat_manager.get_chat_display() - assert display[0]["role"] == "system" + def test_system_messages_included_in_display(self, cm): + cm.add_message("system", "Be helpful.") + assert cm.get_chat_display()[0]["role"] == "system" -def test_chat_manager_demo(): - """Demo test - Shows interactive chat (can be run manually).""" - print("\n" + "=" * 60) - print("ChatManager Demo - Interactive Test") - print("=" * 60 + "\n") +# ── Integration (skipped when API unreachable) ──────────────────────────────── - chat_manager = ChatManager() - - print(f"Connected to API: {chat_manager.api_url}") - print(f"Model: {chat_manager.model}\n") - - # Demo conversation - test_messages = ["Hello! What can you do?", "Tell me a joke", "What is Python?"] - - print("Starting conversation...\n") - - for message in test_messages: - print(f"User: {message}") +class TestSendMessageIntegration: + @pytest.fixture + def cm(self): + return ChatManager() + def test_real_api_returns_non_empty_string(self, cm): try: - response = chat_manager.send_message(message) - print(f"Assistant: {response}\n") - + response = cm.send_message("Reply with exactly the word PONG.") + assert isinstance(response, str) + assert len(response) > 0 + assert len(cm.chat_history) == 2 except Exception as e: - print(f"Error: {str(e)}\n") - pytest.skip(f"API not reachable: {str(e)}") + pytest.skip(f"API not reachable: {e}") - # Display full chat history - print("=" * 60) - print("Chat History:") - print("=" * 60) - - for msg in chat_manager.get_history(): - print(f"{msg['role'].upper()}: {msg['content']}\n") - - -if __name__ == "__main__": - # Run with: pytest tests/test_chat_manager.py -v -s - pytest.main([__file__, "-v", "-s"]) + def test_real_api_multi_turn_history_grows(self, cm): + try: + cm.send_message("Remember the number 42.") + cm.send_message("What number did I ask you to remember?") + assert len(cm.chat_history) == 4 + except Exception as e: + pytest.skip(f"API not reachable: {e}") diff --git a/tests/test_debug_logger.py b/tests/test_debug_logger.py new file mode 100644 index 0000000..cf00812 --- /dev/null +++ b/tests/test_debug_logger.py @@ -0,0 +1,147 @@ +"""Tests for DebugLogger (backend/managers/debug_logger.py).""" + +import sys +from pathlib import Path +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.managers.debug_logger import DebugLogger + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def logger(): + return DebugLogger() + + +# ── Initialization ──────────────────────────────────────────────────────────── + +class TestInit: + def test_logs_start_empty(self, logger): + assert logger.logs == [] + + +# ── log() ───────────────────────────────────────────────────────────────────── + +class TestLog: + def test_log_appends_entry(self, logger): + logger.log("started") + assert len(logger.logs) == 1 + + def test_log_sets_level_info(self, logger): + logger.log("started") + assert logger.logs[0]["level"] == "INFO" + + def test_log_stores_message(self, logger): + logger.log("executing file.py") + assert logger.logs[0]["message"] == "executing file.py" + + def test_log_adds_timestamp(self, logger): + logger.log("started") + ts = logger.logs[0]["timestamp"] + # HH:MM:SS format — exactly 8 chars with two colons + assert len(ts) == 8 + assert ts[2] == ":" and ts[5] == ":" + + +# ── log_error() ─────────────────────────────────────────────────────────────── + +class TestLogError: + def test_log_error_appends_entry(self, logger): + logger.log_error("something broke") + assert len(logger.logs) == 1 + + def test_log_error_sets_level_error(self, logger): + logger.log_error("something broke") + assert logger.logs[0]["level"] == "ERROR" + + def test_log_error_stores_message(self, logger): + logger.log_error("exit code 1") + assert logger.logs[0]["message"] == "exit code 1" + + def test_log_and_log_error_are_distinct_levels(self, logger): + logger.log("info message") + logger.log_error("error message") + assert logger.logs[0]["level"] == "INFO" + assert logger.logs[1]["level"] == "ERROR" + + +# ── get_logs() ──────────────────────────────────────────────────────────────── + +class TestGetLogs: + def test_get_logs_returns_all_entries(self, logger): + logger.log("first") + logger.log_error("second") + assert len(logger.get_logs()) == 2 + + def test_get_logs_returns_internal_list(self, logger): + assert logger.get_logs() is logger.logs + + +# ── clear() ─────────────────────────────────────────────────────────────────── + +class TestClear: + def test_clear_removes_all_entries(self, logger): + logger.log("first") + logger.log_error("second") + logger.clear() + assert logger.logs == [] + + def test_clear_on_empty_is_safe(self, logger): + logger.clear() + assert logger.logs == [] + + def test_log_after_clear_works(self, logger): + logger.log("before") + logger.clear() + logger.log("after") + assert len(logger.logs) == 1 + assert logger.logs[0]["message"] == "after" + + +# ── format_debug_output() ───────────────────────────────────────────────────── + +class TestFormatDebugOutput: + def test_success_exit_code_shows_success(self, logger): + result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""}) + assert "[SUCCESS]" in result + + def test_nonzero_exit_code_shows_failed(self, logger): + result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": ""}) + assert "[FAILED]" in result + + def test_exit_code_included_in_output(self, logger): + result = logger.format_debug_output({"rc": 42, "stdout": "", "stderr": ""}) + assert "42" in result + + def test_stdout_included_when_present(self, logger): + result = logger.format_debug_output({"rc": 0, "stdout": "Hello", "stderr": ""}) + assert "Hello" in result + assert "stdout" in result + + def test_stderr_included_when_present(self, logger): + result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": "NameError"}) + assert "NameError" in result + assert "stderr" in result + + def test_no_output_message_when_both_empty(self, logger): + result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""}) + assert "No output produced." in result + + def test_log_entries_appended_to_output(self, logger): + logger.log("Executing file.py...") + logger.log_error("exit code 1") + result = logger.format_debug_output({"rc": 1, "stdout": "", "stderr": ""}) + assert "Executing file.py..." in result + assert "exit code 1" in result + + def test_returns_string(self, logger): + result = logger.format_debug_output({"rc": 0, "stdout": "", "stderr": ""}) + assert isinstance(result, str) + + def test_missing_keys_do_not_raise(self, logger): + # Defensive: format_debug_output uses .get() so missing keys are safe + result = logger.format_debug_output({}) + assert isinstance(result, str) diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index e69de29..6628fc1 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -0,0 +1,216 @@ +"""Tests for FileManager (backend/managers/file_manager.py).""" + +import sys +from pathlib import Path +import pytest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.managers.file_manager import FileManager + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def mock_streamlit(): + """Suppress all st.error / st.warning calls — they require a running Streamlit app.""" + with patch("backend.managers.file_manager.st"): + yield + + +@pytest.fixture +def fm(tmp_path): + return FileManager(base_path=tmp_path) + + +# ── create_folder ───────────────────────────────────────────────────────────── + +class TestCreateFolder: + def test_creates_folder_successfully(self, fm, tmp_path): + result = fm.create_folder("", "myfolder") + assert result is True + assert (tmp_path / "myfolder").is_dir() + + def test_empty_name_returns_false(self, fm): + assert fm.create_folder("", "") is False + + def test_slash_in_name_returns_false(self, fm): + assert fm.create_folder("", "a/b") is False + + def test_backslash_in_name_returns_false(self, fm): + assert fm.create_folder("", "a\\b") is False + + def test_duplicate_folder_returns_false(self, fm, tmp_path): + (tmp_path / "existing").mkdir() + assert fm.create_folder("", "existing") is False + + def test_path_traversal_returns_false(self, fm): + assert fm.create_folder("../../", "evil") is False + + def test_nested_folder_created_inside_base(self, fm, tmp_path): + (tmp_path / "sub").mkdir() + result = fm.create_folder("sub", "child") + assert result is True + assert (tmp_path / "sub" / "child").is_dir() + + +# ── create_file ─────────────────────────────────────────────────────────────── + +class TestCreateFile: + def test_creates_file_successfully(self, fm, tmp_path): + result = fm.create_file("", "test.py") + assert result is True + assert (tmp_path / "test.py").is_file() + + def test_empty_name_returns_false(self, fm): + assert fm.create_file("", "") is False + + def test_whitespace_only_name_returns_false(self, fm): + assert fm.create_file("", " ") is False + + def test_no_extension_defaults_to_txt(self, fm, tmp_path): + fm.create_file("", "notes") + assert (tmp_path / "notes.txt").is_file() + + def test_duplicate_file_returns_false(self, fm, tmp_path): + (tmp_path / "existing.py").touch() + assert fm.create_file("", "existing.py") is False + + def test_path_traversal_returns_false(self, fm): + assert fm.create_file("../../", "evil.py") is False + + +# ── read_file ───────────────────────────────────────────────────────────────── + +class TestReadFile: + def test_reads_file_content(self, fm, tmp_path): + f = tmp_path / "hello.py" + f.write_text("print('hello')") + assert fm.read_file(f) == "print('hello')" + + def test_nonexistent_file_returns_empty_string(self, fm, tmp_path): + assert fm.read_file(tmp_path / "ghost.py") == "" + + def test_directory_path_returns_empty_string(self, fm, tmp_path): + sub = tmp_path / "subdir" + sub.mkdir() + assert fm.read_file(sub) == "" + + def test_file_outside_workspace_returns_empty_string(self, fm, tmp_path): + outside = tmp_path.parent / "outside.py" + outside.write_text("secret") + result = fm.read_file(outside) + assert result == "" + outside.unlink() + + +# ── save_file ───────────────────────────────────────────────────────────────── + +class TestSaveFile: + def test_saves_content_to_file(self, fm, tmp_path): + f = tmp_path / "output.py" + f.touch() + result = fm.save_file(str(f), "x = 1") + assert result is True + assert f.read_text() == "x = 1" + + def test_overwrites_existing_content(self, fm, tmp_path): + f = tmp_path / "script.py" + f.write_text("old content") + fm.save_file(str(f), "new content") + assert f.read_text() == "new content" + + def test_path_traversal_returns_false(self, fm, tmp_path): + outside = str(tmp_path.parent / "evil.py") + assert fm.save_file(outside, "bad") is False + + +# ── rename_file ─────────────────────────────────────────────────────────────── + +class TestRenameFile: + def test_renames_file_successfully(self, fm, tmp_path): + (tmp_path / "old.py").touch() + result = fm.rename_file("old.py", "new") + assert result is True + assert (tmp_path / "new.py").exists() + assert not (tmp_path / "old.py").exists() + + def test_preserves_original_extension(self, fm, tmp_path): + (tmp_path / "script.py").touch() + fm.rename_file("script.py", "renamed.txt") + assert (tmp_path / "renamed.py").exists() + + def test_empty_new_name_returns_false(self, fm, tmp_path): + (tmp_path / "file.py").touch() + assert fm.rename_file("file.py", "") is False + + def test_nonexistent_file_returns_false(self, fm): + assert fm.rename_file("ghost.py", "new_name") is False + + def test_path_traversal_returns_false(self, fm): + assert fm.rename_file("../../evil.py", "new_name") is False + + +# ── delete_file ─────────────────────────────────────────────────────────────── + +class TestDeleteFile: + def test_deletes_file_successfully(self, fm, tmp_path): + f = tmp_path / "todelete.py" + f.touch() + result = fm.delete_file("todelete.py") + assert result is True + assert not f.exists() + + def test_nonexistent_file_returns_false(self, fm): + assert fm.delete_file("ghost.py") is False + + def test_path_traversal_returns_false(self, fm): + assert fm.delete_file("../../evil.py") is False + + +# ── delete_folder ───────────────────────────────────────────────────────────── + +class TestDeleteFolder: + def test_deletes_folder_and_contents(self, fm, tmp_path): + sub = tmp_path / "todelete" + sub.mkdir() + (sub / "file.py").touch() + result = fm.delete_folder("todelete") + assert result is True + assert not sub.exists() + + def test_nonexistent_folder_returns_false(self, fm): + assert fm.delete_folder("ghost") is False + + def test_path_traversal_returns_false(self, fm): + assert fm.delete_folder("../../") is False + + +# ── get_file_tree ───────────────────────────────────────────────────────────── + +class TestGetFileTree: + def test_empty_workspace_returns_empty_dict(self, fm): + assert fm.get_file_tree() == {} + + def test_file_is_represented_as_none(self, fm, tmp_path): + (tmp_path / "main.py").touch() + tree = fm.get_file_tree() + assert tree["main.py"] is None + + def test_directory_is_represented_as_dict(self, fm, tmp_path): + (tmp_path / "src").mkdir() + tree = fm.get_file_tree() + assert isinstance(tree["src"], dict) + + def test_nested_structure_is_correct(self, fm, tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").touch() + tree = fm.get_file_tree() + assert tree["src"]["app.py"] is None + + def test_entries_are_sorted(self, fm, tmp_path): + (tmp_path / "z_file.py").touch() + (tmp_path / "a_file.py").touch() + keys = list(fm.get_file_tree().keys()) + assert keys == sorted(keys) diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 14df880..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Test cases for the main module.""" - - -def test_placeholder(): - """Placeholder test.""" - assert True diff --git a/tests/test_mcp_server_code_execution.py b/tests/test_mcp_server_code_execution.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mcp_server_file_search.py b/tests/test_mcp_server_file_search.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mcp_server_web_search.py b/tests/test_mcp_server_web_search.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..cebdc96 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,142 @@ +"""Tests for frontend/state.py — init_state() session initialisation.""" + +import sys +from pathlib import Path +import pytest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.managers.chat_manager import ChatManager + + +# ── Fake session_state ──────────────────────────────────────────────────────── + +class FakeSessionState: + """Minimal stand-in for st.session_state that supports attribute and 'in' access.""" + + def __init__(self): + self._data = {} + + def __contains__(self, key): + return key in self._data + + def __setattr__(self, name, value): + if name == "_data": + super().__setattr__(name, value) + else: + self._data[name] = value + + def __getattr__(self, name): + try: + return self._data[name] + except KeyError: + raise AttributeError(name) + + def get(self, key, default=None): + return self._data.get(key, default) + + +@pytest.fixture +def fake_state(): + state = FakeSessionState() + with patch("frontend.state.st.session_state", state): + yield state + + +# ── Key initialisation ──────────────────────────────────────────────────────── + +class TestInitStateKeys: + EXPECTED_KEYS = [ + "last_selected", + "selected_folder", + "selected_folder_rel", + "chat_manager", + "open_files", + "files_content", + "active_file", + "active_tab", + "is_editing", + "code_suggestions", + "code_execution_output", + "chat_history", + "agent_mode", + "coding_agent", + "agent_status", + "agent_log", + "agent_pending_action", + ] + + def test_all_expected_keys_are_set(self, fake_state): + from frontend.state import init_state + init_state() + for key in self.EXPECTED_KEYS: + assert key in fake_state, f"Missing key: {key}" + + def test_open_files_initialised_as_empty_list(self, fake_state): + from frontend.state import init_state + init_state() + assert fake_state.open_files == [] + + def test_files_content_initialised_as_empty_dict(self, fake_state): + from frontend.state import init_state + init_state() + assert fake_state.files_content == {} + + def test_active_file_initialised_as_none(self, fake_state): + from frontend.state import init_state + init_state() + assert fake_state.active_file is None + + def test_chat_history_initialised_as_empty_list(self, fake_state): + from frontend.state import init_state + init_state() + assert fake_state.chat_history == [] + + def test_agent_mode_initialised_as_false(self, fake_state): + from frontend.state import init_state + init_state() + assert fake_state.agent_mode is False + + def test_agent_status_initialised_as_idle(self, fake_state): + from frontend.state import init_state + init_state() + assert fake_state.agent_status == "idle" + + def test_chat_manager_is_chat_manager_instance(self, fake_state): + from frontend.state import init_state + init_state() + assert isinstance(fake_state.chat_manager, ChatManager) + + +# ── Idempotency ─────────────────────────────────────────────────────────────── + +class TestInitStateIdempotency: + 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 + init_state() + fake_state.open_files = ["/workspace/file.py"] + init_state() + assert fake_state.open_files == ["/workspace/file.py"] + + def test_second_call_does_not_overwrite_chat_history(self, fake_state): + from frontend.state import init_state + init_state() + fake_state.chat_history = [{"role": "user", "content": "Hi"}] + init_state() + assert len(fake_state.chat_history) == 1 + + def test_second_call_does_not_replace_chat_manager(self, fake_state): + from frontend.state import init_state + init_state() + original = fake_state.chat_manager + init_state() + assert fake_state.chat_manager is original + + def test_second_call_does_not_overwrite_active_file(self, fake_state): + from frontend.state import init_state + init_state() + fake_state.active_file = "/workspace/main.py" + init_state() + assert fake_state.active_file == "/workspace/main.py"