Add Clide project structure and initial implementation
Set up the TUI IDE wrapper for Claude Code CLI with: - Core app structure using Textual framework - Panel architecture (sidebar, workspace, claude, context) - Theme system with 22 built-in themes (Summer Night default) - Pydantic models for configuration and data - Makefile for development commands - Project documentation and specs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Clide test suite."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared pytest fixtures for Clide tests."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from clide.app import ClideApp
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
from tests.harnesses.app_harness import AppHarness
|
||||
from tests.harnesses.controller_harness import ControllerHarness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workdir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary working directory with sample files."""
|
||||
# Create sample directory structure
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / "src" / "main.py").write_text("# Main file")
|
||||
(tmp_path / "README.md").write_text("# Test Project")
|
||||
|
||||
# Initialize git repo
|
||||
(tmp_path / ".git").mkdir()
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings() -> ClideSettings:
|
||||
"""Create test settings with defaults."""
|
||||
return ClideSettings(
|
||||
theme="dark",
|
||||
claude_path="/usr/bin/echo", # Safe mock
|
||||
auto_save=False,
|
||||
confirm_exit=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_extension_manager() -> ExtensionManager:
|
||||
"""Create an extension manager without loading external extensions."""
|
||||
manager = ExtensionManager()
|
||||
# Don't load entry points in tests
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_harness(
|
||||
temp_workdir: Path,
|
||||
mock_settings: ClideSettings,
|
||||
mock_extension_manager: ExtensionManager,
|
||||
) -> Generator[AppHarness, None, None]:
|
||||
"""Create a full application test harness."""
|
||||
harness = AppHarness(
|
||||
workdir=temp_workdir,
|
||||
settings=mock_settings,
|
||||
extension_manager=mock_extension_manager,
|
||||
)
|
||||
yield harness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def running_app(
|
||||
app_harness: AppHarness,
|
||||
) -> AsyncGenerator[tuple[ClideApp, Pilot], None]:
|
||||
"""Start the app and yield (app, pilot) for interaction."""
|
||||
app, pilot = await app_harness.start()
|
||||
try:
|
||||
yield app, pilot
|
||||
finally:
|
||||
await app_harness.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller_harness() -> ControllerHarness:
|
||||
"""Create an isolated controller test harness."""
|
||||
return ControllerHarness()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Test harnesses for Clide testing."""
|
||||
|
||||
from tests.harnesses.app_harness import AppHarness
|
||||
from tests.harnesses.controller_harness import ControllerHarness
|
||||
|
||||
__all__ = ["AppHarness", "ControllerHarness"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Full application test harness for Clide."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from clide.app import ClideApp
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
from clide.models.config import ClideSettings
|
||||
|
||||
|
||||
class AppHarness:
|
||||
"""Test harness for running the full Clide application.
|
||||
|
||||
Provides a controlled environment for integration testing with
|
||||
mocked services and isolated file systems.
|
||||
|
||||
Usage:
|
||||
harness = AppHarness(workdir=tmp_path)
|
||||
app, pilot = await harness.start()
|
||||
await pilot.press("ctrl+q")
|
||||
await harness.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workdir: Path,
|
||||
settings: Optional[ClideSettings] = None,
|
||||
extension_manager: Optional[ExtensionManager] = None,
|
||||
) -> None:
|
||||
self.workdir = workdir
|
||||
self.settings = settings or ClideSettings()
|
||||
self.extension_manager = extension_manager or ExtensionManager()
|
||||
self._app: Optional[ClideApp] = None
|
||||
self._pilot: Optional[Pilot] = None
|
||||
|
||||
async def start(self) -> tuple[ClideApp, Pilot]:
|
||||
"""Start the application and return app and pilot for testing.
|
||||
|
||||
Returns:
|
||||
Tuple of (ClideApp instance, Pilot for simulating input)
|
||||
"""
|
||||
self._app = ClideApp(workdir=self.workdir)
|
||||
# Inject test dependencies
|
||||
self._app.extension_manager = self.extension_manager
|
||||
|
||||
# Start app in test mode
|
||||
async with self._app.run_test() as pilot:
|
||||
self._pilot = pilot
|
||||
return self._app, pilot
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Clean shutdown of the application."""
|
||||
if self._app:
|
||||
await self._app.action_quit()
|
||||
self._app = None
|
||||
self._pilot = None
|
||||
|
||||
@property
|
||||
def app(self) -> ClideApp:
|
||||
"""Get the running app instance."""
|
||||
if self._app is None:
|
||||
raise RuntimeError("App not started. Call start() first.")
|
||||
return self._app
|
||||
|
||||
@property
|
||||
def pilot(self) -> Pilot:
|
||||
"""Get the pilot for simulating user input."""
|
||||
if self._pilot is None:
|
||||
raise RuntimeError("App not started. Call start() first.")
|
||||
return self._pilot
|
||||
|
||||
async def press_keys(self, *keys: str) -> None:
|
||||
"""Simulate pressing a sequence of keys."""
|
||||
await self.pilot.press(*keys)
|
||||
|
||||
async def click(self, selector: str) -> None:
|
||||
"""Click on a widget by CSS selector."""
|
||||
await self.pilot.click(selector)
|
||||
|
||||
async def wait_for_animation(self) -> None:
|
||||
"""Wait for any running animations to complete."""
|
||||
await self.pilot.pause()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Controller isolation test harness for Clide."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide.controllers.base import BaseController
|
||||
|
||||
|
||||
class MockApp:
|
||||
"""Minimal mock of a Textual App for controller testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[Message] = []
|
||||
self.post_message = MagicMock(side_effect=self._capture_message)
|
||||
|
||||
def _capture_message(self, message: Message) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
|
||||
"""Get captured messages, optionally filtered by type."""
|
||||
if message_type is None:
|
||||
return self.messages.copy()
|
||||
return [m for m in self.messages if isinstance(m, message_type)]
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""Clear captured messages."""
|
||||
self.messages.clear()
|
||||
|
||||
|
||||
class ControllerHarness:
|
||||
"""Test harness for isolated controller testing.
|
||||
|
||||
Provides a mock app environment for testing controllers without
|
||||
the full Textual application overhead.
|
||||
|
||||
Usage:
|
||||
harness = ControllerHarness()
|
||||
controller = GitController(harness.mock_app)
|
||||
await controller.initialize()
|
||||
await controller.refresh_status()
|
||||
messages = harness.get_messages()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mock_app = MockApp()
|
||||
self._controllers: list["BaseController"] = []
|
||||
self._mocks: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def mock_app(self) -> MockApp:
|
||||
"""Get the mock app for controller injection."""
|
||||
return self._mock_app
|
||||
|
||||
def register_controller(self, controller: "BaseController") -> None:
|
||||
"""Register a controller for lifecycle management."""
|
||||
self._controllers.append(controller)
|
||||
|
||||
async def initialize_all(self) -> None:
|
||||
"""Initialize all registered controllers."""
|
||||
for controller in self._controllers:
|
||||
await controller.initialize()
|
||||
|
||||
async def shutdown_all(self) -> None:
|
||||
"""Shutdown all registered controllers."""
|
||||
for controller in self._controllers:
|
||||
await controller.shutdown()
|
||||
|
||||
def get_messages(self, message_type: Optional[type] = None) -> list[Message]:
|
||||
"""Get messages posted to the mock app."""
|
||||
return self._mock_app.get_messages(message_type)
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""Clear all captured messages."""
|
||||
self._mock_app.clear_messages()
|
||||
|
||||
def add_mock(self, name: str, mock: Any) -> None:
|
||||
"""Add a named mock for dependency injection.
|
||||
|
||||
Args:
|
||||
name: Identifier for the mock
|
||||
mock: Mock object or AsyncMock
|
||||
"""
|
||||
self._mocks[name] = mock
|
||||
|
||||
def get_mock(self, name: str) -> Any:
|
||||
"""Retrieve a named mock."""
|
||||
return self._mocks.get(name)
|
||||
|
||||
def create_async_mock(self, return_value: Any = None) -> AsyncMock:
|
||||
"""Create an AsyncMock with optional return value."""
|
||||
mock = AsyncMock()
|
||||
if return_value is not None:
|
||||
mock.return_value = return_value
|
||||
return mock
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for Clide."""
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Integration tests for FilesView widget."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
|
||||
|
||||
class FilesViewTestApp(App):
|
||||
"""Test app for FilesView."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__()
|
||||
self.test_path = path
|
||||
self.selected_files: list[Path] = []
|
||||
self.selected_dirs: list[Path] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield FilesView(self.test_path, id="files")
|
||||
|
||||
def on_files_view_file_selected(self, event: FilesView.FileSelected) -> None:
|
||||
self.selected_files.append(event.path)
|
||||
|
||||
def on_files_view_directory_selected(self, event: FilesView.DirectorySelected) -> None:
|
||||
self.selected_dirs.append(event.path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_directory(tmp_path: Path) -> Path:
|
||||
"""Create a test directory structure."""
|
||||
# Create directories
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "components").mkdir()
|
||||
(tmp_path / "tests").mkdir()
|
||||
|
||||
# Create files
|
||||
(tmp_path / "README.md").write_text("# Test")
|
||||
(tmp_path / "src" / "main.py").write_text("print('hello')")
|
||||
(tmp_path / "src" / "components" / "button.py").write_text("class Button: pass")
|
||||
(tmp_path / "tests" / "test_main.py").write_text("def test_main(): pass")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
async def test_files_view_renders(test_directory: Path):
|
||||
"""Test that FilesView renders without errors."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
assert files_view is not None
|
||||
assert files_view.path == test_directory
|
||||
|
||||
|
||||
async def test_files_view_shows_files(test_directory: Path):
|
||||
"""Test that FilesView shows files in the directory."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
# The root should be loaded
|
||||
assert files_view.root is not None
|
||||
|
||||
|
||||
async def test_directory_click_expands(test_directory: Path):
|
||||
"""Test that clicking a directory expands it and emits event."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Find the src directory node and click it
|
||||
for node in files_view.root.children:
|
||||
if node.data and node.data.path.name == "src":
|
||||
files_view.select_node(node)
|
||||
await pilot.pause()
|
||||
break
|
||||
|
||||
# Check that directory was selected
|
||||
assert len(app.selected_dirs) >= 1
|
||||
assert any(p.name == "src" for p in app.selected_dirs)
|
||||
|
||||
|
||||
async def test_file_click_emits_event(test_directory: Path):
|
||||
"""Test that clicking a file emits FileSelected event."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Find and click README.md
|
||||
for node in files_view.root.children:
|
||||
if node.data and node.data.path.name == "README.md":
|
||||
files_view.select_node(node)
|
||||
await pilot.pause()
|
||||
break
|
||||
|
||||
# Check that file was selected
|
||||
assert len(app.selected_files) >= 1
|
||||
assert any(p.name == "README.md" for p in app.selected_files)
|
||||
|
||||
|
||||
async def test_filter_paths_hides_hidden_files(test_directory: Path):
|
||||
"""Test that hidden files are filtered out."""
|
||||
# Create hidden files/dirs
|
||||
(test_directory / ".git").mkdir()
|
||||
(test_directory / ".hidden_file").write_text("hidden")
|
||||
(test_directory / "__pycache__").mkdir()
|
||||
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Check that hidden items are not in the tree
|
||||
visible_names = {
|
||||
node.data.path.name
|
||||
for node in files_view.root.children
|
||||
if node.data
|
||||
}
|
||||
|
||||
assert ".git" not in visible_names
|
||||
assert ".hidden_file" not in visible_names
|
||||
assert "__pycache__" not in visible_names
|
||||
assert "src" in visible_names
|
||||
assert "README.md" in visible_names
|
||||
|
||||
|
||||
async def test_render_label_shows_icons(test_directory: Path):
|
||||
"""Test that render_label produces proper icons."""
|
||||
app = FilesViewTestApp(test_directory)
|
||||
async with app.run_test() as pilot:
|
||||
files_view = app.query_one("#files", FilesView)
|
||||
|
||||
# Wait for initial load
|
||||
await pilot.pause()
|
||||
|
||||
# Check that nodes have labels rendered (icons are part of label)
|
||||
for node in files_view.root.children:
|
||||
if node.data:
|
||||
# The label should contain the filename
|
||||
label_text = str(node.label)
|
||||
assert node.data.path.name in label_text
|
||||
@@ -0,0 +1 @@
|
||||
"""Snapshot (visual regression) tests for Clide."""
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,37 @@
|
||||
"""Snapshot tests for Clide UI."""
|
||||
|
||||
from clide.app import ClideApp
|
||||
|
||||
|
||||
def test_initial_layout(snap_compare) -> None:
|
||||
"""Test the initial application layout renders correctly."""
|
||||
app = ClideApp(test_mode=True)
|
||||
assert snap_compare(app, terminal_size=(120, 40))
|
||||
|
||||
|
||||
def test_layout_without_right_panel(snap_compare) -> None:
|
||||
"""Test layout with right panel hidden."""
|
||||
|
||||
async def hide_right_panel(pilot):
|
||||
await pilot.press("f2")
|
||||
|
||||
app = ClideApp(test_mode=True)
|
||||
assert snap_compare(
|
||||
app,
|
||||
terminal_size=(120, 40),
|
||||
run_before=hide_right_panel,
|
||||
)
|
||||
|
||||
|
||||
def test_layout_without_left_panel(snap_compare) -> None:
|
||||
"""Test layout with left panel hidden."""
|
||||
|
||||
async def hide_left_panel(pilot):
|
||||
await pilot.press("f1")
|
||||
|
||||
app = ClideApp(test_mode=True)
|
||||
assert snap_compare(
|
||||
app,
|
||||
terminal_size=(120, 40),
|
||||
run_before=hide_left_panel,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for Clide."""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for ClideApp."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.models.config import ClideSettings
|
||||
|
||||
|
||||
class TestClideAppInit:
|
||||
"""Tests for ClideApp initialization."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test default initialization."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
assert app.workdir == Path.cwd()
|
||||
assert isinstance(app.settings, ClideSettings)
|
||||
# Note: reactive properties can't be tested directly without running the app
|
||||
# because watchers try to query the DOM
|
||||
|
||||
def test_initialization_with_workdir(self, tmp_path: Path):
|
||||
"""Test initialization with custom workdir."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp(workdir=tmp_path)
|
||||
assert app.workdir == tmp_path
|
||||
|
||||
def test_initialization_with_settings(self):
|
||||
"""Test initialization with custom settings."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
settings = ClideSettings(theme="dracula", jira_enabled=True)
|
||||
app = ClideApp(settings=settings)
|
||||
assert app.settings.theme == "dracula"
|
||||
assert app.settings.jira_enabled is True
|
||||
|
||||
def test_controllers_initialized(self, tmp_path: Path):
|
||||
"""Test that all controllers are initialized."""
|
||||
from clide.app import ClideApp
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
|
||||
app = ClideApp(workdir=tmp_path)
|
||||
assert isinstance(app.git_controller, GitController)
|
||||
assert isinstance(app.editor_controller, EditorController)
|
||||
assert isinstance(app.diff_controller, DiffController)
|
||||
assert isinstance(app.problems_controller, ProblemsController)
|
||||
assert isinstance(app.todos_controller, TodosController)
|
||||
assert isinstance(app.jira_controller, JiraController)
|
||||
|
||||
def test_jira_controller_enabled_from_settings(self):
|
||||
"""Test Jira controller uses settings."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
settings = ClideSettings(jira_enabled=True)
|
||||
app = ClideApp(settings=settings)
|
||||
assert app.jira_controller.enabled is True
|
||||
|
||||
settings_disabled = ClideSettings(jira_enabled=False)
|
||||
app_disabled = ClideApp(settings=settings_disabled)
|
||||
assert app_disabled.jira_controller.enabled is False
|
||||
|
||||
|
||||
class TestClideAppBindings:
|
||||
"""Tests for ClideApp keybindings."""
|
||||
|
||||
def test_bindings_defined(self):
|
||||
"""Test that keybindings are defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
bindings = {b.key for b in app.BINDINGS}
|
||||
|
||||
# Check key bindings exist
|
||||
assert "ctrl+q" in bindings
|
||||
assert "ctrl+b" in bindings
|
||||
assert "ctrl+shift+p" in bindings
|
||||
assert "ctrl+`" in bindings
|
||||
assert "ctrl+1" in bindings
|
||||
assert "f11" in bindings
|
||||
assert "escape" in bindings
|
||||
|
||||
|
||||
class TestClideAppMeta:
|
||||
"""Tests for ClideApp metadata."""
|
||||
|
||||
def test_title(self):
|
||||
"""Test app title."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
assert app.TITLE == "Clide"
|
||||
assert app.SUB_TITLE == "Claude Code IDE"
|
||||
|
||||
def test_css_defined(self):
|
||||
"""Test CSS is defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
assert ClideApp.CSS
|
||||
assert "#main-container" in ClideApp.CSS
|
||||
assert "SidebarPanel" in ClideApp.CSS
|
||||
assert "ContextPanel" in ClideApp.CSS
|
||||
assert "WorkspacePanel" in ClideApp.CSS
|
||||
assert "ClaudePanel" in ClideApp.CSS
|
||||
|
||||
|
||||
class TestClideAppReactive:
|
||||
"""Tests for ClideApp reactive properties.
|
||||
|
||||
Note: Most reactive property tests require running the app
|
||||
because accessing them triggers watchers that query the DOM.
|
||||
These tests verify the property definitions exist.
|
||||
"""
|
||||
|
||||
def test_reactive_properties_defined(self):
|
||||
"""Test that reactive properties are defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
# Check the reactive descriptors exist on the class
|
||||
assert hasattr(ClideApp, "workspace_visible")
|
||||
assert hasattr(ClideApp, "compact_mode")
|
||||
assert hasattr(ClideApp, "fullscreen_panel")
|
||||
assert hasattr(ClideApp, "current_file")
|
||||
|
||||
|
||||
class TestClideAppThemes:
|
||||
"""Tests for ClideApp theme registration."""
|
||||
|
||||
def test_themes_registered(self):
|
||||
"""Test that themes are registered."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
# Default theme should be set
|
||||
assert app.theme == "summer-night"
|
||||
|
||||
def test_custom_theme_from_settings(self):
|
||||
"""Test that custom theme from settings is applied."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
settings = ClideSettings(theme="dracula")
|
||||
app = ClideApp(settings=settings)
|
||||
assert app.theme == "dracula"
|
||||
|
||||
|
||||
class TestClideAppActions:
|
||||
"""Tests for ClideApp action methods.
|
||||
|
||||
Note: Action methods that modify reactive properties or
|
||||
query the DOM cannot be fully tested without running the app.
|
||||
"""
|
||||
|
||||
def test_action_methods_exist(self):
|
||||
"""Test that action methods are defined."""
|
||||
from clide.app import ClideApp
|
||||
|
||||
app = ClideApp()
|
||||
# Verify action methods exist
|
||||
assert hasattr(app, "action_toggle_compact")
|
||||
assert hasattr(app, "action_toggle_sidebar")
|
||||
assert hasattr(app, "action_toggle_context")
|
||||
assert hasattr(app, "action_toggle_terminal")
|
||||
assert hasattr(app, "action_focus_claude")
|
||||
assert callable(app.action_toggle_compact)
|
||||
|
||||
|
||||
class TestClideSettings:
|
||||
"""Tests for ClideSettings."""
|
||||
|
||||
def test_default_settings(self):
|
||||
"""Test default settings values."""
|
||||
settings = ClideSettings()
|
||||
assert settings.theme == "summer-night"
|
||||
assert settings.jira_enabled is False
|
||||
|
||||
def test_custom_settings(self):
|
||||
"""Test custom settings values."""
|
||||
settings = ClideSettings(theme="nord", jira_enabled=True)
|
||||
assert settings.theme == "nord"
|
||||
assert settings.jira_enabled is True
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for configuration models."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from clide.models.config import ClideSettings, KeybindingsConfig, PanelConfig
|
||||
|
||||
|
||||
class TestClideSettings:
|
||||
"""Tests for ClideSettings model."""
|
||||
|
||||
def test_default_settings(self) -> None:
|
||||
"""Default settings should be valid."""
|
||||
settings = ClideSettings()
|
||||
assert settings.theme == "summer-night"
|
||||
assert settings.auto_save is True
|
||||
assert settings.confirm_exit is True
|
||||
assert settings.jira_enabled is False
|
||||
|
||||
def test_custom_settings(self) -> None:
|
||||
"""Custom settings should be applied."""
|
||||
settings = ClideSettings(
|
||||
theme="dracula",
|
||||
claude_path="/custom/path/claude",
|
||||
auto_save=False,
|
||||
jira_enabled=True,
|
||||
)
|
||||
assert settings.theme == "dracula"
|
||||
assert settings.claude_path == "/custom/path/claude"
|
||||
assert settings.auto_save is False
|
||||
assert settings.jira_enabled is True
|
||||
|
||||
|
||||
class TestPanelConfig:
|
||||
"""Tests for PanelConfig model."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Default values should be set correctly."""
|
||||
config = PanelConfig()
|
||||
assert config.sidebar_visible is True
|
||||
assert config.context_visible is True
|
||||
assert config.workspace_visible is False
|
||||
assert config.sidebar_width_percent == 20
|
||||
assert config.context_width_percent == 25
|
||||
|
||||
def test_frozen_model(self) -> None:
|
||||
"""PanelConfig should be immutable."""
|
||||
config = PanelConfig()
|
||||
with pytest.raises(ValidationError):
|
||||
config.sidebar_visible = False # type: ignore
|
||||
|
||||
def test_strict_mode(self) -> None:
|
||||
"""PanelConfig should enforce strict types."""
|
||||
with pytest.raises(ValidationError):
|
||||
PanelConfig(sidebar_width_percent="30") # type: ignore
|
||||
|
||||
|
||||
class TestKeybindingsConfig:
|
||||
"""Tests for KeybindingsConfig model."""
|
||||
|
||||
def test_default_keybindings(self) -> None:
|
||||
"""Default keybindings should be valid."""
|
||||
config = KeybindingsConfig()
|
||||
assert config.toggle_sidebar == "ctrl+b"
|
||||
assert config.toggle_terminal == "ctrl+`"
|
||||
assert config.focus_claude == "ctrl+1"
|
||||
|
||||
def test_custom_keybindings(self) -> None:
|
||||
"""Custom keybindings should be applied."""
|
||||
config = KeybindingsConfig(
|
||||
toggle_sidebar="ctrl+shift+s",
|
||||
save="cmd+s",
|
||||
)
|
||||
assert config.toggle_sidebar == "ctrl+shift+s"
|
||||
assert config.save == "cmd+s"
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Tests for controller classes."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.controllers.diff import DiffController
|
||||
from clide.controllers.editor import EditorController
|
||||
from clide.controllers.git import GitController
|
||||
from clide.controllers.jira import JiraController
|
||||
from clide.controllers.problems import ProblemsController
|
||||
from clide.controllers.todos import TodosController
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine
|
||||
from clide.models.editor import CursorPosition, FileBuffer
|
||||
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitStatus
|
||||
from clide.models.problems import Problem, ProblemsSummary, Severity
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodoType
|
||||
from clide.services.git_service import GitService
|
||||
from clide.services.process_service import CommandResult
|
||||
|
||||
|
||||
class TestGitController:
|
||||
"""Tests for GitController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> GitController:
|
||||
return GitController(tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status(self, controller: GitController):
|
||||
mock_status = GitStatus(
|
||||
branch="main",
|
||||
staged=(GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True),),
|
||||
unstaged=(),
|
||||
)
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
status = await controller.get_status()
|
||||
assert status.branch == "main"
|
||||
assert len(status.staged) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branches(self, controller: GitController):
|
||||
mock_branches = [
|
||||
GitBranch(name="main", is_current=True, is_remote=False),
|
||||
GitBranch(name="develop", is_current=False, is_remote=False),
|
||||
]
|
||||
with patch.object(controller._service, "get_branches", return_value=mock_branches):
|
||||
branches = await controller.get_branches()
|
||||
assert len(branches) == 2
|
||||
assert branches[0].is_current is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_log(self, controller: GitController):
|
||||
mock_commits = [
|
||||
GitCommit(
|
||||
hash="abc123def456",
|
||||
short_hash="abc123",
|
||||
message="Initial commit",
|
||||
author="Test",
|
||||
date="2024-01-01",
|
||||
)
|
||||
]
|
||||
with patch.object(controller._service, "get_log", return_value=mock_commits):
|
||||
commits = await controller.get_log(limit=10)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].message == "Initial commit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_file(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="main", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "stage_file", return_value=True):
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
result = await controller.stage_file("test.py")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unstage_file(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="main", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "unstage_file", return_value=True):
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
result = await controller.unstage_file("test.py")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_branch(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="develop", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "checkout_branch", return_value=True):
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
with patch.object(controller._service, "get_branches", return_value=[]):
|
||||
result = await controller.checkout_branch("develop")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_branch_property(self, controller: GitController):
|
||||
mock_status = GitStatus(branch="feature", staged=(), unstaged=())
|
||||
with patch.object(controller._service, "get_status", return_value=mock_status):
|
||||
await controller.get_status() # Populate _status
|
||||
assert controller.current_branch == "feature"
|
||||
|
||||
def test_current_branch_unknown(self, controller: GitController):
|
||||
assert controller.current_branch == "unknown"
|
||||
|
||||
|
||||
class TestEditorController:
|
||||
"""Tests for EditorController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self) -> EditorController:
|
||||
return EditorController()
|
||||
|
||||
def test_initial_state(self, controller: EditorController):
|
||||
assert controller.active_buffer is None
|
||||
assert controller.open_files == []
|
||||
assert controller.has_unsaved_changes is False
|
||||
|
||||
def test_update_content(self, controller: EditorController):
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="original")
|
||||
controller._state.buffers.append(buffer)
|
||||
|
||||
controller.update_content(Path("/test.py"), "modified")
|
||||
assert buffer.content == "modified"
|
||||
assert buffer.is_modified is True
|
||||
|
||||
def test_update_cursor(self, controller: EditorController):
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="test")
|
||||
controller._state.buffers.append(buffer)
|
||||
|
||||
controller.update_cursor(Path("/test.py"), 5, 10)
|
||||
assert buffer.cursor.line == 5
|
||||
assert buffer.cursor.column == 10
|
||||
|
||||
def test_set_active_by_index(self, controller: EditorController):
|
||||
buffer1 = FileBuffer(path=Path("/a.py"), content="a")
|
||||
buffer2 = FileBuffer(path=Path("/b.py"), content="b")
|
||||
controller._state.buffers = [buffer1, buffer2]
|
||||
|
||||
controller.set_active_by_index(1)
|
||||
assert controller._state.active_buffer_index == 1
|
||||
|
||||
def test_set_active_invalid_index(self, controller: EditorController):
|
||||
controller.set_active_by_index(10) # Should not crash
|
||||
assert controller._state.active_buffer_index is None
|
||||
|
||||
|
||||
class TestDiffController:
|
||||
"""Tests for DiffController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> DiffController:
|
||||
return DiffController(tmp_path)
|
||||
|
||||
def test_initial_state(self, controller: DiffController):
|
||||
assert controller.diff is None
|
||||
assert controller.is_proposal is False
|
||||
|
||||
def test_load_proposal(self, controller: DiffController):
|
||||
old_content = "line1\nline2\n"
|
||||
new_content = "line1\nmodified\n"
|
||||
|
||||
diff = controller.load_proposal("test.py", old_content, new_content)
|
||||
assert diff.file_path == "test.py"
|
||||
assert controller.is_proposal is True
|
||||
|
||||
def test_accept_hunk(self, controller: DiffController):
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=())
|
||||
controller.accept_hunk(0)
|
||||
assert 0 in controller._state.accepted_hunks
|
||||
assert 0 not in controller._state.rejected_hunks
|
||||
|
||||
def test_reject_hunk(self, controller: DiffController):
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=())
|
||||
controller.reject_hunk(0)
|
||||
assert 0 in controller._state.rejected_hunks
|
||||
assert 0 not in controller._state.accepted_hunks
|
||||
|
||||
def test_accept_all(self, controller: DiffController):
|
||||
hunk = DiffHunk(
|
||||
header="@@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=(),
|
||||
)
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=(hunk, hunk))
|
||||
controller.accept_all()
|
||||
assert len(controller._state.accepted_hunks) == 2
|
||||
|
||||
def test_reject_all(self, controller: DiffController):
|
||||
hunk = DiffHunk(
|
||||
header="@@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=(),
|
||||
)
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=(hunk,))
|
||||
controller.reject_all()
|
||||
assert len(controller._state.rejected_hunks) == 1
|
||||
|
||||
def test_clear(self, controller: DiffController):
|
||||
controller._state.diff = DiffContent(file_path="t.py", hunks=())
|
||||
controller._state.is_proposal = True
|
||||
controller.clear()
|
||||
assert controller.diff is None
|
||||
assert controller.is_proposal is False
|
||||
|
||||
def test_toggle_side_by_side(self, controller: DiffController):
|
||||
assert controller._state.side_by_side is True # Default is True
|
||||
result = controller.toggle_side_by_side()
|
||||
assert result is False # Toggled to False
|
||||
assert controller._state.side_by_side is False
|
||||
|
||||
|
||||
class TestProblemsController:
|
||||
"""Tests for ProblemsController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> ProblemsController:
|
||||
return ProblemsController(tmp_path)
|
||||
|
||||
def test_initial_state(self, controller: ProblemsController):
|
||||
assert controller.problems == []
|
||||
assert controller.error_count == 0
|
||||
assert controller.warning_count == 0
|
||||
|
||||
def test_filter_by_severity(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="test",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.WARNING,
|
||||
message="warn",
|
||||
source="test",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
errors = controller.filter_by_severity(Severity.ERROR)
|
||||
assert len(errors) == 1
|
||||
assert errors[0].severity == Severity.ERROR
|
||||
|
||||
def test_filter_by_source(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="ruff",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="mypy",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
ruff_problems = controller.filter_by_source("ruff")
|
||||
assert len(ruff_problems) == 1
|
||||
|
||||
def test_next_problem(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="1",
|
||||
source="test",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=2,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="2",
|
||||
source="test",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
p1 = controller.next_problem()
|
||||
assert p1.message == "1"
|
||||
p2 = controller.next_problem()
|
||||
assert p2.message == "2"
|
||||
# Should wrap around
|
||||
p3 = controller.next_problem()
|
||||
assert p3.message == "1"
|
||||
|
||||
def test_prev_problem(self, controller: ProblemsController):
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="1",
|
||||
source="test",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=2,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="2",
|
||||
source="test",
|
||||
),
|
||||
]
|
||||
controller._state.problems = problems
|
||||
|
||||
p = controller.prev_problem()
|
||||
assert p.message == "2"
|
||||
|
||||
def test_clear(self, controller: ProblemsController):
|
||||
controller._state.problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="test",
|
||||
)
|
||||
]
|
||||
controller.clear()
|
||||
assert controller.problems == []
|
||||
|
||||
|
||||
class TestTodosController:
|
||||
"""Tests for TodosController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self, tmp_path: Path) -> TodosController:
|
||||
return TodosController(tmp_path)
|
||||
|
||||
def test_initial_state(self, controller: TodosController):
|
||||
assert controller.items == []
|
||||
assert controller.total_count == 0
|
||||
|
||||
def test_filter_by_type(self, controller: TodosController):
|
||||
items = [
|
||||
TodoItem(file_path=Path("/a.py"), line=1, column=1, todo_type=TodoType.TODO, text="1", context_line="# TODO: 1"),
|
||||
TodoItem(file_path=Path("/b.py"), line=2, column=1, todo_type=TodoType.FIXME, text="2", context_line="# FIXME: 2"),
|
||||
]
|
||||
controller._state.items = items
|
||||
|
||||
todos = controller.filter_by_type(TodoType.TODO)
|
||||
assert len(todos) == 1
|
||||
assert todos[0].todo_type == TodoType.TODO
|
||||
|
||||
def test_get_grouped_items(self, controller: TodosController):
|
||||
items = [
|
||||
TodoItem(file_path=Path("/a.py"), line=1, column=1, todo_type=TodoType.TODO, text="1", context_line="# TODO: 1"),
|
||||
TodoItem(file_path=Path("/a.py"), line=5, column=1, todo_type=TodoType.FIXME, text="2", context_line="# FIXME: 2"),
|
||||
TodoItem(file_path=Path("/b.py"), line=1, column=1, todo_type=TodoType.TODO, text="3", context_line="# TODO: 3"),
|
||||
]
|
||||
controller._state.items = items
|
||||
|
||||
grouped = controller.get_grouped_items()
|
||||
assert len(grouped) == 2
|
||||
assert len(grouped[Path("/a.py")]) == 2
|
||||
assert len(grouped[Path("/b.py")]) == 1
|
||||
|
||||
def test_toggle_group_by_file(self, controller: TodosController):
|
||||
assert controller._state.group_by_file is True # Default is True per TodosState model
|
||||
result = controller.toggle_group_by_file()
|
||||
assert result is False
|
||||
assert controller._state.group_by_file is False
|
||||
|
||||
|
||||
class TestJiraController:
|
||||
"""Tests for JiraController."""
|
||||
|
||||
@pytest.fixture
|
||||
def controller(self) -> JiraController:
|
||||
return JiraController(enabled=True)
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_controller(self) -> JiraController:
|
||||
return JiraController(enabled=False)
|
||||
|
||||
def test_initial_enabled_state(self, controller: JiraController):
|
||||
assert controller.enabled is True
|
||||
|
||||
def test_initial_disabled_state(self, disabled_controller: JiraController):
|
||||
assert disabled_controller.enabled is False
|
||||
|
||||
def test_enable_disable(self, disabled_controller: JiraController):
|
||||
disabled_controller.enable()
|
||||
assert disabled_controller.enabled is True
|
||||
disabled_controller.disable()
|
||||
assert disabled_controller.enabled is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_command_when_disabled(self, disabled_controller: JiraController):
|
||||
result = await disabled_controller.run_command("issue", "list")
|
||||
assert "disabled" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_content_when_disabled(self, disabled_controller: JiraController):
|
||||
result = await disabled_controller.get_content()
|
||||
assert result is None
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for extension system."""
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.extensions import hookimpl
|
||||
from clide.extensions.manager import ExtensionManager
|
||||
|
||||
|
||||
class SampleExtension:
|
||||
"""Sample extension for testing."""
|
||||
|
||||
@hookimpl
|
||||
def clide_on_app_startup(self, app: object) -> None:
|
||||
"""Track that startup was called."""
|
||||
self.startup_called = True
|
||||
self.received_app = app
|
||||
|
||||
|
||||
class TestExtensionManager:
|
||||
"""Tests for ExtensionManager."""
|
||||
|
||||
def test_register_plugin(self) -> None:
|
||||
"""Plugins can be registered manually."""
|
||||
manager = ExtensionManager()
|
||||
extension = SampleExtension()
|
||||
|
||||
manager.register_plugin(extension, "sample")
|
||||
|
||||
assert "sample" in manager.list_extensions()
|
||||
|
||||
def test_unregister_plugin(self) -> None:
|
||||
"""Plugins can be unregistered."""
|
||||
manager = ExtensionManager()
|
||||
extension = SampleExtension()
|
||||
manager.register_plugin(extension, "sample")
|
||||
|
||||
manager.unregister_plugin("sample")
|
||||
|
||||
assert "sample" not in manager.list_extensions()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_startup_hook(self) -> None:
|
||||
"""Startup hooks are triggered for all extensions."""
|
||||
manager = ExtensionManager()
|
||||
extension = SampleExtension()
|
||||
manager.register_plugin(extension, "sample")
|
||||
mock_app = object()
|
||||
|
||||
await manager.trigger_app_startup(mock_app)
|
||||
|
||||
assert extension.startup_called is True
|
||||
assert extension.received_app is mock_app
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Tests for Pydantic models."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from clide.models.config import ClideSettings, KeybindingsConfig, PanelConfig
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine, DiffViewState
|
||||
from clide.models.editor import CursorPosition, EditorState, FileBuffer, Selection
|
||||
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit, GitGraph, GitStatus
|
||||
from clide.models.problems import Problem, ProblemsSummary, ProblemsState, Severity
|
||||
from clide.models.theme import ThemeColors, ThemeDefinition, ThemeMetadata
|
||||
from clide.models.todos import TodoItem, TodosSummary, TodosState, TodoType
|
||||
|
||||
|
||||
class TestCursorPosition:
|
||||
"""Tests for CursorPosition model."""
|
||||
|
||||
def test_create_cursor(self):
|
||||
cursor = CursorPosition(line=10, column=5)
|
||||
assert cursor.line == 10
|
||||
assert cursor.column == 5
|
||||
|
||||
def test_cursor_is_frozen(self):
|
||||
cursor = CursorPosition(line=0, column=0)
|
||||
with pytest.raises(ValidationError):
|
||||
cursor.line = 1
|
||||
|
||||
|
||||
class TestFileBuffer:
|
||||
"""Tests for FileBuffer model."""
|
||||
|
||||
def test_create_buffer(self):
|
||||
buffer = FileBuffer(
|
||||
path=Path("/test/file.py"),
|
||||
content="print('hello')",
|
||||
language="python",
|
||||
)
|
||||
assert buffer.path == Path("/test/file.py")
|
||||
assert buffer.content == "print('hello')"
|
||||
assert buffer.language == "python"
|
||||
assert buffer.is_modified is False
|
||||
|
||||
def test_buffer_display_name(self):
|
||||
buffer = FileBuffer(path=Path("/test/file.py"), content="")
|
||||
assert buffer.display_name == "file.py"
|
||||
|
||||
def test_buffer_modified_display_name(self):
|
||||
buffer = FileBuffer(path=Path("/test/file.py"), content="", is_modified=True)
|
||||
assert buffer.display_name == "● file.py"
|
||||
|
||||
def test_buffer_filename(self):
|
||||
buffer = FileBuffer(path=Path("/some/deep/path/script.js"), content="")
|
||||
assert buffer.filename == "script.js"
|
||||
|
||||
|
||||
class TestEditorState:
|
||||
"""Tests for EditorState model."""
|
||||
|
||||
def test_empty_state(self):
|
||||
state = EditorState()
|
||||
assert state.buffers == []
|
||||
assert state.active_buffer_index is None
|
||||
assert state.active_buffer is None
|
||||
|
||||
def test_active_buffer(self):
|
||||
buffer1 = FileBuffer(path=Path("/a.py"), content="a")
|
||||
buffer2 = FileBuffer(path=Path("/b.py"), content="b")
|
||||
state = EditorState(buffers=[buffer1, buffer2], active_buffer_index=1)
|
||||
assert state.active_buffer == buffer2
|
||||
|
||||
def test_get_buffer_by_path(self):
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="test")
|
||||
state = EditorState(buffers=[buffer])
|
||||
assert state.get_buffer_by_path(Path("/test.py")) == buffer
|
||||
assert state.get_buffer_by_path(Path("/other.py")) is None
|
||||
|
||||
|
||||
class TestGitChange:
|
||||
"""Tests for GitChange model."""
|
||||
|
||||
def test_create_change(self):
|
||||
from clide.models.git import ChangeStatus
|
||||
change = GitChange(path="src/main.py", status=ChangeStatus.MODIFIED, staged=True)
|
||||
assert change.path == "src/main.py"
|
||||
assert change.status == ChangeStatus.MODIFIED
|
||||
assert change.staged is True
|
||||
|
||||
def test_change_is_frozen(self):
|
||||
from clide.models.git import ChangeStatus
|
||||
change = GitChange(path="f", status=ChangeStatus.ADDED, staged=False)
|
||||
with pytest.raises(ValidationError):
|
||||
change.path = "new.py"
|
||||
|
||||
|
||||
class TestGitStatus:
|
||||
"""Tests for GitStatus model."""
|
||||
|
||||
def test_create_status(self):
|
||||
staged = (GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True),)
|
||||
unstaged = (GitChange(path="b.py", status=ChangeStatus.MODIFIED, staged=False),)
|
||||
status = GitStatus(branch="main", staged=staged, unstaged=unstaged)
|
||||
assert status.branch == "main"
|
||||
assert len(status.staged) == 1
|
||||
assert len(status.unstaged) == 1
|
||||
|
||||
def test_empty_status(self):
|
||||
status = GitStatus(branch="main", staged=(), unstaged=())
|
||||
assert status.branch == "main"
|
||||
assert len(status.staged) == 0
|
||||
assert len(status.unstaged) == 0
|
||||
|
||||
|
||||
class TestGitBranch:
|
||||
"""Tests for GitBranch model."""
|
||||
|
||||
def test_create_branch(self):
|
||||
branch = GitBranch(name="feature/test", is_current=True, is_remote=False)
|
||||
assert branch.name == "feature/test"
|
||||
assert branch.is_current is True
|
||||
assert branch.is_remote is False
|
||||
|
||||
|
||||
class TestGitCommit:
|
||||
"""Tests for GitCommit model."""
|
||||
|
||||
def test_create_commit(self):
|
||||
commit = GitCommit(
|
||||
hash="abc123def456789",
|
||||
short_hash="abc123",
|
||||
message="Test commit",
|
||||
author="Test Author",
|
||||
date="2024-01-01",
|
||||
)
|
||||
assert commit.hash == "abc123def456789"
|
||||
assert commit.short_hash == "abc123"
|
||||
assert commit.message == "Test commit"
|
||||
|
||||
|
||||
class TestDiffLine:
|
||||
"""Tests for DiffLine model."""
|
||||
|
||||
def test_added_line(self):
|
||||
line = DiffLine(change_type=ChangeType.ADDED, content="new line", new_line_num=10)
|
||||
assert line.change_type == ChangeType.ADDED
|
||||
assert line.content == "new line"
|
||||
|
||||
def test_removed_line(self):
|
||||
line = DiffLine(change_type=ChangeType.REMOVED, content="old line", old_line_num=5)
|
||||
assert line.change_type == ChangeType.REMOVED
|
||||
|
||||
def test_context_line(self):
|
||||
line = DiffLine(
|
||||
change_type=ChangeType.CONTEXT,
|
||||
content="unchanged",
|
||||
old_line_num=5,
|
||||
new_line_num=5,
|
||||
)
|
||||
assert line.change_type == ChangeType.CONTEXT
|
||||
|
||||
|
||||
class TestDiffHunk:
|
||||
"""Tests for DiffHunk model."""
|
||||
|
||||
def test_create_hunk(self):
|
||||
lines = (
|
||||
DiffLine(change_type=ChangeType.REMOVED, content="old", old_line_num=1),
|
||||
DiffLine(change_type=ChangeType.ADDED, content="new", new_line_num=1),
|
||||
)
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1,1 +1,1 @@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=lines,
|
||||
)
|
||||
assert len(hunk.lines) == 2
|
||||
|
||||
|
||||
class TestDiffContent:
|
||||
"""Tests for DiffContent model."""
|
||||
|
||||
def test_create_diff(self):
|
||||
hunk = DiffHunk(
|
||||
header="@@ -1 +1 @@",
|
||||
old_start=1,
|
||||
old_count=1,
|
||||
new_start=1,
|
||||
new_count=1,
|
||||
lines=(),
|
||||
)
|
||||
diff = DiffContent(file_path="test.py", hunks=(hunk,))
|
||||
assert diff.file_path == "test.py"
|
||||
assert len(diff.hunks) == 1
|
||||
|
||||
|
||||
class TestProblem:
|
||||
"""Tests for Problem model."""
|
||||
|
||||
def test_create_problem(self):
|
||||
problem = Problem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=5,
|
||||
severity=Severity.ERROR,
|
||||
message="Syntax error",
|
||||
source="ruff",
|
||||
code="E999",
|
||||
)
|
||||
assert problem.line == 10
|
||||
assert problem.severity == Severity.ERROR
|
||||
|
||||
def test_severity_icon(self):
|
||||
error = Problem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="err",
|
||||
source="test",
|
||||
)
|
||||
assert error.severity_icon == "✖"
|
||||
|
||||
warning = Problem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.WARNING,
|
||||
message="warn",
|
||||
source="test",
|
||||
)
|
||||
assert warning.severity_icon == "⚠"
|
||||
|
||||
|
||||
class TestProblemsSummary:
|
||||
"""Tests for ProblemsSummary model."""
|
||||
|
||||
def test_create_summary(self):
|
||||
summary = ProblemsSummary(errors=5, warnings=3, infos=1, hints=0)
|
||||
assert summary.total == 9
|
||||
|
||||
|
||||
class TestTodoItem:
|
||||
"""Tests for TodoItem model."""
|
||||
|
||||
def test_create_todo(self):
|
||||
todo = TodoItem(
|
||||
file_path=Path("/src/main.py"),
|
||||
line=42,
|
||||
column=5,
|
||||
todo_type=TodoType.TODO,
|
||||
text="Implement this feature",
|
||||
context_line="# TODO: Implement this feature",
|
||||
)
|
||||
assert todo.line == 42
|
||||
assert todo.todo_type == TodoType.TODO
|
||||
|
||||
def test_type_icon(self):
|
||||
todo = TodoItem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="todo",
|
||||
context_line="# TODO: todo",
|
||||
)
|
||||
assert todo.type_icon == "☐"
|
||||
|
||||
fixme = TodoItem(
|
||||
file_path=Path("/t.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
todo_type=TodoType.FIXME,
|
||||
text="fixme",
|
||||
context_line="# FIXME: fixme",
|
||||
)
|
||||
assert fixme.type_icon == "🔧"
|
||||
|
||||
|
||||
class TestTodosSummary:
|
||||
"""Tests for TodosSummary model."""
|
||||
|
||||
def test_create_summary(self):
|
||||
summary = TodosSummary(
|
||||
todo_count=5,
|
||||
fixme_count=3,
|
||||
hack_count=2,
|
||||
other_count=0,
|
||||
)
|
||||
assert summary.total == 10
|
||||
assert summary.todo_count == 5
|
||||
|
||||
|
||||
class TestThemeColors:
|
||||
"""Tests for ThemeColors model."""
|
||||
|
||||
def test_valid_colors(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
assert colors.primary == "#00a3d2"
|
||||
|
||||
def test_invalid_color_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
ThemeColors(
|
||||
primary="invalid",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
|
||||
def test_color_normalized_to_lowercase(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00A3D2",
|
||||
secondary="#00A9B9",
|
||||
accent="#FA5F8B",
|
||||
background="#21262F",
|
||||
surface="#393E48",
|
||||
panel="#292E38",
|
||||
foreground="#E2E8F5",
|
||||
success="#00AB9A",
|
||||
warning="#D08447",
|
||||
error="#F06C6F",
|
||||
)
|
||||
assert colors.primary == "#00a3d2"
|
||||
|
||||
|
||||
class TestThemeDefinition:
|
||||
"""Tests for ThemeDefinition model."""
|
||||
|
||||
def test_create_theme(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
theme = ThemeDefinition(
|
||||
name="test-theme",
|
||||
display_name="Test Theme",
|
||||
dark=True,
|
||||
colors=colors,
|
||||
)
|
||||
assert theme.name == "test-theme"
|
||||
assert theme.dark is True
|
||||
|
||||
def test_to_textual_theme(self):
|
||||
colors = ThemeColors(
|
||||
primary="#00a3d2",
|
||||
secondary="#00a9b9",
|
||||
accent="#fa5f8b",
|
||||
background="#21262f",
|
||||
surface="#393e48",
|
||||
panel="#292e38",
|
||||
foreground="#e2e8f5",
|
||||
success="#00ab9a",
|
||||
warning="#d08447",
|
||||
error="#f06c6f",
|
||||
)
|
||||
theme_def = ThemeDefinition(
|
||||
name="test",
|
||||
display_name="Test",
|
||||
dark=True,
|
||||
colors=colors,
|
||||
)
|
||||
textual_theme = theme_def.to_textual_theme()
|
||||
assert textual_theme.name == "test"
|
||||
|
||||
|
||||
class TestThemeMetadata:
|
||||
"""Tests for ThemeMetadata model."""
|
||||
|
||||
def test_create_metadata(self):
|
||||
meta = ThemeMetadata(
|
||||
name="summer-night",
|
||||
display_name="Summer Night",
|
||||
dark=True,
|
||||
category="core",
|
||||
)
|
||||
assert meta.name == "summer-night"
|
||||
assert meta.category == "core"
|
||||
|
||||
|
||||
class TestClideSettings:
|
||||
"""Tests for ClideSettings model."""
|
||||
|
||||
def test_default_settings(self):
|
||||
settings = ClideSettings()
|
||||
assert settings.theme == "summer-night"
|
||||
assert settings.jira_enabled is False
|
||||
|
||||
def test_custom_settings(self):
|
||||
settings = ClideSettings(theme="dracula", jira_enabled=True)
|
||||
assert settings.theme == "dracula"
|
||||
assert settings.jira_enabled is True
|
||||
|
||||
|
||||
class TestPanelConfig:
|
||||
"""Tests for PanelConfig model."""
|
||||
|
||||
def test_default_panel_config(self):
|
||||
config = PanelConfig()
|
||||
assert config.sidebar_visible is True
|
||||
assert config.context_visible is True
|
||||
assert config.workspace_visible is False
|
||||
|
||||
|
||||
class TestKeybindingsConfig:
|
||||
"""Tests for KeybindingsConfig model."""
|
||||
|
||||
def test_default_keybindings(self):
|
||||
config = KeybindingsConfig()
|
||||
assert config.toggle_sidebar == "ctrl+b"
|
||||
assert config.toggle_terminal == "ctrl+`"
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Tests for panel widgets."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.models.diff import DiffContent
|
||||
from clide.models.git import GitBranch, GitChange
|
||||
from clide.models.problems import Problem, Severity
|
||||
from clide.models.todos import TodoItem, TodoType
|
||||
|
||||
|
||||
class TestSidebarPanel:
|
||||
"""Tests for SidebarPanel."""
|
||||
|
||||
def test_initial_state(self, tmp_path: Path):
|
||||
"""Test initial state."""
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
|
||||
panel = SidebarPanel(workdir=tmp_path)
|
||||
assert panel._workdir == tmp_path
|
||||
assert panel.current_branch == "main"
|
||||
assert panel.visible is True
|
||||
assert panel.id == "panel-sidebar"
|
||||
|
||||
def test_default_workdir(self):
|
||||
"""Test default workdir is cwd."""
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
|
||||
panel = SidebarPanel()
|
||||
assert panel._workdir == Path.cwd()
|
||||
|
||||
def test_file_selected_message(self):
|
||||
"""Test FileSelected message."""
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
|
||||
msg = SidebarPanel.FileSelected(Path("/test/file.py"))
|
||||
assert msg.path == Path("/test/file.py")
|
||||
|
||||
def test_git_file_selected_message(self):
|
||||
"""Test GitFileSelected message."""
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
|
||||
msg = SidebarPanel.GitFileSelected(Path("/test/file.py"), staged=True)
|
||||
assert msg.path == Path("/test/file.py")
|
||||
assert msg.staged is True
|
||||
|
||||
def test_branch_changed_message(self):
|
||||
"""Test BranchChanged message."""
|
||||
from clide.widgets.panels.sidebar import SidebarPanel
|
||||
|
||||
msg = SidebarPanel.BranchChanged("develop")
|
||||
assert msg.branch == "develop"
|
||||
|
||||
|
||||
class TestWorkspacePanel:
|
||||
"""Tests for WorkspacePanel."""
|
||||
|
||||
def test_initial_state(self, tmp_path: Path):
|
||||
"""Test initial state."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
panel = WorkspacePanel(workdir=tmp_path)
|
||||
assert panel._workdir == tmp_path
|
||||
assert panel.visible is False
|
||||
assert panel.active_tab == "editor"
|
||||
assert panel.id == "panel-workspace"
|
||||
|
||||
def test_default_workdir(self):
|
||||
"""Test default workdir is cwd."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
panel = WorkspacePanel()
|
||||
assert panel._workdir == Path.cwd()
|
||||
|
||||
def test_show_hide_toggle(self):
|
||||
"""Test show/hide/toggle methods."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
panel = WorkspacePanel()
|
||||
assert panel.visible is False
|
||||
|
||||
panel.show()
|
||||
assert panel.visible is True
|
||||
|
||||
panel.hide()
|
||||
assert panel.visible is False
|
||||
|
||||
panel.toggle()
|
||||
assert panel.visible is True
|
||||
|
||||
panel.toggle()
|
||||
assert panel.visible is False
|
||||
|
||||
def test_file_saved_message(self):
|
||||
"""Test FileSaved message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
msg = WorkspacePanel.FileSaved(Path("/test/file.py"))
|
||||
assert msg.path == Path("/test/file.py")
|
||||
|
||||
def test_diff_accepted_message(self):
|
||||
"""Test DiffAccepted message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
msg = WorkspacePanel.DiffAccepted("test.py")
|
||||
assert msg.file_path == "test.py"
|
||||
|
||||
def test_diff_rejected_message(self):
|
||||
"""Test DiffRejected message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
msg = WorkspacePanel.DiffRejected("test.py")
|
||||
assert msg.file_path == "test.py"
|
||||
|
||||
def test_command_submitted_message(self):
|
||||
"""Test CommandSubmitted message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
msg = WorkspacePanel.CommandSubmitted("ls -la")
|
||||
assert msg.command == "ls -la"
|
||||
|
||||
def test_close_requested_message(self):
|
||||
"""Test CloseRequested message."""
|
||||
from clide.widgets.panels.workspace import WorkspacePanel
|
||||
|
||||
msg = WorkspacePanel.CloseRequested()
|
||||
assert msg is not None
|
||||
|
||||
|
||||
class TestClaudePanel:
|
||||
"""Tests for ClaudePanel."""
|
||||
|
||||
def test_initial_state(self, tmp_path: Path):
|
||||
"""Test initial state."""
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
|
||||
panel = ClaudePanel(workdir=tmp_path, auto_start=False)
|
||||
assert panel.id == "panel-claude"
|
||||
assert panel.workspace_visible is False
|
||||
assert panel._workdir == tmp_path
|
||||
assert panel._auto_start is False
|
||||
assert panel._restart_on_exit is True
|
||||
|
||||
def test_default_workdir(self):
|
||||
"""Test default workdir is cwd."""
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
|
||||
panel = ClaudePanel(auto_start=False)
|
||||
assert panel._workdir == Path.cwd()
|
||||
|
||||
def test_claude_exited_message(self):
|
||||
"""Test ClaudeExited message."""
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
|
||||
msg = ClaudePanel.ClaudeExited(0)
|
||||
assert msg.return_code == 0
|
||||
|
||||
def test_claude_started_message(self):
|
||||
"""Test ClaudeStarted message."""
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
|
||||
msg = ClaudePanel.ClaudeStarted()
|
||||
assert msg is not None
|
||||
|
||||
def test_workdir_property(self, tmp_path: Path):
|
||||
"""Test workdir property."""
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
|
||||
panel = ClaudePanel(auto_start=False)
|
||||
panel.workdir = tmp_path
|
||||
assert panel.workdir == tmp_path
|
||||
|
||||
def test_set_restart_on_exit(self):
|
||||
"""Test set_restart_on_exit method."""
|
||||
from clide.widgets.panels.claude import ClaudePanel
|
||||
|
||||
panel = ClaudePanel(auto_start=False)
|
||||
assert panel._restart_on_exit is True
|
||||
|
||||
panel.set_restart_on_exit(False)
|
||||
assert panel._restart_on_exit is False
|
||||
|
||||
|
||||
class TestContextPanel:
|
||||
"""Tests for ContextPanel."""
|
||||
|
||||
def test_initial_state_enabled(self):
|
||||
"""Test initial state with Jira enabled."""
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
|
||||
panel = ContextPanel(jira_enabled=True)
|
||||
assert panel._jira_enabled is True
|
||||
assert panel.problem_count == 0
|
||||
assert panel.todo_count == 0
|
||||
assert panel.visible is True
|
||||
assert panel.id == "panel-context"
|
||||
|
||||
def test_initial_state_disabled(self):
|
||||
"""Test initial state with Jira disabled."""
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
|
||||
panel = ContextPanel(jira_enabled=False)
|
||||
assert panel._jira_enabled is False
|
||||
|
||||
def test_problem_clicked_message(self):
|
||||
"""Test ProblemClicked message."""
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
|
||||
problem = Problem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=5,
|
||||
severity=Severity.ERROR,
|
||||
message="Test error",
|
||||
source="ruff",
|
||||
)
|
||||
msg = ContextPanel.ProblemClicked(problem)
|
||||
assert msg.problem == problem
|
||||
|
||||
def test_todo_clicked_message(self):
|
||||
"""Test TodoClicked message."""
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
|
||||
item = TodoItem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="Test todo",
|
||||
context_line="# TODO: Test todo",
|
||||
)
|
||||
msg = ContextPanel.TodoClicked(item)
|
||||
assert msg.item == item
|
||||
|
||||
def test_jira_refresh_requested_message(self):
|
||||
"""Test JiraRefreshRequested message."""
|
||||
from clide.widgets.panels.context import ContextPanel
|
||||
|
||||
msg = ContextPanel.JiraRefreshRequested()
|
||||
assert msg is not None
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Tests for service classes."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.models.git import GitBranch, GitChange, GitCommit, GitStatus
|
||||
from clide.models.problems import Problem, Severity
|
||||
from clide.models.todos import TodoItem, TodoType
|
||||
from clide.services.file_service import FileService
|
||||
from clide.services.git_service import GitService
|
||||
from clide.services.linter_service import LinterService
|
||||
from clide.services.process_service import CommandResult, ProcessService
|
||||
from clide.services.todo_scanner import TodoScanner
|
||||
|
||||
|
||||
class TestProcessService:
|
||||
"""Tests for ProcessService."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, tmp_path: Path) -> ProcessService:
|
||||
return ProcessService(cwd=tmp_path)
|
||||
|
||||
def test_run_sync_success(self, service: ProcessService):
|
||||
result = service.run_sync("echo", "hello")
|
||||
assert result.success is True
|
||||
assert "hello" in result.stdout
|
||||
|
||||
def test_run_sync_failure(self, service: ProcessService):
|
||||
result = service.run_sync("false") # Unix command that always fails
|
||||
assert result.success is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_success(self, service: ProcessService):
|
||||
result = await service.run("echo", "world")
|
||||
assert result.success is True
|
||||
assert "world" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_with_timeout(self, service: ProcessService):
|
||||
# This should complete quickly
|
||||
result = await service.run("echo", "fast", timeout=5.0)
|
||||
assert result.success is True
|
||||
|
||||
def test_command_result(self):
|
||||
result = CommandResult(
|
||||
returncode=0,
|
||||
stdout="output",
|
||||
stderr="",
|
||||
)
|
||||
assert result.success is True
|
||||
assert result.stdout == "output"
|
||||
|
||||
def test_command_result_failure(self):
|
||||
result = CommandResult(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="error",
|
||||
)
|
||||
assert result.success is False
|
||||
|
||||
|
||||
class TestFileService:
|
||||
"""Tests for FileService."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file(self, tmp_path: Path):
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_file.write_text("Hello, World!")
|
||||
|
||||
service = FileService(tmp_path)
|
||||
content = await service.read_file(test_file)
|
||||
assert content == "Hello, World!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_not_found(self, tmp_path: Path):
|
||||
service = FileService(tmp_path)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await service.read_file(tmp_path / "nonexistent.txt")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_file(self, tmp_path: Path):
|
||||
test_file = tmp_path / "output.txt"
|
||||
service = FileService(tmp_path)
|
||||
await service.write_file(test_file, "Test content")
|
||||
assert test_file.read_text() == "Test content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_language_python(self, tmp_path: Path):
|
||||
service = FileService(tmp_path)
|
||||
assert await service.get_language(Path("test.py")) == "python"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_language_javascript(self, tmp_path: Path):
|
||||
service = FileService(tmp_path)
|
||||
assert await service.get_language(Path("app.js")) == "javascript"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_language_typescript(self, tmp_path: Path):
|
||||
service = FileService(tmp_path)
|
||||
assert await service.get_language(Path("component.tsx")) == "tsx"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_language_unknown(self, tmp_path: Path):
|
||||
service = FileService(tmp_path)
|
||||
assert await service.get_language(Path("file.xyz")) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_language_markdown(self, tmp_path: Path):
|
||||
service = FileService(tmp_path)
|
||||
assert await service.get_language(Path("README.md")) == "markdown"
|
||||
|
||||
|
||||
class TestGitService:
|
||||
"""Tests for GitService."""
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(self, tmp_path: Path) -> Path:
|
||||
"""Create a minimal git repo for testing."""
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main")
|
||||
(git_dir / "config").write_text("")
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, git_repo: Path) -> GitService:
|
||||
return GitService(git_repo)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status(self, service: GitService):
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
async def mock_status(*args, **kwargs):
|
||||
if "status" in args:
|
||||
return CommandResult(returncode=0, stdout="", stderr="")
|
||||
elif "branch" in args:
|
||||
return CommandResult(returncode=0, stdout="main\n", stderr="")
|
||||
return CommandResult(returncode=0, stdout="0\t0", stderr="")
|
||||
mock_run.side_effect = mock_status
|
||||
status = await service.get_status()
|
||||
assert status is not None
|
||||
assert status.branch == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branches(self, service: GitService):
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
returncode=0,
|
||||
stdout="*main|origin/main|abc1234|Test\n feature|origin/feature|def5678|Test2\n",
|
||||
stderr="",
|
||||
)
|
||||
branches = await service.get_branches()
|
||||
assert len(branches) == 2
|
||||
assert branches[0].name == "main"
|
||||
assert branches[0].is_current is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_log(self, service: GitService):
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
returncode=0,
|
||||
stdout="abc1234567890|abc1234|Test commit|Author|2024-01-01||HEAD -> main\n",
|
||||
stderr="",
|
||||
)
|
||||
commits = await service.get_log(max_count=10)
|
||||
assert len(commits) == 1
|
||||
assert commits[0].message == "Test commit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_file(self, service: GitService):
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
result = await service.stage_file("test.py")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unstage_file(self, service: GitService):
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
result = await service.unstage_file("test.py")
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestLinterService:
|
||||
"""Tests for LinterService."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, tmp_path: Path) -> LinterService:
|
||||
return LinterService(tmp_path)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_ruff_no_issues(self, service: LinterService):
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
returncode=0,
|
||||
stdout="[]",
|
||||
stderr="",
|
||||
)
|
||||
problems = await service.run_ruff()
|
||||
assert problems == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_ruff_with_issues(self, service: LinterService):
|
||||
ruff_output = """[
|
||||
{
|
||||
"filename": "/test/file.py",
|
||||
"location": {"row": 10, "column": 5},
|
||||
"code": "E501",
|
||||
"message": "Line too long"
|
||||
}
|
||||
]"""
|
||||
with patch.object(service._process, "run") as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
returncode=1,
|
||||
stdout=ruff_output,
|
||||
stderr="",
|
||||
)
|
||||
problems = await service.run_ruff()
|
||||
assert len(problems) == 1
|
||||
assert problems[0].code == "E501"
|
||||
|
||||
|
||||
class TestTodoScanner:
|
||||
"""Tests for TodoScanner."""
|
||||
|
||||
@pytest.fixture
|
||||
def scanner(self, tmp_path: Path) -> TodoScanner:
|
||||
return TodoScanner(tmp_path)
|
||||
|
||||
@pytest.fixture
|
||||
def project_with_todos(self, tmp_path: Path) -> Path:
|
||||
"""Create a project with TODO comments."""
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
|
||||
(src / "main.py").write_text("""
|
||||
# TODO: Implement feature
|
||||
def main():
|
||||
pass # FIXME: Handle errors
|
||||
|
||||
# HACK: Temporary workaround
|
||||
""")
|
||||
return tmp_path
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_finds_todos(self, project_with_todos: Path):
|
||||
scanner = TodoScanner(project_with_todos)
|
||||
items, summary = await scanner.scan()
|
||||
# Should find TODO, FIXME, and HACK
|
||||
assert len(items) >= 1
|
||||
assert summary.total >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_empty_project(self, tmp_path: Path):
|
||||
scanner = TodoScanner(tmp_path)
|
||||
items, summary = await scanner.scan()
|
||||
assert items == []
|
||||
assert summary.total == 0
|
||||
|
||||
def test_parse_ripgrep_output_todo(self, scanner: TodoScanner):
|
||||
output = "test.py:10:# TODO: Fix this"
|
||||
items = scanner._parse_ripgrep_output(output)
|
||||
assert len(items) == 1
|
||||
assert items[0].todo_type == TodoType.TODO
|
||||
assert "Fix this" in items[0].text
|
||||
|
||||
def test_parse_ripgrep_output_fixme(self, scanner: TodoScanner):
|
||||
output = "test.py:20:// FIXME: Broken code"
|
||||
items = scanner._parse_ripgrep_output(output)
|
||||
assert len(items) == 1
|
||||
assert items[0].todo_type == TodoType.FIXME
|
||||
|
||||
def test_parse_ripgrep_output_hack(self, scanner: TodoScanner):
|
||||
output = "test.py:30:/* HACK: Workaround */"
|
||||
items = scanner._parse_ripgrep_output(output)
|
||||
assert len(items) == 1
|
||||
assert items[0].todo_type == TodoType.HACK
|
||||
@@ -0,0 +1,645 @@
|
||||
"""Tests for widget components."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from clide.models.diff import ChangeType, DiffContent, DiffHunk, DiffLine
|
||||
from clide.models.editor import CursorPosition, FileBuffer
|
||||
from clide.models.git import ChangeStatus, GitBranch, GitChange, GitCommit
|
||||
from clide.models.problems import Problem, Severity
|
||||
from clide.models.todos import TodoItem, TodoType
|
||||
|
||||
|
||||
class TestFilesView:
|
||||
"""Tests for FilesView component."""
|
||||
|
||||
def test_filter_paths_excludes_hidden(self, tmp_path: Path):
|
||||
"""Test that hidden files are filtered out."""
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
|
||||
view = FilesView(path=tmp_path)
|
||||
paths = [
|
||||
tmp_path / "visible.py",
|
||||
tmp_path / ".hidden",
|
||||
tmp_path / ".git",
|
||||
tmp_path / "__pycache__",
|
||||
tmp_path / "node_modules",
|
||||
tmp_path / ".venv",
|
||||
tmp_path / "src",
|
||||
]
|
||||
filtered = view.filter_paths(paths)
|
||||
|
||||
assert tmp_path / "visible.py" in filtered
|
||||
assert tmp_path / "src" in filtered
|
||||
assert tmp_path / ".hidden" not in filtered
|
||||
assert tmp_path / ".git" not in filtered
|
||||
assert tmp_path / "__pycache__" not in filtered
|
||||
assert tmp_path / "node_modules" not in filtered
|
||||
assert tmp_path / ".venv" not in filtered
|
||||
|
||||
def test_file_selected_message(self):
|
||||
"""Test FileSelected message."""
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
|
||||
mock_node = MagicMock()
|
||||
msg = FilesView.FileSelected(mock_node, Path("/test/file.py"))
|
||||
assert msg.path == Path("/test/file.py")
|
||||
assert msg.node == mock_node
|
||||
|
||||
def test_directory_selected_message(self):
|
||||
"""Test DirectorySelected message."""
|
||||
from clide.widgets.components.files_view import FilesView
|
||||
|
||||
mock_node = MagicMock()
|
||||
msg = FilesView.DirectorySelected(mock_node, Path("/test/dir"))
|
||||
assert msg.path == Path("/test/dir")
|
||||
assert msg.node == mock_node
|
||||
|
||||
|
||||
class TestGitChangesView:
|
||||
"""Tests for GitChangesView component."""
|
||||
|
||||
def test_initial_state(self):
|
||||
"""Test initial state with no changes."""
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
|
||||
view = GitChangesView()
|
||||
assert view._staged == []
|
||||
assert view._unstaged == []
|
||||
|
||||
def test_initial_state_with_changes(self):
|
||||
"""Test initial state with provided changes."""
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
|
||||
staged = [GitChange(path="a.py", status=ChangeStatus.ADDED, staged=True)]
|
||||
unstaged = [GitChange(path="b.py", status=ChangeStatus.MODIFIED, staged=False)]
|
||||
|
||||
view = GitChangesView(staged=staged, unstaged=unstaged)
|
||||
assert len(view._staged) == 1
|
||||
assert len(view._unstaged) == 1
|
||||
|
||||
def test_file_clicked_message(self):
|
||||
"""Test FileClicked message."""
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
|
||||
change = GitChange(path="test.py", status=ChangeStatus.MODIFIED, staged=False)
|
||||
msg = GitChangesView.FileClicked(change)
|
||||
assert msg.change == change
|
||||
|
||||
def test_stage_requested_message(self):
|
||||
"""Test StageRequested message."""
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
|
||||
msg = GitChangesView.StageRequested("test.py")
|
||||
assert msg.path == "test.py"
|
||||
|
||||
def test_unstage_requested_message(self):
|
||||
"""Test UnstageRequested message."""
|
||||
from clide.widgets.components.git_changes import GitChangesView
|
||||
|
||||
msg = GitChangesView.UnstageRequested("test.py")
|
||||
assert msg.path == "test.py"
|
||||
|
||||
|
||||
class TestGitChangeItem:
|
||||
"""Tests for GitChangeItem component."""
|
||||
|
||||
def test_status_icons(self):
|
||||
"""Test status icon mapping."""
|
||||
from clide.widgets.components.git_changes import GitChangeItem
|
||||
|
||||
assert GitChangeItem.STATUS_ICONS[ChangeStatus.ADDED] == "+"
|
||||
assert GitChangeItem.STATUS_ICONS[ChangeStatus.MODIFIED] == "~"
|
||||
assert GitChangeItem.STATUS_ICONS[ChangeStatus.DELETED] == "-"
|
||||
assert GitChangeItem.STATUS_ICONS[ChangeStatus.RENAMED] == "→"
|
||||
assert GitChangeItem.STATUS_ICONS[ChangeStatus.UNTRACKED] == "?"
|
||||
|
||||
def test_create_item(self):
|
||||
"""Test creating a GitChangeItem."""
|
||||
from clide.widgets.components.git_changes import GitChangeItem
|
||||
|
||||
change = GitChange(path="test.py", status=ChangeStatus.ADDED, staged=True)
|
||||
item = GitChangeItem(change)
|
||||
assert item.change == change
|
||||
|
||||
|
||||
class TestGitGraphView:
|
||||
"""Tests for GitGraphView component."""
|
||||
|
||||
def test_initial_state(self):
|
||||
"""Test initial state with no commits."""
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
view = GitGraphView()
|
||||
assert view._commits == []
|
||||
|
||||
def test_initial_state_with_commits(self):
|
||||
"""Test initial state with provided commits."""
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
commits = [
|
||||
GitCommit(
|
||||
hash="abc123def456789",
|
||||
short_hash="abc123",
|
||||
message="Test commit",
|
||||
author="Author",
|
||||
date="2024-01-01",
|
||||
)
|
||||
]
|
||||
view = GitGraphView(commits=commits)
|
||||
assert len(view._commits) == 1
|
||||
|
||||
def test_graph_symbols(self):
|
||||
"""Test graph drawing symbols."""
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
assert GitGraphView.COMMIT == "●"
|
||||
assert GitGraphView.MERGE == "◆"
|
||||
assert GitGraphView.LINE == "│"
|
||||
assert GitGraphView.BRANCH == "├"
|
||||
assert GitGraphView.JOIN == "┴"
|
||||
|
||||
def test_commit_selected_message(self):
|
||||
"""Test CommitSelected message."""
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
commit = GitCommit(
|
||||
hash="abc123def456",
|
||||
short_hash="abc",
|
||||
message="Test",
|
||||
author="A",
|
||||
date="2024-01-01",
|
||||
)
|
||||
msg = GitGraphView.CommitSelected(commit)
|
||||
assert msg.commit == commit
|
||||
|
||||
def test_format_commit_line(self):
|
||||
"""Test commit line formatting."""
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
view = GitGraphView()
|
||||
commit = GitCommit(
|
||||
hash="abc123def456789",
|
||||
short_hash="abc123",
|
||||
message="Test commit message",
|
||||
author="Author",
|
||||
date="2024-01-01",
|
||||
is_merge=False,
|
||||
refs=(),
|
||||
)
|
||||
line = view._format_commit_line(commit)
|
||||
assert "abc123" in line
|
||||
assert "Test commit message" in line
|
||||
assert "Author" in line
|
||||
|
||||
def test_format_merge_commit_line(self):
|
||||
"""Test merge commit line formatting."""
|
||||
from clide.widgets.components.git_graph import GitGraphView
|
||||
|
||||
view = GitGraphView()
|
||||
commit = GitCommit(
|
||||
hash="abc123def456789",
|
||||
short_hash="abc123",
|
||||
message="Merge branch",
|
||||
author="Author",
|
||||
date="2024-01-01",
|
||||
is_merge=True,
|
||||
refs=("main", "HEAD"),
|
||||
)
|
||||
line = view._format_commit_line(commit)
|
||||
assert "◆" in line # Merge symbol
|
||||
assert "main" in line
|
||||
assert "HEAD" in line
|
||||
|
||||
|
||||
class TestBranchStatus:
|
||||
"""Tests for BranchStatus component."""
|
||||
|
||||
def test_initial_state(self):
|
||||
"""Test initial state."""
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
|
||||
status = BranchStatus()
|
||||
assert status._current == "main"
|
||||
assert status._branches == []
|
||||
assert status._popout_visible is False
|
||||
|
||||
def test_initial_state_with_branch(self):
|
||||
"""Test initial state with custom branch."""
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
|
||||
status = BranchStatus(current_branch="develop")
|
||||
assert status._current == "develop"
|
||||
|
||||
def test_branch_property(self):
|
||||
"""Test branch property getter."""
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
|
||||
status = BranchStatus(current_branch="feature")
|
||||
assert status.branch == "feature"
|
||||
|
||||
def test_branch_changed_message(self):
|
||||
"""Test BranchChanged message."""
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
|
||||
msg = BranchStatus.BranchChanged("develop")
|
||||
assert msg.branch == "develop"
|
||||
|
||||
def test_branch_change_requested_alias(self):
|
||||
"""Test BranchChangeRequested is alias for BranchChanged."""
|
||||
from clide.widgets.components.branch_status import BranchStatus
|
||||
|
||||
assert BranchStatus.BranchChangeRequested is BranchStatus.BranchChanged
|
||||
|
||||
|
||||
class TestEditorPane:
|
||||
"""Tests for EditorPane component."""
|
||||
|
||||
def test_initial_state_no_buffer(self):
|
||||
"""Test initial state without buffer."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
pane = EditorPane()
|
||||
assert pane._buffer is None
|
||||
assert pane.current_file is None
|
||||
assert pane.modified is False
|
||||
|
||||
def test_initial_state_with_buffer(self):
|
||||
"""Test initial state with buffer."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="print('hello')")
|
||||
pane = EditorPane(buffer=buffer)
|
||||
assert pane._buffer == buffer
|
||||
assert pane.current_file == Path("/test.py")
|
||||
|
||||
def test_status_text_no_buffer(self):
|
||||
"""Test status text with no buffer."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
pane = EditorPane()
|
||||
assert pane._get_status_text() == ""
|
||||
|
||||
def test_status_text_with_buffer(self):
|
||||
"""Test status text with buffer."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
buffer = FileBuffer(
|
||||
path=Path("/test.py"),
|
||||
content="print('hello')",
|
||||
language="python",
|
||||
)
|
||||
pane = EditorPane(buffer=buffer)
|
||||
status = pane._get_status_text()
|
||||
assert "Ln" in status
|
||||
assert "Col" in status
|
||||
assert "python" in status
|
||||
|
||||
def test_content_changed_message(self):
|
||||
"""Test ContentChanged message."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
msg = EditorPane.ContentChanged(Path("/test.py"), "new content")
|
||||
assert msg.path == Path("/test.py")
|
||||
assert msg.content == "new content"
|
||||
|
||||
def test_cursor_moved_message(self):
|
||||
"""Test CursorMoved message."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
msg = EditorPane.CursorMoved(Path("/test.py"), 10, 5)
|
||||
assert msg.path == Path("/test.py")
|
||||
assert msg.line == 10
|
||||
assert msg.column == 5
|
||||
|
||||
def test_save_requested_message(self):
|
||||
"""Test SaveRequested message."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
msg = EditorPane.SaveRequested(Path("/test.py"))
|
||||
assert msg.path == Path("/test.py")
|
||||
|
||||
def test_file_saved_message(self):
|
||||
"""Test FileSaved message."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
msg = EditorPane.FileSaved(Path("/test.py"))
|
||||
assert msg.path == Path("/test.py")
|
||||
|
||||
def test_modified_property(self):
|
||||
"""Test modified property."""
|
||||
from clide.widgets.components.editor_pane import EditorPane
|
||||
|
||||
buffer = FileBuffer(path=Path("/test.py"), content="", is_modified=True)
|
||||
pane = EditorPane(buffer=buffer)
|
||||
assert pane.modified is True
|
||||
|
||||
buffer2 = FileBuffer(path=Path("/test2.py"), content="", is_modified=False)
|
||||
pane2 = EditorPane(buffer=buffer2)
|
||||
assert pane2.modified is False
|
||||
|
||||
|
||||
class TestDiffPane:
|
||||
"""Tests for DiffPane component."""
|
||||
|
||||
def test_initial_state_no_diff(self):
|
||||
"""Test initial state without diff."""
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
|
||||
pane = DiffPane()
|
||||
assert pane._diff is None
|
||||
assert pane._is_proposal is False
|
||||
|
||||
def test_initial_state_with_diff(self):
|
||||
"""Test initial state with diff."""
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
|
||||
diff = DiffContent(file_path="test.py", hunks=())
|
||||
pane = DiffPane(diff=diff, is_proposal=True)
|
||||
assert pane._diff == diff
|
||||
assert pane._is_proposal is True
|
||||
|
||||
def test_accept_clicked_message(self):
|
||||
"""Test AcceptClicked message."""
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
|
||||
msg = DiffPane.AcceptClicked("test.py")
|
||||
assert msg.file_path == "test.py"
|
||||
|
||||
def test_reject_clicked_message(self):
|
||||
"""Test RejectClicked message."""
|
||||
from clide.widgets.components.diff_pane import DiffPane
|
||||
|
||||
msg = DiffPane.RejectClicked("test.py")
|
||||
assert msg.file_path == "test.py"
|
||||
|
||||
|
||||
class TestTerminalPane:
|
||||
"""Tests for TerminalPane component."""
|
||||
|
||||
def test_initial_state(self, tmp_path: Path):
|
||||
"""Test initial state."""
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
pane = TerminalPane(cwd=tmp_path)
|
||||
assert pane.cwd == tmp_path
|
||||
assert pane._history == []
|
||||
assert pane._history_index == 0
|
||||
|
||||
def test_default_cwd(self):
|
||||
"""Test default cwd is current directory."""
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
pane = TerminalPane()
|
||||
assert pane.cwd == Path.cwd()
|
||||
|
||||
def test_cwd_property(self, tmp_path: Path):
|
||||
"""Test cwd property."""
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
pane = TerminalPane()
|
||||
pane._cwd = tmp_path
|
||||
assert pane.cwd == tmp_path
|
||||
|
||||
def test_command_submitted_message(self):
|
||||
"""Test CommandSubmitted message."""
|
||||
from clide.widgets.components.terminal_pane import TerminalPane
|
||||
|
||||
msg = TerminalPane.CommandSubmitted("ls -la")
|
||||
assert msg.command == "ls -la"
|
||||
|
||||
|
||||
class TestProblemsView:
|
||||
"""Tests for ProblemsView component."""
|
||||
|
||||
def test_initial_state_no_problems(self):
|
||||
"""Test initial state without problems."""
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
|
||||
view = ProblemsView()
|
||||
assert view._problems == []
|
||||
|
||||
def test_initial_state_with_problems(self):
|
||||
"""Test initial state with problems."""
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=5,
|
||||
severity=Severity.ERROR,
|
||||
message="Error",
|
||||
source="ruff",
|
||||
)
|
||||
]
|
||||
view = ProblemsView(problems=problems)
|
||||
assert len(view._problems) == 1
|
||||
|
||||
def test_filter_by_file(self):
|
||||
"""Test filtering problems by file."""
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
|
||||
problems = [
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="Error 1",
|
||||
source="ruff",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/b.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
severity=Severity.ERROR,
|
||||
message="Error 2",
|
||||
source="ruff",
|
||||
),
|
||||
Problem(
|
||||
file_path=Path("/a.py"),
|
||||
line=5,
|
||||
column=1,
|
||||
severity=Severity.WARNING,
|
||||
message="Warning 1",
|
||||
source="ruff",
|
||||
),
|
||||
]
|
||||
view = ProblemsView(problems=problems)
|
||||
filtered = view.filter_by_file(Path("/a.py"))
|
||||
assert len(filtered) == 2
|
||||
|
||||
def test_problem_clicked_message(self):
|
||||
"""Test ProblemClicked message."""
|
||||
from clide.widgets.components.problems_view import ProblemsView
|
||||
|
||||
problem = Problem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=5,
|
||||
severity=Severity.ERROR,
|
||||
message="Test error",
|
||||
source="ruff",
|
||||
)
|
||||
msg = ProblemsView.ProblemClicked(problem)
|
||||
assert msg.problem == problem
|
||||
|
||||
|
||||
class TestProblemItem:
|
||||
"""Tests for ProblemItem component."""
|
||||
|
||||
def test_create_item(self):
|
||||
"""Test creating a ProblemItem."""
|
||||
from clide.widgets.components.problems_view import ProblemItem
|
||||
|
||||
problem = Problem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=5,
|
||||
severity=Severity.ERROR,
|
||||
message="Test error",
|
||||
source="ruff",
|
||||
)
|
||||
item = ProblemItem(problem)
|
||||
assert item.problem == problem
|
||||
|
||||
|
||||
class TestTodosView:
|
||||
"""Tests for TodosView component."""
|
||||
|
||||
def test_initial_state_no_items(self):
|
||||
"""Test initial state without items."""
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
view = TodosView()
|
||||
assert view._items == []
|
||||
|
||||
def test_initial_state_with_items(self):
|
||||
"""Test initial state with items."""
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
items = [
|
||||
TodoItem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="Fix this",
|
||||
context_line="# TODO: Fix this",
|
||||
)
|
||||
]
|
||||
view = TodosView(items=items)
|
||||
assert len(view._items) == 1
|
||||
|
||||
def test_filter_by_type(self):
|
||||
"""Test filtering items by type."""
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
items = [
|
||||
TodoItem(
|
||||
file_path=Path("/a.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="TODO 1",
|
||||
context_line="# TODO: TODO 1",
|
||||
),
|
||||
TodoItem(
|
||||
file_path=Path("/b.py"),
|
||||
line=1,
|
||||
column=1,
|
||||
todo_type=TodoType.FIXME,
|
||||
text="FIXME 1",
|
||||
context_line="# FIXME: FIXME 1",
|
||||
),
|
||||
TodoItem(
|
||||
file_path=Path("/a.py"),
|
||||
line=5,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="TODO 2",
|
||||
context_line="# TODO: TODO 2",
|
||||
),
|
||||
]
|
||||
view = TodosView(items=items)
|
||||
todos = view.filter_by_type(TodoType.TODO)
|
||||
assert len(todos) == 2
|
||||
fixmes = view.filter_by_type(TodoType.FIXME)
|
||||
assert len(fixmes) == 1
|
||||
|
||||
def test_todo_clicked_message(self):
|
||||
"""Test TodoClicked message."""
|
||||
from clide.widgets.components.todos_view import TodosView
|
||||
|
||||
item = TodoItem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=1,
|
||||
todo_type=TodoType.TODO,
|
||||
text="Test todo",
|
||||
context_line="# TODO: Test todo",
|
||||
)
|
||||
msg = TodosView.TodoClicked(item)
|
||||
assert msg.item == item
|
||||
|
||||
|
||||
class TestTodoListItem:
|
||||
"""Tests for TodoListItem component."""
|
||||
|
||||
def test_create_item(self):
|
||||
"""Test creating a TodoListItem."""
|
||||
from clide.widgets.components.todos_view import TodoListItem
|
||||
|
||||
item = TodoItem(
|
||||
file_path=Path("/test.py"),
|
||||
line=10,
|
||||
column=1,
|
||||
todo_type=TodoType.FIXME,
|
||||
text="Fix this bug",
|
||||
context_line="# FIXME: Fix this bug",
|
||||
)
|
||||
list_item = TodoListItem(item)
|
||||
assert list_item.item == item
|
||||
|
||||
|
||||
class TestJiraView:
|
||||
"""Tests for JiraView component."""
|
||||
|
||||
def test_initial_state_enabled(self):
|
||||
"""Test initial state when enabled."""
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
|
||||
view = JiraView(enabled=True)
|
||||
assert view._enabled is True
|
||||
assert view._content == ""
|
||||
|
||||
def test_initial_state_disabled(self):
|
||||
"""Test initial state when disabled."""
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
|
||||
view = JiraView(enabled=False)
|
||||
assert view._enabled is False
|
||||
|
||||
def test_initial_state_with_content(self):
|
||||
"""Test initial state with content."""
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
|
||||
view = JiraView(content="# Issues\n- PROJ-123")
|
||||
assert view._content == "# Issues\n- PROJ-123"
|
||||
|
||||
def test_refresh_requested_message(self):
|
||||
"""Test RefreshRequested message."""
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
|
||||
msg = JiraView.RefreshRequested()
|
||||
assert msg is not None
|
||||
|
||||
def test_issue_clicked_message(self):
|
||||
"""Test IssueClicked message."""
|
||||
from clide.widgets.components.jira_view import JiraView
|
||||
|
||||
msg = JiraView.IssueClicked("PROJ-123")
|
||||
assert msg.issue_key == "PROJ-123"
|
||||
Reference in New Issue
Block a user