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 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 00:24:13 +02:00
1 parent 80f3f78152
commit 8415dee347
8 files changed
+505 -4

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
// Minimal Arduino API for running the Nano code on the PC (native test environment)
#pragma once
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <string>
#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<char> 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;
+58
View File
@@ -0,0 +1,58 @@
// Fake display driver for the native tests: records every call instead of flipping dots
#pragma once
#include <string>
#include <vector>
#include "Arduino.h"
struct DisplayCall
{
std::string what;
int a, b, c;
};
class BROSE9323
{
public:
std::vector<DisplayCall> 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}); }
};
+30
View File
@@ -0,0 +1,30 @@
// In-memory EEPROM for the native tests (starts erased, all 0xFF like a new chip)
#pragma once
#include <cstdint>
#include <cstring>
class FakeEEPROM
{
public:
uint8_t mem[1024];
FakeEEPROM() { erase(); }
void erase() { memset(mem, 0xFF, sizeof(mem)); }
template <typename T>
T &get(int addr, T &t)
{
memcpy(&t, mem + addr, sizeof(T));
return t;
}
template <typename T>
const T &put(int addr, const T &t)
{
memcpy(mem + addr, &t, sizeof(T));
return t;
}
};
inline FakeEEPROM EEPROM;
+321
View File
@@ -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 <unity.h>
#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();
}