From 8415dee3475cce70e213be800476e40acdb5f179 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Tue, 13 Oct 2026 00:23:07 +0200 Subject: [PATCH] Add native unit tests for the Nano firmware - New native environment: pio test -e native - test/mocks: fake Arduino core, EEPROM and recording BROSE9323 driver - test/test_nano: 25 tests covering every command, bitmaps, bad input, flip time storage and the periodic full redraw - Board environments ignore tests so they are never uploaded Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +- README.md | 6 + platformio.ini | 18 ++ src/nano/README.md | 8 + test/mocks/Arduino.h | 55 ++++++ test/mocks/BROSE9323.h | 58 +++++++ test/mocks/EEPROM.h | 30 ++++ test/test_nano/test_main.cpp | 321 +++++++++++++++++++++++++++++++++++ 8 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 test/mocks/Arduino.h create mode 100644 test/mocks/BROSE9323.h create mode 100644 test/mocks/EEPROM.h create mode 100644 test/test_nano/test_main.cpp diff --git a/CLAUDE.md b/CLAUDE.md index b685bb8..934f15b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,8 @@ See README.md for the overview. or `$env:USERPROFILE\.platformio\penv\Scripts\pio.exe run` (PowerShell). - If an ESP32 build fails with `No module named 'intelhex'`, run `~/.platformio/penv/Scripts/python.exe -m pip install intelhex`. -- After any change, build both targets: `pio run -e nano -e esp32`. +- After any change, build both targets (`pio run`). After Nano changes, also run + `pio test -e native`. ## Constraints @@ -55,9 +56,13 @@ See README.md for the overview. - The Nano only replies `READY` (at boot) and `ERR: ...`. There is no per-frame acknowledgement. Whether the Nano's TX is wired to the ESP's RX is unconfirmed, so every wait for a reply needs a timeout. -- To test the parser without hardware, compile all `src/nano/*.cpp` files with g++ (Strawberry Perl - provides it at `C:\Strawberry\c\bin`) and mock headers for `Arduino.h`, `EEPROM.h` and - `BROSE9323.h`. +- Unit tests: `pio test -e native` runs `test/test_nano/` on the PC with the fakes in + `test/mocks/` (Arduino, EEPROM, and a `BROSE9323` that records calls). It needs gcc on + PATH (Strawberry Perl provides it at `C:\Strawberry\c\bin`). Add a test for every + protocol change. The mocks only implement what the Nano code uses, so extend them when + new Arduino or driver functions are called. +- The board environments have `test_ignore = *`, so test firmware is never uploaded. Plain + `pio test` runs nothing; always pass `-e native`. ## Git diff --git a/README.md b/README.md index 60bcc3d..ba4c975 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ lib/BROSE9323/ shared flipdot driver (used by both targets) src/nano/ Arduino Nano firmware src/esp32/ ESP32 firmware secrets.example.h template for WiFi / MQTT credentials +test/test_nano/ unit tests for the Nano firmware (run on the PC) +test/mocks/ fake Arduino, EEPROM and display driver for the tests archive/ old, unused sketches kept for reference CLAUDE.md project notes for Claude Code ``` @@ -64,8 +66,12 @@ pio run -e nano -t upload # flash the Nano pio run -e esp32 -t upload # flash the ESP32 over the air pio run -e esp32_usb -t upload # flash the ESP32 over USB pio device monitor -e nano # serial monitor +pio test -e native # run the Nano unit tests on the PC ``` +The unit tests need `gcc`/`g++` on your PATH. On Windows you can get it from +[MSYS2](https://www.msys2.org/) or Strawberry Perl. + In VS Code you can also pick the environment in the PlatformIO status bar. > **Tip:** The ESP32 → Nano wire goes to the Nano's RX pin, which the Nano's USB chip diff --git a/platformio.ini b/platformio.ini index 9972f9e..91531f9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,11 +3,13 @@ ; nano Arduino Nano that drives the Brose flipdot panels directly ; esp32 ESP32 "brain" (WiFi, NTP clock, Home Assistant) - OTA upload ; esp32_usb Same ESP32 firmware, flashed over USB (first flash / recovery) +; native Unit tests for the Nano firmware, run on the PC (needs gcc on PATH) ; ; Build all: pio run ; Build one: pio run -e nano ; Upload one: pio run -e nano -t upload ; Serial monitor: pio device monitor -e nano +; Run tests: pio test -e native ; ; https://docs.platformio.org/page/projectconf.html @@ -19,6 +21,8 @@ framework = arduino monitor_speed = 115200 lib_deps = adafruit/Adafruit GFX Library@^1.10.12 +; Tests only run in the native environment, never on the boards +test_ignore = * ; --------------------------------------------------------------------------- ; Arduino Nano - display driver @@ -57,3 +61,17 @@ monitor_port = COM11 extends = env:esp32 upload_protocol = esptool upload_port = COM11 + +; --------------------------------------------------------------------------- +; Native - unit tests for the Nano firmware on the PC (test/test_nano) +; The Arduino core, EEPROM and the display driver are replaced by test/mocks. +; --------------------------------------------------------------------------- +[env:native] +platform = native +framework = +lib_deps = +lib_ignore = BROSE9323 +test_ignore = +test_build_src = yes +build_src_filter = + +build_flags = -std=gnu++17 -I test/mocks diff --git a/src/nano/README.md b/src/nano/README.md index 91aae5f..6f427c2 100644 --- a/src/nano/README.md +++ b/src/nano/README.md @@ -17,6 +17,14 @@ logic. The display driver itself is in [lib/BROSE9323](../../lib/BROSE9323/README.md). +## Tests + +`pio test -e native` runs the unit tests in [test/test_nano](../../test/test_nano/test_main.cpp) +on the PC. They feed serial data to the firmware and check what it asks the display to do: +every command, bitmaps, bad input, flip time storage and the full-redraw timing. The +Arduino core, EEPROM and the display driver are replaced by fakes in +[test/mocks](../../test/mocks). + ## Behaviour - **Boot:** the Nano loads the flip time from EEPROM, sets up the pins and sends `READY`. diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h new file mode 100644 index 0000000..eb2e07c --- /dev/null +++ b/test/mocks/Arduino.h @@ -0,0 +1,55 @@ +// Minimal Arduino API for running the Nano code on the PC (native test environment) +#pragma once + +#include +#include +#include +#include +#include +#include + +#define HEX 16 + +class __FlashStringHelper; +#define F(s) ((const __FlashStringHelper *)(s)) + +// Time is controlled by the tests +inline uint32_t fake_millis = 0; +inline uint32_t millis() { return fake_millis; } +inline void delay(uint32_t ms) { fake_millis += ms; } + +// Serial port: tests push bytes into `in` and read replies from `out` +class FakeSerial +{ +public: + std::deque in; + std::string out; + + void begin(unsigned long) {} + int available() { return in.size(); } + int read() + { + if (in.empty()) + return -1; + char c = in.front(); + in.pop_front(); + return (uint8_t)c; + } + void flush() {} + + void print(const __FlashStringHelper *s) { out += (const char *)s; } + void print(const char *s) { out += s; } + void print(int v, int base = 10) + { + char buf[16]; + snprintf(buf, sizeof(buf), base == HEX ? "%X" : "%d", v); + out += buf; + } + void println(const __FlashStringHelper *s) { print(s); out += "\n"; } + void println(const char *s) { print(s); out += "\n"; } + void println(unsigned long v) { out += std::to_string(v) + "\n"; } +}; + +using Print = FakeSerial; + +inline FakeSerial Serial; diff --git a/test/mocks/BROSE9323.h b/test/mocks/BROSE9323.h new file mode 100644 index 0000000..8489ef3 --- /dev/null +++ b/test/mocks/BROSE9323.h @@ -0,0 +1,58 @@ +// Fake display driver for the native tests: records every call instead of flipping dots +#pragma once + +#include +#include + +#include "Arduino.h" + +struct DisplayCall +{ + std::string what; + int a, b, c; +}; + +class BROSE9323 +{ +public: + std::vector calls; + bool direct = false; + + BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {} + + void begin() { log("begin"); } + void display(bool force = false) { log("display", force); } + void drawPixel(int16_t x, int16_t y, uint16_t color) { log(direct ? "directPixel" : "pixel", x, y, color); } + void fillScreen(uint16_t color) { log("fill", color); } + void setDirect(bool d) { direct = d; } + void setTiming(uint16_t t) { log("timing", t); } + void printBuffer() { log("printBuffer"); } + void setCursor(int16_t x, int16_t y) { log("cursor", x, y); } + void setTextColor(uint16_t color, uint16_t background) { log("textColor", color, background); } + void setTextSize(uint8_t size) { log("textSize", size); } + size_t write(uint8_t ch) + { + log("write", ch); + return 1; + } + void println() { log("newline"); } + + // Helpers for the tests + int count(const std::string &what) const + { + int n = 0; + for (const DisplayCall &call : calls) + n += call.what == what; + return n; + } + const DisplayCall *last(const std::string &what) const + { + for (auto it = calls.rbegin(); it != calls.rend(); ++it) + if (it->what == what) + return &*it; + return nullptr; + } + +private: + void log(const char *what, int a = 0, int b = 0, int c = 0) { calls.push_back({what, a, b, c}); } +}; diff --git a/test/mocks/EEPROM.h b/test/mocks/EEPROM.h new file mode 100644 index 0000000..614d534 --- /dev/null +++ b/test/mocks/EEPROM.h @@ -0,0 +1,30 @@ +// In-memory EEPROM for the native tests (starts erased, all 0xFF like a new chip) +#pragma once + +#include +#include + +class FakeEEPROM +{ +public: + uint8_t mem[1024]; + + FakeEEPROM() { erase(); } + void erase() { memset(mem, 0xFF, sizeof(mem)); } + + template + T &get(int addr, T &t) + { + memcpy(&t, mem + addr, sizeof(T)); + return t; + } + + template + const T &put(int addr, const T &t) + { + memcpy(mem + addr, &t, sizeof(T)); + return t; + } +}; + +inline FakeEEPROM EEPROM; diff --git a/test/test_nano/test_main.cpp b/test/test_nano/test_main.cpp new file mode 100644 index 0000000..377950a --- /dev/null +++ b/test/test_nano/test_main.cpp @@ -0,0 +1,321 @@ +// Tests for the Nano firmware (src/nano), run on the PC: pio test -e native +// +// The real Arduino, EEPROM and display driver are replaced by the fakes in test/mocks. +// Tests push bytes into Serial.in, call loop() and check what the display was asked to do. + +#include + +#include "Arduino.h" +#include "BROSE9323.h" +#include "EEPROM.h" + +// From src/nano/main.cpp +extern BROSE9323 display; +extern uint32_t last_refresh; +void setup(); +void loop(); + +// Send bytes to the Nano and let it process them +static void send(const std::string &data) +{ + for (char c : data) + Serial.in.push_back(c); + loop(); +} + +static bool replied(const char *text) +{ + return Serial.out.find(text) != std::string::npos; +} + +void setUp() +{ + // Finish any half-received line from the previous test + send("\n"); + fake_millis += 1; + last_refresh = fake_millis; // no full redraw due unless a test asks for one + display.calls.clear(); + display.direct = false; + Serial.out.clear(); +} + +void tearDown() {} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +void test_boot_does_not_flip_dots() +{ + EEPROM.erase(); + setup(); + TEST_ASSERT_EQUAL(1, display.count("begin")); + TEST_ASSERT_EQUAL(0, display.count("display")); + TEST_ASSERT_EQUAL(0, display.count("fill")); +} + +void test_boot_sends_ready() +{ + setup(); + TEST_ASSERT_TRUE(replied("READY\n")); +} + +void test_boot_uses_default_flip_time_with_empty_eeprom() +{ + EEPROM.erase(); + setup(); + TEST_ASSERT_EQUAL(200, display.last("timing")->a); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +void test_pixel_runs_without_waiting_for_next_command() +{ + send("s0a051\n"); + TEST_ASSERT_EQUAL(1, display.count("pixel")); + TEST_ASSERT_EQUAL(10, display.last("pixel")->a); + TEST_ASSERT_EQUAL(5, display.last("pixel")->b); + TEST_ASSERT_EQUAL(1, display.last("pixel")->c); +} + +void test_direct_pixel_flips_immediately_and_resets_direct_mode() +{ + send("S010200\n"); + TEST_ASSERT_EQUAL(1, display.count("directPixel")); + TEST_ASSERT_FALSE(display.direct); +} + +void test_fill() +{ + send("F1\n"); + TEST_ASSERT_EQUAL(1, display.last("fill")->a); + send("F0\n"); + TEST_ASSERT_EQUAL(0, display.last("fill")->a); +} + +void test_display() +{ + send("D\n"); + TEST_ASSERT_EQUAL(1, display.count("display")); + TEST_ASSERT_EQUAL(0, display.last("display")->a); // not forced +} + +void test_lowercase_and_uppercase_commands() +{ + send("f1\nd\n"); + TEST_ASSERT_EQUAL(1, display.count("fill")); + TEST_ASSERT_EQUAL(1, display.count("display")); +} + +void test_command_split_over_several_reads_with_crlf() +{ + send("s0"); + send("1"); + send("021\r\n"); + TEST_ASSERT_EQUAL(1, display.count("pixel")); + TEST_ASSERT_EQUAL(1, display.last("pixel")->a); + TEST_ASSERT_EQUAL(2, display.last("pixel")->b); +} + +void test_print_text_with_newline_escape() +{ + send("P0102115\\nab\n"); + TEST_ASSERT_EQUAL(1, display.last("cursor")->a); + TEST_ASSERT_EQUAL(2, display.last("cursor")->b); + TEST_ASSERT_EQUAL(1, display.last("textColor")->a); + TEST_ASSERT_EQUAL(0, display.last("textColor")->b); + TEST_ASSERT_EQUAL(1, display.last("textSize")->a); + TEST_ASSERT_EQUAL(3, display.count("write")); // '5', 'a', 'b' and no garbage + TEST_ASSERT_EQUAL(1, display.count("newline")); +} + +void test_print_buffer() +{ + send("R\n"); + TEST_ASSERT_EQUAL(1, display.count("printBuffer")); +} + +// --------------------------------------------------------------------------- +// Bitmaps +// --------------------------------------------------------------------------- + +void test_bitmap_pixels() +{ + // 8x2 at (4,3), color 1, rows F0 and 81 + send("B040308021F081\n"); + const int expected[][2] = {{4, 3}, {5, 3}, {6, 3}, {7, 3}, {4, 4}, {11, 4}}; + TEST_ASSERT_EQUAL(6, display.count("pixel")); + int i = 0; + for (const DisplayCall &call : display.calls) + { + if (call.what != "pixel") + continue; + TEST_ASSERT_EQUAL(expected[i][0], call.a); + TEST_ASSERT_EQUAL(expected[i][1], call.b); + TEST_ASSERT_EQUAL(1, call.c); + i++; + } +} + +void test_full_screen_bitmap_is_streamed() +{ + // 84x19 = 399 hex digits, much longer than the 64 character line buffer + send("B000054131" + std::string(399, 'F') + "\n"); + TEST_ASSERT_EQUAL(84 * 19, display.count("pixel")); +} + +void test_invalid_bitmap_data_is_not_run_as_commands() +{ + // width 5 is invalid; the data contains the command letters B, D and F + send("B0000050100BDFDFDF\nD\n"); + TEST_ASSERT_TRUE(replied("ERR")); + TEST_ASSERT_EQUAL(0, display.count("fill")); + TEST_ASSERT_EQUAL(1, display.count("display")); +} + +void test_incomplete_bitmap_header() +{ + send("B00\nD\n"); + TEST_ASSERT_TRUE(replied("ERR")); + TEST_ASSERT_EQUAL(1, display.count("display")); +} + +// --------------------------------------------------------------------------- +// Recovering from bad input +// --------------------------------------------------------------------------- + +void test_too_long_line_is_rejected() +{ + send(std::string(200, 'x') + "\nF1\n"); + TEST_ASSERT_TRUE(replied("Line too long")); + TEST_ASSERT_EQUAL(1, display.count("fill")); +} + +void test_truncated_command_is_ignored() +{ + send("s01\nF1\n"); + TEST_ASSERT_EQUAL(0, display.count("pixel")); + TEST_ASSERT_EQUAL(1, display.count("fill")); +} + +void test_unknown_command_is_ignored() +{ + send("xyz\nF1\n"); + TEST_ASSERT_EQUAL(1, display.count("fill")); +} + +// --------------------------------------------------------------------------- +// Flip time +// --------------------------------------------------------------------------- + +void test_timing_is_applied_immediately() +{ + send("T225\n"); + TEST_ASSERT_EQUAL(225, display.last("timing")->a); +} + +void test_timing_above_255_is_stored_and_restored() +{ + send("T300\n"); + TEST_ASSERT_EQUAL(300, display.last("timing")->a); + display.calls.clear(); + setup(); // reboot + TEST_ASSERT_EQUAL(300, display.last("timing")->a); +} + +void test_timing_out_of_range_is_rejected() +{ + send("T5\n"); + send("T99999\n"); + TEST_ASSERT_EQUAL(0, display.count("timing")); + TEST_ASSERT_TRUE(replied("ERR")); +} + +// --------------------------------------------------------------------------- +// Periodic full redraw +// --------------------------------------------------------------------------- + +void test_refresh_waits_for_end_of_frame() +{ + last_refresh = fake_millis; + fake_millis += 120000; + send("s000001\n"); + TEST_ASSERT_EQUAL(0, display.count("display")); + send("D\n"); + TEST_ASSERT_EQUAL(2, display.count("display")); + TEST_ASSERT_EQUAL(1, display.calls.back().a); // forced full redraw after the frame +} + +void test_refresh_only_once_per_interval() +{ + last_refresh = fake_millis; + fake_millis += 120000; + send("D\n"); + display.calls.clear(); + send("D\n"); + TEST_ASSERT_EQUAL(1, display.count("display")); +} + +void test_refresh_when_serial_is_idle() +{ + last_refresh = fake_millis; + fake_millis += 120000; + loop(); + TEST_ASSERT_EQUAL(1, display.count("display")); + TEST_ASSERT_EQUAL(1, display.last("display")->a); +} + +void test_no_refresh_storm_after_millis_wraps() +{ + fake_millis = 0xFFFFFFFFu - 1000; + last_refresh = fake_millis; + fake_millis += 5000; // wraps past 0 + for (int i = 0; i < 5; i++) + send("D\n"); + TEST_ASSERT_EQUAL(5, display.count("display")); + + fake_millis += 120000; + display.calls.clear(); + send("D\n"); + TEST_ASSERT_EQUAL(2, display.count("display")); +} + +int main() +{ + UNITY_BEGIN(); + + RUN_TEST(test_boot_does_not_flip_dots); + RUN_TEST(test_boot_sends_ready); + RUN_TEST(test_boot_uses_default_flip_time_with_empty_eeprom); + + RUN_TEST(test_pixel_runs_without_waiting_for_next_command); + RUN_TEST(test_direct_pixel_flips_immediately_and_resets_direct_mode); + RUN_TEST(test_fill); + RUN_TEST(test_display); + RUN_TEST(test_lowercase_and_uppercase_commands); + RUN_TEST(test_command_split_over_several_reads_with_crlf); + RUN_TEST(test_print_text_with_newline_escape); + RUN_TEST(test_print_buffer); + + RUN_TEST(test_bitmap_pixels); + RUN_TEST(test_full_screen_bitmap_is_streamed); + RUN_TEST(test_invalid_bitmap_data_is_not_run_as_commands); + RUN_TEST(test_incomplete_bitmap_header); + + RUN_TEST(test_too_long_line_is_rejected); + RUN_TEST(test_truncated_command_is_ignored); + RUN_TEST(test_unknown_command_is_ignored); + + RUN_TEST(test_timing_is_applied_immediately); + RUN_TEST(test_timing_above_255_is_stored_and_restored); + RUN_TEST(test_timing_out_of_range_is_rejected); + + RUN_TEST(test_refresh_waits_for_end_of_frame); + RUN_TEST(test_refresh_only_once_per_interval); + RUN_TEST(test_refresh_when_serial_is_idle); + RUN_TEST(test_no_refresh_storm_after_millis_wraps); + + return UNITY_END(); +}