Kaynağa Gözat

test(backend-testing-01): add integration test skeleton for hardware

- Add conftest.py with hardware detection fixtures
- Add test_hardware.py with @pytest.mark.hardware tests
- Tests auto-skip without --run-hardware flag
- Tests auto-skip in CI (when CI=true)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 1 hafta önce
ebeveyn
işleme
c7e93a4466
2 değiştirilmiş dosya ile 236 ekleme ve 0 silme
  1. 72 0
      tests/integration/conftest.py
  2. 164 0
      tests/integration/test_hardware.py

+ 72 - 0
tests/integration/conftest.py

@@ -0,0 +1,72 @@
+"""
+Integration test fixtures - Hardware detection and setup.
+
+These fixtures help determine if real hardware is available
+and provide setup/teardown for hardware-dependent tests.
+"""
+import pytest
+import serial.tools.list_ports
+
+
+def pytest_addoption(parser):
+    """Add --run-hardware option to pytest CLI."""
+    parser.addoption(
+        "--run-hardware",
+        action="store_true",
+        default=False,
+        help="Run tests that require real hardware connection"
+    )
+
+
+@pytest.fixture
+def run_hardware(request):
+    """Check if hardware tests should run."""
+    return request.config.getoption("--run-hardware")
+
+
+@pytest.fixture
+def available_serial_ports():
+    """Return list of available serial ports on this machine.
+
+    Filters out known non-hardware ports like debug consoles.
+    """
+    IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
+    ports = serial.tools.list_ports.comports()
+    return [port.device for port in ports if port.device not in IGNORE_PORTS]
+
+
+@pytest.fixture
+def hardware_port(available_serial_ports, run_hardware):
+    """Get a hardware port for testing, or skip if not available.
+
+    This fixture:
+    1. Checks if --run-hardware flag was passed
+    2. Checks if any serial ports are available
+    3. Returns the first available port or skips the test
+    """
+    if not run_hardware:
+        pytest.skip("Hardware tests disabled (use --run-hardware to enable)")
+
+    if not available_serial_ports:
+        pytest.skip("No serial ports available for hardware testing")
+
+    # Prefer USB ports over built-in ports
+    usb_ports = [p for p in available_serial_ports if 'usb' in p.lower() or 'USB' in p]
+    if usb_ports:
+        return usb_ports[0]
+
+    return available_serial_ports[0]
+
+
+@pytest.fixture
+def serial_connection(hardware_port):
+    """Create a real serial connection for testing.
+
+    This fixture establishes an actual serial connection to the hardware.
+    The connection is automatically closed after the test.
+    """
+    import serial
+
+    conn = serial.Serial(hardware_port, baudrate=115200, timeout=2)
+    yield conn
+    conn.close()

+ 164 - 0
tests/integration/test_hardware.py

@@ -0,0 +1,164 @@
+"""
+Integration tests for hardware communication.
+
+These tests require real hardware to be connected and are skipped by default.
+Run with: pytest tests/integration/ --run-hardware
+
+All tests in this file are marked with @pytest.mark.hardware and will
+be automatically skipped in CI environments (when CI=true).
+"""
+import pytest
+import time
+
+
+@pytest.mark.hardware
+class TestSerialConnection:
+    """Tests for real serial connection to sand table hardware."""
+
+    def test_serial_port_opens(self, serial_connection):
+        """Test that we can open a serial connection to the hardware."""
+        assert serial_connection.is_open
+        assert serial_connection.baudrate == 115200
+
+    def test_grbl_status_query(self, serial_connection):
+        """Test querying GRBL status with '?' command.
+
+        GRBL should respond with a status string like:
+        <Idle|MPos:0.000,0.000,0.000|Bf:15,128>
+        or
+        <Idle|WPos:0.000,0.000,0.000|Bf:15,128>
+        """
+        # Clear any stale data
+        serial_connection.reset_input_buffer()
+
+        # Send status query
+        serial_connection.write(b'?')
+        serial_connection.flush()
+
+        # Wait for response
+        time.sleep(0.1)
+        response = serial_connection.readline().decode().strip()
+
+        # GRBL status starts with '<' and contains position info
+        assert response.startswith('<'), f"Expected GRBL status, got: {response}"
+        assert 'Pos:' in response, f"Expected position data in: {response}"
+
+    def test_grbl_settings_query(self, serial_connection):
+        """Test querying GRBL settings with '$$' command.
+
+        GRBL should respond with settings like:
+        $0=10
+        $1=25
+        ...
+        ok
+        """
+        # Clear any stale data
+        serial_connection.reset_input_buffer()
+
+        # Send settings query
+        serial_connection.write(b'$$\n')
+        serial_connection.flush()
+
+        # Collect all response lines
+        responses = []
+        timeout = time.time() + 2  # 2 second timeout
+
+        while time.time() < timeout:
+            if serial_connection.in_waiting:
+                line = serial_connection.readline().decode().strip()
+                responses.append(line)
+                if line == 'ok':
+                    break
+            time.sleep(0.01)
+
+        # Should have received settings
+        assert len(responses) > 1, "Expected GRBL settings response"
+        assert responses[-1] == 'ok', f"Expected 'ok' at end, got: {responses[-1]}"
+
+        # At least some settings should start with '$'
+        settings = [r for r in responses if r.startswith('$')]
+        assert len(settings) > 0, "Expected at least one setting line"
+
+
+@pytest.mark.hardware
+class TestConnectionManager:
+    """Integration tests for the connection_manager module with real hardware."""
+
+    def test_list_serial_ports_finds_hardware(self, available_serial_ports, run_hardware):
+        """Test that list_serial_ports finds the connected hardware."""
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection import connection_manager
+
+        ports = connection_manager.list_serial_ports()
+
+        # Should find at least one port
+        assert len(ports) > 0, "Expected to find at least one serial port"
+
+        # Should match what we found independently
+        for port in available_serial_ports:
+            if 'usb' in port.lower() or 'tty' in port.lower():
+                assert port in ports or any(port in p for p in ports)
+
+    def test_serial_connection_class(self, hardware_port, run_hardware):
+        """Test SerialConnection class with real hardware.
+
+        This tests the actual SerialConnection wrapper from connection_manager.
+        """
+        if not run_hardware:
+            pytest.skip("Hardware tests disabled")
+
+        from modules.connection.connection_manager import SerialConnection
+
+        conn = SerialConnection(hardware_port)
+        try:
+            assert conn.is_connected()
+
+            # Send status query
+            conn.send('?')
+            time.sleep(0.1)
+
+            response = conn.readline()
+            assert '<' in response, f"Expected GRBL status, got: {response}"
+        finally:
+            conn.close()
+
+
+@pytest.mark.hardware
+@pytest.mark.slow
+class TestHardwareOperations:
+    """Slow integration tests that perform actual hardware operations.
+
+    These tests take longer and may move the hardware.
+    Use with caution!
+    """
+
+    def test_soft_reset(self, serial_connection):
+        """Test GRBL soft reset (Ctrl+X).
+
+        This sends a soft reset command and verifies GRBL responds
+        with its startup message.
+        """
+        # Send soft reset (Ctrl+X = 0x18)
+        serial_connection.write(b'\x18')
+        serial_connection.flush()
+
+        # Wait for reset and startup message
+        time.sleep(1)
+
+        # Collect responses
+        responses = []
+        timeout = time.time() + 3
+
+        while time.time() < timeout:
+            if serial_connection.in_waiting:
+                line = serial_connection.readline().decode().strip()
+                if line:
+                    responses.append(line)
+            time.sleep(0.01)
+
+        # GRBL should output startup message containing "Grbl"
+        all_responses = ' '.join(responses)
+        assert 'Grbl' in all_responses or 'grbl' in all_responses.lower(), \
+            f"Expected GRBL startup message, got: {responses}"