dashboard sync app

This commit is contained in:
Tim Bendt
2025-12-16 17:13:26 -05:00
parent 73079f743a
commit d7c82a0da0
25 changed files with 4181 additions and 69 deletions

400
tests/test_platform.py Normal file
View File

@@ -0,0 +1,400 @@
"""Tests for platform compatibility utilities."""
import pytest
import os
import sys
import platform
from pathlib import Path
from unittest.mock import patch, MagicMock
from src.utils.platform import (
get_platform_info,
is_supported_platform,
get_default_config_dir,
get_default_data_dir,
get_default_log_dir,
get_default_maildir_path,
check_dependencies,
get_shell_info,
get_shell_config_file,
get_platform_specific_commands,
check_terminal_compatibility,
check_textual_support,
get_platform_recommendations,
validate_environment,
)
class TestGetPlatformInfo:
"""Tests for get_platform_info function."""
def test_returns_dict(self):
"""Test that get_platform_info returns a dictionary."""
info = get_platform_info()
assert isinstance(info, dict)
def test_contains_required_keys(self):
"""Test that info contains all required keys."""
info = get_platform_info()
required_keys = [
"system",
"release",
"version",
"machine",
"processor",
"python_version",
"python_implementation",
]
for key in required_keys:
assert key in info
def test_values_are_strings(self):
"""Test that all values are strings."""
info = get_platform_info()
for value in info.values():
assert isinstance(value, str)
class TestIsSupportedPlatform:
"""Tests for is_supported_platform function."""
def test_current_platform_supported(self):
"""Test that current platform is supported (if Python 3.12+)."""
python_version = tuple(map(int, platform.python_version().split(".")))
if python_version >= (3, 12):
assert is_supported_platform() is True
@patch("src.utils.platform.platform.python_version")
def test_old_python_not_supported(self, mock_version):
"""Test that old Python versions are not supported."""
mock_version.return_value = "3.10.0"
assert is_supported_platform() is False
@patch("src.utils.platform.platform.system")
@patch("src.utils.platform.platform.python_version")
def test_supported_systems(self, mock_version, mock_system):
"""Test that Darwin, Linux, Windows are supported."""
mock_version.return_value = "3.12.0"
for system in ["Darwin", "Linux", "Windows"]:
mock_system.return_value = system
assert is_supported_platform() is True
@patch("src.utils.platform.platform.system")
@patch("src.utils.platform.platform.python_version")
def test_unsupported_system(self, mock_version, mock_system):
"""Test that unknown systems are not supported."""
mock_version.return_value = "3.12.0"
mock_system.return_value = "Unknown"
assert is_supported_platform() is False
class TestGetDefaultDirectories:
"""Tests for default directory functions."""
def test_config_dir_returns_path(self):
"""Test that get_default_config_dir returns a Path."""
result = get_default_config_dir()
assert isinstance(result, Path)
def test_data_dir_returns_path(self):
"""Test that get_default_data_dir returns a Path."""
result = get_default_data_dir()
assert isinstance(result, Path)
def test_log_dir_returns_path(self):
"""Test that get_default_log_dir returns a Path."""
result = get_default_log_dir()
assert isinstance(result, Path)
def test_maildir_path_returns_path(self):
"""Test that get_default_maildir_path returns a Path."""
result = get_default_maildir_path()
assert isinstance(result, Path)
@patch("src.utils.platform.platform.system")
def test_macos_config_dir(self, mock_system):
"""Test macOS config directory."""
mock_system.return_value = "Darwin"
result = get_default_config_dir()
assert "Library" in str(result)
assert "Application Support" in str(result)
@patch("src.utils.platform.platform.system")
def test_linux_config_dir_default(self, mock_system):
"""Test Linux config directory (default)."""
mock_system.return_value = "Linux"
with patch.dict(os.environ, {}, clear=True):
result = get_default_config_dir()
assert ".config" in str(result)
@patch("src.utils.platform.platform.system")
def test_linux_config_dir_xdg(self, mock_system):
"""Test Linux config directory with XDG_CONFIG_HOME."""
mock_system.return_value = "Linux"
with patch.dict(os.environ, {"XDG_CONFIG_HOME": "/custom/config"}):
result = get_default_config_dir()
assert "/custom/config" in str(result)
@patch("src.utils.platform.platform.system")
def test_windows_config_dir(self, mock_system):
"""Test Windows config directory."""
mock_system.return_value = "Windows"
with patch.dict(os.environ, {"APPDATA": "C:\\Users\\test\\AppData\\Roaming"}):
result = get_default_config_dir()
assert "luk" in str(result)
class TestCheckDependencies:
"""Tests for check_dependencies function."""
def test_returns_dict(self):
"""Test that check_dependencies returns a dictionary."""
result = check_dependencies()
assert isinstance(result, dict)
def test_python_always_available(self):
"""Test that Python is always marked as available."""
result = check_dependencies()
assert result["python"] is True
def test_contains_expected_keys(self):
"""Test that result contains expected dependency keys."""
result = check_dependencies()
expected_keys = ["python", "pip", "git", "curl", "wget"]
for key in expected_keys:
assert key in result
def test_values_are_bool(self):
"""Test that all values are boolean."""
result = check_dependencies()
for value in result.values():
assert isinstance(value, bool)
class TestGetShellInfo:
"""Tests for shell info functions."""
def test_get_shell_info_returns_dict(self):
"""Test that get_shell_info returns a dictionary."""
result = get_shell_info()
assert isinstance(result, dict)
def test_get_shell_info_keys(self):
"""Test that result has expected keys."""
result = get_shell_info()
assert "shell_path" in result
assert "shell_name" in result
assert "config_file" in result
def test_get_shell_config_file_bash(self):
"""Test shell config file for bash."""
result = get_shell_config_file("bash")
assert result == "~/.bashrc"
def test_get_shell_config_file_zsh(self):
"""Test shell config file for zsh."""
result = get_shell_config_file("zsh")
assert result == "~/.zshrc"
def test_get_shell_config_file_fish(self):
"""Test shell config file for fish."""
result = get_shell_config_file("fish")
assert result == "~/.config/fish/config.fish"
def test_get_shell_config_file_unknown(self):
"""Test shell config file for unknown shell."""
result = get_shell_config_file("unknown")
assert result == "~/.profile"
class TestGetPlatformSpecificCommands:
"""Tests for get_platform_specific_commands function."""
@patch("src.utils.platform.platform.system")
def test_macos_commands(self, mock_system):
"""Test macOS-specific commands."""
mock_system.return_value = "Darwin"
result = get_platform_specific_commands()
assert result["open"] == "open"
assert result["copy"] == "pbcopy"
@patch("src.utils.platform.platform.system")
def test_linux_commands(self, mock_system):
"""Test Linux-specific commands."""
mock_system.return_value = "Linux"
result = get_platform_specific_commands()
assert result["open"] == "xdg-open"
assert "xclip" in result["copy"]
@patch("src.utils.platform.platform.system")
def test_windows_commands(self, mock_system):
"""Test Windows-specific commands."""
mock_system.return_value = "Windows"
result = get_platform_specific_commands()
assert result["open"] == "start"
assert result["copy"] == "clip"
@patch("src.utils.platform.platform.system")
def test_unknown_system_commands(self, mock_system):
"""Test unknown system returns empty dict."""
mock_system.return_value = "Unknown"
result = get_platform_specific_commands()
assert result == {}
class TestCheckTerminalCompatibility:
"""Tests for terminal compatibility functions."""
def test_returns_dict(self):
"""Test that check_terminal_compatibility returns a dictionary."""
result = check_terminal_compatibility()
assert isinstance(result, dict)
def test_contains_expected_keys(self):
"""Test that result contains expected keys."""
result = check_terminal_compatibility()
expected_keys = [
"color_support",
"unicode_support",
"mouse_support",
"textual_support",
]
for key in expected_keys:
assert key in result
def test_values_are_bool(self):
"""Test that all values are boolean."""
result = check_terminal_compatibility()
for value in result.values():
assert isinstance(value, bool)
def test_check_textual_support(self):
"""Test check_textual_support."""
# Textual should be available in our test environment
result = check_textual_support()
assert isinstance(result, bool)
class TestGetPlatformRecommendations:
"""Tests for get_platform_recommendations function."""
def test_returns_list(self):
"""Test that get_platform_recommendations returns a list."""
result = get_platform_recommendations()
assert isinstance(result, list)
@patch("src.utils.platform.platform.system")
def test_macos_recommendations(self, mock_system):
"""Test macOS recommendations."""
mock_system.return_value = "Darwin"
result = get_platform_recommendations()
assert len(result) > 0
# Check for macOS-specific content
assert any("iTerm2" in r or "Terminal.app" in r for r in result)
@patch("src.utils.platform.platform.system")
def test_linux_recommendations(self, mock_system):
"""Test Linux recommendations."""
mock_system.return_value = "Linux"
result = get_platform_recommendations()
assert len(result) > 0
# Check for Linux-specific content
assert any("UTF-8" in r or "GNOME" in r for r in result)
@patch("src.utils.platform.platform.system")
def test_windows_recommendations(self, mock_system):
"""Test Windows recommendations."""
mock_system.return_value = "Windows"
result = get_platform_recommendations()
assert len(result) > 0
# Check for Windows-specific content
assert any("Windows Terminal" in r or "WSL" in r for r in result)
class TestValidateEnvironment:
"""Tests for validate_environment function."""
def test_returns_dict(self):
"""Test that validate_environment returns a dictionary."""
result = validate_environment()
assert isinstance(result, dict)
def test_contains_required_keys(self):
"""Test that result contains required keys."""
result = validate_environment()
required_keys = [
"platform_supported",
"platform_info",
"dependencies",
"terminal_compatibility",
"recommendations",
"config_dir",
"data_dir",
"log_dir",
]
for key in required_keys:
assert key in result
def test_platform_supported_is_bool(self):
"""Test that platform_supported is boolean."""
result = validate_environment()
assert isinstance(result["platform_supported"], bool)
def test_platform_info_is_dict(self):
"""Test that platform_info is a dictionary."""
result = validate_environment()
assert isinstance(result["platform_info"], dict)
def test_dependencies_is_dict(self):
"""Test that dependencies is a dictionary."""
result = validate_environment()
assert isinstance(result["dependencies"], dict)
def test_recommendations_is_list(self):
"""Test that recommendations is a list."""
result = validate_environment()
assert isinstance(result["recommendations"], list)
def test_directory_paths_are_strings(self):
"""Test that directory paths are strings."""
result = validate_environment()
assert isinstance(result["config_dir"], str)
assert isinstance(result["data_dir"], str)
assert isinstance(result["log_dir"], str)

239
tests/test_sync_daemon.py Normal file
View File

@@ -0,0 +1,239 @@
"""Tests for the sync daemon."""
import pytest
import os
import tempfile
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch, AsyncMock
from src.cli.sync_daemon import (
SyncDaemon,
create_daemon_config,
)
class TestCreateDaemonConfig:
"""Tests for create_daemon_config function."""
def test_default_config(self):
"""Test default configuration values."""
config = create_daemon_config()
assert config["dry_run"] is False
assert config["vdir"] == "~/Calendar"
assert config["icsfile"] is None
assert config["org"] == "corteva"
assert config["days_back"] == 1
assert config["days_forward"] == 30
assert config["continue_iteration"] is False
assert config["download_attachments"] is False
assert config["two_way_calendar"] is False
assert config["notify"] is False
assert config["sync_interval"] == 300
assert config["check_interval"] == 10
def test_custom_config(self):
"""Test custom configuration values."""
config = create_daemon_config(
dry_run=True,
org="mycompany",
vdir="~/MyCalendar",
notify=True,
sync_interval=600,
)
assert config["dry_run"] is True
assert config["org"] == "mycompany"
assert config["vdir"] == "~/MyCalendar"
assert config["notify"] is True
assert config["sync_interval"] == 600
def test_pid_file_default(self):
"""Test default PID file path."""
config = create_daemon_config()
assert "luk.pid" in config["pid_file"]
def test_log_file_default(self):
"""Test default log file path."""
config = create_daemon_config()
assert "luk.log" in config["log_file"]
class TestSyncDaemon:
"""Tests for the SyncDaemon class."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def daemon_config(self, temp_dir):
"""Create a daemon config with temp paths."""
return create_daemon_config(
pid_file=str(temp_dir / "test_daemon.pid"),
log_file=str(temp_dir / "test_daemon.log"),
)
def test_init(self, daemon_config):
"""Test daemon initialization."""
daemon = SyncDaemon(daemon_config)
assert daemon.config == daemon_config
assert daemon.running is False
assert daemon.sync_interval == 300
assert daemon.check_interval == 10
def test_init_custom_intervals(self, temp_dir):
"""Test daemon with custom intervals."""
config = create_daemon_config(
pid_file=str(temp_dir / "test_daemon.pid"),
log_file=str(temp_dir / "test_daemon.log"),
sync_interval=600,
check_interval=30,
)
daemon = SyncDaemon(config)
assert daemon.sync_interval == 600
assert daemon.check_interval == 30
def test_setup_logging(self, daemon_config, temp_dir):
"""Test logging setup."""
daemon = SyncDaemon(daemon_config)
# Logger should be configured
assert daemon.logger is not None
assert daemon.logger.level == logging.INFO
# Log directory should be created
log_file = temp_dir / "test_daemon.log"
assert log_file.parent.exists()
def test_is_running_no_pid_file(self, daemon_config):
"""Test is_running when no PID file exists."""
daemon = SyncDaemon(daemon_config)
assert daemon.is_running() is False
def test_is_running_stale_pid_file(self, daemon_config, temp_dir):
"""Test is_running with stale PID file."""
daemon = SyncDaemon(daemon_config)
# Create a PID file with non-existent process
pid_file = temp_dir / "test_daemon.pid"
pid_file.write_text("99999999") # Very unlikely to exist
# Should detect stale PID and clean up
assert daemon.is_running() is False
assert not pid_file.exists()
def test_get_pid(self, daemon_config, temp_dir):
"""Test get_pid reads PID correctly."""
daemon = SyncDaemon(daemon_config)
# Create a PID file
pid_file = temp_dir / "test_daemon.pid"
pid_file.write_text("12345")
assert daemon.get_pid() == 12345
def test_stop_not_running(self, daemon_config, capsys):
"""Test stop when daemon is not running."""
daemon = SyncDaemon(daemon_config)
daemon.stop()
captured = capsys.readouterr()
assert "not running" in captured.out.lower()
def test_status_not_running(self, daemon_config, capsys):
"""Test status when daemon is not running."""
daemon = SyncDaemon(daemon_config)
daemon.status()
captured = capsys.readouterr()
assert "not running" in captured.out.lower()
class TestSyncDaemonAsync:
"""Tests for async daemon methods."""
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def daemon_config(self, temp_dir):
"""Create a daemon config with temp paths."""
return create_daemon_config(
pid_file=str(temp_dir / "test_daemon.pid"),
log_file=str(temp_dir / "test_daemon.log"),
)
@pytest.mark.asyncio
async def test_check_for_changes_no_changes(self, daemon_config):
"""Test _check_for_changes when no changes."""
daemon = SyncDaemon(daemon_config)
with patch("src.cli.sync_daemon.should_run_godspeed_sync", return_value=False):
with patch("src.cli.sync_daemon.should_run_sweep", return_value=False):
result = await daemon._check_for_changes()
assert result is False
@pytest.mark.asyncio
async def test_check_for_changes_godspeed_due(self, daemon_config):
"""Test _check_for_changes when godspeed sync is due."""
daemon = SyncDaemon(daemon_config)
with patch("src.cli.sync_daemon.should_run_godspeed_sync", return_value=True):
with patch("src.cli.sync_daemon.should_run_sweep", return_value=False):
result = await daemon._check_for_changes()
assert result is True
@pytest.mark.asyncio
async def test_check_for_changes_sweep_due(self, daemon_config):
"""Test _check_for_changes when sweep is due."""
daemon = SyncDaemon(daemon_config)
with patch("src.cli.sync_daemon.should_run_godspeed_sync", return_value=False):
with patch("src.cli.sync_daemon.should_run_sweep", return_value=True):
result = await daemon._check_for_changes()
assert result is True
@pytest.mark.asyncio
async def test_perform_sync_success(self, daemon_config):
"""Test _perform_sync success."""
daemon = SyncDaemon(daemon_config)
with patch(
"src.cli.sync_daemon._sync_outlook_data", new_callable=AsyncMock
) as mock_sync:
await daemon._perform_sync()
mock_sync.assert_called_once()
@pytest.mark.asyncio
async def test_perform_sync_failure(self, daemon_config):
"""Test _perform_sync handles failure."""
daemon = SyncDaemon(daemon_config)
with patch(
"src.cli.sync_daemon._sync_outlook_data", new_callable=AsyncMock
) as mock_sync:
mock_sync.side_effect = Exception("Sync failed")
# Should not raise
await daemon._perform_sync()
# Error should be logged (we'd need to check the log file)

View File

@@ -0,0 +1,235 @@
"""Tests for the sync dashboard TUI."""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from src.cli.sync_dashboard import (
SyncDashboard,
SyncProgressTracker,
TaskStatus,
TaskListItem,
get_dashboard,
get_progress_tracker,
)
class TestSyncProgressTracker:
"""Tests for the SyncProgressTracker class."""
def test_init(self):
"""Test tracker initialization."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
assert tracker.dashboard == mock_dashboard
def test_start_task(self):
"""Test starting a new task."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.start_task("inbox", 100)
mock_dashboard.start_task.assert_called_once_with("inbox", 100)
def test_start_task_default_total(self):
"""Test starting a task with default total."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.start_task("calendar")
mock_dashboard.start_task.assert_called_once_with("calendar", 100)
def test_update_task(self):
"""Test updating task progress."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.update_task("inbox", 50, "Processing...")
mock_dashboard.update_task.assert_called_once_with("inbox", 50, "Processing...")
def test_update_task_without_message(self):
"""Test updating task without message."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.update_task("inbox", 75)
mock_dashboard.update_task.assert_called_once_with("inbox", 75, "")
def test_complete_task(self):
"""Test completing a task."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.complete_task("inbox", "Done!")
mock_dashboard.complete_task.assert_called_once_with("inbox", "Done!")
def test_complete_task_no_message(self):
"""Test completing a task without a message."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.complete_task("inbox")
mock_dashboard.complete_task.assert_called_once_with("inbox", "")
def test_error_task(self):
"""Test marking a task as failed."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.error_task("inbox", "Connection failed")
mock_dashboard.error_task.assert_called_once_with("inbox", "Connection failed")
def test_skip_task(self):
"""Test skipping a task."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.skip_task("sweep", "Not needed")
mock_dashboard.skip_task.assert_called_once_with("sweep", "Not needed")
def test_skip_task_no_reason(self):
"""Test skipping a task without a reason."""
mock_dashboard = MagicMock()
tracker = SyncProgressTracker(mock_dashboard)
tracker.skip_task("sweep")
mock_dashboard.skip_task.assert_called_once_with("sweep", "")
class TestTaskListItem:
"""Tests for the TaskListItem class."""
def test_init(self):
"""Test TaskListItem initialization."""
item = TaskListItem("inbox", "Inbox Sync")
assert item.task_id == "inbox"
assert item.task_name == "Inbox Sync"
assert item.status == TaskStatus.PENDING
assert item.progress == 0
assert item.total == 100
def test_get_status_icon_pending(self):
"""Test status icon for pending."""
item = TaskListItem("inbox", "Inbox Sync")
assert item._get_status_icon() == ""
def test_get_status_icon_running(self):
"""Test status icon for running (animated spinner)."""
from src.cli.sync_dashboard import SPINNER_FRAMES
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.RUNNING
# Running uses spinner frames, starts at frame 0
assert item._get_status_icon() == SPINNER_FRAMES[0]
def test_get_status_icon_completed(self):
"""Test status icon for completed."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.COMPLETED
assert item._get_status_icon() == ""
def test_get_status_icon_error(self):
"""Test status icon for error."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.ERROR
assert item._get_status_icon() == ""
def test_get_status_color_pending(self):
"""Test status color for pending."""
item = TaskListItem("inbox", "Inbox Sync")
assert item._get_status_color() == "dim"
def test_get_status_color_running(self):
"""Test status color for running."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.RUNNING
assert item._get_status_color() == "cyan"
def test_get_status_color_completed(self):
"""Test status color for completed."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.COMPLETED
assert item._get_status_color() == "bright_white"
def test_get_status_color_error(self):
"""Test status color for error."""
item = TaskListItem("inbox", "Inbox Sync")
item.status = TaskStatus.ERROR
assert item._get_status_color() == "red"
class TestTaskStatus:
"""Tests for the TaskStatus constants."""
def test_status_values(self):
"""Test TaskStatus values."""
assert TaskStatus.PENDING == "pending"
assert TaskStatus.RUNNING == "running"
assert TaskStatus.COMPLETED == "completed"
assert TaskStatus.ERROR == "error"
class TestSyncDashboard:
"""Tests for the SyncDashboard class."""
def test_bindings_defined(self):
"""Test that key bindings are defined."""
assert len(SyncDashboard.BINDINGS) > 0
# Bindings use the Binding class which has a key attribute
binding_keys = [b.key for b in SyncDashboard.BINDINGS] # type: ignore
assert "q" in binding_keys
assert "r" in binding_keys
assert "s" in binding_keys # Sync now
assert "+" in binding_keys # Increase interval
assert "-" in binding_keys # Decrease interval
assert "ctrl+c" in binding_keys
assert "up" in binding_keys
assert "down" in binding_keys
def test_css_defined(self):
"""Test that CSS is defined."""
assert SyncDashboard.CSS is not None
assert len(SyncDashboard.CSS) > 0
assert ".dashboard" in SyncDashboard.CSS
assert ".sidebar" in SyncDashboard.CSS
assert ".main-panel" in SyncDashboard.CSS
def test_reactive_selected_task(self):
"""Test selected_task reactive attribute is defined."""
assert hasattr(SyncDashboard, "selected_task")
class TestGlobalInstances:
"""Tests for global dashboard instances."""
def test_get_dashboard_initial(self):
"""Test get_dashboard returns None initially."""
# Reset global state
import src.cli.sync_dashboard as dashboard_module
dashboard_module._dashboard_instance = None
result = get_dashboard()
assert result is None
def test_get_progress_tracker_initial(self):
"""Test get_progress_tracker returns None initially."""
# Reset global state
import src.cli.sync_dashboard as dashboard_module
dashboard_module._progress_tracker = None
result = get_progress_tracker()
assert result is None