From 8afcdd30cfb4bda06b64e0c63088030e1c7f827f Mon Sep 17 00:00:00 2001 From: Marisol Date: Sat, 28 Feb 2026 09:02:31 +0000 Subject: [PATCH] Add automated tests --- .github/workflows/test.yml | 30 +++++ .gitignore | 15 ++- tests/conftest.py | 45 ++++++++ tests/test_example.py | 55 ++++++++++ tests/test_inplants.py | 220 +++++++++++++++++++++++++++++++++++++ 5 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/conftest.py create mode 100644 tests/test_example.py create mode 100644 tests/test_inplants.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..569af3c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: CI +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: arduino/compile-sketches@v1 + with: + sketch-paths: | + - . + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r requirements.txt 2>/dev/null || true + - name: Run logic tests + run: | + if [ -d tests ]; then python -m pytest tests/ -v --tb=short; fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 496ee2c..4785022 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ -.DS_Store \ No newline at end of file +.DS_Store +# Auto-added by Marisol pipeline +node_modules/ +__pycache__/ +*.pyc +.pytest_cache/ +*.o +*.so +.env +debug_*.py +.cache/ +dist/ +build/ +*.egg-info/ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..53aba3b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Auto-generated conftest.py -- stubs for Arduino/embedded C testing. + +Strategy: Arduino .ino/.cpp files can't be imported in Python, so we test by +re-implementing the core algorithms (state machines, math, protocol encoding, +parsers) in Python and verifying them with pytest. The conftest provides mock +serial and common helper dependencies. +""" +import sys +import os +import pytest +from unittest.mock import MagicMock, patch + +# Add repo root to path so test files can import source modules +sys.path.insert(0, '') + +# Mock common Arduino Python helper dependencies +for mod_name in ['serial', 'serial.tools', 'serial.tools.list_ports', + 'pyserial', 'pyfirmata', 'pyfirmata2']: + sys.modules[mod_name] = MagicMock() + + +class ArduinoConstants: + """Provides Arduino-style constants for Python re-implementations.""" + HIGH = 1 + LOW = 0 + INPUT = 0 + OUTPUT = 1 + INPUT_PULLUP = 2 + A0, A1, A2, A3, A4, A5 = range(14, 20) + + @staticmethod + def map_value(x, in_min, in_max, out_min, out_max): + """Arduino map() function.""" + return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) + + @staticmethod + def constrain(x, a, b): + """Arduino constrain() function.""" + return max(a, min(x, b)) + + +@pytest.fixture +def arduino(): + """Provides Arduino-like constants and helpers for testing re-implemented logic.""" + return ArduinoConstants() diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..b3d25ea --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,55 @@ +"""test_example.py — Starter template for Arduino project tests. + +Arduino .ino/.cpp files CANNOT be imported in Python. Instead: +1. READ the source files to understand the algorithms +2. RE-IMPLEMENT the logic in Python in this test file +3. Test the Python re-implementation with pytest + +The `arduino` fixture provides constants (HIGH, LOW, A0-A5) and helpers +(map_value, constrain). Also see embedded_mocks.py for I2C/SPI/UART mocks. +DO NOT modify conftest.py. +""" +import pytest +from embedded_mocks import ( + MockI2C, MockSPI, MockUART, MockGPIO, MockNeoPixel, + MockWiFi, MockBLE, MockPreferences, + arduino_map, arduino_constrain, analog_to_voltage, +) + + +def test_arduino_map(): + """Test Arduino map() function re-implementation.""" + assert arduino_map(0, 0, 1023, 0, 255) == 0 + assert arduino_map(512, 0, 1023, 0, 255) == 127 + assert arduino_map(1023, 0, 1023, 0, 255) == 255 + + +def test_arduino_constrain(): + """Test Arduino constrain() function.""" + assert arduino_constrain(150, 0, 100) == 100 + assert arduino_constrain(-10, 0, 100) == 0 + assert arduino_constrain(50, 0, 100) == 50 + + +def test_analog_voltage_conversion(): + """Test ADC raw value to voltage conversion.""" + assert abs(analog_to_voltage(512, bits=10, ref=3.3) - 1.65) < 0.01 + assert abs(analog_to_voltage(0, bits=10) - 0.0) < 0.01 + assert abs(analog_to_voltage(1023, bits=10, ref=3.3) - 3.3) < 0.01 + + +def test_serial_protocol_example(): + """Example: test a serial command protocol.""" + uart = MockUART(baudrate=115200) + # Simulate sending a command + uart.write(b"AT+CMD\r\n") + assert uart._tx_log == b"AT+CMD\r\n" + # Simulate receiving a response + uart.inject_rx(b"OK\r\n") + assert uart.readline() == b"OK\r\n" + + +# TODO: Read the .ino source files and re-implement key algorithms below: +# def test_state_machine(): +# """Re-implement the state machine from the Arduino source.""" +# pass diff --git a/tests/test_inplants.py b/tests/test_inplants.py new file mode 100644 index 0000000..028d47e --- /dev/null +++ b/tests/test_inplants.py @@ -0,0 +1,220 @@ +"""Tests for in-plants Arduino project. + +Re-implements key algorithms from inplants-argon.ino and inplants-xenon.ino +for testing in Python environment. +""" +import pytest + + +# Constants from the Arduino source files +CAPACITIVE_MOISTURE_SENSOR_PIN = "A0" +MOISTURE_DRY_VALUE = 680 # Analog reading when sensor is dry +MOISTURE_WET_VALUE = 330 # Analog reading when sensor is wet + + +def map_value(x, in_min, in_max, out_min, out_max): + """Re-implementation of Arduino map() function.""" + return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) + + +def constrain_value(x, a, b): + """Re-implementation of Arduino constrain() function.""" + return max(a, min(x, b)) + + +def map_moisture_to_percentage(analog_value): + """Re-implementation of moisture percentage mapping from Arduino code. + + Maps raw analog sensor reading (0-1023) to moisture percentage (0-100%). + Higher analog values = drier soil, lower values = wetter soil. + """ + # Map from sensor range to percentage + # When sensor reads MOISTURE_DRY_VALUE (680), percentage = 0% (dry) + # When sensor reads MOISTURE_WET_VALUE (330), percentage = 100% (wet) + percentage = map_value(analog_value, MOISTURE_DRY_VALUE, MOISTURE_WET_VALUE, 0, 100) + # Constrain to valid percentage range + return constrain_value(percentage, 0, 100) + + +def analog_to_voltage(analog_value, bits=10, ref=3.3): + """Convert analog ADC reading to voltage.""" + max_adc = 2 ** bits - 1 + return (analog_value / max_adc) * ref + + +class TestMoistureSensorAlgorithms: + """Test moisture sensor reading and percentage conversion algorithms.""" + + def test_dry_sensor_reading(self): + """When sensor is dry (high resistance), should read ~680 and 0% moisture.""" + analog_value = 680 # Dry sensor reading from source + percentage = map_moisture_to_percentage(analog_value) + assert percentage == 0 # Should be 0% moisture when dry + + def test_wet_sensor_reading(self): + """When sensor is wet (low resistance), should read ~330 and 100% moisture.""" + analog_value = 330 # Wet sensor reading from source + percentage = map_moisture_to_percentage(analog_value) + assert percentage == 100 # Should be 100% moisture when wet + + def test_mid_range_moisture(self): + """Test mid-range moisture percentage calculation.""" + # Mid-point between 330 and 680 is approximately 505 + analog_value = 505 + percentage = map_moisture_to_percentage(analog_value) + # Should be around 50% moisture + assert 40 <= percentage <= 60 + + def test_over_wet_clamped_to_100(self): + """Sensor reading below wet threshold should clamp to 100%.""" + analog_value = 200 # Lower than MOISTURE_WET_VALUE (330) + percentage = map_moisture_to_percentage(analog_value) + assert percentage == 100 # Should clamp to 100% + + def test_over_dry_clamped_to_0(self): + """Sensor reading above dry threshold should clamp to 0%.""" + analog_value = 800 # Higher than MOISTURE_DRY_VALUE (680) + percentage = map_moisture_to_percentage(analog_value) + assert percentage == 0 # Should clamp to 0% + + def test_analog_to_voltage_conversion(self): + """Test that analog readings can be converted to voltage.""" + # 10-bit ADC with 3.3V reference + analog_value = 512 + voltage = analog_to_voltage(analog_value, bits=10, ref=3.3) + # 512/1023 * 3.3V ≈ 1.65V + assert abs(voltage - 1.65) < 0.01 + + +class TestSensorDataProcessing: + """Test sensor data processing and publishing logic.""" + + def test_moisture_percentage_calculation_various_values(self): + """Test moisture percentage calculation across the sensor range.""" + test_cases = [ + (680, 0), # Dry + (505, 50), # Mid + (330, 100), # Wet + (200, 100), # Over wet (clamped) + (800, 0), # Over dry (clamped) + ] + + for analog_value, expected_percentage in test_cases: + percentage = map_moisture_to_percentage(analog_value) + # Allow some tolerance due to integer math + assert abs(percentage - expected_percentage) <= 2 + + def test_sensor_reading_range(self): + """Test that sensor readings are within valid ADC range.""" + # ADC is 10-bit, so values should be 0-1023 + assert 0 <= MOISTURE_DRY_VALUE <= 1023 + assert 0 <= MOISTURE_WET_VALUE <= 1023 + assert MOISTURE_DRY_VALUE > MOISTURE_WET_VALUE # Dry > Wet for capacitive sensor + + +class TestArduinoHelpers: + """Test Arduino helper functions.""" + + def test_map_function_edge_cases(self): + """Test map() function edge cases.""" + # Identity mapping + assert map_value(50, 0, 100, 0, 100) == 50 + + # Scaling up + assert map_value(512, 0, 1023, 0, 255) == 127 + + # Scaling down (integer math: 127 * 1023 / 255 = 509) + assert map_value(127, 0, 255, 0, 1023) == 509 + + def test_constrain_function_edge_cases(self): + """Test constrain() function edge cases.""" + # Within bounds + assert constrain_value(50, 0, 100) == 50 + + # Below lower bound + assert constrain_value(-10, 0, 100) == 0 + + # Above upper bound + assert constrain_value(150, 0, 100) == 100 + + def test_analog_voltage_full_range(self): + """Test voltage conversion across full ADC range.""" + # 0 should be 0V + assert abs(analog_to_voltage(0, bits=10, ref=3.3) - 0.0) < 0.01 + + # 1023 should be 3.3V + assert abs(analog_to_voltage(1023, bits=10, ref=3.3) - 3.3) < 0.01 + + +class TestGPIOControl: + """Test GPIO pin control for LED and other outputs.""" + + def test_gpio_output_high(self): + """Test GPIO pin set to HIGH.""" + # Simulate GPIO behavior + pins = {} + pins[13] = 0 # Initialize pin 13 (LED_BUILTIN) + pins[13] = 1 # Set to HIGH + assert pins[13] == 1 + + def test_gpio_output_low(self): + """Test GPIO pin set to LOW.""" + pins = {} + pins[13] = 1 # Initialize pin 13 + pins[13] = 0 # Set to LOW + assert pins[13] == 0 + + +class TestTimingAndDelays: + """Test timing calculations for sensor readings.""" + + def test_publish_interval(self): + """Test that publish interval is 30 minutes (1800000ms).""" + # From the source: delay(1800000) = 30 minutes + delay_ms = 1800000 + delay_minutes = delay_ms / 1000 / 60 + assert delay_minutes == 30 + + def test_sensor_reading_frequency(self): + """Test sensor reading frequency.""" + # Sensor reads every 30 minutes + interval_minutes = 30 + readings_per_hour = 60 / interval_minutes + assert readings_per_hour == 2 + + +class TestParticlePublishing: + """Test Particle cloud publishing simulation.""" + + def test_event_name_format(self): + """Test that event names match the source code format.""" + # From source: "plantStatus_analog" and "plantStatus_percentage" + event_names = ["plantStatus_analog", "plantStatus_percentage"] + assert "plantStatus_analog" in event_names + assert "plantStatus_percentage" in event_names + + def test_sensor_pin_configuration(self): + """Test sensor pin configuration.""" + # From source: A0 for capacitive moisture sensor + sensor_pin = "A0" + assert sensor_pin == "A0" + + +class TestSensorCalibration: + """Test sensor calibration values.""" + + def test_dry_calibration_value(self): + """Test dry calibration value from source.""" + # From source comment: "680 when sensor is dry" + assert MOISTURE_DRY_VALUE == 680 + + def test_wet_calibration_value(self): + """Test wet calibration value from source.""" + # From source comment: "330 when sensor is wet" + assert MOISTURE_WET_VALUE == 330 + + def test_sensor_range(self): + """Test the sensor's measurable range.""" + sensor_range = MOISTURE_DRY_VALUE - MOISTURE_WET_VALUE + # Range should be 350 (680 - 330) + assert sensor_range == 350