From d30743aa200fb56d3f97bb199aeea754a6368f9e Mon Sep 17 00:00:00 2001 From: Marisol Date: Mon, 23 Feb 2026 10:22:51 +0000 Subject: [PATCH] Add automated tests --- .github/workflows/test.yml | 29 ++ .gitignore | 8 +- tests/conftest.py | 45 +++ tests/embedded_mocks.py | 616 +++++++++++++++++++++++++++++++++++++ tests/test_example.py | 55 ++++ tests/test_inplants.py | 362 ++++++++++++++++++++++ 6 files changed, 1114 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..4287bae 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -.DS_Store \ No newline at end of file +.DS_Store +# Build artifacts +*.pyc +__pycache__/ +*.o +*.so +.pytest_cache/ 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..4c59a62 --- /dev/null +++ b/tests/embedded_mocks.py @@ -0,0 +1,616 @@ +"""embedded_mocks.py — Shared hardware mock library for testing embedded projects. + +Import these mocks in your test files: + from embedded_mocks import MockI2C, MockSPI, MockUART, MockGPIO, MockNeoPixel, ... + +All mocks track state so tests can assert pin values, bytes sent, etc. +""" +from unittest.mock import MagicMock + + +# ── GPIO Pin Simulator ────────────────────────────────────────────────────── +class MockGPIO: + """Simulates GPIO pins with state tracking.""" + HIGH = 1 + LOW = 0 + INPUT = 0 + OUTPUT = 1 + INPUT_PULLUP = 2 + BCM = 11 + BOARD = 10 + PUD_UP = 22 + PUD_DOWN = 21 + + def __init__(self): + self._mode = None + self._pins = {} # pin -> {mode, value, pud} + self._warnings = True + + def setmode(self, mode): + self._mode = mode + + def setup(self, pin, mode, pull_up_down=None): + if isinstance(pin, (list, tuple)): + for p in pin: + self.setup(p, mode, pull_up_down) + return + self._pins[pin] = {"mode": mode, "value": self.LOW, "pud": pull_up_down} + + def output(self, pin, value): + if isinstance(pin, (list, tuple)): + vals = value if isinstance(value, (list, tuple)) else [value] * len(pin) + for p, v in zip(pin, vals): + self.output(p, v) + return + if pin in self._pins: + self._pins[pin]["value"] = value + + def input(self, pin): + return self._pins.get(pin, {}).get("value", self.LOW) + + def cleanup(self): + self._pins.clear() + self._mode = None + + def setwarnings(self, flag): + self._warnings = flag + + +# ── I2C Bus Simulator ─────────────────────────────────────────────────────── +class MockI2C: + """Simulates an I2C bus — tracks writes and returns configurable read data.""" + + def __init__(self, scl=None, sda=None, frequency=100000): + self.scl = scl + self.sda = sda + self.frequency = frequency + self.written = [] # list of (address, bytes) + self._read_responses = {} # address -> bytes to return on read + + def writeto(self, address, buffer, *, start=0, end=None): + self.written.append((address, bytes(buffer[start:end]))) + + def readfrom_into(self, address, buffer, *, start=0, end=None): + data = self._read_responses.get(address, b"\x00" * len(buffer)) + end = end or len(buffer) + for i in range(start, min(end, len(buffer))): + if i - start < len(data): + buffer[i] = data[i - start] + + def writeto_then_readfrom(self, address, out_buffer, in_buffer, + *, out_start=0, out_end=None, in_start=0, in_end=None): + self.writeto(address, out_buffer, start=out_start, end=out_end) + self.readfrom_into(address, in_buffer, start=in_start, end=in_end) + + def scan(self): + return list(self._read_responses.keys()) + + def set_read_response(self, address, data): + """Configure what readfrom_into returns for a given address.""" + self._read_responses[address] = data + + def try_lock(self): + return True + + def unlock(self): + pass + + +# ── SPI Bus Simulator ─────────────────────────────────────────────────────── +class MockSPI: + """Simulates an SPI bus with MOSI/MISO tracking.""" + + def __init__(self, clock=None, MOSI=None, MISO=None, baudrate=1000000): + self.clock = clock + self.MOSI = MOSI + self.MISO = MISO + self.baudrate = baudrate + self.written = bytearray() + self._read_data = bytearray() + + def write(self, buffer): + self.written.extend(buffer) + + def readinto(self, buffer): + for i in range(len(buffer)): + if i < len(self._read_data): + buffer[i] = self._read_data[i] + else: + buffer[i] = 0 + + def write_readinto(self, out_buffer, in_buffer): + self.write(out_buffer) + self.readinto(in_buffer) + + def set_read_data(self, data): + self._read_data = bytearray(data) + + def try_lock(self): + return True + + def unlock(self): + pass + + def configure(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +# ── UART / Serial Simulator ───────────────────────────────────────────────── +class MockUART: + """Simulates UART / serial communication with configurable rx buffer.""" + + def __init__(self, tx=None, rx=None, baudrate=9600, timeout=1): + self.tx = tx + self.rx = rx + self.baudrate = baudrate + self.timeout = timeout + self._rx_buffer = bytearray() + self._tx_log = bytearray() + + def write(self, data): + if isinstance(data, str): + data = data.encode() + self._tx_log.extend(data) + return len(data) + + def read(self, nbytes=None): + if nbytes is None: + data = bytes(self._rx_buffer) + self._rx_buffer.clear() + return data + data = bytes(self._rx_buffer[:nbytes]) + self._rx_buffer = self._rx_buffer[nbytes:] + return data + + def readline(self): + idx = self._rx_buffer.find(b"\n") + if idx == -1: + return self.read() + data = bytes(self._rx_buffer[:idx + 1]) + self._rx_buffer = self._rx_buffer[idx + 1:] + return data + + @property + def in_waiting(self): + return len(self._rx_buffer) + + def inject_rx(self, data): + """Inject data into the receive buffer for test simulation.""" + if isinstance(data, str): + data = data.encode() + self._rx_buffer.extend(data) + + def reset_input_buffer(self): + self._rx_buffer.clear() + + def close(self): + pass + + +# ── NeoPixel Simulator ────────────────────────────────────────────────────── +class MockNeoPixel: + """Simulates a NeoPixel strip — tracks color values per pixel.""" + + def __init__(self, pin=None, n=1, brightness=1.0, auto_write=True, pixel_order="GRB"): + self.pin = pin + self.n = n + self.brightness = brightness + self.auto_write = auto_write + self._pixels = [(0, 0, 0)] * n + self._shown = False + + def __setitem__(self, idx, color): + if isinstance(idx, slice): + indices = range(*idx.indices(self.n)) + for i in indices: + self._pixels[i] = color + else: + self._pixels[idx] = color + + def __getitem__(self, idx): + return self._pixels[idx] + + def __len__(self): + return self.n + + def fill(self, color): + self._pixels = [color] * self.n + + def show(self): + self._shown = True + + def deinit(self): + pass + + +# ── Display Simulators ────────────────────────────────────────────────────── +class MockSSD1306: + """Simulates an SSD1306 OLED display (128x64 or 128x32).""" + + def __init__(self, width=128, height=64, i2c=None, addr=0x3C): + self.width = width + self.height = height + self.i2c = i2c + self.addr = addr + self._buffer = bytearray(width * height // 8) + self._shown = False + self.rotation = 0 + + def fill(self, color): + val = 0xFF if color else 0x00 + for i in range(len(self._buffer)): + self._buffer[i] = val + + def text(self, text, x, y, color=1): + pass # Text rendering is display-internal + + def show(self): + self._shown = True + + def pixel(self, x, y, color=None): + if color is not None: + pass # Set pixel + return 0 + + def fill_rect(self, x, y, w, h, color): + pass + + def rect(self, x, y, w, h, color): + pass + + def line(self, x0, y0, x1, y1, color): + pass + + def invert(self, flag): + pass + + @property + def poweron(self): + return True + + +class MockHT16K33: + """Simulates an HT16K33 LED matrix/7-segment backpack.""" + + def __init__(self, i2c=None, address=0x70): + self.i2c = i2c + self.address = address + self._buffer = [0] * 16 + self.brightness = 1.0 + self.blink_rate = 0 + self.auto_write = True + + def fill(self, color): + val = 0xFF if color else 0x00 + self._buffer = [val] * 16 + + def show(self): + pass + + def __setitem__(self, idx, val): + if idx < len(self._buffer): + self._buffer[idx] = val + + def __getitem__(self, idx): + return self._buffer[idx] if idx < len(self._buffer) else 0 + + +class MockSeg7x4(MockHT16K33): + """Simulates a 4-digit 7-segment display.""" + + def __init__(self, i2c=None, address=0x70): + super().__init__(i2c, address) + self._text = " " + self.colon = False + + def print(self, value): + self._text = str(value)[:4] + + def marquee(self, text, delay=0.25, loop=True): + self._text = text[:4] + + @property + def text(self): + return self._text + + +# ── Rotary Encoder Simulator ──────────────────────────────────────────────── +class MockRotaryEncoder: + """Simulates a rotary encoder with position and button.""" + + def __init__(self, pin_a=None, pin_b=None, pin_button=None): + self._position = 0 + self._last_position = 0 + self._button_pressed = False + + @property + def position(self): + return self._position + + @position.setter + def position(self, val): + self._last_position = self._position + self._position = val + + def simulate_turn(self, clicks): + """Simulate turning the encoder by N clicks (positive=CW, negative=CCW).""" + self._last_position = self._position + self._position += clicks + + def simulate_press(self): + self._button_pressed = True + + def simulate_release(self): + self._button_pressed = False + + +# ── ADC / PWM / DAC Simulators ────────────────────────────────────────────── +class MockADC: + """Simulates an analog-to-digital converter.""" + + def __init__(self, pin=None, bits=10): + self.pin = pin + self.bits = bits + self._value = 0 + self._voltage = 0.0 + + @property + def value(self): + return self._value + + @value.setter + def value(self, v): + self._value = max(0, min(v, (2 ** self.bits) - 1)) + + @property + def voltage(self): + return self._voltage + + def set_voltage(self, v, ref=3.3): + """Set the simulated voltage and update the raw value accordingly.""" + self._voltage = v + self._value = int((v / ref) * ((2 ** self.bits) - 1)) + + +class MockPWM: + """Simulates a PWM output.""" + + def __init__(self, pin=None, frequency=1000, duty_cycle=0): + self.pin = pin + self.frequency = frequency + self.duty_cycle = duty_cycle + + def deinit(self): + pass + + +class MockDAC: + """Simulates a digital-to-analog converter.""" + + def __init__(self, pin=None): + self.pin = pin + self._value = 0 + + @property + def value(self): + return self._value + + @value.setter + def value(self, v): + self._value = max(0, min(v, 65535)) + + +# ── Sensor Simulators ─────────────────────────────────────────────────────── +class MockTemperatureSensor: + """Generic temperature sensor mock (DHT, BME280, etc.).""" + + def __init__(self, temp_c=22.0, humidity=50.0, pressure=1013.25): + self.temperature = temp_c + self.humidity = humidity + self.pressure = pressure + self.altitude = 0.0 + + def set_reading(self, temp_c=None, humidity=None, pressure=None): + if temp_c is not None: + self.temperature = temp_c + if humidity is not None: + self.humidity = humidity + if pressure is not None: + self.pressure = pressure + + +class MockAccelerometer: + """Simulates a 3-axis accelerometer (MPU6050, LIS3DH, etc.).""" + + def __init__(self): + self._acceleration = (0.0, 0.0, 9.8) + self._gyro = (0.0, 0.0, 0.0) + + @property + def acceleration(self): + return self._acceleration + + def set_acceleration(self, x, y, z): + self._acceleration = (x, y, z) + + @property + def gyro(self): + return self._gyro + + def set_gyro(self, x, y, z): + self._gyro = (x, y, z) + + +# ── ESP32-Specific Mocks ──────────────────────────────────────────────────── +class MockWiFi: + """Simulates ESP32 WiFi module.""" + + def __init__(self): + self.ssid = "" + self._connected = False + self._ip = "192.168.1.100" + self.radio = self # CircuitPython wifi.radio pattern + + def connect(self, ssid, password="", **kwargs): + self.ssid = ssid + self._connected = True + + def disconnect(self): + self._connected = False + self.ssid = "" + + @property + def connected(self): + return self._connected + + @property + def ipv4_address(self): + return self._ip if self._connected else None + + @property + def ap_info(self): + return MagicMock(ssid=self.ssid, rssi=-50) if self._connected else None + + +class MockBLE: + """Simulates BLE peripheral/central.""" + + def __init__(self): + self._advertising = False + self._connected = False + self._services = [] + self._scan_results = [] + + def start_advertising(self, advertisement=None, scan_response=None): + self._advertising = True + + def stop_advertising(self): + self._advertising = False + + def start_scan(self, *args, **kwargs): + return iter(self._scan_results) + + def stop_scan(self): + pass + + @property + def connected(self): + return self._connected + + def add_scan_result(self, name="device", rssi=-60, address="AA:BB:CC:DD:EE:FF"): + self._scan_results.append(MagicMock( + complete_name=name, rssi=rssi, address=MagicMock(string=address))) + + +class MockPreferences: + """Simulates ESP32 Preferences / NVS storage.""" + + def __init__(self, namespace="app"): + self.namespace = namespace + self._storage = {} + + def begin(self, namespace=None, read_only=False): + if namespace: + self.namespace = namespace + + def end(self): + pass + + def put_string(self, key, value): + self._storage[f"{self.namespace}:{key}"] = value + + def get_string(self, key, default=""): + return self._storage.get(f"{self.namespace}:{key}", default) + + def put_int(self, key, value): + self._storage[f"{self.namespace}:{key}"] = value + + def get_int(self, key, default=0): + return self._storage.get(f"{self.namespace}:{key}", default) + + def put_float(self, key, value): + self._storage[f"{self.namespace}:{key}"] = value + + def get_float(self, key, default=0.0): + return self._storage.get(f"{self.namespace}:{key}", default) + + def remove(self, key): + self._storage.pop(f"{self.namespace}:{key}", None) + + def clear(self): + prefix = f"{self.namespace}:" + self._storage = {k: v for k, v in self._storage.items() if not k.startswith(prefix)} + + +class MockSPIFFS: + """Simulates ESP32 SPIFFS / LittleFS filesystem.""" + + def __init__(self): + self._files = {} + self._mounted = False + + def mount(self, path="/spiffs"): + self._mounted = True + + def open(self, path, mode="r"): + if "w" in mode: + self._files[path] = "" + return MockFile(self._files, path, mode) + if path in self._files: + return MockFile(self._files, path, mode) + raise FileNotFoundError(f"No such file: {path}") + + def exists(self, path): + return path in self._files + + def listdir(self, path="/"): + return [k for k in self._files.keys() if k.startswith(path)] + + def remove(self, path): + self._files.pop(path, None) + + +class MockFile: + """Helper for MockSPIFFS file operations.""" + + def __init__(self, storage, path, mode): + self._storage = storage + self._path = path + self._mode = mode + + def read(self): + return self._storage.get(self._path, "") + + def write(self, data): + self._storage[self._path] = data + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +# ── Convenience: Arduino-compatible helpers ────────────────────────────────── +def arduino_map(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 arduino_constrain(x, a, b): + """Arduino constrain() function.""" + return max(a, min(x, b)) + + +def millis_to_seconds(ms): + """Convert Arduino millis() to seconds.""" + return ms / 1000.0 + + +def analog_to_voltage(raw, bits=10, ref=3.3): + """Convert raw ADC reading to voltage.""" + return (raw / ((2 ** bits) - 1)) * ref 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..3ca0ad9 --- /dev/null +++ b/tests/test_inplants.py @@ -0,0 +1,362 @@ +import pytest +from embedded_mocks import arduino_map, arduino_constrain, analog_to_voltage + + +def test_analog_to_percentage_mapping(): + """Test the moisture percentage mapping logic from the .ino files. + + The code uses map() to convert raw analog readings (0-1023) to percentage. + Based on the code, it appears to map 0-1023 to 0-100 percentage. + """ + # Dry condition (low moisture = high resistance = low analog reading) + assert arduino_map(0, 0, 1023, 0, 100) == 0 + + # Wet condition (high moisture = low resistance = high analog reading) + assert arduino_map(1023, 0, 1023, 0, 100) == 100 + + # Mid-range reading + assert arduino_map(512, 0, 1023, 0, 100) == 50 + + # Another mid-range value + assert arduino_map(256, 0, 1023, 0, 100) == 25 + + assert arduino_map(768, 0, 1023, 0, 100) == 75 + + +def test_analog_to_percentage_with_realistic_values(): + """Test with realistic moisture sensor values. + + Capacitive moisture sensors typically output: + - ~200-300 when dry + - ~500-600 when moist + - ~800-900 when wet + """ + # Dry soil + dry_reading = 250 + dry_percent = arduino_map(dry_reading, 0, 1023, 0, 100) + assert 0 <= dry_percent <= 30 + + # Moist soil + moist_reading = 550 + moist_percent = arduino_map(moist_reading, 0, 1023, 0, 100) + assert 40 <= moist_percent <= 60 + + # Wet soil + wet_reading = 850 + wet_percent = arduino_map(wet_reading, 0, 1023, 0, 100) + assert 70 <= wet_percent <= 90 + + +def test_percentage_constrain(): + """Test that percentage values are constrained to 0-100 range.""" + # Values within range should stay the same + assert arduino_constrain(50, 0, 100) == 50 + assert arduino_constrain(0, 0, 100) == 0 + assert arduino_constrain(100, 0, 100) == 100 + + # Values outside range should be clamped + assert arduino_constrain(-10, 0, 100) == 0 + assert arduino_constrain(150, 0, 100) == 100 + + +def test_analog_to_voltage_conversion(): + """Test analog to voltage conversion for sensor readings.""" + # 10-bit ADC with 3.3V reference + assert analog_to_voltage(0, 10, 3.3) == 0.0 + assert analog_to_voltage(1023, 10, 3.3) == 3.3 + assert analog_to_voltage(512, 10, 3.3) == pytest.approx(1.65, rel=0.01) + + +def test_sensor_reading_logic(): + """Test the complete sensor reading and conversion logic. + + This simulates the logic from loop() in both .ino files: + 1. Read analog value from sensor + 2. Map to percentage + 3. Constrain to valid range + """ + # Simulate reading from A0 pin + moisture_analog = 600 # Typical moist reading + + # Map to percentage (0-1023 -> 0-100) + moisture_percentage = arduino_map(moisture_analog, 0, 1023, 0, 100) + + # Constrain to valid range + moisture_percentage = arduino_constrain(moisture_percentage, 0, 100) + + # Verify reasonable result for moist reading + assert 40 <= moisture_percentage <= 70 + + +def test_multiple_sensor_readings(): + """Test multiple sensor readings as would be done in the Xenon device.""" + # Simulate 6 sensors (A0-A5) + sensor_readings = [300, 500, 650, 750, 850, 450] + + percentages = [] + for reading in sensor_readings: + pct = arduino_map(reading, 0, 1023, 0, 100) + pct = arduino_constrain(pct, 0, 100) + percentages.append(pct) + + # Verify all percentages are in valid range + for pct in percentages: + assert 0 <= pct <= 100 + + # Verify ordering is preserved (higher analog = higher percentage) + for i in range(len(percentages) - 1): + if sensor_readings[i] < sensor_readings[i+1]: + assert percentages[i] < percentages[i+1] + + +def test_button_trigger_logic(): + """Test the button trigger logic from Xenon .ino file. + + The code checks if button is pressed (LOW when pressed due to pull-up). + """ + # Button pressed (connected to GND, reads LOW) + button_state_pressed = 0 # LOW + + # Button not pressed (pulled HIGH) + button_state_not_pressed = 1 # HIGH + + # Logic: if buttonState == LOW, trigger reading + reading_triggered = (button_state_pressed == 0) + assert reading_triggered is True + + reading_not_triggered = (button_state_not_pressed == 0) + assert reading_not_triggered is False + + +def test_publish_data_format(): + """Test the data format for Particle.publish(). + + The code publishes: + - plantStatus_analog: String(moisture_analog) + - plantStatus_percentage: String(moisture_percentage) + """ + moisture_analog = 600 + moisture_percentage = arduino_map(moisture_analog, 0, 1023, 0, 100) + + # Verify string conversion matches what would be published + analog_str = str(moisture_analog) + percentage_str = str(moisture_percentage) + + assert analog_str == "600" + assert percentage_str == "58" # 600 mapped to percentage + + +def test_led_indicator_logic(): + """Test the LED indicator logic. + + Code turns LED on (HIGH) before reading, then off (LOW) after. + """ + # LED on during reading + led_on = 1 # HIGH + + # LED off after reading + led_off = 0 # LOW + + assert led_on == 1 + assert led_off == 0 + + +def test_delay_timing(): + """Test the timing logic for automatic readings. + + Argon: 30 minutes = 1800000 milliseconds + Xenon: 1 minute = 60000 milliseconds + """ + # 30 minutes in milliseconds + argon_delay = 1800000 + assert argon_delay == 30 * 60 * 1000 + + # 1 minute in milliseconds + xenon_delay = 60000 + assert xenon_delay == 1 * 60 * 1000 + + +def test_analog_pin_selection(): + """Test that analog pins A0-A5 are correctly mapped. + + In Particle/Arduino, A0-A5 map to specific pin numbers. + """ + # Standard analog pin mappings + analog_pins = ["A0", "A1", "A2", "A3", "A4", "A5"] + + # All pins should be valid + assert len(analog_pins) == 6 + + # Verify pin names are correct + assert analog_pins[0] == "A0" + assert analog_pins[5] == "A5" + + +def test_sensor_calibration_range(): + """Test sensor calibration for typical capacitive moisture sensor range. + + Capacitive sensors typically read: + - ~200-400 when dry (air) + - ~500-700 when in moist soil + - ~800-950 when in water + """ + # Dry air reading + dry_air = 300 + dry_pct = arduino_map(dry_air, 0, 1023, 0, 100) + assert 0 <= dry_pct <= 40 + + # Water reading + water = 900 + water_pct = arduino_map(water, 0, 1023, 0, 100) + assert 80 <= water_pct <= 100 + + # Mid-range (moist soil) + moist = 600 + moist_pct = arduino_map(moist, 0, 1023, 0, 100) + assert 40 <= moist_pct <= 70 + + +def test_data_publishing_interval(): + """Test the timing between data publications. + + Argon publishes every 30 minutes. + Xenon publishes every 1 minute. + """ + # Time in milliseconds + argon_interval = 1800000 # 30 minutes + xenon_interval = 60000 # 1 minute + + # Verify intervals + assert argon_interval > xenon_interval + assert argon_interval == 30 * xenon_interval + + # Convert to minutes + argon_minutes = argon_interval / (1000 * 60) + xenon_minutes = xenon_interval / (1000 * 60) + + assert argon_minutes == 30 + assert xenon_minutes == 1 + + +def test_complete_sensor_workflow(): + """Test the complete sensor reading workflow. + + 1. Turn on LED (digitalWrite(boardLed, HIGH)) + 2. Read analog value (analogRead(A0)) + 3. Map to percentage (map(analogValue, 0, 1023, 0, 100)) + 4. Constrain value (constrain(value, 0, 100)) + 5. Turn off LED (digitalWrite(boardLed, LOW)) + 6. Publish data + """ + # Simulate workflow + board_led = 1 # Start HIGH (on) + assert board_led == 1 + + # Read analog (simulated value) + analog_value = 550 + + # Map to percentage + percentage = arduino_map(analog_value, 0, 1023, 0, 100) + percentage = arduino_constrain(percentage, 0, 100) + + # LED off + board_led = 0 # LOW + assert board_led == 0 + + # Verify results + assert 40 <= percentage <= 60 + assert isinstance(percentage, int) + + +def test_edge_case_analog_values(): + """Test edge cases for analog readings.""" + # Minimum analog value + assert arduino_map(0, 0, 1023, 0, 100) == 0 + + # Maximum analog value + assert arduino_map(1023, 0, 1023, 0, 100) == 100 + + # Single bit change from minimum + assert arduino_map(1, 0, 1023, 0, 100) == 0 # Rounds down + + # Single bit change from maximum + assert arduino_map(1022, 0, 1023, 0, 100) == 99 # Rounds down + + +def test_percentage_to_analog_reverse(): + """Test reverse mapping from percentage to analog value.""" + # 50% should map to approximately 512 + analog_50 = arduino_map(50, 0, 100, 0, 1023) + assert 500 <= analog_50 <= 525 + + # 25% should map to approximately 256 + analog_25 = arduino_map(25, 0, 100, 0, 1023) + assert 240 <= analog_25 <= 270 + + # 75% should map to approximately 768 + analog_75 = arduino_map(75, 0, 100, 0, 1023) + assert 750 <= analog_75 <= 780 + + +def test_sensor_noise_handling(): + """Test handling of small variations in sensor readings. + + Small variations should not cause large percentage changes. + """ + # Base reading + base = 500 + base_pct = arduino_map(base, 0, 1023, 0, 100) + + # Small variation (+/- 5) + variation = 5 + high = arduino_map(base + variation, 0, 1023, 0, 100) + low = arduino_map(base - variation, 0, 1023, 0, 100) + + # Changes should be small + assert abs(high - base_pct) <= 1 + assert abs(low - base_pct) <= 1 + assert abs(high - low) <= 2 + + +def test_data_consistency(): + """Test that the same input always produces the same output.""" + test_values = [0, 100, 256, 512, 768, 1023] + + for value in test_values: + pct1 = arduino_map(value, 0, 1023, 0, 100) + pct2 = arduino_map(value, 0, 1023, 0, 100) + assert pct1 == pct2 + + +def test_sensor_range_validation(): + """Test that sensor readings stay within expected ranges.""" + # Simulate multiple readings + for analog_value in range(0, 1024, 100): + percentage = arduino_map(analog_value, 0, 1023, 0, 100) + + # Should always be in 0-100 range + assert 0 <= percentage <= 100 + + # Should be monotonically increasing + if analog_value > 0: + prev_pct = arduino_map(analog_value - 100, 0, 1023, 0, 100) + assert percentage >= prev_pct + + +def test_publish_event_names(): + """Test that event names match what's published in the .ino files.""" + # From inplants-argon.ino and inplants-xenon.ino + analog_event = "plantStatus_analog" + percentage_event = "plantStatus_percentage" + + assert isinstance(analog_event, str) + assert isinstance(percentage_event, str) + assert len(analog_event) > 0 + assert len(percentage_event) > 0 + + +def test_public_data_visibility(): + """Test that published data is public (as per Particle.publish).""" + # In Particle, 60 = private, but the code uses PUBLIC + # This test verifies the concept of public data visibility + public_visibility = True