// 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" #include "buffer_crc.h" // the real one, from lib/BROSE9323 #include "frame_commands.h" // the real one, from lib/BROSE9323 #include #include #include #include // 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 TEST_ASSERT_EQUAL(0, display.last("display")->b); // with the effect } void test_display_instant_frame() { send("D1\n"); TEST_ASSERT_EQUAL(1, display.count("display")); TEST_ASSERT_EQUAL(0, display.last("display")->a); TEST_ASSERT_EQUAL(1, display.last("display")->b); // without the effect } void test_refresh_starts_between_instant_frames() { // The driver pauses the refresh while frames arrive, so it may start at any time last_refresh = fake_millis; fake_millis += 120000; send("D1\n"); TEST_ASSERT_EQUAL(1, display.count("display")); TEST_ASSERT_EQUAL(0, display.last("display")->a); // the frame is not forced TEST_ASSERT_EQUAL(1, display.count("refresh")); } 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")); } // The commands the ESP32 driver sends for a frame (lib/BROSE9323/frame_commands.h), run // through this parser: the drawn dots must give the wanted image. static const int IMG_BYTES = 11 * 19; static std::string frame(const uint8_t *wanted, const uint8_t *shown, bool instant) { std::string text; frameCommands(wanted, shown, 84, 19, instant, [&text](const char *t) { text += t; }); return text; } static void setDot(uint8_t *img, int x, int y, bool on) { if (on) img[y * 11 + x / 8] |= 0x80 >> (x & 7); else img[y * 11 + x / 8] &= ~(0x80 >> (x & 7)); } // The recorded fill and pixel calls applied to an image static void applyCalls(uint8_t *img) { for (const DisplayCall &call : display.calls) if (call.what == "fill") memset(img, call.a ? 0xFF : 0, IMG_BYTES); else if (call.what == "pixel") setDot(img, call.a, call.b, call.c); } static void pattern(uint8_t *img, int seed) { for (int i = 0; i < IMG_BYTES; i++) img[i] = (uint8_t)(i * 37 + seed * 11); for (int y = 0; y < 19; y++) // nothing past the 84th dot img[y * 11 + 10] &= 0xF0; } void test_frame_commands_send_only_the_changed_dots() { uint8_t shown[IMG_BYTES], wanted[IMG_BYTES]; pattern(shown, 1); memcpy(wanted, shown, IMG_BYTES); int changed = 0; for (int x = 30; x < 50; x++) // a scroll window, 6 rows high for (int y = 12; y < 18; y++) if ((x + y) % 3 == 0) { bool now = (shown[y * 11 + x / 8] >> (7 - (x & 7))) & 1; setDot(wanted, x, y, !now); changed++; } std::string text = frame(wanted, shown, true); TEST_ASSERT_TRUE(text.size() < 150); // instead of the whole screen send(text); TEST_ASSERT_FALSE(replied("ERR")); TEST_ASSERT_EQUAL(0, display.count("fill")); TEST_ASSERT_EQUAL(changed, display.count("pixel")); // only the changed dots uint8_t result[IMG_BYTES]; memcpy(result, shown, IMG_BYTES); applyCalls(result); TEST_ASSERT_EQUAL_MEMORY(wanted, result, IMG_BYTES); TEST_ASSERT_EQUAL(1, display.count("display")); TEST_ASSERT_EQUAL(1, display.last("display")->b); // D1: instant } void test_frame_commands_full_frame() { uint8_t wanted[IMG_BYTES]; pattern(wanted, 2); std::string text = frame(wanted, nullptr, false); TEST_ASSERT_TRUE(text.size() < 420); send(text); TEST_ASSERT_FALSE(replied("ERR")); TEST_ASSERT_EQUAL(1, display.count("fill")); uint8_t result[IMG_BYTES]; memset(result, 0xAA, IMG_BYTES); applyCalls(result); TEST_ASSERT_EQUAL_MEMORY(wanted, result, IMG_BYTES); TEST_ASSERT_EQUAL(0, display.last("display")->b); // D: with the effect } void test_frame_commands_edge_and_nothing_changed() { uint8_t shown[IMG_BYTES] = {}, wanted[IMG_BYTES] = {}; TEST_ASSERT_EQUAL_STRING("", frame(wanted, shown, true).c_str()); setDot(wanted, 83, 18, true); // the last dot: the B width (4) goes past the edge std::string text = frame(wanted, shown, false); send(text); TEST_ASSERT_FALSE(replied("ERR")); TEST_ASSERT_EQUAL(1, display.count("pixel")); TEST_ASSERT_EQUAL(83, display.last("pixel")->a); TEST_ASSERT_EQUAL(18, display.last("pixel")->b); } 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")); } // --------------------------------------------------------------------------- // Flip gap, pulses and refresh effect // --------------------------------------------------------------------------- void test_defaults_with_empty_eeprom() { EEPROM.erase(); setup(); TEST_ASSERT_EQUAL(200, display.last("gap")->a); TEST_ASSERT_EQUAL(2, display.last("pulses")->a); TEST_ASSERT_EQUAL(EFFECT_INSTANT, display.last("effect")->a); TEST_ASSERT_EQUAL(1500, display.last("effect")->b); TEST_ASSERT_EQUAL(0, display.last("effect")->c); } void test_eeprom_from_before_the_new_settings_keeps_flip_time() { // Old firmware only stored the magic and the flip time; the rest is still 0xFF EEPROM.erase(); send("T300\n"); EEPROM.put(4, (uint16_t)0xFFFF); EEPROM.put(6, (uint8_t)0xFF); EEPROM.put(7, (uint8_t)0xFF); EEPROM.put(8, (uint16_t)0xFFFF); EEPROM.put(10, (uint8_t)0xFF); display.calls.clear(); setup(); TEST_ASSERT_EQUAL(300, display.last("timing")->a); TEST_ASSERT_EQUAL(200, display.last("gap")->a); TEST_ASSERT_EQUAL(2, display.last("pulses")->a); TEST_ASSERT_EQUAL(EFFECT_INSTANT, display.last("effect")->a); } void test_gap_is_applied_and_stored() { send("G350\n"); TEST_ASSERT_EQUAL(350, display.last("gap")->a); send("G0\n"); TEST_ASSERT_EQUAL(0, display.last("gap")->a); send("G1200\n"); display.calls.clear(); setup(); TEST_ASSERT_EQUAL(1200, display.last("gap")->a); } void test_gap_out_of_range_is_rejected() { send("G5001\n"); send("G\n"); TEST_ASSERT_EQUAL(0, display.count("gap")); TEST_ASSERT_TRUE(replied("ERR")); } void test_pulses_are_applied_and_stored() { send("N3\n"); TEST_ASSERT_EQUAL(3, display.last("pulses")->a); send("N1\n"); display.calls.clear(); setup(); TEST_ASSERT_EQUAL(1, display.last("pulses")->a); } void test_pulses_out_of_range_are_rejected() { send("N0\n"); send("N4\n"); TEST_ASSERT_EQUAL(0, display.count("pulses")); TEST_ASSERT_TRUE(replied("ERR")); } void test_effect_is_applied_and_stored() { send("E1,2500\n"); TEST_ASSERT_EQUAL(EFFECT_SPARKLE, display.last("effect")->a); TEST_ASSERT_EQUAL(2500, display.last("effect")->b); display.calls.clear(); setup(); TEST_ASSERT_EQUAL(EFFECT_SPARKLE, display.last("effect")->a); TEST_ASSERT_EQUAL(2500, display.last("effect")->b); TEST_ASSERT_EQUAL(0, display.last("effect")->c); // no flags given send("E9,1000,1\n"); TEST_ASSERT_EQUAL(EFFECT_RANDOM, display.last("effect")->a); TEST_ASSERT_EQUAL(EFFECT_FLAG_SMALL_ONLY, display.last("effect")->c); display.calls.clear(); setup(); TEST_ASSERT_EQUAL(EFFECT_FLAG_SMALL_ONLY, display.last("effect")->c); send("E0,1500,0\n"); } void test_invalid_effect_is_rejected() { send("E10,1500\n"); // unknown effect send("E1,1500,2\n"); // unknown flag send("E1,50\n"); // too fast send("E1\n"); // no duration TEST_ASSERT_EQUAL(0, display.count("effect")); TEST_ASSERT_TRUE(replied("ERR")); } // Every effect order visits each dot exactly once static void checkSequence(uint8_t effect, uint8_t w, uint8_t h, uint16_t seed) { std::vector seen(w * h, 0); DotSequence sequence; sequence.begin(effect, w, h, seed); uint8_t x, y; int n = 0; while (sequence.next(x, y)) { TEST_ASSERT_TRUE(x < w && y < h); seen[y * w + x]++; n++; } TEST_ASSERT_EQUAL(w * h, n); TEST_ASSERT_EQUAL(w * h, sequence.total()); for (int count : seen) TEST_ASSERT_EQUAL(1, count); } void test_every_effect_visits_each_dot_once() { for (uint8_t effect = 0; effect < EFFECT_COUNT; effect++) { checkSequence(effect, 84, 19, 0); checkSequence(effect, 84, 19, 12345); checkSequence(effect, 7, 5, 3); checkSequence(effect, 5, 7, 3); checkSequence(effect, 1, 1, 0); checkSequence(effect, 1, 6, 0); checkSequence(effect, 6, 1, 0); checkSequence(effect, 2, 2, 0); } // Sparkle with every LFSR size up to 4095 dots for (int n = 2; n < 4096; n = n * 2 + 1) checkSequence(EFFECT_SPARKLE, n > 255 ? 255 : n, n > 255 ? (n + 254) / 255 : 1, 7); } void test_wipe_goes_left_to_right_and_center_starts_in_the_middle() { DotSequence sequence; uint8_t x, y; sequence.begin(EFFECT_WIPE, 84, 19, 0); sequence.next(x, y); TEST_ASSERT_EQUAL(0, x); sequence.begin(EFFECT_CENTER, 84, 19, 0); sequence.next(x, y); TEST_ASSERT_EQUAL(42, x); sequence.begin(EFFECT_DIAGONAL, 84, 19, 0); sequence.next(x, y); TEST_ASSERT_TRUE(x == 0 && y == 0); sequence.next(x, y); TEST_ASSERT_TRUE(x == 1 && y == 0); sequence.next(x, y); TEST_ASSERT_TRUE(x == 0 && y == 1); // Spiral: the whole top row, then down the right side sequence.begin(EFFECT_SPIRAL, 84, 19, 0); for (int i = 0; i < 84; i++) { sequence.next(x, y); TEST_ASSERT_TRUE(x == i && y == 0); } sequence.next(x, y); TEST_ASSERT_TRUE(x == 83 && y == 1); sequence.begin(EFFECT_RAIN, 84, 19, 0); sequence.next(x, y); sequence.next(x, y); TEST_ASSERT_EQUAL(1, x); TEST_ASSERT_EQUAL(0, y); } void test_sparkle_order_is_scrambled() { // Neighbouring dots should rarely follow each other DotSequence sequence; sequence.begin(EFFECT_SPARKLE, 84, 19, 99); uint8_t x, y, px = 0, py = 0; int neighbours = 0, n = 0; while (sequence.next(x, y)) { if (n++ && abs(x - px) <= 1 && abs(y - py) <= 1) neighbours++; px = x; py = y; } TEST_ASSERT_LESS_THAN(n / 20, neighbours); } // --------------------------------------------------------------------------- // Periodic full redraw // --------------------------------------------------------------------------- void test_refresh_when_due() { last_refresh = fake_millis; fake_millis += 119999; loop(); TEST_ASSERT_EQUAL(0, display.count("refresh")); fake_millis += 1; loop(); TEST_ASSERT_EQUAL(1, display.count("refresh")); TEST_ASSERT_EQUAL(0, display.count("display")); // no frame, the driver does it } void test_refresh_only_once_per_interval() { last_refresh = fake_millis; fake_millis += 120000; send("D\n"); send("D\n"); loop(); TEST_ASSERT_EQUAL(1, display.count("refresh")); } void test_running_refresh_is_not_restarted() { last_refresh = fake_millis; fake_millis += 120000; display.refresh_running = true; loop(); display.refresh_running = false; TEST_ASSERT_EQUAL(0, display.count("refresh")); } 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(0, display.count("refresh")); fake_millis += 120000; send("D\n"); TEST_ASSERT_EQUAL(1, display.count("refresh")); } void test_refresh_interval_is_applied_and_stored() { send("I5\n"); TEST_ASSERT_FALSE(replied("ERR")); fake_millis += 120000; loop(); TEST_ASSERT_EQUAL(0, display.count("refresh")); fake_millis += 180000; loop(); TEST_ASSERT_EQUAL(1, display.count("refresh")); // Kept over a power cycle setup(); last_refresh = fake_millis; display.calls.clear(); fake_millis += 120000; loop(); TEST_ASSERT_EQUAL(0, display.count("refresh")); send("I2\n"); } void test_refresh_interval_zero_never_refreshes() { send("I0\n"); TEST_ASSERT_FALSE(replied("ERR")); fake_millis += 24UL * 3600 * 1000; loop(); TEST_ASSERT_EQUAL(0, display.count("refresh")); send("I2\n"); } void test_refresh_interval_out_of_range_is_rejected() { send("I1441\n"); send("I-1\n"); send("I\n"); TEST_ASSERT_TRUE(replied("ERR")); fake_millis += 120000; loop(); TEST_ASSERT_EQUAL(1, display.count("refresh")); // still every 2 minutes } // --------------------------------------------------------------------------- // Checksum // --------------------------------------------------------------------------- void test_checksum_reply() { display.crc_value = 0x0A1F; send("C\n"); TEST_ASSERT_EQUAL_STRING("C 0A1F\n", Serial.out.c_str()); display.crc_value = 0xFFFF; send("c\n"); TEST_ASSERT_EQUAL_STRING("C 0A1F\nC FFFF\n", Serial.out.c_str()); } void test_checksum_follows_the_frame() { // The reply comes after the frame's commands ran (they are handled in order) display.crc_value = 0x1234; send("B0000040118\nD\nC\n"); TEST_ASSERT_EQUAL(1, display.count("display")); TEST_ASSERT_EQUAL(1, display.count("pixel")); TEST_ASSERT_EQUAL_STRING("C 1234\n", Serial.out.c_str()); } void test_crc_counts_only_the_dots() { // 84 x 19: 11 bytes per row, the top 4 bits of each 11th byte are not dots uint8_t a[11 * 19] = {}, b[11 * 19] = {}; TEST_ASSERT_EQUAL_HEX16(bufferCrc(a, 84, 19), bufferCrc(b, 84, 19)); for (int y = 0; y < 19; y++) b[y * 11 + 10] = 0xF0; // what fillScreen(1) leaves there TEST_ASSERT_EQUAL_HEX16(bufferCrc(a, 84, 19), bufferCrc(b, 84, 19)); b[5] = 0x01; // one dot TEST_ASSERT_NOT_EQUAL(bufferCrc(a, 84, 19), bufferCrc(b, 84, 19)); // CRC-16/CCITT-FALSE check value const uint8_t check[9] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'}; TEST_ASSERT_EQUAL_HEX16(0x29B1, bufferCrc(check, 72, 1)); } 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_display_instant_frame); RUN_TEST(test_refresh_starts_between_instant_frames); 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_frame_commands_send_only_the_changed_dots); RUN_TEST(test_frame_commands_full_frame); RUN_TEST(test_frame_commands_edge_and_nothing_changed); 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_defaults_with_empty_eeprom); RUN_TEST(test_eeprom_from_before_the_new_settings_keeps_flip_time); RUN_TEST(test_gap_is_applied_and_stored); RUN_TEST(test_gap_out_of_range_is_rejected); RUN_TEST(test_pulses_are_applied_and_stored); RUN_TEST(test_pulses_out_of_range_are_rejected); RUN_TEST(test_effect_is_applied_and_stored); RUN_TEST(test_invalid_effect_is_rejected); RUN_TEST(test_every_effect_visits_each_dot_once); RUN_TEST(test_wipe_goes_left_to_right_and_center_starts_in_the_middle); RUN_TEST(test_sparkle_order_is_scrambled); RUN_TEST(test_refresh_when_due); RUN_TEST(test_refresh_only_once_per_interval); RUN_TEST(test_running_refresh_is_not_restarted); RUN_TEST(test_no_refresh_storm_after_millis_wraps); RUN_TEST(test_refresh_interval_is_applied_and_stored); RUN_TEST(test_refresh_interval_zero_never_refreshes); RUN_TEST(test_refresh_interval_out_of_range_is_rejected); RUN_TEST(test_checksum_reply); RUN_TEST(test_checksum_follows_the_frame); RUN_TEST(test_crc_counts_only_the_dots); return UNITY_END(); }