|
@@ -0,0 +1,165 @@
|
|
|
|
|
+"""
|
|
|
|
|
+Root conftest.py - Shared fixtures for all tests.
|
|
|
|
|
+
|
|
|
|
|
+This file provides:
|
|
|
|
|
+- CI environment detection for auto-skipping hardware tests
|
|
|
|
|
+- AsyncClient fixture for API testing
|
|
|
|
|
+- Mock state fixture for isolated testing
|
|
|
|
|
+"""
|
|
|
|
|
+import os
|
|
|
|
|
+import pytest
|
|
|
|
|
+from unittest.mock import MagicMock, AsyncMock
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def pytest_configure(config):
|
|
|
|
|
+ """Configure pytest with custom markers and CI detection."""
|
|
|
|
|
+ # Register custom markers
|
|
|
|
|
+ config.addinivalue_line(
|
|
|
|
|
+ "markers", "hardware: marks tests requiring real hardware (skip in CI)"
|
|
|
|
|
+ )
|
|
|
|
|
+ config.addinivalue_line(
|
|
|
|
|
+ "markers", "slow: marks slow tests"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def pytest_collection_modifyitems(config, items):
|
|
|
|
|
+ """Auto-skip hardware tests when CI=true environment variable is set."""
|
|
|
|
|
+ if os.environ.get("CI"):
|
|
|
|
|
+ skip_hardware = pytest.mark.skip(reason="Hardware not available in CI")
|
|
|
|
|
+ for item in items:
|
|
|
|
|
+ if "hardware" in item.keywords:
|
|
|
|
|
+ item.add_marker(skip_hardware)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+async def async_client():
|
|
|
|
|
+ """Async HTTP client for testing API endpoints.
|
|
|
|
|
+
|
|
|
|
|
+ Uses httpx AsyncClient with ASGITransport to test FastAPI app directly
|
|
|
|
|
+ without starting a server.
|
|
|
|
|
+ """
|
|
|
|
|
+ from httpx import ASGITransport, AsyncClient
|
|
|
|
|
+ from main import app
|
|
|
|
|
+
|
|
|
|
|
+ async with AsyncClient(
|
|
|
|
|
+ transport=ASGITransport(app=app),
|
|
|
|
|
+ base_url="http://test"
|
|
|
|
|
+ ) as client:
|
|
|
|
|
+ yield client
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def mock_state():
|
|
|
|
|
+ """Mock global state object for isolated testing.
|
|
|
|
|
+
|
|
|
|
|
+ Returns a MagicMock configured with common defaults to simulate
|
|
|
|
|
+ the application state without affecting real state.
|
|
|
|
|
+ """
|
|
|
|
|
+ mock = MagicMock()
|
|
|
|
|
+
|
|
|
|
|
+ # Connection mock
|
|
|
|
|
+ mock.conn = MagicMock()
|
|
|
|
|
+ mock.conn.is_connected.return_value = False
|
|
|
|
|
+ mock.port = None
|
|
|
|
|
+ mock.is_connected = False
|
|
|
|
|
+
|
|
|
|
|
+ # Pattern execution state
|
|
|
|
|
+ mock.current_playing_file = None
|
|
|
|
|
+ mock.is_running = False
|
|
|
|
|
+ mock.pause_requested = False
|
|
|
|
|
+ mock.stop_requested = False
|
|
|
|
|
+ mock.skip_requested = False
|
|
|
|
|
+ mock.execution_progress = None
|
|
|
|
|
+ mock.is_homing = False
|
|
|
|
|
+ mock.is_clearing = False
|
|
|
|
|
+
|
|
|
|
|
+ # Position state
|
|
|
|
|
+ mock.current_theta = 0.0
|
|
|
|
|
+ mock.current_rho = 0.0
|
|
|
|
|
+ mock.machine_x = 0.0
|
|
|
|
|
+ mock.machine_y = 0.0
|
|
|
|
|
+
|
|
|
|
|
+ # Speed and settings
|
|
|
|
|
+ mock.speed = 100
|
|
|
|
|
+ mock.clear_pattern_speed = None
|
|
|
|
|
+ mock.table_type = "dune_weaver"
|
|
|
|
|
+ mock.table_type_override = None
|
|
|
|
|
+ mock.homing = 0
|
|
|
|
|
+
|
|
|
|
|
+ # Playlist state
|
|
|
|
|
+ mock.current_playlist = None
|
|
|
|
|
+ mock.current_playlist_name = None
|
|
|
|
|
+ mock.current_playlist_index = None
|
|
|
|
|
+ mock.playlist_mode = None
|
|
|
|
|
+ mock.pause_time_remaining = 0
|
|
|
|
|
+ mock.original_pause_time = None
|
|
|
|
|
+
|
|
|
|
|
+ # LED state
|
|
|
|
|
+ mock.led_controller = None
|
|
|
|
|
+ mock.led_provider = "none"
|
|
|
|
|
+ mock.wled_ip = None
|
|
|
|
|
+ mock.dw_led_idle_effect = "solid"
|
|
|
|
|
+ mock.dw_led_playing_effect = "rainbow"
|
|
|
|
|
+ mock.dw_led_idle_timeout_enabled = False
|
|
|
|
|
+ mock.dw_led_idle_timeout_minutes = 30
|
|
|
|
|
+
|
|
|
|
|
+ # Scheduled pause
|
|
|
|
|
+ mock.scheduled_pause_enabled = False
|
|
|
|
|
+ mock.scheduled_pause_time_slots = []
|
|
|
|
|
+ mock.scheduled_pause_control_wled = False
|
|
|
|
|
+ mock.scheduled_pause_finish_pattern = False
|
|
|
|
|
+ mock.scheduled_pause_timezone = None
|
|
|
|
|
+
|
|
|
|
|
+ # Steps and gear ratio
|
|
|
|
|
+ mock.x_steps_per_mm = 200.0
|
|
|
|
|
+ mock.y_steps_per_mm = 287.0
|
|
|
|
|
+ mock.gear_ratio = 10.0
|
|
|
|
|
+
|
|
|
|
|
+ # Auto-home settings
|
|
|
|
|
+ mock.auto_home_enabled = False
|
|
|
|
|
+ mock.auto_home_after_patterns = 10
|
|
|
|
|
+ mock.patterns_since_last_home = 0
|
|
|
|
|
+
|
|
|
|
|
+ # Custom clear patterns
|
|
|
|
|
+ mock.custom_clear_from_out = None
|
|
|
|
|
+ mock.custom_clear_from_in = None
|
|
|
|
|
+
|
|
|
|
|
+ # Homing offset
|
|
|
|
|
+ mock.angular_homing_offset_degrees = 0.0
|
|
|
|
|
+
|
|
|
|
|
+ # Methods
|
|
|
|
|
+ mock.save = MagicMock()
|
|
|
|
|
+ mock.get_stop_event = MagicMock(return_value=None)
|
|
|
|
|
+ mock.get_skip_event = MagicMock(return_value=None)
|
|
|
|
|
+ mock.wait_for_interrupt = AsyncMock(return_value='timeout')
|
|
|
|
|
+
|
|
|
|
|
+ return mock
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def mock_connection():
|
|
|
|
|
+ """Mock connection object for testing hardware communication.
|
|
|
|
|
+
|
|
|
|
|
+ Returns a MagicMock configured to simulate serial/websocket connection.
|
|
|
|
|
+ """
|
|
|
|
|
+ mock = MagicMock()
|
|
|
|
|
+ mock.is_connected.return_value = True
|
|
|
|
|
+ mock.send = MagicMock()
|
|
|
|
|
+ mock.readline = MagicMock(return_value="ok")
|
|
|
|
|
+ mock.in_waiting = MagicMock(return_value=0)
|
|
|
|
|
+ mock.flush = MagicMock()
|
|
|
|
|
+ mock.close = MagicMock()
|
|
|
|
|
+ mock.reset_input_buffer = MagicMock()
|
|
|
|
|
+ return mock
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.fixture
|
|
|
|
|
+def patterns_dir(tmp_path):
|
|
|
|
|
+ """Create a temporary patterns directory for testing.
|
|
|
|
|
+
|
|
|
|
|
+ Returns the path to a temporary directory that can be used
|
|
|
|
|
+ for pattern file operations during tests.
|
|
|
|
|
+ """
|
|
|
|
|
+ patterns = tmp_path / "patterns"
|
|
|
|
|
+ patterns.mkdir()
|
|
|
|
|
+ return patterns
|