Files
Flipdot-display/test/test_esp32/test_main.cpp
T
mangavalkandClaude Opus 5.5 fc6fa1a180 Add scrolling, countdown, stopwatch, images, seconds, multiplier and degree sign
- Text elements can scroll in a window at a set speed; clock, countdown and
  stopwatch can show seconds. Moving pages are sent as instant frames (new
  Nano command D1) and paced by the estimated flip time.
- Countdown to a date and time (smart, days, days + time, with seconds),
  stopwatch (web UI, HA switch and reset button), image element (fixed or
  by Home Assistant state), multiplier for HA values, degree sign (char 127)
  in all fonts and the font converter.
- Page format version 2; version 1 pages are converted.

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

1633 lines
51 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/builtin_fonts.h"
#include "logic/builtin_pages.h"
#include "logic/font.h"
#include "logic/boot_policy.h"
#include "logic/display_controller.h"
#include "logic/page.h"
#include "logic/page_render.h"
#include "logic/power_timer.h"
#include "logic/schedule.h"
#include "logic/settings.h"
#include "logic/status_led.h"
#include "logic/stopwatch.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;
uint32_t wait = 0; // what dynamicPage() returns
bool last_redraw = false;
uint32_t dynamicPage(const LocalTime &time, uint32_t, bool redraw) override
{
char s[24];
snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute);
shown.push_back(s);
last_redraw = redraw;
return wait;
}
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
{
LocalTime time;
time.hour = hour;
time.minute = minute;
controller->update(now, true, time, 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);
}
// ---------------------------------------------------------------------------
// 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, LocalTime(), 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", 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", 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_a_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_TRUE(sameName("Clock", "clock"));
TEST_ASSERT_FALSE(sameName("Clock", "Clocks"));
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);
}
// ---------------------------------------------------------------------------
// 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::Dynamic);
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_dynamic_page_redrawn_when_its_content_changes()
{
run(18, 0);
controller->pageChanged(); // e.g. a Home Assistant value changed
run(18, 0);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
void test_settings_change_redraws()
{
run(18, 0);
ClockSettings c;
c.no_wifi_screen = false;
controller->setSettings(defaults.schedule, c);
run(18, 0);
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
}
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, LocalTime(), 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")));
}
// ---------------------------------------------------------------------------
// Fonts
// ---------------------------------------------------------------------------
static Font fontNamed(const char *name)
{
const BuiltinFont *b = findBuiltinFont(name);
TEST_ASSERT_NOT_NULL(b);
return builtinFont(*b);
}
void test_builtin_fonts_are_valid()
{
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
TEST_ASSERT_TRUE_MESSAGE(validFont(BUILTIN_FONTS[i].data, BUILTIN_FONTS[i].size), BUILTIN_FONTS[i].name);
TEST_ASSERT_NOT_NULL(findBuiltinFont("normal"));
TEST_ASSERT_NULL(findBuiltinFont("Comic Sans"));
TEST_ASSERT_EQUAL(2, fontNamed("Large").scale);
}
void test_font_sizes()
{
// Monospaced digits: the clock doesn't move when the time changes
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "12:34"));
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "00:00"));
TEST_ASSERT_EQUAL(58, textWidth(fontNamed("Large"), "12:34"));
TEST_ASSERT_EQUAL(17, textWidth(fontNamed("Small"), "12:34"));
TEST_ASSERT_EQUAL(51, textWidth(fontNamed("Full height"), "12:34"));
TEST_ASSERT_EQUAL(8, fontHeight(fontNamed("Normal")));
TEST_ASSERT_EQUAL(16, fontHeight(fontNamed("Large")));
TEST_ASSERT_EQUAL(6, fontHeight(fontNamed("Small")));
TEST_ASSERT_EQUAL(19, fontHeight(fontNamed("Full height")));
// Characters that are not in the font are skipped
TEST_ASSERT_EQUAL(10, textWidth(fontNamed("Full height"), "a1b"));
TEST_ASSERT_EQUAL(0, textWidth(fontNamed("Normal"), ""));
}
void test_draw_text_uses_the_classic_glyphs()
{
uint8_t b[BITMAP_BYTES] = {};
drawText(b, fontNamed("Normal"), 10, 2, "1", DrawMode::Set);
// glcdfont '1': 0x00, 0x42, 0x7F, 0x40, 0x00 (columns, bit 0 = top)
for (int row = 0; row < 7; row++)
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2 + row));
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 9));
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 3));
TEST_ASSERT_FALSE(bitmapPixel(b, 11, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 13, 8));
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 5));
}
void test_draw_text_scaled_and_inverted()
{
uint8_t b[BITMAP_BYTES];
memset(b, 0xFF, sizeof(b));
drawText(b, fontNamed("Large"), 0, 0, "1", DrawMode::Invert);
TEST_ASSERT_FALSE(bitmapPixel(b, 4, 0)); // column 2 doubled: 4 and 5
TEST_ASSERT_FALSE(bitmapPixel(b, 5, 13));
TEST_ASSERT_TRUE(bitmapPixel(b, 4, 14));
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
}
void test_draw_text_is_clipped()
{
uint8_t b[BITMAP_BYTES] = {};
drawText(b, fontNamed("Full height"), 80, 5, "88", DrawMode::Set); // must not write outside
drawText(b, fontNamed("Full height"), -5, -10, "88", DrawMode::Set);
TEST_ASSERT_TRUE(bitmapPixel(b, 81, 6));
}
void test_invalid_fonts_are_rejected()
{
const BuiltinFont *n = findBuiltinFont("Normal");
std::vector<uint8_t> f(n->data, n->data + n->size);
TEST_ASSERT_TRUE(validFont(f.data(), f.size()));
TEST_ASSERT_FALSE(validFont(f.data(), f.size() - 1));
f.push_back(0);
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
f.pop_back();
f[0] = 'X';
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
f[0] = 'F';
f[4] = 20; // too high
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
TEST_ASSERT_FALSE(validFont(nullptr, 0));
}
// ---------------------------------------------------------------------------
// Dynamic pages
// ---------------------------------------------------------------------------
static DynamicPage twoSlotPage()
{
DynamicPage page;
page.slot_count = 2;
page.slots[0].start = 8 * 60;
strcpy(page.slots[0].bitmap, "Day");
page.slots[1].start = 17 * 60;
strcpy(page.slots[1].bitmap, "Evening");
return page;
}
void test_active_slot_follows_the_time()
{
DynamicPage page = twoSlotPage();
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 7 * 60)->bitmap); // from the day before
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 8 * 60)->bitmap);
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 17 * 60 - 1)->bitmap);
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 17 * 60)->bitmap);
TEST_ASSERT_NULL(activeSlot(DynamicPage(), 600));
}
void test_page_validation()
{
DynamicPage page = twoSlotPage();
TEST_ASSERT_NULL(validatePage(page));
page.slots[1].start = 8 * 60;
TEST_ASSERT_NOT_NULL(validatePage(page)); // not in order
page = twoSlotPage();
page.slots[1].start = 24 * 60;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = twoSlotPage();
strcpy(page.slots[0].bitmap, "../x");
TEST_ASSERT_NOT_NULL(validatePage(page));
page = DynamicPage();
page.element_count = 1;
PageElement &e = page.elements[0];
TEST_ASSERT_NULL(validatePage(page));
e.type = ElementType::HaValue;
TEST_ASSERT_NOT_NULL(validatePage(page)); // needs an entity
strcpy(e.entity, "sensor.outside_temperature");
TEST_ASSERT_NULL(validatePage(page));
strcpy(e.attribute, "bad attr");
TEST_ASSERT_NOT_NULL(validatePage(page));
e.attribute[0] = '\0';
strcpy(e.text, "caf\xc3\xa9");
TEST_ASSERT_NOT_NULL(validatePage(page)); // the fonts have no accents
e.text[0] = '\0';
e.decimals = 5;
TEST_ASSERT_NOT_NULL(validatePage(page));
e.decimals = 1;
e.type = ElementType::Date;
e.format = DATE_FORMAT_COUNT;
TEST_ASSERT_NOT_NULL(validatePage(page));
page.element_count = PAGE_ELEMENTS + 1;
TEST_ASSERT_NOT_NULL(validatePage(page));
TEST_ASSERT_TRUE(validEntityId("weather.home"));
TEST_ASSERT_FALSE(validEntityId("Sensor.x"));
TEST_ASSERT_FALSE(validEntityId("sensor"));
TEST_ASSERT_FALSE(validEntityId("a.b.c"));
TEST_ASSERT_FALSE(validEntityId(".x"));
}
static std::string textOf(const PageElement &e, const char *ha_value = nullptr)
{
LocalTime t; // Thursday 15 October 2026, 08:05
t.year = 2026;
t.month = 10;
t.day = 15;
t.weekday = 4;
t.hour = 8;
t.minute = 5;
char out[64];
ElementValues values;
values.ha_value = ha_value;
values.stopwatch_ms = 65000;
elementText(e, t, values, out, sizeof(out));
return out;
}
void test_clock_and_date_texts()
{
PageElement e;
TEST_ASSERT_EQUAL_STRING("08:05", textOf(e).c_str());
e.format = CLOCK_H_MM;
TEST_ASSERT_EQUAL_STRING("8:05", textOf(e).c_str());
e.type = ElementType::Date;
const char *dutch[] = {"do 15 okt", "15 okt", "15 oktober", "15-10", "15-10-2026", "donderdag", "do"};
const char *english[] = {"Thu 15 Oct", "15 Oct", "15 October", "15-10", "15-10-2026", "Thursday", "Thu"};
for (uint8_t f = 0; f < DATE_FORMAT_COUNT; f++)
{
e.format = f;
e.language = Language::Dutch;
TEST_ASSERT_EQUAL_STRING(dutch[f], textOf(e).c_str());
e.language = Language::English;
TEST_ASSERT_EQUAL_STRING(english[f], textOf(e).c_str());
}
e = PageElement();
e.type = ElementType::Text;
strcpy(e.text, "Hallo");
TEST_ASSERT_EQUAL_STRING("Hallo", textOf(e).c_str());
}
void test_home_assistant_value_texts()
{
PageElement e;
e.type = ElementType::HaValue;
strcpy(e.text, "T ");
strcpy(e.suffix, "C");
TEST_ASSERT_EQUAL_STRING("T 21.46C", textOf(e, "21.46").c_str());
TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, nullptr).c_str());
TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, "unavailable").c_str());
e.decimals = 1;
TEST_ASSERT_EQUAL_STRING("T 21.5C", textOf(e, "21.46").c_str());
TEST_ASSERT_EQUAL_STRING("T 0.0C", textOf(e, "-0.04").c_str());
TEST_ASSERT_EQUAL_STRING("T -1.0C", textOf(e, "-1").c_str());
TEST_ASSERT_EQUAL_STRING("T onC", textOf(e, "on").c_str()); // not a number: as is
e.decimals = 0;
TEST_ASSERT_EQUAL_STRING("T 21C", textOf(e, "21.46").c_str());
}
// Backgrounds: "Full" is all dots on, "Day"/"Evening" have one dot at x 0 / x 1
class FakeResources : public PageResources
{
public:
const char *value = "21.5";
bool background(const char *name, uint8_t *bitmap) override
{
memset(bitmap, 0, BITMAP_BYTES);
if (strcmp(name, "Full") == 0)
memset(bitmap, 0xFF, BITMAP_BYTES);
else if (strcmp(name, "Day") == 0)
setBitmapPixel(bitmap, 0, 0, true);
else if (strcmp(name, "Evening") == 0)
setBitmapPixel(bitmap, 1, 0, true);
else
{
memset(bitmap, 0xAA, BITMAP_BYTES); // garbage: must be cleared
return false;
}
return true;
}
Font font(const char *name) override
{
const BuiltinFont *b = findBuiltinFont(name);
return b ? builtinFont(*b) : Font();
}
const char *haValue(const PageElement &) override { return value; }
};
static LocalTime at(int hour, int minute)
{
LocalTime t;
t.hour = hour;
t.minute = minute;
return t;
}
void test_render_background_follows_the_slots()
{
FakeResources res;
DynamicPage page = twoSlotPage();
uint8_t b[BITMAP_BYTES];
renderPage(page, at(9, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0));
renderPage(page, at(18, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0));
strcpy(page.slots[1].bitmap, "Gone");
renderPage(page, at(18, 0), 0, res, b);
for (int i = 0; i < BITMAP_BYTES; i++)
TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank
}
void test_render_overlays()
{
FakeResources res;
DynamicPage page;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "Full");
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Text;
strcpy(e.text, "1");
e.x = 10;
e.y = 2;
uint8_t b[BITMAP_BYTES];
e.overlay = Overlay::OnTop; // on a full background nothing changes
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
e.overlay = Overlay::Box; // cleared area with one dot around it, then the text
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_FALSE(bitmapPixel(b, 9, 1));
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 2));
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
TEST_ASSERT_FALSE(bitmapPixel(b, 15, 10));
TEST_ASSERT_TRUE(bitmapPixel(b, 16, 10));
TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2));
e.overlay = Overlay::Invert;
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 2));
}
void test_render_alignment_and_boxes()
{
FakeResources res;
DynamicPage page;
page.element_count = 2;
page.elements[0].x = 84;
page.elements[0].align = Align::Right;
page.elements[1].type = ElementType::HaValue;
strcpy(page.elements[1].entity, "sensor.t");
page.elements[1].x = 42;
page.elements[1].y = 11;
page.elements[1].align = Align::Center;
ElementBox boxes[2];
uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), 0, res, b, boxes);
TEST_ASSERT_EQUAL(84 - 29, boxes[0].x);
TEST_ASSERT_EQUAL(29, boxes[0].width);
TEST_ASSERT_EQUAL(8, boxes[0].height);
TEST_ASSERT_EQUAL(23, boxes[1].width); // "21.5"
TEST_ASSERT_EQUAL(42 - 11, boxes[1].x);
TEST_ASSERT_EQUAL(11, boxes[1].y);
}
void test_render_unknown_font_uses_normal()
{
FakeResources res;
DynamicPage page;
page.element_count = 1;
strcpy(page.elements[0].font, "Gone");
ElementBox box;
uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), 0, res, b, &box);
TEST_ASSERT_EQUAL(29, box.width);
}
void test_builtin_clock_page_is_the_old_clock()
{
DynamicPage page;
TEST_ASSERT_TRUE(builtinPage("clock", page)); // old settings stored "clock"
TEST_ASSERT_NULL(validatePage(page));
TEST_ASSERT_EQUAL_STRING("Pikachu", activeSlot(page, 16 * 60 + 59)->bitmap);
TEST_ASSERT_EQUAL_STRING("Pikachu yawning", activeSlot(page, 17 * 60)->bitmap);
TEST_ASSERT_EQUAL_STRING(BUILTIN_PAGE_CLOCK, builtinPageName(0));
TEST_ASSERT_FALSE(builtinPage("Pikachu", page));
// The time where the old firmware printed it: cursor 46, 4 with the classic font
FakeResources res;
TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page));
uint8_t b[BITMAP_BYTES];
renderPage(page, at(12, 34), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 4)); // the '1'
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 10));
TEST_ASSERT_FALSE(bitmapPixel(b, 48, 11));
}
void test_default_page_is_the_builtin_clock()
{
DynamicPage page;
TEST_ASSERT_TRUE(builtinPage(defaults.page, page));
}
// ---------------------------------------------------------------------------
// Seconds, countdown, stopwatch, multiplier, degree sign
// ---------------------------------------------------------------------------
void test_clock_with_seconds()
{
PageElement e;
e.format = CLOCK_HH_MM_SS;
TEST_ASSERT_EQUAL_STRING("08:05:00", textOf(e).c_str());
e.format = CLOCK_H_MM_SS;
TEST_ASSERT_EQUAL_STRING("8:05:00", textOf(e).c_str());
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), false));
e.format = CLOCK_HH_MM;
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false));
}
void test_countdown_target_parse_and_format()
{
uint32_t m = 0;
TEST_ASSERT_TRUE(parseTarget("2000-01-01T00:00", m));
TEST_ASSERT_EQUAL_UINT32(0, m);
TEST_ASSERT_TRUE(parseTarget("2026-12-24T18:30", m));
char out[17];
formatTarget(m, out);
TEST_ASSERT_EQUAL_STRING("2026-12-24T18:30", out);
TEST_ASSERT_FALSE(parseTarget("2026-02-31T10:00", m)); // no 31 February
TEST_ASSERT_FALSE(parseTarget("2026-12-24 18:30", m));
TEST_ASSERT_FALSE(parseTarget("1999-12-24T18:30", m));
TEST_ASSERT_FALSE(parseTarget("2026-12-24T24:00", m));
TEST_ASSERT_TRUE(parseTarget("2028-02-29T00:00", m)); // leap year
LocalTime t;
t.year = 2026;
t.month = 10;
t.day = 15;
t.hour = 8;
t.minute = 5;
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", m));
TEST_ASSERT_EQUAL_UINT32(m, minutesSince2000(t));
}
// Countdown from Thursday 15 October 2026, 08:05:00 (textOf) to `target`
static std::string countdown(const char *target, uint8_t format, Language lang = Language::Dutch)
{
PageElement e;
e.type = ElementType::Countdown;
e.format = format;
e.language = lang;
TEST_ASSERT_TRUE(parseTarget(target, e.target));
return textOf(e);
}
void test_countdown_formats()
{
TEST_ASSERT_EQUAL_STRING("12 dagen", countdown("2026-10-27T09:00", COUNTDOWN_SMART).c_str());
TEST_ASSERT_EQUAL_STRING("1 day", countdown("2026-10-16T09:00", COUNTDOWN_SMART, Language::English).c_str());
TEST_ASSERT_EQUAL_STRING("5 uur", countdown("2026-10-15T13:30", COUNTDOWN_SMART).c_str());
TEST_ASSERT_EQUAL_STRING("1 hour", countdown("2026-10-15T09:30", COUNTDOWN_SMART, Language::English).c_str());
TEST_ASSERT_EQUAL_STRING("34 min", countdown("2026-10-15T08:39", COUNTDOWN_SMART).c_str());
TEST_ASSERT_EQUAL_STRING("1 min", countdown("2026-10-15T08:06", COUNTDOWN_SMART).c_str());
// Calendar days: tomorrow 00:30 is 1 day away
TEST_ASSERT_EQUAL_STRING("1 dag", countdown("2026-10-16T00:30", COUNTDOWN_DAYS).c_str());
TEST_ASSERT_EQUAL_STRING("12 days", countdown("2026-10-27T01:00", COUNTDOWN_DAYS, Language::English).c_str());
TEST_ASSERT_EQUAL_STRING("12d 00:55", countdown("2026-10-27T09:00", COUNTDOWN_DAYS_TIME).c_str());
TEST_ASSERT_EQUAL_STRING("05:25", countdown("2026-10-15T13:30", COUNTDOWN_DAYS_TIME).c_str());
TEST_ASSERT_EQUAL_STRING("12d 00:55:00", countdown("2026-10-27T09:00", COUNTDOWN_SECONDS).c_str());
TEST_ASSERT_EQUAL_STRING("05:25:00", countdown("2026-10-15T13:30", COUNTDOWN_SECONDS).c_str());
// Passed: the suffix, or 0
TEST_ASSERT_EQUAL_STRING("0", countdown("2026-10-15T08:05", COUNTDOWN_SMART).c_str());
PageElement e;
e.type = ElementType::Countdown;
strcpy(e.text, "Disney ");
strcpy(e.suffix, "nu!");
TEST_ASSERT_TRUE(parseTarget("2026-01-01T00:00", e.target));
TEST_ASSERT_EQUAL_STRING("Disney nu!", textOf(e).c_str());
}
void test_countdown_shows_seconds_only_when_needed()
{
PageElement e;
e.type = ElementType::Countdown;
LocalTime t;
t.year = 2026;
t.month = 10;
t.day = 15;
t.hour = 8;
t.minute = 5;
t.second = 30;
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:06", e.target)); // 30 s left
e.format = COUNTDOWN_SMART;
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
char out[32];
elementText(e, t, ElementValues(), out, sizeof(out));
TEST_ASSERT_EQUAL_STRING("0:30", out);
e.format = COUNTDOWN_DAYS_TIME;
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
e.format = COUNTDOWN_SECONDS;
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", e.target)); // passed
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
}
void test_stopwatch()
{
Stopwatch sw;
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(5000));
sw.start(1000);
TEST_ASSERT_TRUE(sw.running());
TEST_ASSERT_EQUAL_UINT32(2000, sw.elapsed(3000));
sw.stop(4000);
TEST_ASSERT_EQUAL_UINT32(3000, sw.elapsed(9000));
sw.start(10000);
sw.start(11000); // already running: no restart
TEST_ASSERT_EQUAL_UINT32(5000, sw.elapsed(12000));
sw.reset();
TEST_ASSERT_FALSE(sw.running());
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(20000));
sw.start(0xFFFFFF00u); // across the millis() overflow
TEST_ASSERT_EQUAL_UINT32(0x200, sw.elapsed(0x100));
PageElement e; // textOf: 65 s on the stopwatch
e.type = ElementType::Stopwatch;
e.format = STOPWATCH_M_SS;
TEST_ASSERT_EQUAL_STRING("1:05", textOf(e).c_str());
e.format = STOPWATCH_H_MM;
TEST_ASSERT_EQUAL_STRING("0:01", textOf(e).c_str());
e.format = STOPWATCH_H_MM_SS;
strcpy(e.text, "T ");
TEST_ASSERT_EQUAL_STRING("T 0:01:05", textOf(e).c_str());
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false)); // stopped
}
void test_home_assistant_multiplier()
{
PageElement e;
e.type = ElementType::HaValue;
e.factor = 1000;
strcpy(e.suffix, " l");
TEST_ASSERT_EQUAL_STRING("637 l", textOf(e, "0.637").c_str());
e.factor = 0.001f;
e.decimals = 1;
strcpy(e.suffix, "kWh");
TEST_ASSERT_EQUAL_STRING("26.3kWh", textOf(e, "26300").c_str());
TEST_ASSERT_EQUAL_STRING("--kWh", textOf(e, "unknown").c_str());
DynamicPage page;
page.element_count = 1;
page.elements[0] = e;
strcpy(page.elements[0].entity, "sensor.x");
page.elements[0].factor = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_degree_sign_in_the_fonts()
{
const char degree[] = {DEGREE_SIGN, 0};
TEST_ASSERT_EQUAL(4, textWidth(fontNamed("Normal"), degree));
TEST_ASSERT_EQUAL(3, textWidth(fontNamed("Small"), degree));
TEST_ASSERT_EQUAL(6, textWidth(fontNamed("Full height"), degree));
DynamicPage page;
page.element_count = 1;
page.elements[0].type = ElementType::Text;
strcpy(page.elements[0].text, "17.5\x7f" "C");
TEST_ASSERT_NULL(validatePage(page));
}
// ---------------------------------------------------------------------------
// Scrolling and images
// ---------------------------------------------------------------------------
static DynamicPage scrollPage()
{
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Text;
strcpy(e.text, "A long text that does not fit");
e.x = 10;
e.y = 2;
e.scroll_width = 30;
e.scroll_speed = 10;
return page;
}
static bool anyDot(const uint8_t *b, int x0, int x1)
{
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = x0; x < x1; x++)
if (bitmapPixel(b, x, y))
return true;
return false;
}
void test_scrolling_text_moves_in_its_window()
{
FakeResources res;
DynamicPage page = scrollPage();
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
ElementBox box;
uint32_t wait = renderPage(page, at(12, 0), 0, res, a, &box);
TEST_ASSERT_EQUAL(100, wait); // 10 dots per second
TEST_ASSERT_EQUAL(10, box.x);
TEST_ASSERT_EQUAL(30, box.width);
TEST_ASSERT_FALSE(anyDot(a, 0, 10)); // clipped to the window
TEST_ASSERT_FALSE(anyDot(a, 40, BITMAP_WIDTH));
TEST_ASSERT_TRUE(anyDot(a, 10, 40));
TEST_ASSERT_EQUAL(60, renderPage(page, at(12, 0), 1040, res, b, &box)); // until 1100
TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0);
// One round later the same image again: text width + gap
int period = textWidth(fontNamed("Normal"), page.elements[0].text) + SCROLL_GAP;
renderPage(page, at(12, 0), period * 100, res, b);
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
}
void test_short_text_in_a_window_does_not_scroll()
{
FakeResources res;
DynamicPage page = scrollPage();
strcpy(page.elements[0].text, "Hi");
page.elements[0].align = Align::Right; // right edge of the window at x 10
uint8_t b[BITMAP_BYTES];
ElementBox box;
TEST_ASSERT_EQUAL(0, renderPage(page, at(12, 0), 0, res, b, &box));
TEST_ASSERT_EQUAL(-20, box.x);
TEST_ASSERT_TRUE(anyDot(b, 0, 10)); // the text at the right of the window
}
void test_render_asks_for_the_next_second()
{
FakeResources res;
DynamicPage page;
page.element_count = 1;
uint8_t b[BITMAP_BYTES];
LocalTime t = at(12, 0);
t.millisecond = 300;
TEST_ASSERT_EQUAL(0, renderPage(page, t, 0, res, b));
page.elements[0].format = CLOCK_HH_MM_SS;
TEST_ASSERT_EQUAL(700, renderPage(page, t, 0, res, b));
}
class ImageResources : public FakeResources
{
public:
bool background(const char *name, uint8_t *bitmap) override
{
memset(bitmap, 0, BITMAP_BYTES);
if (strcmp(name, "icon sunny") == 0)
{
setBitmapPixel(bitmap, 1, 1, true);
setBitmapPixel(bitmap, 3, 2, true);
return true;
}
if (strcmp(name, "icon default") == 0)
{
setBitmapPixel(bitmap, 0, 0, true);
return true;
}
return false;
}
};
void test_image_element_by_state()
{
ImageResources res;
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Image;
strcpy(e.text, "icon ");
strcpy(e.entity, "weather.home");
e.x = 50;
e.y = 5;
TEST_ASSERT_NULL(validatePage(page));
uint8_t b[BITMAP_BYTES];
ElementBox box;
res.value = "sunny";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
TEST_ASSERT_TRUE(bitmapPixel(b, 53, 7));
TEST_ASSERT_EQUAL(51, box.x);
TEST_ASSERT_EQUAL(6, box.y);
TEST_ASSERT_EQUAL(3, box.width);
TEST_ASSERT_EQUAL(2, box.height);
res.value = "rainy"; // no bitmap for it: the default
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_TRUE(bitmapPixel(b, 50, 5));
TEST_ASSERT_FALSE(bitmapPixel(b, 51, 6));
e.entity[0] = '\0'; // fixed bitmap
strcpy(e.text, "icon sunny");
TEST_ASSERT_NULL(validatePage(page));
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
strcpy(e.text, "../x");
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_moving_page_is_redrawn_when_due()
{
frames.wait = 250;
run(12, 0);
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
TEST_ASSERT_TRUE(frames.last_redraw);
run(12, 0, 200); // not yet (the run steps are 100 ms)
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
TEST_ASSERT_FALSE(frames.last_redraw);
frames.wait = 0; // stops moving
run(12, 0, 1000);
TEST_ASSERT_EQUAL(3, (int)frames.shown.size());
}
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_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_a_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_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_dynamic_page_redrawn_when_its_content_changes);
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);
RUN_TEST(test_builtin_fonts_are_valid);
RUN_TEST(test_font_sizes);
RUN_TEST(test_draw_text_uses_the_classic_glyphs);
RUN_TEST(test_draw_text_scaled_and_inverted);
RUN_TEST(test_draw_text_is_clipped);
RUN_TEST(test_invalid_fonts_are_rejected);
RUN_TEST(test_active_slot_follows_the_time);
RUN_TEST(test_page_validation);
RUN_TEST(test_clock_and_date_texts);
RUN_TEST(test_home_assistant_value_texts);
RUN_TEST(test_render_background_follows_the_slots);
RUN_TEST(test_render_overlays);
RUN_TEST(test_render_alignment_and_boxes);
RUN_TEST(test_render_unknown_font_uses_normal);
RUN_TEST(test_builtin_clock_page_is_the_old_clock);
RUN_TEST(test_default_page_is_the_builtin_clock);
RUN_TEST(test_clock_with_seconds);
RUN_TEST(test_countdown_target_parse_and_format);
RUN_TEST(test_countdown_formats);
RUN_TEST(test_countdown_shows_seconds_only_when_needed);
RUN_TEST(test_stopwatch);
RUN_TEST(test_home_assistant_multiplier);
RUN_TEST(test_degree_sign_in_the_fonts);
RUN_TEST(test_scrolling_text_moves_in_its_window);
RUN_TEST(test_short_text_in_a_window_does_not_scroll);
RUN_TEST(test_render_asks_for_the_next_second);
RUN_TEST(test_image_element_by_state);
RUN_TEST(test_moving_page_is_redrawn_when_due);
return UNITY_END();
}