Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi
- name: Run logic tests
run: |
if [ -d tests ]; then python -m pytest tests/ -v; fi
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
.DS_Store
.DS_Store
# Auto-added by Marisol pipeline
node_modules/
__pycache__/
*.pyc
.pytest_cache/
*.o
*.so
.env
debug_*.py
.cache/
dist/
build/
*.egg-info/
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
55 changes: 55 additions & 0 deletions tests/test_example.py
Original file line number Diff line number Diff line change
@@ -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
235 changes: 235 additions & 0 deletions tests/test_inplants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"""Tests for in-plants Arduino project.

Re-implements key algorithms from inplants-argon.ino and inplants-xenon.ino
for Python-based testing.
"""
import pytest


def map_value(x, in_min, in_max, out_min, out_max):
"""Arduino map() function - re-implemented in Python."""
return int((x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)


def constrain(x, a, b):
"""Arduino constrain() function - re-implemented in Python."""
return max(a, min(x, b))


def analog_to_voltage(analog_value, bits=10, ref=3.3):
"""Convert ADC raw value to voltage.

Args:
analog_value: Raw ADC reading (0 to 2^bits - 1)
bits: ADC resolution (default 10 for Arduino)
ref: Reference voltage (default 3.3V)
"""
max_adc = (1 << bits) - 1 # 2^bits - 1
return (analog_value / max_adc) * ref


class MockParticle:
"""Mock Particle.publish() function."""
def __init__(self):
self.published_events = []

def publish(self, name, value, ttl=60, is_private=False):
self.published_events.append({
'name': name,
'value': str(value),
'ttl': ttl,
'is_private': is_private
})


def moisture_analog_to_percentage(analog_value, dry_value=1650, wet_value=2450):
"""Convert raw analog moisture reading to percentage.

Based on inplants-argon.ino and inplants-xenon.ino logic:
- Raw analog values from capacitive moisture sensor
- Calibrated range: dry_value (0%) to wet_value (100%)
- Returns percentage clamped between 0-100
"""
# Map analog value to percentage
percentage = map_value(analog_value, dry_value, wet_value, 0, 100)
# Constrain to valid percentage range
return constrain(percentage, 0, 100)


class TestMoistureCalculations:
"""Test moisture percentage calculations."""

def test_moisture_percentage_calculation(self):
"""Test moisture percentage calculation from analog readings."""
# Dry sensor (1650) should give 0%
assert moisture_analog_to_percentage(1650) == 0

# Wet sensor (2450) should give 100%
assert moisture_analog_to_percentage(2450) == 100

# Mid-range value
mid_value = (1650 + 2450) // 2 # 2050
mid_percentage = moisture_analog_to_percentage(mid_value)
assert 45 <= mid_percentage <= 55 # Should be around 50%

def test_moisture_percentage_clamping(self):
"""Test that moisture percentage is properly clamped."""
# Value below dry calibration (should clamp to 0)
assert moisture_analog_to_percentage(1000) == 0
assert moisture_analog_to_percentage(0) == 0

# Value above wet calibration (should clamp to 100)
assert moisture_analog_to_percentage(3000) == 100
assert moisture_analog_to_percentage(5000) == 100

def test_moisture_percentage_interpolation(self):
"""Test percentage interpolation within calibration range."""
dry = 1650
wet = 2450

# Test various points in the range
test_cases = [
(dry, 0), # Dry point
(1850, 25), # 25%
(2050, 50), # 50% (midpoint)
(2250, 75), # 75%
(wet, 100), # Wet point
]

for analog_val, expected_pct in test_cases:
result = moisture_analog_to_percentage(analog_val)
# Allow small tolerance due to integer math
assert abs(result - expected_pct) <= 2, f"Failed for analog={analog_val}: got {result}, expected ~{expected_pct}"


class TestAnalogConversion:
"""Test ADC to voltage conversion."""

def test_analog_voltage_conversion(self):
"""Test ADC raw value to voltage conversion for moisture sensor."""
# 10-bit ADC with 3.3V reference
# Mid-range value (512) should be ~1.65V
assert abs(analog_to_voltage(512, bits=10, ref=3.3) - 1.65) < 0.01

# Maximum value (1023) should be ~3.3V
assert abs(analog_to_voltage(1023, bits=10, ref=3.3) - 3.3) < 0.01

# Minimum value (0) should be 0V
assert abs(analog_to_voltage(0, bits=10, ref=3.3) - 0.0) < 0.01


class TestCustomCalibration:
"""Test moisture calculation with custom calibration values."""

def test_moisture_with_custom_calibration(self):
"""Test moisture calculation with custom calibration values."""
# Different sensor with different calibration
custom_dry = 2000
custom_wet = 3000

# At custom dry point
assert moisture_analog_to_percentage(custom_dry, custom_dry, custom_wet) == 0

# At custom wet point
assert moisture_analog_to_percentage(custom_wet, custom_dry, custom_wet) == 100

# Midpoint
mid = (custom_dry + custom_wet) // 2
assert 48 <= moisture_analog_to_percentage(mid, custom_dry, custom_wet) <= 52


class TestParticlePublish:
"""Test Particle.publish() mock functionality."""

def test_particle_publish_mock(self):
"""Test Particle.publish() mock functionality."""
particle = MockParticle()

# Simulate publishing moisture data
particle.publish("plantStatus_analog", "2050", 60, False)
particle.publish("plantStatus_percentage", "50", 60, False)

assert len(particle.published_events) == 2
assert particle.published_events[0]['name'] == "plantStatus_analog"
assert particle.published_events[0]['value'] == "2050"
assert particle.published_events[1]['name'] == "plantStatus_percentage"
assert particle.published_events[1]['value'] == "50"


class TestSensorReadingSimulation:
"""Test complete sensor reading cycles."""

def test_sensor_reading_simulation(self):
"""Simulate a complete sensor reading cycle."""
# Simulate reading from analog pin A0 (10-bit ADC)
# In real code: int moisture_analog = analogRead(A0);
moisture_analog = 2050 # Simulated mid-range reading

# Calculate percentage
moisture_percentage = moisture_analog_to_percentage(moisture_analog)

# Verify the reading is in the expected range
assert 45 <= moisture_percentage <= 55

# Simulate publishing (as in the Arduino code)
particle = MockParticle()
particle.publish("plantStatus_analog", str(moisture_analog), 60, False)
particle.publish("plantStatus_percentage", str(moisture_percentage), 60, False)

assert len(particle.published_events) == 2

def test_multiple_sensor_readings(self):
"""Test multiple sensor readings to simulate real-world usage."""
particle = MockParticle()

# Simulate readings over time
readings = [
(1800, "dry"), # 17% - dry soil
(2050, "moist"), # 50% - optimal
(2300, "wet"), # 81% - wet soil
]

for analog_val, condition in readings:
pct = moisture_analog_to_percentage(analog_val)
particle.publish("plantStatus_analog", str(analog_val), 60, False)
particle.publish("plantStatus_percentage", str(pct), 60, False)

# Verify percentage makes sense for condition
if condition == "dry":
assert pct < 30
elif condition == "moist":
assert 40 <= pct <= 60
elif condition == "wet":
assert pct > 70

assert len(particle.published_events) == 6 # 2 events per reading


class TestArduinoHelpers:
"""Test Arduino helper functions (map and constrain)."""

def test_map_edge_cases(self):
"""Test edge cases for map() function."""
# Identity mapping
assert map_value(50, 0, 100, 0, 100) == 50

# Negative ranges
assert map_value(0, -100, 100, 0, 200) == 100

# Inverted mapping
assert map_value(0, 0, 100, 100, 0) == 100
assert map_value(100, 0, 100, 100, 0) == 0

def test_constrain_edge_cases(self):
"""Test edge cases for constrain() function."""
# Already in range
assert constrain(50, 0, 100) == 50

# Below minimum
assert constrain(-10, 0, 100) == 0

# Above maximum
assert constrain(150, 0, 100) == 100

# Equal min and max (edge case)
assert constrain(50, 50, 50) == 50
Loading