Files
Flipdot-display/test/test_esp32/test_main.cpp
T
mangavalkandClaude Opus 5.5 1d3b084de1 Add HA dropdowns, off image, flip fixes and refresh effects
Home Assistant: remove the Clock switch (its entity is deleted on connect),
add an IP sensor and Mode / Off image dropdowns whose options follow the
saved bitmaps. Remove the built-in "Nintendo border" bitmap.

Off image: a bitmap (or blank) left on the dots when the display switches off.

Nano: pause after each flip pulse and repeat pulses for changed dots, both
stored in EEPROM (G/N commands). Refresh effects (E command): instant,
sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then
reveal, push and random, optionally only for small changes. Effects run in
steps from loop() so serial input keeps flowing.

Web UI: flip settings, refresh effect and test patterns under Settings; the
System tab shows why the ESP32 last restarted. Settings records from
versions 1 and 2 are migrated. Firmware 2.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:29:01 +02:00

922 lines
28 KiB
C++

// Tests for the ESP32 logic (src/esp32/logic), run on the PC: pio test -e test_esp32
//
// The display controller is tested with a fake power switch and fake frames that record
// what the controller asks for.
#include <unity.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "config.h"
#include "logic/bitmap_format.h"
#include "logic/boot_policy.h"
#include "logic/display_controller.h"
#include "logic/power_timer.h"
#include "logic/schedule.h"
#include "logic/settings.h"
#include "logic/status_led.h"
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
class FakePower : public DisplayPower
{
public:
bool is_on = false;
int on_count = 0;
int off_count = 0;
bool isOn() override { return is_on; }
void on() override
{
is_on = true;
on_count++;
}
void off() override
{
is_on = false;
off_count++;
}
};
class FakeFrames : public Frames
{
public:
std::vector<std::string> shown;
void clock(int hour, int minute, bool evening) override
{
char s[24];
snprintf(s, sizeof(s), "clock %02d:%02d%s", hour, minute, evening ? " evening" : "");
shown.push_back(s);
}
void page() override { shown.push_back("page"); }
void live() override { shown.push_back("live"); }
void noWifi(int hour, int minute) override
{
char s[16];
snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute);
shown.push_back(s);
}
void off() override { shown.push_back("off"); }
std::string last() const { return shown.empty() ? "" : shown.back(); }
};
static const Settings defaults;
static FakePower power;
static FakeFrames frames;
static DisplayController *controller;
static uint32_t now;
static bool wifi;
void setUp()
{
power = FakePower();
frames = FakeFrames();
delete controller;
controller = new DisplayController(power, frames);
now = 1000;
wifi = true;
}
void tearDown() {}
// Run the controller at a time of day; `ms` passes in steps like the real loop
static void run(int hour, int minute, uint32_t ms = 0)
{
uint32_t end = now + ms;
do
{
controller->update(now, true, hour, minute, wifi);
now += 100;
} while ((int32_t)(end - now) >= 0);
}
// ---------------------------------------------------------------------------
// Schedule
// ---------------------------------------------------------------------------
void test_schedule_night_until_8()
{
TEST_ASSERT_TRUE(scheduleFor(0, 0, defaults.schedule) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(7, 0, defaults.schedule) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(7, 59, defaults.schedule) == ScheduleMode::Night);
}
void test_schedule_clock_from_8_to_22()
{
TEST_ASSERT_TRUE(scheduleFor(8, 0, defaults.schedule) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(12, 34, defaults.schedule) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(21, 59, defaults.schedule) == ScheduleMode::Clock);
}
void test_schedule_quarter_hours_until_23_30()
{
TEST_ASSERT_TRUE(scheduleFor(22, 0, defaults.schedule) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 1, defaults.schedule) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(22, 14, defaults.schedule) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(22, 15, defaults.schedule) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 45, defaults.schedule) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 0, defaults.schedule) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 29, defaults.schedule) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(23, 30, defaults.schedule) == ScheduleMode::Clock);
}
void test_schedule_night_from_23_31()
{
TEST_ASSERT_TRUE(scheduleFor(23, 31, defaults.schedule) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(23, 45, defaults.schedule) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(23, 59, defaults.schedule) == ScheduleMode::Night);
}
void test_evening_from_17()
{
TEST_ASSERT_FALSE(isEvening(16, 59, defaults.clock));
TEST_ASSERT_TRUE(isEvening(17, 0, defaults.clock));
TEST_ASSERT_TRUE(isEvening(23, 0, defaults.clock));
}
// ---------------------------------------------------------------------------
// Power timer
// ---------------------------------------------------------------------------
void test_timer_expires_once_after_delay()
{
PowerTimer t;
t.request(1000, 500);
TEST_ASSERT_FALSE(t.expired(1499));
TEST_ASSERT_TRUE(t.expired(1500));
TEST_ASSERT_FALSE(t.expired(1600));
}
void test_timer_keeps_earlier_deadline()
{
PowerTimer t;
t.request(0, 1000);
t.request(500, 1000); // later deadline: ignored
TEST_ASSERT_TRUE(t.expired(1000));
t.request(0, 30000);
t.request(100, 1000); // earlier deadline: used
TEST_ASSERT_TRUE(t.expired(1100));
}
void test_timer_cancel()
{
PowerTimer t;
t.request(0, 100);
t.cancel();
TEST_ASSERT_FALSE(t.pending());
TEST_ASSERT_FALSE(t.expired(1000));
}
void test_timer_across_millis_overflow()
{
PowerTimer t;
t.request(0xFFFFFF00u, 0x200); // deadline wraps past 0
TEST_ASSERT_FALSE(t.expired(0xFFFFFFFFu));
TEST_ASSERT_FALSE(t.expired(0x00000050u));
TEST_ASSERT_TRUE(t.expired(0x00000100u));
}
// ---------------------------------------------------------------------------
// Boot policy (keeping OTA alive)
// ---------------------------------------------------------------------------
void test_power_on_starts_normally_and_clears_crashes()
{
BootDecision d = decideBoot(ResetKind::PowerOn, 2);
TEST_ASSERT_TRUE(d.mode == BootMode::Normal);
TEST_ASSERT_EQUAL(0, d.crash_count);
}
void test_software_reset_starts_normally_and_keeps_count()
{
// e.g. the restart after an OTA update
BootDecision d = decideBoot(ResetKind::Software, 1);
TEST_ASSERT_TRUE(d.mode == BootMode::Normal);
TEST_ASSERT_EQUAL(1, d.crash_count);
}
void test_other_reset_starts_normally_and_keeps_count()
{
BootDecision d = decideBoot(ResetKind::Other, 2);
TEST_ASSERT_TRUE(d.mode == BootMode::Normal);
TEST_ASSERT_EQUAL(2, d.crash_count);
}
void test_first_crashes_give_a_panic_window()
{
BootDecision d = decideBoot(ResetKind::Crash, 0);
TEST_ASSERT_TRUE(d.mode == BootMode::PanicWindow);
TEST_ASSERT_EQUAL(1, d.crash_count);
d = decideBoot(ResetKind::Crash, d.crash_count);
TEST_ASSERT_TRUE(d.mode == BootMode::PanicWindow);
TEST_ASSERT_EQUAL(2, d.crash_count);
}
void test_repeated_crashes_give_safe_mode()
{
uint8_t count = 0;
BootDecision d = {BootMode::Normal, 0};
for (int i = 0; i < SAFE_MODE_CRASHES; i++)
{
d = decideBoot(ResetKind::Crash, count);
count = d.crash_count;
}
TEST_ASSERT_TRUE(d.mode == BootMode::SafeMode);
}
void test_after_safe_mode_one_more_crash_gives_safe_mode_again()
{
uint8_t count = crashCountAfterSafeMode();
TEST_ASSERT_TRUE(decideBoot(ResetKind::Software, count).mode == BootMode::Normal);
TEST_ASSERT_TRUE(decideBoot(ResetKind::Crash, count).mode == BootMode::SafeMode);
}
void test_crash_count_does_not_overflow()
{
BootDecision d = decideBoot(ResetKind::Crash, 255);
TEST_ASSERT_EQUAL(255, d.crash_count);
TEST_ASSERT_TRUE(d.mode == BootMode::SafeMode);
}
void test_new_firmware_needs_time_and_wifi_to_be_accepted()
{
TEST_ASSERT_FALSE(shouldMarkFirmwareValid(FIRMWARE_VALID_TIME - 1, true));
TEST_ASSERT_FALSE(shouldMarkFirmwareValid(FIRMWARE_VALID_TIME, false));
TEST_ASSERT_TRUE(shouldMarkFirmwareValid(FIRMWARE_VALID_TIME, true));
}
void test_crash_count_cleared_after_running_long_enough()
{
TEST_ASSERT_FALSE(shouldClearCrashCount(CRASH_FREE_TIME - 1));
TEST_ASSERT_TRUE(shouldClearCrashCount(CRASH_FREE_TIME));
}
void test_quick_power_cycles_request_recovery()
{
for (uint8_t count = 0; count < POWER_CYCLES_FOR_RECOVERY; count++)
TEST_ASSERT_FALSE(powerCyclesRequestRecovery(count));
TEST_ASSERT_TRUE(powerCyclesRequestRecovery(POWER_CYCLES_FOR_RECOVERY));
}
void test_recovery_for_wifi_starts_clock_when_wifi_works()
{
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, 0, false, true));
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_TIME * 10, false, true));
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_MIN_TIME - 1, true, true));
TEST_ASSERT_TRUE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_MIN_TIME, true, true));
}
void test_recovery_for_other_reasons_waits_recovery_time()
{
const RecoveryReason reasons[] = {RecoveryReason::Crash, RecoveryReason::Manual,
RecoveryReason::PowerCycle, RecoveryReason::None};
for (RecoveryReason reason : reasons)
{
TEST_ASSERT_FALSE(recoveryShouldStartMain(reason, RECOVERY_TIME - 1, true, true));
TEST_ASSERT_TRUE(recoveryShouldStartMain(reason, RECOVERY_TIME, true, true));
// The time counts, not the WiFi: an OTA upload over the setup WiFi is possible
TEST_ASSERT_TRUE(recoveryShouldStartMain(reason, RECOVERY_TIME, false, true));
}
}
void test_recovery_never_starts_missing_clock_firmware()
{
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_TIME, true, false));
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Manual, RECOVERY_TIME, true, false));
}
void test_every_recovery_reason_has_a_text()
{
for (uint8_t r = 0; r <= (uint8_t)RecoveryReason::PowerCycle; r++)
{
TEST_ASSERT_TRUE(strlen(recoveryReasonText((RecoveryReason)r)) > 0);
// fits on one line of the display
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) > 0);
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) <= 14);
}
TEST_ASSERT_TRUE(strcmp(recoveryReasonText(RecoveryReason::Wifi),
recoveryReasonText(RecoveryReason::Crash)) != 0);
}
// ---------------------------------------------------------------------------
// Status LED
// ---------------------------------------------------------------------------
void test_led_off_while_display_off()
{
for (uint32_t t = 0; t < 2000; t += 50)
TEST_ASSERT_FALSE(statusLedLevel(t, true, false));
}
void test_led_blinks_slow_with_wifi_and_fast_without()
{
TEST_ASSERT_TRUE(statusLedLevel(0, true, true));
TEST_ASSERT_TRUE(statusLedLevel(499, true, true));
TEST_ASSERT_FALSE(statusLedLevel(500, true, true));
TEST_ASSERT_TRUE(statusLedLevel(0, false, true));
TEST_ASSERT_FALSE(statusLedLevel(100, false, true));
TEST_ASSERT_TRUE(statusLedLevel(200, false, true));
}
// ---------------------------------------------------------------------------
// Display controller
// ---------------------------------------------------------------------------
void test_nothing_happens_until_time_is_synced()
{
power.is_on = true; // showing "Connecting..."
controller->update(now, false, 0, 0, true);
TEST_ASSERT_EQUAL(0, power.off_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
void test_daytime_powers_on_and_shows_clock()
{
run(12, 34);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 12:34", frames.last().c_str());
}
void test_clock_is_only_sent_when_the_minute_changes()
{
run(12, 34, 5000);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
run(12, 35);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("clock 12:35", frames.last().c_str());
TEST_ASSERT_EQUAL(1, power.on_count);
}
void test_night_clears_then_powers_off_after_delay()
{
run(23, 30);
run(23, 31, POWER_OFF_DELAY - 200);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on);
run(23, 31, 300);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL(1, power.off_count);
}
void test_night_does_not_power_on()
{
run(3, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
}
void test_standby_keeps_the_last_time_and_powers_off()
{
run(22, 0);
run(22, 1, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no off screen
}
void test_quarter_hour_powers_on_again_and_redraws()
{
run(22, 1, POWER_OFF_DELAY + 200);
run(22, 14);
TEST_ASSERT_FALSE(power.is_on);
run(22, 15);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 22:15 evening", frames.last().c_str());
}
void test_clock_cancels_pending_power_off()
{
run(22, 0);
run(22, 14, 10000); // power off countdown running
TEST_ASSERT_TRUE(power.is_on);
run(22, 15, POWER_OFF_DELAY);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL(0, power.off_count);
}
void test_power_switch_off_clears_and_powers_off_quickly()
{
run(12, 0);
run(22, 14, 1000); // 30 s countdown already running
controller->setPowerEnabled(false);
run(22, 14, CLEAR_BEFORE_OFF_TIME + 200);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
void test_power_switch_off_waits_for_the_refresh_effect()
{
controller->setEffectTime(3000);
run(12, 0);
controller->setPowerEnabled(false);
run(12, 0, CLEAR_BEFORE_OFF_TIME + 2000);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on); // the effect is still flipping dots
run(12, 0, 1400);
TEST_ASSERT_FALSE(power.is_on);
}
void test_power_switch_off_keeps_display_off_during_the_day()
{
controller->setPowerEnabled(false);
run(12, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
}
void test_power_switch_on_again_shows_clock()
{
run(12, 0);
controller->setPowerEnabled(false);
run(12, 0, CLEAR_BEFORE_OFF_TIME + 200);
controller->setPowerEnabled(true);
run(12, 1);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("clock 12:01", frames.last().c_str());
}
void test_redraws_after_power_was_switched_off_elsewhere()
{
// e.g. after flashing the Nano: the Nano restarts without a frame
run(12, 0);
power.off();
run(12, 0);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_no_wifi_is_shown_after_a_minute_without_wifi()
{
run(12, 0);
wifi = false;
run(12, 0, NO_WIFI_SHOW_TIME - 200);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
run(12, 1, 300);
TEST_ASSERT_EQUAL_STRING("nowifi 12:01", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on);
}
void test_no_wifi_screen_keeps_the_time_running()
{
wifi = false;
run(12, 0, NO_WIFI_SHOW_TIME + 100);
run(12, 1);
TEST_ASSERT_EQUAL_STRING("nowifi 12:01", frames.last().c_str());
int frames_before = (int)frames.shown.size();
run(12, 1, 5000);
TEST_ASSERT_EQUAL(frames_before, (int)frames.shown.size()); // only on change
}
void test_clock_returns_when_wifi_is_back()
{
wifi = false;
run(12, 0, NO_WIFI_SHOW_TIME + 100);
wifi = true;
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_short_wifi_drop_does_not_change_the_display()
{
run(12, 0);
wifi = false;
run(12, 0, 10000);
wifi = true;
run(12, 0, NO_WIFI_SHOW_TIME);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
}
void test_night_without_wifi_still_powers_off()
{
run(23, 30);
wifi = false;
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
void test_default_settings_are_valid()
{
TEST_ASSERT_NULL(validateSettings(defaults));
}
void test_settings_times_must_be_in_order()
{
Settings s;
s.schedule.late_start = 7 * 60; // before day_start
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.schedule.night_start = 21 * 60; // before late_start
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.schedule.night_start = MINUTES_PER_DAY; // never night: fine
TEST_ASSERT_NULL(validateSettings(s));
s.schedule.night_start = MINUTES_PER_DAY + 1;
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_settings_drive_ranges()
{
Settings s;
TEST_ASSERT_NULL(validateSettings(s));
s.drive.flip_gap = 5001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.pulses = 0;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.drive.pulses = 4;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.effect = DRIVE_EFFECT_COUNT;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.effect_time = 99;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.drive.effect_time = 10001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_effect_time_is_zero_when_instant()
{
DriveSettings d;
TEST_ASSERT_EQUAL(0, (int)effectTime(d)); // default: instant
d.effect = 1;
d.effect_time = 2500;
TEST_ASSERT_EQUAL(2500, (int)effectTime(d));
d.effect = DRIVE_EFFECT_PUSH; // can take much longer
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
d.effect = DRIVE_EFFECT_RANDOM; // can be a push
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
}
void test_settings_off_page_blank_or_bitmap_name()
{
Settings s;
TEST_ASSERT_EQUAL_STRING("", s.off_page); // default: blank
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.off_page, "Nintendo");
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.off_page, "../x");
TEST_ASSERT_NOT_NULL(validateSettings(s));
memset(s.off_page, 'a', sizeof(s.off_page)); // not terminated
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_changed_off_image_is_redrawn_while_the_display_is_on()
{
run(23, 30);
run(23, 31, 1000);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
controller->pageChanged();
run(23, 31, 1000);
TEST_ASSERT_EQUAL(2, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("off")));
TEST_ASSERT_TRUE(power.is_on);
}
void test_settings_flip_time_and_port_ranges()
{
Settings s;
s.flip_time = 49;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.flip_time = 2001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.home_assistant.port = 0;
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_settings_page_must_be_clock_or_bitmap_name()
{
Settings s;
strcpy(s.page, "My bitmap");
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.page, "../evil");
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_time_parse_and_format()
{
uint16_t m = 0;
TEST_ASSERT_TRUE(parseTime("08:30", m));
TEST_ASSERT_EQUAL(510, m);
TEST_ASSERT_TRUE(parseTime("24:00", m));
TEST_ASSERT_EQUAL(MINUTES_PER_DAY, m);
TEST_ASSERT_FALSE(parseTime("24:01", m));
TEST_ASSERT_FALSE(parseTime("12:60", m));
TEST_ASSERT_FALSE(parseTime("noon", m));
TEST_ASSERT_FALSE(parseTime("12:30x", m));
TEST_ASSERT_FALSE(parseTime(nullptr, m));
char out[6];
formatTime(23 * 60 + 31, out);
TEST_ASSERT_EQUAL_STRING("23:31", out);
formatTime(MINUTES_PER_DAY, out);
TEST_ASSERT_EQUAL_STRING("24:00", out);
}
// ---------------------------------------------------------------------------
// Bitmap format
// ---------------------------------------------------------------------------
void test_bitmap_is_84_by_19_in_drawbitmap_format()
{
TEST_ASSERT_EQUAL(11, BITMAP_ROW_BYTES);
TEST_ASSERT_EQUAL(209, BITMAP_BYTES);
uint8_t b[BITMAP_BYTES] = {};
setBitmapPixel(b, 0, 0, true);
TEST_ASSERT_EQUAL_HEX8(0x80, b[0]); // leftmost dot = most significant bit
setBitmapPixel(b, 83, 18, true);
TEST_ASSERT_EQUAL_HEX8(0x10, b[18 * 11 + 10]); // x = 83: bit 3 of byte 10
TEST_ASSERT_TRUE(bitmapPixel(b, 83, 18));
TEST_ASSERT_FALSE(bitmapPixel(b, 84, 0)); // outside
setBitmapPixel(b, 0, 0, false);
TEST_ASSERT_EQUAL_HEX8(0x00, b[0]);
}
void test_bitmap_hex_round_trip()
{
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
for (int i = 0; i < BITMAP_BYTES; i++)
a[i] = (uint8_t)(i * 37);
char hex[BITMAP_HEX_LENGTH + 1];
bitmapToHex(a, hex);
TEST_ASSERT_EQUAL(BITMAP_HEX_LENGTH, (int)strlen(hex));
TEST_ASSERT_TRUE(bitmapFromHex(hex, b));
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
}
void test_bitmap_hex_rejects_wrong_input()
{
uint8_t b[BITMAP_BYTES];
TEST_ASSERT_FALSE(bitmapFromHex("abcd", b));
TEST_ASSERT_FALSE(bitmapFromHex(nullptr, b));
std::string bad(BITMAP_HEX_LENGTH, 'f');
bad[100] = 'x';
TEST_ASSERT_FALSE(bitmapFromHex(bad.c_str(), b));
std::string upper(BITMAP_HEX_LENGTH, 'F');
TEST_ASSERT_TRUE(bitmapFromHex(upper.c_str(), b));
}
void test_bitmap_names()
{
TEST_ASSERT_TRUE(validBitmapName("Nintendo"));
TEST_ASSERT_TRUE(validBitmapName("Happy Birthday 2"));
TEST_ASSERT_TRUE(validBitmapName("a-b_c"));
TEST_ASSERT_FALSE(validBitmapName(""));
TEST_ASSERT_FALSE(validBitmapName(" lead"));
TEST_ASSERT_FALSE(validBitmapName("trail "));
TEST_ASSERT_FALSE(validBitmapName("../etc"));
TEST_ASSERT_FALSE(validBitmapName("a/b"));
TEST_ASSERT_FALSE(validBitmapName("Clock")); // the clock page
TEST_ASSERT_TRUE(validBitmapName("123456789012345678901234"));
TEST_ASSERT_FALSE(validBitmapName("1234567890123456789012345"));
}
// ---------------------------------------------------------------------------
// Schedule with settings
// ---------------------------------------------------------------------------
void test_schedule_disabled_is_always_on()
{
ScheduleSettings s;
s.enabled = false;
TEST_ASSERT_TRUE(scheduleFor(3, 0, s) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock);
}
void test_schedule_custom_times()
{
ScheduleSettings s;
s.day_start = 6 * 60 + 30;
s.late_start = 20 * 60;
s.night_start = 21 * 60;
TEST_ASSERT_TRUE(scheduleFor(6, 29, s) == ScheduleMode::Night);
TEST_ASSERT_TRUE(scheduleFor(6, 30, s) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(20, 1, s) == ScheduleMode::Standby);
TEST_ASSERT_TRUE(scheduleFor(20, 30, s) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(21, 0, s) == ScheduleMode::Night);
}
void test_schedule_without_quarter_hours_stays_on_until_night()
{
ScheduleSettings s;
s.quarter_hours = false;
TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock);
TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night);
}
void test_evening_can_be_disabled()
{
ClockSettings c;
c.evening_enabled = false;
TEST_ASSERT_FALSE(isEvening(20, 0, c));
}
// ---------------------------------------------------------------------------
// Pages and live mode
// ---------------------------------------------------------------------------
void test_bitmap_page_is_shown_once()
{
controller->setPage(Page::Bitmap);
run(12, 0);
run(12, 1, 5000);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
TEST_ASSERT_EQUAL_STRING("page", frames.last().c_str());
}
void test_bitmap_page_redrawn_when_changed()
{
controller->setPage(Page::Bitmap);
run(12, 0);
controller->pageChanged();
run(12, 0);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_switching_back_to_the_clock_page()
{
controller->setPage(Page::Bitmap);
run(12, 0);
controller->setPage(Page::Clock);
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_bitmap_page_follows_the_schedule()
{
controller->setPage(Page::Bitmap);
run(23, 30);
run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
void test_evening_clock_redrawn_when_the_evening_starts()
{
run(16, 59);
run(17, 0);
TEST_ASSERT_EQUAL_STRING("clock 17:00 evening", frames.last().c_str());
}
void test_settings_change_redraws()
{
run(18, 0);
ClockSettings c;
c.evening_enabled = false;
controller->setSettings(defaults.schedule, c);
run(18, 0);
TEST_ASSERT_EQUAL_STRING("clock 18:00", frames.last().c_str());
}
void test_live_is_shown_right_away_even_at_night()
{
run(3, 0);
controller->showLive(now);
run(3, 0);
TEST_ASSERT_TRUE(power.is_on);
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
}
void test_live_works_before_the_time_is_synced()
{
controller->showLive(now);
controller->update(now, false, 0, 0, true);
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
}
void test_each_live_image_is_drawn()
{
controller->showLive(now);
run(12, 0);
controller->showLive(now);
run(12, 0);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_stop_live_returns_to_the_page()
{
controller->showLive(now);
run(12, 0);
controller->stopLive();
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
TEST_ASSERT_FALSE(controller->liveActive());
}
void test_live_ends_after_timeout()
{
controller->showLive(now);
run(12, 0, LIVE_TIMEOUT + 200);
TEST_ASSERT_FALSE(controller->liveActive());
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_live_respects_the_power_switch()
{
controller->setPowerEnabled(false);
controller->showLive(now);
run(12, 0, 5000);
TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL(0, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("live")));
}
int main()
{
UNITY_BEGIN();
RUN_TEST(test_schedule_night_until_8);
RUN_TEST(test_schedule_clock_from_8_to_22);
RUN_TEST(test_schedule_quarter_hours_until_23_30);
RUN_TEST(test_schedule_night_from_23_31);
RUN_TEST(test_evening_from_17);
RUN_TEST(test_timer_expires_once_after_delay);
RUN_TEST(test_timer_keeps_earlier_deadline);
RUN_TEST(test_timer_cancel);
RUN_TEST(test_timer_across_millis_overflow);
RUN_TEST(test_power_on_starts_normally_and_clears_crashes);
RUN_TEST(test_software_reset_starts_normally_and_keeps_count);
RUN_TEST(test_other_reset_starts_normally_and_keeps_count);
RUN_TEST(test_first_crashes_give_a_panic_window);
RUN_TEST(test_repeated_crashes_give_safe_mode);
RUN_TEST(test_after_safe_mode_one_more_crash_gives_safe_mode_again);
RUN_TEST(test_crash_count_does_not_overflow);
RUN_TEST(test_new_firmware_needs_time_and_wifi_to_be_accepted);
RUN_TEST(test_crash_count_cleared_after_running_long_enough);
RUN_TEST(test_quick_power_cycles_request_recovery);
RUN_TEST(test_recovery_for_wifi_starts_clock_when_wifi_works);
RUN_TEST(test_recovery_for_other_reasons_waits_recovery_time);
RUN_TEST(test_recovery_never_starts_missing_clock_firmware);
RUN_TEST(test_every_recovery_reason_has_a_text);
RUN_TEST(test_led_off_while_display_off);
RUN_TEST(test_led_blinks_slow_with_wifi_and_fast_without);
RUN_TEST(test_nothing_happens_until_time_is_synced);
RUN_TEST(test_daytime_powers_on_and_shows_clock);
RUN_TEST(test_clock_is_only_sent_when_the_minute_changes);
RUN_TEST(test_night_clears_then_powers_off_after_delay);
RUN_TEST(test_night_does_not_power_on);
RUN_TEST(test_standby_keeps_the_last_time_and_powers_off);
RUN_TEST(test_quarter_hour_powers_on_again_and_redraws);
RUN_TEST(test_clock_cancels_pending_power_off);
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect);
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
RUN_TEST(test_power_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);
RUN_TEST(test_no_wifi_screen_keeps_the_time_running);
RUN_TEST(test_clock_returns_when_wifi_is_back);
RUN_TEST(test_short_wifi_drop_does_not_change_the_display);
RUN_TEST(test_night_without_wifi_still_powers_off);
RUN_TEST(test_default_settings_are_valid);
RUN_TEST(test_settings_times_must_be_in_order);
RUN_TEST(test_settings_drive_ranges);
RUN_TEST(test_effect_time_is_zero_when_instant);
RUN_TEST(test_settings_off_page_blank_or_bitmap_name);
RUN_TEST(test_changed_off_image_is_redrawn_while_the_display_is_on);
RUN_TEST(test_settings_flip_time_and_port_ranges);
RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name);
RUN_TEST(test_time_parse_and_format);
RUN_TEST(test_bitmap_is_84_by_19_in_drawbitmap_format);
RUN_TEST(test_bitmap_hex_round_trip);
RUN_TEST(test_bitmap_hex_rejects_wrong_input);
RUN_TEST(test_bitmap_names);
RUN_TEST(test_schedule_disabled_is_always_on);
RUN_TEST(test_schedule_custom_times);
RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night);
RUN_TEST(test_evening_can_be_disabled);
RUN_TEST(test_bitmap_page_is_shown_once);
RUN_TEST(test_bitmap_page_redrawn_when_changed);
RUN_TEST(test_switching_back_to_the_clock_page);
RUN_TEST(test_bitmap_page_follows_the_schedule);
RUN_TEST(test_evening_clock_redrawn_when_the_evening_starts);
RUN_TEST(test_settings_change_redraws);
RUN_TEST(test_live_is_shown_right_away_even_at_night);
RUN_TEST(test_live_works_before_the_time_is_synced);
RUN_TEST(test_each_live_image_is_drawn);
RUN_TEST(test_stop_live_returns_to_the_page);
RUN_TEST(test_live_ends_after_timeout);
RUN_TEST(test_live_respects_the_power_switch);
return UNITY_END();
}