diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a68bf2b --- /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 --ignore=tests/test_example.py 2>/dev/null || python -m pytest tests/ -v; 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..1a7ece5 --- /dev/null +++ b/tests/test_inplants.py @@ -0,0 +1,158 @@ +"""Tests for in-plants Arduino firmware. + +Re-implements key algorithms from inplants-argon.ino and inplants-xenon.ino: +- Moisture percentage calculation +- Battery voltage calculation +- Analog to percentage conversion +""" +import pytest + + +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, a, b): + """Re-implementation of Arduino's constrain() function.""" + return max(a, min(x, b)) + + +def moisture_percentage(moisture_analog): + """Re-implementation of moisture percentage calculation from .ino files. + + Formula: 100 - (moisture_analog / 4095.00) * 100 + This converts the raw analog reading to a moisture percentage. + Higher analog values = more moisture (lower resistance). + """ + return 100 - (moisture_analog / 4095.00) * 100 + + +def battery_voltage(analog_value): + """Re-implementation of battery voltage calculation. + + Formula: analog_value * 0.0011224 + Based on 12-bit ADC (4095 steps) with 3.3V reference. + """ + return analog_value * 0.0011224 + + +def test_moisture_percentage_dry_sensor(): + """Test moisture percentage for dry sensor (high resistance, low analog value).""" + # Dry sensor reads ~0-200 analog + assert moisture_percentage(0) == 100.0 + assert moisture_percentage(100) == pytest.approx(97.5555, rel=0.001) + assert moisture_percentage(200) == pytest.approx(95.1160, rel=0.001) + + +def test_moisture_percentage_wet_sensor(): + """Test moisture percentage for wet sensor (low resistance, high analog value).""" + # Wet sensor reads ~3500-4000 analog + assert moisture_percentage(3500) == pytest.approx(14.5299, rel=0.001) + assert moisture_percentage(3800) == pytest.approx(7.2044, rel=0.001) + assert moisture_percentage(4000) == pytest.approx(2.3201, rel=0.001) + + +def test_moisture_percentage_mid_range(): + """Test moisture percentage for mid-range values.""" + # Mid-range around 2000-2500 analog + assert moisture_percentage(2000) == pytest.approx(51.1700, rel=0.001) + assert moisture_percentage(2500) == pytest.approx(38.9499, rel=0.001) + + +def test_moisture_percentage_edge_cases(): + """Test edge cases for moisture calculation.""" + # Maximum analog value (saturated) + assert moisture_percentage(4095) == 0.0 + # Minimum analog value (completely dry) + assert moisture_percentage(0) == 100.0 + + +def test_battery_voltage_calculation(): + """Test battery voltage calculation from ADC reading.""" + # 0 ADC = 0V + assert battery_voltage(0) == 0.0 + # Mid-range: 1838 * 0.0011224 ≈ 2.06V + assert battery_voltage(1838) == pytest.approx(2.0622, rel=0.001) + # Full scale: 4095 * 0.0011224 ≈ 4.59V + assert battery_voltage(4095) == pytest.approx(4.5970, rel=0.001) + + +def test_battery_voltage_thresholds(): + """Test battery voltage calculations for common thresholds.""" + # 3.3V threshold corresponds to ~2940 ADC + assert battery_voltage(2940) == pytest.approx(3.2999, rel=0.001) + # 3.7V (typical LiPo nominal) corresponds to ~3297 ADC + assert battery_voltage(3297) == pytest.approx(3.6985, rel=0.001) + # 4.2V (full charge) corresponds to ~3742 ADC + assert battery_voltage(3742) == pytest.approx(4.1995, rel=0.001) + + +def test_voltage_to_percentage_conversion(): + """Test converting battery voltage to percentage using Arduino map.""" + # Map battery voltage from 3.0V-4.2V to 0-100% + # 3.0V = 0%, 4.2V = 100% + + # 3.0V (2671 ADC) = 0% + voltage_3v = battery_voltage(2671) + percent_3v = arduino_map(int(voltage_3v * 1000), 3000, 4200, 0, 100) + assert percent_3v == 0 + + # 4.2V (3742 ADC) = 100% + voltage_4v = battery_voltage(3742) + percent_4v = arduino_map(int(voltage_4v * 1000), 3000, 4200, 0, 100) + assert percent_4v == 100 + + # 3.6V (3207 ADC) = 49% (due to integer math in map()) + voltage_36v = battery_voltage(3207) + percent_36v = arduino_map(int(voltage_36v * 1000), 3000, 4200, 0, 100) + assert percent_36v == 49 + + +def test_moisture_percentage_clamping(): + """Test that moisture percentage stays within valid bounds.""" + # Test with values outside normal range + assert moisture_percentage(-100) == pytest.approx(102.4420, rel=0.001) + assert moisture_percentage(5000) == pytest.approx(-22.1001, rel=0.001) + + +def test_arduino_map_helper(): + """Test the Arduino map() re-implementation.""" + assert arduino_map(512, 0, 1023, 0, 255) == 127 + assert arduino_map(0, 0, 1023, 0, 255) == 0 + assert arduino_map(1023, 0, 1023, 0, 255) == 255 + assert arduino_map(200, 0, 4095, 0, 100) == 4 + + +def test_arduino_constrain_helper(): + """Test the Arduino constrain() re-implementation.""" + assert arduino_constrain(5, 0, 10) == 5 + assert arduino_constrain(-5, 0, 10) == 0 + assert arduino_constrain(15, 0, 10) == 10 + + +def test_combined_moisture_and_battery_logic(): + """Test combined moisture and battery calculations.""" + # Simulate a reading scenario + moisture_analog = 2500 + battery_analog = 3200 + + moisture_pct = moisture_percentage(moisture_analog) + battery_v = battery_voltage(battery_analog) + + # Verify moisture is around 39% + assert moisture_pct == pytest.approx(38.9499, rel=0.001) + + # Verify battery is around 3.59V + assert battery_v == pytest.approx(3.5917, rel=0.001) + + +def test_sensor_calibration_values(): + """Test with typical sensor calibration values.""" + # Dry air reading (typical): ~800-1000 + assert moisture_percentage(800) == pytest.approx(80.4884, rel=0.001) + assert moisture_percentage(1000) == pytest.approx(75.5555, rel=0.001) + + # Water reading (typical): ~2000-2500 + assert moisture_percentage(2000) == pytest.approx(51.1700, rel=0.001) + assert moisture_percentage(2200) == pytest.approx(46.2759, rel=0.001)