From 5f0a2eb0b8abe847156c8ab2f38059a9a18b184b Mon Sep 17 00:00:00 2001 From: Marisol Date: Sat, 28 Feb 2026 08:16:00 +0000 Subject: [PATCH] Add automated tests --- .github/workflows/test.yml | 29 +++++ .gitignore | 15 ++- tests/conftest.py | 45 ++++++++ tests/embedded_mocks.py | 226 +++++++++++++++++++++++++++++++++++++ tests/test_example.py | 55 +++++++++ tests/test_inplants.py | 149 ++++++++++++++++++++++++ 6 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/conftest.py create mode 100644 tests/embedded_mocks.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..6e112d6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +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 + - name: Run logic tests + run: | + if [ -d tests ]; then python -m pytest tests/ -v; fi 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/embedded_mocks.py b/tests/embedded_mocks.py new file mode 100644 index 0000000..08f89ca --- /dev/null +++ b/tests/embedded_mocks.py @@ -0,0 +1,226 @@ +"""Mock libraries for embedded C testing.""" + + +def arduino_map(x, in_min, in_max, out_min, out_max): + """Re-implementation of Arduino's map() function.""" + return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) + + +def arduino_constrain(x, low, high): + """Re-implementation of Arduino's constrain() function.""" + return max(low, min(x, high)) + + +def analog_to_voltage(analog_value, bits=10, ref=3.3): + """Convert analog reading to voltage.""" + max_value = (1 << bits) - 1 + return analog_value * (ref / max_value) + + +class MockI2C: + """Mock I2C bus.""" + def __init__(self, sda=21, scl=22, frequency=100000): + self.sda = sda + self.scl = scl + self.frequency = frequency + self._tx_buffer = b"" + self._rx_buffer = b"" + + def write(self, data): + self._tx_buffer = data + + def read(self, n): + return self._rx_buffer[:n] + + +class MockSPI: + """Mock SPI bus.""" + def __init__(self, sck=18, miso=19, mosi=23, cs=5): + self.sck = sck + self.miso = miso + self.mosi = mosi + self.cs = cs + + def transfer(self, data): + return data + + +class MockUART: + """Mock UART serial port.""" + def __init__(self, baudrate=115200, rx_pin=3, tx_pin=1): + self.baudrate = baudrate + self.rx_pin = rx_pin + self.tx_pin = tx_pin + self._tx_log = b"" + self._rx_buffer = b"" + + def write(self, data): + self._tx_log += data + + def read(self, n=-1): + if n < 0: + result = self._rx_buffer + self._rx_buffer = b"" + return result + result = self._rx_buffer[:n] + self._rx_buffer = self._rx_buffer[n:] + return result + + def readline(self): + if b"\n" in self._rx_buffer: + idx = self._rx_buffer.index(b"\n") + 1 + result = self._rx_buffer[:idx] + self._rx_buffer = self._rx_buffer[idx:] + return result + return b"" + + def inject_rx(self, data): + self._rx_buffer += data + + +class MockGPIO: + """Mock GPIO pin.""" + def __init__(self, pin, mode="OUTPUT"): + self.pin = pin + self.mode = mode + self.value = 0 + + def high(self): + self.value = 1 + + def low(self): + self.value = 0 + + def read(self): + return self.value + + +class MockNeoPixel: + """Mock NeoPixel LED strip.""" + def __init__(self, pin, n, brightness=0.5): + self.pin = pin + self.n = n + self.brightness = brightness + self.pixels = [(0, 0, 0)] * n + + def set_pixel(self, i, r, g, b): + if 0 <= i < self.n: + self.pixels[i] = (r, g, b) + + def show(self): + pass + + +class MockWiFi: + """Mock WiFi connection.""" + def __init__(self): + self.connected = False + self.ssid = None + + def connect(self, ssid, password): + self.ssid = ssid + self.connected = True + + def disconnect(self): + self.connected = False + + +class MockBLE: + """Mock BLE connection.""" + def __init__(self): + self.connected = False + + def connect(self): + self.connected = True + + def disconnect(self): + self.connected = False + + +class MockPreferences: + """Mock non-volatile storage.""" + def __init__(self): + self._data = {} + + def put(self, key, value): + self._data[key] = value + + def get(self, key, default=None): + return self._data.get(key, default) + + +class MockSSD1306: + """Mock SSD1306 OLED display.""" + def __init__(self, width=128, height=64): + self.width = width + self.height = height + self.buffer = [[0] * width for _ in range(height)] + + def pixel(self, x, y, color=1): + if 0 <= x < self.width and 0 <= y < self.height: + self.buffer[y][x] = color + + +class MockHT16K33: + """Mock HT16K33 LED driver.""" + def __init__(self, address=0x70): + self.address = address + self.leds = [0] * 16 + + def set_led(self, pin, state): + if 0 <= pin < 16: + self.leds[pin] = 1 if state else 0 + + +class MockRotaryEncoder: + """Mock rotary encoder.""" + def __init__(self, pin_a=2, pin_b=3): + self.pin_a = pin_a + self.pin_b = pin_b + self.position = 0 + + def update(self): + pass + + +class MockADC: + """Mock ADC channel.""" + def __init__(self, pin, bits=12): + self.pin = pin + self.bits = bits + self.value = 0 + + def read(self): + return self.value + + +class MockPWM: + """Mock PWM channel.""" + def __init__(self, pin, frequency=50): + self.pin = pin + self.frequency = frequency + self.duty_cycle = 0 + + def write(self, value): + self.duty_cycle = value + + +class MockTemperatureSensor: + """Mock temperature sensor.""" + def __init__(self, pin=0): + self.pin = pin + self.temperature = 25.0 + + def read_temperature(self): + return self.temperature + + +class MockAccelerometer: + """Mock accelerometer.""" + def __init__(self): + self.x = 0 + self.y = 0 + self.z = 1.0 # 1G default + + def read_acceleration(self): + return self.x, self.y, self.z 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..5d1037c --- /dev/null +++ b/tests/test_inplants.py @@ -0,0 +1,149 @@ +"""Tests for in-plants Arduino project. + +Tests re-implemented algorithms from inplants-argon.ino and inplants-xenon.ino. +""" + +import pytest + + +def map_value(x, in_min, in_max, out_min, out_max): + """Re-implementation of Arduino's map() function.""" + return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min) + + +def constrain(x, low, high): + """Re-implementation of Arduino's constrain() function.""" + return max(low, min(x, high)) + + +def calculate_moisture_percentage(analog_value): + """Calculate moisture percentage from analog reading. + + From the Arduino code: + float moisture_percentage = (100 - ( (moisture_analog/4095.00) * 100 ) ); + """ + return 100 - ((analog_value / 4095.00) * 100) + + +def calculate_voltage(analog_value): + """Calculate battery voltage from analog reading. + + From the Arduino code: + float voltage = analogRead(BATT) * 0.0011224; + """ + return analog_value * 0.0011224 + + +class TestMoistureCalculations: + """Test moisture percentage calculations.""" + + def test_moisture_percentage_dry_sensor(self): + """Dry sensor should read ~0% moisture (high analog value).""" + # When sensor is dry, analog reading is high (near 4095) + analog_dry = 4095 + percentage = calculate_moisture_percentage(analog_dry) + assert percentage == 0.0 + + def test_moisture_percentage_wet_sensor(self): + """Wet sensor should read ~100% moisture (low analog value).""" + # When sensor is wet, analog reading is low (near 0) + analog_wet = 0 + percentage = calculate_moisture_percentage(analog_wet) + assert percentage == 100.0 + + def test_moisture_percentage_mid_range(self): + """Test mid-range moisture percentage.""" + # Mid-range analog value + analog_mid = 2048 + percentage = calculate_moisture_percentage(analog_mid) + expected = 100 - ((2048 / 4095.00) * 100) + assert abs(percentage - expected) < 0.01 + + def test_moisture_percentage_with_realistic_values(self): + """Test with realistic sensor readings.""" + # Typical dry reading for capacitive sensor + analog_dry = 3500 + percentage_dry = calculate_moisture_percentage(analog_dry) + assert 14 < percentage_dry < 15 + + # Typical wet reading for capacitive sensor + analog_wet = 1500 + percentage_wet = calculate_moisture_percentage(analog_wet) + assert 63 < percentage_wet < 64 + + +class TestVoltageCalculations: + """Test battery voltage calculations.""" + + def test_voltage_zero_reading(self): + """Zero analog reading should give zero voltage.""" + voltage = calculate_voltage(0) + assert voltage == 0.0 + + def test_voltage_full_scale(self): + """Full scale analog reading (4095) should give ~4.59V.""" + voltage = calculate_voltage(4095) + expected = 4095 * 0.0011224 + assert abs(voltage - expected) < 0.001 + + def test_voltage_with_realistic_values(self): + """Test with realistic battery readings.""" + # Typical battery voltage around 3.7V + analog_for_3v7 = 3300 + voltage = calculate_voltage(analog_for_3v7) + expected = 3300 * 0.0011224 + assert abs(voltage - expected) < 0.001 + + +class TestAnalogReadMapping: + """Test analog read value mapping.""" + + def test_map_full_range(self): + """Test mapping full 12-bit range to percentage.""" + # 0 -> 100%, 4095 -> 0% + assert calculate_moisture_percentage(0) == 100.0 + assert calculate_moisture_percentage(4095) == 0.0 + + def test_map_half_range(self): + """Test mapping at mid-range.""" + # 2047/2048 should be ~50% + mid = 2047 + percentage = calculate_moisture_percentage(mid) + expected = 100 - ((2047 / 4095.00) * 100) + assert abs(percentage - expected) < 0.01 + + +class TestConstrainFunction: + """Test the constrain helper function.""" + + def test_constrain_within_bounds(self): + """Value within bounds should be unchanged.""" + assert constrain(50, 0, 100) == 50 + + def test_constrain_below_lower(self): + """Value below lower bound should be constrained to lower.""" + assert constrain(-10, 0, 100) == 0 + + def test_constrain_above_upper(self): + """Value above upper bound should be constrained to upper.""" + assert constrain(150, 0, 100) == 100 + + +class TestMapFunction: + """Test the map helper function.""" + + def test_map_identity(self): + """Map with same ranges should return original value.""" + assert map_value(512, 0, 1023, 0, 1023) == 512 + + def test_map_half_scale(self): + """Map half scale value.""" + assert map_value(512, 0, 1023, 0, 255) == 127 + + def test_map_full_scale(self): + """Map full scale value.""" + assert map_value(1023, 0, 1023, 0, 255) == 255 + + def test_map_zero_scale(self): + """Map zero value.""" + assert map_value(0, 0, 1023, 0, 255) == 0