Files
Flipdot-display/test/test_esp32/test_main.cpp
T
mangavalkandClaude Opus 5.5 a904615c51 Refresh effect per page
Pages choose their own refresh effect (or the Hardware setting), used when
the page appears and for its own changes. Schedule blocks that switch
instantly and rules still switch instantly. The ESP32 sends page effects
to the Nano with a new no-save flag (E ...,2), so page switches don't wear
the Nano's EEPROM. Page format 7 (version 6 pages are converted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:57:31 +02:00

2540 lines
82 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/frame_check.h"
#include "logic/loop_stats.h"
#include "logic/bitmap_format.h"
#include "logic/builtin_fonts.h"
#include "logic/builtin_pages.h"
#include "logic/font.h"
#include "logic/icon_format.h"
#include "logic/boot_policy.h"
#include "logic/display_controller.h"
#include "logic/page.h"
#include "logic/page_render.h"
#include "logic/page_schedule.h"
#include "logic/page_selector.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 = twoSlotPage();
TEST_ASSERT_EQUAL(PAGE_EFFECT_DEFAULT, page.effect);
page.effect = DRIVE_EFFECT_RANDOM;
TEST_ASSERT_NULL(validatePage(page));
page.effect = DRIVE_EFFECT_COUNT;
TEST_ASSERT_NOT_NULL(validatePage(page)); // unknown refresh effect
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_slow_scrolling_below_one_dot_per_second()
{
FakeResources res;
DynamicPage page = scrollPage();
page.elements[0].scroll_speed = 0.25f; // a dot every 4 s
TEST_ASSERT_NULL(validatePage(page));
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
TEST_ASSERT_EQUAL(4000, renderPage(page, at(12, 0), 0, res, a));
TEST_ASSERT_EQUAL(1, renderPage(page, at(12, 0), 3999, res, b));
TEST_ASSERT_EQUAL(0, memcmp(a, b, BITMAP_BYTES)); // not moved yet
TEST_ASSERT_EQUAL(4000, renderPage(page, at(12, 0), 4000, res, b));
TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0); // one dot on
TEST_ASSERT_EQUAL(1500, renderPage(page, at(12, 0), 6500, res, b));
page.elements[0].scroll_speed = 0.3f; // 3333.3 ms per dot: no drift over a long time
TEST_ASSERT_EQUAL(3334, renderPage(page, at(12, 0), 0, res, a));
uint32_t t = 3000000000UL; // ~35 days of millis()
uint32_t wait = renderPage(page, at(12, 0), t, res, a);
TEST_ASSERT_TRUE(wait >= 1 && wait <= 3334);
page.elements[0].scroll_speed = 0.01f; // too slow
TEST_ASSERT_NOT_NULL(validatePage(page));
page.elements[0].scroll_speed = NAN;
TEST_ASSERT_NOT_NULL(validatePage(page));
page.elements[0].scroll_speed = 31;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
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_icon_pixels_and_hex()
{
Icon icon;
icon.width = 11; // 2 bytes per row
icon.height = 3;
TEST_ASSERT_EQUAL(2, iconRowBytes(11));
TEST_ASSERT_EQUAL(6, iconBytes(11, 3));
setIconPixel(icon, 0, 0, true);
setIconPixel(icon, 10, 2, true);
setIconPixel(icon, 11, 0, true); // outside
TEST_ASSERT_TRUE(iconPixel(icon, 0, 0));
TEST_ASSERT_TRUE(iconPixel(icon, 10, 2));
TEST_ASSERT_FALSE(iconPixel(icon, 11, 0));
TEST_ASSERT_FALSE(iconPixel(icon, 1, 0));
char hex[BITMAP_HEX_LENGTH + 1];
iconToHex(icon, hex);
TEST_ASSERT_EQUAL_STRING("800000000020", hex);
Icon back;
TEST_ASSERT_TRUE(iconFromHex(11, 3, hex, back));
TEST_ASSERT_EQUAL(11, back.width);
TEST_ASSERT_EQUAL(3, back.height);
TEST_ASSERT_TRUE(iconPixel(back, 10, 2));
TEST_ASSERT_FALSE(iconPixel(back, 9, 2));
// Bits after the last dot of a row are cleared
TEST_ASSERT_TRUE(iconFromHex(11, 1, "ffff", back));
TEST_ASSERT_EQUAL_HEX8(0xE0, back.data[1]);
TEST_ASSERT_FALSE(iconFromHex(11, 3, "8000", back)); // too short
TEST_ASSERT_FALSE(iconFromHex(11, 3, "80000000002g", back)); // not hex
TEST_ASSERT_FALSE(iconFromHex(0, 3, "", back));
TEST_ASSERT_FALSE(iconFromHex(85, 1, "0000000000000000000000", back));
TEST_ASSERT_FALSE(iconFromHex(1, 20, "0000000000000000000000000000000000000000", back));
TEST_ASSERT_FALSE(iconFromHex(1, 1, nullptr, back));
TEST_ASSERT_TRUE(iconFromHex(84, 19, std::string(BITMAP_HEX_LENGTH, 'f').c_str(), back));
TEST_ASSERT_TRUE(iconPixel(back, 83, 18));
TEST_ASSERT_EQUAL(84, back.width);
TEST_ASSERT_TRUE(validIconSize(1, 1));
TEST_ASSERT_FALSE(validIconSize(84, 0));
}
void test_icon_from_bitmap_keeps_its_dots_in_place()
{
// Like "bg dag": a sun at x 9-19, y 0-10, nothing on the left
uint8_t bitmap[BITMAP_BYTES] = {};
setBitmapPixel(bitmap, 9, 5, true);
setBitmapPixel(bitmap, 19, 0, true);
setBitmapPixel(bitmap, 14, 10, true);
Icon icon;
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(20, icon.width);
TEST_ASSERT_EQUAL(11, icon.height);
uint8_t back[BITMAP_BYTES];
memset(back, 0xAA, sizeof(back));
iconToBitmap(icon, back);
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
// Full screen
memset(bitmap, 0, sizeof(bitmap));
setBitmapPixel(bitmap, 83, 18, true);
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(84, icon.width);
TEST_ASSERT_EQUAL(19, icon.height);
iconToBitmap(icon, back);
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
// No dots: a blank dot
memset(bitmap, 0, sizeof(bitmap));
iconFromBitmap(bitmap, icon);
TEST_ASSERT_EQUAL(1, icon.width);
TEST_ASSERT_EQUAL(1, icon.height);
TEST_ASSERT_FALSE(iconPixel(icon, 0, 0));
}
void test_page_uses_image()
{
DynamicPage page;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "bg printer");
page.element_count = 3;
page.elements[0].type = ElementType::Image;
strcpy(page.elements[0].text, "plex logo");
page.elements[1].type = ElementType::Image;
strcpy(page.elements[1].text, "weer ");
strcpy(page.elements[1].entity, "weather.home");
page.elements[2].type = ElementType::Text; // texts are no images
strcpy(page.elements[2].text, "hello");
TEST_ASSERT_TRUE(pageUsesImage(page, "BG Printer"));
TEST_ASSERT_TRUE(pageUsesImage(page, "plex logo"));
TEST_ASSERT_FALSE(pageUsesImage(page, "hello"));
TEST_ASSERT_FALSE(pageUsesImage(page, "plex"));
// By state: only the default, unless all states are asked for
TEST_ASSERT_TRUE(pageUsesImage(page, "weer default"));
TEST_ASSERT_FALSE(pageUsesImage(page, "weer sunny"));
TEST_ASSERT_TRUE(pageUsesImage(page, "Weer sunny", true));
TEST_ASSERT_FALSE(pageUsesImage(page, "weer", true));
TEST_ASSERT_FALSE(pageUsesImage(page, "weerbericht", false));
TEST_ASSERT_FALSE(pageUsesImage(page, "sunny", true));
}
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());
}
// ---------------------------------------------------------------------------
// Page schedule, rules and the page selector
// ---------------------------------------------------------------------------
static void setItem(ScheduleBlock &b, const char *page, uint16_t seconds)
{
strcpy(b.items[b.item_count].page, page);
b.items[b.item_count].seconds = seconds;
b.item_count++;
}
// 07:00 Weer (20 s) + Datum (10 s), with effect; 17:00 Energie, instant
static PageSchedule twoBlocks()
{
PageSchedule s;
s.block_count = 2;
s.blocks[0].start = 7 * 60;
setItem(s.blocks[0], "Weer", 20);
setItem(s.blocks[0], "Datum", 10);
s.blocks[1].start = 17 * 60;
s.blocks[1].effect = false;
setItem(s.blocks[1], "Energie", 30);
return s;
}
static TriggerRule rule(const char *page, TriggerMode mode, uint16_t seconds = 30)
{
TriggerRule r;
strcpy(r.entity, "sensor.printer");
strcpy(r.value, "printing");
strcpy(r.page, page);
r.mode = mode;
r.seconds = seconds;
return r;
}
void test_schedule_validation()
{
PageSchedule s = twoBlocks();
TEST_ASSERT_NULL(validateSchedule(s));
s.blocks[1].start = 7 * 60;
TEST_ASSERT_NOT_NULL(validateSchedule(s)); // same time
s = twoBlocks();
s.blocks[0].items[0].seconds = 4;
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s = twoBlocks();
s.blocks[1].item_count = 0;
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s = twoBlocks();
s.rule_count = 1;
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
TEST_ASSERT_NULL(validateSchedule(s));
s.rules[0].interval = 1;
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
strcpy(s.rules[0].entity, "printer");
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s.rules[0] = rule("", TriggerMode::WhileTrue);
TEST_ASSERT_NOT_NULL(validateSchedule(s));
s = twoBlocks();
TEST_ASSERT_TRUE(scheduleUses(s, "datum"));
TEST_ASSERT_FALSE(scheduleUses(s, "Clock"));
}
void test_rule_comparisons()
{
TriggerRule r = rule("x", TriggerMode::WhileTrue);
TEST_ASSERT_TRUE(ruleMatches(r, "Printing"));
TEST_ASSERT_FALSE(ruleMatches(r, "standby"));
TEST_ASSERT_FALSE(ruleMatches(r, nullptr));
r.compare = Compare::NotEqual;
TEST_ASSERT_TRUE(ruleMatches(r, "standby"));
TEST_ASSERT_FALSE(ruleMatches(r, nullptr)); // unknown is never true
r.compare = Compare::Above;
strcpy(r.value, "1000");
TEST_ASSERT_TRUE(ruleMatches(r, "1257"));
TEST_ASSERT_FALSE(ruleMatches(r, "999.5"));
TEST_ASSERT_FALSE(ruleMatches(r, "unavailable"));
r.compare = Compare::Below;
TEST_ASSERT_TRUE(ruleMatches(r, "-3"));
}
void test_selector_without_schedule_shows_the_chosen_page()
{
PageSelector sel;
sel.setChosen("Clock");
PageChoice c = sel.update(600, 0);
TEST_ASSERT_EQUAL_STRING("Clock", c.page);
TEST_ASSERT_TRUE(c.changed);
TEST_ASSERT_FALSE(sel.update(601, 100000).changed);
sel.pick("Weer");
TEST_ASSERT_FALSE(sel.picked()); // no schedule: just the new chosen page
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(601, 100100).page);
}
void test_selector_rotates_the_block()
{
PageSchedule s = twoBlocks();
PageSelector sel;
sel.setSchedule(&s);
sel.setChosen("Clock");
PageChoice c = sel.update(8 * 60, 0);
TEST_ASSERT_EQUAL_STRING("Weer", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Schedule);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 19999).page);
c = sel.update(8 * 60, 20000);
TEST_ASSERT_EQUAL_STRING("Datum", c.page);
TEST_ASSERT_TRUE(c.changed);
TEST_ASSERT_FALSE(c.instant); // this block uses the effect
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60 + 1, 30000).page);
c = sel.update(17 * 60, 31000); // the next block
TEST_ASSERT_EQUAL_STRING("Energie", c.page);
TEST_ASSERT_TRUE(c.instant);
// Before 07:00: the last block of the day before
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(3 * 60, 40000).page);
}
void test_selector_manual_pick_until_the_next_block()
{
PageSchedule s = twoBlocks();
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.pick("Grote klok");
TEST_ASSERT_TRUE(sel.picked());
TEST_ASSERT_EQUAL(17 * 60, sel.pickedUntil());
PageChoice c = sel.update(8 * 60, 1000);
TEST_ASSERT_EQUAL_STRING("Grote klok", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen);
TEST_ASSERT_EQUAL_STRING("Grote klok", sel.update(16 * 60 + 59, 900000).page);
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60, 901000).page);
TEST_ASSERT_FALSE(sel.picked());
sel.pick("Grote klok");
sel.backToSchedule();
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60 + 1, 902000).page);
}
void test_selector_one_block_starts_again_each_day()
{
PageSchedule s = twoBlocks();
s.block_count = 1;
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.pick("Clock");
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(6 * 60 + 59, 1000).page); // next morning, before 07:00
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(7 * 60, 2000).page);
s.enabled = false; // switched off: the chosen page
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(7 * 60 + 1, 3000).page);
}
void test_selector_take_over_rules()
{
PageSchedule s = twoBlocks();
s.rule_count = 2;
s.rules[0] = rule("Deurbel", TriggerMode::ForSeconds, 30);
s.rules[1] = rule("3D printer", TriggerMode::WhileTrue);
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.pick("Clock"); // a rule beats a manual pick
sel.setRuleState(1, true, 1000);
PageChoice c = sel.update(8 * 60, 1000);
TEST_ASSERT_EQUAL_STRING("3D printer", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Trigger);
TEST_ASSERT_EQUAL(1, c.rule);
TEST_ASSERT_TRUE(c.instant);
sel.setRuleState(0, true, 2000); // the first rule wins
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 2000).page);
sel.setRuleState(0, true, 20000); // still true: no restart
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 31999).page);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 32000).page); // 30 s over
sel.setRuleState(0, false, 33000);
sel.setRuleState(0, true, 34000); // true again: 30 s again
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 34000).page);
sel.setRuleState(0, false, 70000);
sel.setRuleState(1, false, 70000);
c = sel.update(8 * 60, 70000);
TEST_ASSERT_EQUAL_STRING("Clock", c.page); // the manual pick again
TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once
}
void test_selector_rule_can_leave_a_manual_pick_alone()
{
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
s.rule_count = 2;
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
s.rules[0].over_manual = false;
s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10);
s.rules[1].over_manual = false;
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 0).page); // no pick: it takes over
sel.pick("Clock");
PageChoice c = sel.update(8 * 60, 1000);
TEST_ASSERT_EQUAL_STRING("Clock", c.page); // waits while the pick lasts
TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen);
sel.setRuleState(1, true, 1000);
for (uint32_t t = 1000; t < 120000; t += 5000)
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(8 * 60, t).page); // not in the rotation either
sel.backToSchedule();
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 121000).page);
// The next block ends a pick too
sel.pick("Clock");
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(16 * 60, 122000).page);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 123000).page);
}
void test_selector_rules_only_in_their_blocks()
{
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
s.rule_count = 2;
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
s.rules[0].blocks = 1 << 1; // only from 17:00
s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10);
s.rules[1].blocks = 1 << 0; // only in the morning block
TEST_ASSERT_NULL(validateSchedule(s));
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 1000).page); // not in this block
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 2000).page);
sel.setRuleState(0, false, 3000);
sel.setRuleState(1, true, 3000);
sel.update(17 * 60, 3000);
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60, 40000).page); // not added here
// Without a running schedule the blocks don't matter
s.enabled = false;
sel.setRuleState(0, true, 50000);
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 50000).page);
s.enabled = true;
s.rules[0].blocks = 1 << 2; // a third block doesn't exist
TEST_ASSERT_NOT_NULL(validateSchedule(s));
TEST_ASSERT_TRUE(ruleInBlock(s.rules[1], -1));
TEST_ASSERT_FALSE(ruleInBlock(s.rules[1], 1));
}
void test_selector_add_to_rotation()
{
PageSchedule s = twoBlocks();
s.rule_count = 1;
s.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15);
PageSelector sel;
sel.setSchedule(&s);
sel.update(8 * 60, 0);
sel.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 0).page);
TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 20000).page);
PageChoice c = sel.update(8 * 60, 30000);
TEST_ASSERT_EQUAL_STRING("3D printer", c.page);
TEST_ASSERT_TRUE(c.reason == ShowReason::Rotation);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 45000).page);
sel.setRuleState(0, false, 46000);
TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 65000).page);
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 75000).page); // printer gone
// Without a schedule the chosen page rotates with it
PageSchedule rules_only;
rules_only.rule_count = 1;
rules_only.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15);
PageSelector sel2;
sel2.setSchedule(&rules_only);
sel2.setChosen("Clock");
sel2.setRuleState(0, true, 0);
TEST_ASSERT_EQUAL_STRING("Clock", sel2.update(600, 0).page);
TEST_ASSERT_EQUAL_STRING("3D printer", sel2.update(600, CHOSEN_PAGE_SECONDS * 1000).page);
}
// ---------------------------------------------------------------------------
// Home Assistant value conversions
// ---------------------------------------------------------------------------
static PageElement haElement(HaShow show, uint8_t format = 0)
{
PageElement e;
e.type = ElementType::HaValue;
strcpy(e.entity, "sensor.x");
e.show = show;
e.format = format;
return e;
}
void test_ha_auto_unit_and_absolute()
{
PageElement e = haElement(HaShow::Number);
e.auto_unit = true;
strcpy(e.suffix, " W");
TEST_ASSERT_EQUAL_STRING("1.3 kW", textOf(e, "1257").c_str());
TEST_ASSERT_EQUAL_STRING("950 W", textOf(e, "950").c_str());
TEST_ASSERT_EQUAL_STRING("-2.5 kW", textOf(e, "-2500").c_str());
e.absolute = true;
TEST_ASSERT_EQUAL_STRING("2.5 kW", textOf(e, "-2500").c_str());
strcpy(e.suffix, "Wh");
e.decimals = 2;
TEST_ASSERT_EQUAL_STRING("26.30kWh", textOf(e, "26300").c_str());
strcpy(e.suffix, "kWh");
e.decimals = DECIMALS_AS_IS;
TEST_ASSERT_EQUAL_STRING("18.6MWh", textOf(e, "18621.326").c_str());
TEST_ASSERT_EQUAL_STRING("500.0Wh", textOf(e, "0.5").c_str()); // below 1: smaller unit
strcpy(e.suffix, "%"); // not a unit it knows
TEST_ASSERT_EQUAL_STRING("37%", textOf(e, "37").c_str());
}
void test_ha_duration()
{
PageElement e = haElement(HaShow::Duration, DURATION_PARTS);
TEST_ASSERT_EQUAL_STRING("2h 20m", textOf(e, "8452.2").c_str());
TEST_ASSERT_EQUAL_STRING("1d 1h 1m", textOf(e, "90061").c_str());
TEST_ASSERT_EQUAL_STRING("2d", textOf(e, "172800").c_str());
TEST_ASSERT_EQUAL_STRING("45s", textOf(e, "45").c_str());
e.input_unit = UNIT_MINUTES;
TEST_ASSERT_EQUAL_STRING("1h 30m", textOf(e, "90").c_str());
e.input_unit = UNIT_HOURS;
e.format = DURATION_H_MM_SS;
TEST_ASSERT_EQUAL_STRING("26:30:00", textOf(e, "26.5").c_str());
e.format = DURATION_H_MM;
TEST_ASSERT_EQUAL_STRING("26:30", textOf(e, "26.5").c_str());
e.input_unit = UNIT_SECONDS;
e.format = DURATION_SMART;
TEST_ASSERT_EQUAL_STRING("2 uur", textOf(e, "8452").c_str());
TEST_ASSERT_EQUAL_STRING("1 dag", textOf(e, "90000").c_str());
TEST_ASSERT_EQUAL_STRING("34 min", textOf(e, "2040").c_str());
e.language = Language::English;
TEST_ASSERT_EQUAL_STRING("2 hours", textOf(e, "8452").c_str());
TEST_ASSERT_EQUAL_STRING("--", textOf(e, "unknown").c_str());
TEST_ASSERT_EQUAL_STRING("idle", textOf(e, "idle").c_str()); // not a number: as is
}
void test_ha_timestamps()
{
int64_t s = 0;
TEST_ASSERT_TRUE(parseTimestamp("2000-01-01T00:00:00+00:00", s));
TEST_ASSERT_EQUAL_INT64(0, s);
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T21:07:00+00:00", s));
int64_t a = s;
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T23:07:00+02:00", s));
TEST_ASSERT_EQUAL_INT64(a, s);
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T21:07:00.515369Z", s));
TEST_ASSERT_EQUAL_INT64(a, s);
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15 21:07:00", s));
TEST_ASSERT_EQUAL_INT64(a, s);
TEST_ASSERT_FALSE(parseTimestamp("21:07", s));
TEST_ASSERT_FALSE(parseTimestamp("2026-10-15T21:07:00+0200x", s));
// textOf: Thursday 15 October 2026 08:05:00 local; CEST is UTC+2
PageElement e = haElement(HaShow::Time, TIMESTAMP_H_MM);
auto at = [&](const char *value) {
LocalTime t;
t.year = 2026;
t.month = 10;
t.day = 15;
t.weekday = 4;
t.hour = 8;
t.minute = 5;
t.utc_offset = 120;
char out[64];
ElementValues values;
values.ha_value = value;
elementText(e, t, values, out, sizeof(out));
return std::string(out);
};
TEST_ASSERT_EQUAL_STRING("23:07", at("2026-10-15T21:07:00+00:00").c_str());
e.format = TIMESTAMP_WEEKDAY_H_MM;
TEST_ASSERT_EQUAL_STRING("vr 01:30", at("2026-10-15T23:30:00+00:00").c_str()); // the next day
e.format = TIMESTAMP_DATE_H_MM;
TEST_ASSERT_EQUAL_STRING("16-10 01:30", at("2026-10-15T23:30:00+00:00").c_str());
e.format = TIMESTAMP_RELATIVE;
TEST_ASSERT_EQUAL_STRING("over 34 min", at("2026-10-15T06:39:00+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("12 min geleden", at("2026-10-15T05:53:00+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("nu", at("2026-10-15T06:05:30+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("over 2 uur", at("2026-10-15T08:30:00+00:00").c_str());
e.language = Language::English;
TEST_ASSERT_EQUAL_STRING("3 days ago", at("2026-10-12T06:00:00+00:00").c_str());
TEST_ASSERT_EQUAL_STRING("soon", at("soon").c_str()); // not a timestamp: as is
}
void test_ha_text_map()
{
PageElement e = haElement(HaShow::Map);
strcpy(e.map, "printing=Bezig, standby = Klaar ,paused=Pauze,=leeg");
strcpy(e.text, "3D ");
TEST_ASSERT_EQUAL_STRING("3D Bezig", textOf(e, "printing").c_str());
TEST_ASSERT_EQUAL_STRING("3D Klaar", textOf(e, "Standby").c_str());
TEST_ASSERT_EQUAL_STRING("3D Pauze", textOf(e, "paused").c_str());
TEST_ASSERT_EQUAL_STRING("3D error", textOf(e, "error").c_str()); // not in the list: as is
TEST_ASSERT_EQUAL_STRING("3D --", textOf(e, "unavailable").c_str());
DynamicPage page;
page.element_count = 1;
page.elements[0] = e;
TEST_ASSERT_NULL(validatePage(page));
page.elements[0].show = HaShow::Count;
TEST_ASSERT_NOT_NULL(validatePage(page));
page.elements[0] = haElement(HaShow::Duration, DURATION_FORMAT_COUNT);
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_loop_stats()
{
LoopStats stats;
TEST_ASSERT_EQUAL_UINT32(0, stats.loopsPerSecond());
// 995 passes of 10 ms (the micros() counter wraps on the way), one of them 50 ms
uint32_t t = 0xFFFFFFFFUL - 5000000UL;
stats.tick(t);
for (int i = 0; i < 995; i++)
{
t += i == 500 ? 50000 : 10000;
stats.tick(t);
}
TEST_ASSERT_EQUAL_UINT32(0, stats.loopsPerSecond()); // window not complete yet
t += 10000;
stats.tick(t);
TEST_ASSERT_EQUAL_UINT32(99, stats.loopsPerSecond());
TEST_ASSERT_EQUAL_UINT32(10040, stats.averageUs());
TEST_ASSERT_EQUAL_UINT32(50000, stats.maxUs());
TEST_ASSERT_EQUAL_UINT32(50000, stats.maxEverUs());
// Next window without the slow pass: the window max drops, the boot max stays
for (int i = 0; i < 1000; i++)
{
t += 10000;
stats.tick(t);
}
TEST_ASSERT_EQUAL_UINT32(100, stats.loopsPerSecond());
TEST_ASSERT_EQUAL_UINT32(10000, stats.maxUs());
TEST_ASSERT_EQUAL_UINT32(50000, stats.maxEverUs());
}
void test_frame_check_ok()
{
FrameCheck c;
c.reset(1000);
TEST_ASSERT_TRUE(c.due(5, 1000)); // first check right after switching on
c.sent(0x1234, 5, 1000);
TEST_ASSERT_FALSE(c.due(6, 5000)); // waiting for the answer
c.line("C 1234\r", 1010);
TEST_ASSERT_EQUAL_UINT32(1, c.stats().checks);
TEST_ASSERT_FALSE(c.resyncNow(1020, 0));
// The next one after a new frame, at most every FRAME_CHECK_INTERVAL
TEST_ASSERT_FALSE(c.due(5, 10000)); // no new frame
TEST_ASSERT_FALSE(c.due(6, 1000 + FRAME_CHECK_INTERVAL - 1));
TEST_ASSERT_TRUE(c.due(6, 1000 + FRAME_CHECK_INTERVAL));
}
void test_frame_check_mismatch_repairs()
{
FrameCheck c;
c.reset(0);
c.sent(0x1234, 1, 20000);
c.line("C 1235", 20010);
TEST_ASSERT_EQUAL_UINT32(1, c.stats().mismatches);
TEST_ASSERT_FALSE(c.due(2, 30000)); // the repair comes first
TEST_ASSERT_TRUE(c.resyncNow(20010, 0));
TEST_ASSERT_EQUAL_UINT32(1, c.stats().repairs);
TEST_ASSERT_FALSE(c.resyncNow(20020, 0)); // once
TEST_ASSERT_TRUE(c.due(2, 30000)); // then the repaired frame is checked
}
void test_frame_check_repairs_at_most_every_gap()
{
FrameCheck c;
c.reset(0);
c.sent(1, 1, 100);
c.line("C 0002", 110);
TEST_ASSERT_FALSE(c.resyncNow(FRAME_REPAIR_MIN_GAP - 1, 0)); // switched on just now
TEST_ASSERT_TRUE(c.resyncNow(FRAME_REPAIR_MIN_GAP, 0));
}
void test_frame_check_no_reply()
{
FrameCheck c;
c.reset(0);
// Never answered (old Nano firmware, TX not wired): counted, but no repairs
c.sent(1, 1, 100);
TEST_ASSERT_FALSE(c.resyncNow(100 + FRAME_CHECK_TIMEOUT - 1, 0));
TEST_ASSERT_FALSE(c.resyncNow(100 + FRAME_CHECK_TIMEOUT + FRAME_REPAIR_MIN_GAP, 0));
TEST_ASSERT_EQUAL_UINT32(1, c.stats().no_reply);
TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs);
// It answered before: a lost answer means lost bytes, repair
uint32_t t = 100000;
c.sent(1, 2, t);
c.line("C 0001", t + 5);
t += FRAME_CHECK_INTERVAL;
c.sent(1, 3, t);
TEST_ASSERT_TRUE(c.resyncNow(t + FRAME_CHECK_TIMEOUT, 0));
TEST_ASSERT_EQUAL_UINT32(2, c.stats().no_reply);
TEST_ASSERT_EQUAL_UINT32(1, c.stats().repairs);
// A late answer to the timed out check is ignored
c.line("C 0009", t + FRAME_CHECK_TIMEOUT + 10);
TEST_ASSERT_EQUAL_UINT32(0, c.stats().mismatches);
}
void test_frame_check_errors_and_restarts()
{
FrameCheck c;
c.reset(0);
c.line("ERR: Line too long", 10);
c.line("ERR: Invalid bitmap width (not dividable by 4) and a lot more text than fits", 20);
TEST_ASSERT_EQUAL_UINT32(2, c.stats().errors);
TEST_ASSERT_EQUAL_UINT32(sizeof(c.stats().last_error) - 1, strlen(c.stats().last_error));
TEST_ASSERT_EQUAL(0, strncmp(c.stats().last_error, "ERR: Invalid bitmap", 19));
TEST_ASSERT_FALSE(c.resyncNow(FRAME_REPAIR_MIN_GAP, 0)); // errors alone: the check decides
c.line("READY", 20000);
TEST_ASSERT_EQUAL_UINT32(1, c.stats().restarts);
TEST_ASSERT_TRUE(c.resyncNow(20000, 0));
c.line("garbage", 20010);
c.line("C 12", 20010);
TEST_ASSERT_EQUAL_UINT32(0, c.stats().checks + c.stats().mismatches);
}
void test_frame_check_periodic_resync()
{
FrameCheck c;
c.reset(1000);
TEST_ASSERT_FALSE(c.resyncNow(1000 + 600000 - 1, 600000));
TEST_ASSERT_TRUE(c.resyncNow(1000 + 600000, 600000));
TEST_ASSERT_FALSE(c.resyncNow(1000 + 600001, 600000));
TEST_ASSERT_FALSE(c.resyncNow(0xFFFFFFF0u, 0)); // 0: never
TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs); // not counted as a repair
}
static int countOn(const uint8_t *b)
{
int n = 0;
for (int y = 0; y < BITMAP_HEIGHT; y++)
for (int x = 0; x < BITMAP_WIDTH; x++)
n += bitmapPixel(b, x, y);
return n;
}
static DynamicPage barPage()
{
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Bar;
strcpy(e.entity, "sensor.{plex_user}_progress");
e.x = 10;
e.y = 14;
e.bar_width = 22; // 20 inside the frame
e.bar_height = 4;
return page;
}
void test_bar_fill()
{
PageElement e = barPage().elements[0];
TEST_ASSERT_EQUAL(0, barFill(e, nullptr, 20));
TEST_ASSERT_EQUAL(0, barFill(e, "unavailable", 20));
TEST_ASSERT_EQUAL(0, barFill(e, "-5", 20));
TEST_ASSERT_EQUAL(0, barFill(e, "0", 20));
TEST_ASSERT_EQUAL(10, barFill(e, "50", 20));
TEST_ASSERT_EQUAL(7, barFill(e, "33.3", 20));
TEST_ASSERT_EQUAL(20, barFill(e, "100", 20));
TEST_ASSERT_EQUAL(20, barFill(e, "250", 20));
e.bar_max = 1;
e.factor = 0.01f; // 0-100 as 0-1
TEST_ASSERT_EQUAL(5, barFill(e, "25", 20));
e.bar_max = 7200;
e.factor = 1;
TEST_ASSERT_EQUAL(10, barFill(e, "3600", 20)); // e.g. seconds of a 2 hour film
}
void test_bar_render()
{
FakeResources res;
DynamicPage page = barPage();
TEST_ASSERT_NULL(validatePage(page));
uint8_t b[BITMAP_BYTES];
ElementBox box;
res.value = "50";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_EQUAL(10, box.x);
TEST_ASSERT_EQUAL(14, box.y);
TEST_ASSERT_EQUAL(22, box.width);
TEST_ASSERT_EQUAL(4, box.height);
// Frame: 2 x 22 + 2 x 2 dots; inside 10 of 20 columns x 2 rows
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2 + 10 * 2, countOn(b));
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 14)); // corner
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 15)); // first filled dot
TEST_ASSERT_TRUE(bitmapPixel(b, 20, 16)); // last filled dot
TEST_ASSERT_FALSE(bitmapPixel(b, 21, 16)); // empty half
res.value = nullptr; // unknown: only the frame
renderPage(page, at(12, 0), 0, res, b);
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2, countOn(b));
// Without frame, right aligned, inverted over a full background
PageElement &e = page.elements[0];
e.bar_frame = false;
e.align = Align::Right;
e.x = 84;
e.overlay = Overlay::Invert;
page.slot_count = 1;
strcpy(page.slots[0].bitmap, "Full");
res.value = "100";
renderPage(page, at(12, 0), 0, res, b, &box);
TEST_ASSERT_EQUAL(62, box.x);
TEST_ASSERT_EQUAL(BITMAP_WIDTH * BITMAP_HEIGHT - 22 * 4, countOn(b));
TEST_ASSERT_FALSE(bitmapPixel(b, 83, 17));
TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17));
}
void test_date_days_ahead()
{
LocalTime t; // Thursday 31 December 2026
t.year = 2026;
t.month = 12;
t.day = 31;
t.weekday = 4;
LocalTime n = addDays(t, 1);
TEST_ASSERT_EQUAL(2027, n.year);
TEST_ASSERT_EQUAL(1, n.month);
TEST_ASSERT_EQUAL(1, n.day);
TEST_ASSERT_EQUAL(5, n.weekday);
t.year = 2028; // leap year: 28 February + 1 = 29 February
t.month = 2;
t.day = 28;
TEST_ASSERT_EQUAL(29, addDays(t, 1).day);
TEST_ASSERT_EQUAL(3, addDays(t, 2).month);
t.year = 2027;
TEST_ASSERT_EQUAL(1, addDays(t, 1).day);
DynamicPage page;
page.element_count = 1;
PageElement &e = page.elements[0];
e.type = ElementType::Date;
e.format = DATE_WEEKDAY_SHORT;
LocalTime thu;
thu.year = 2026;
thu.month = 10;
thu.day = 15;
thu.weekday = 4;
char text[32];
elementText(e, thu, ElementValues(), text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("do", text);
e.day_offset = 3;
elementText(e, thu, ElementValues(), text, sizeof(text));
TEST_ASSERT_EQUAL_STRING("zo", text);
TEST_ASSERT_NULL(validatePage(page));
e.day_offset = 8;
TEST_ASSERT_NOT_NULL(validatePage(page));
e.day_offset = -1;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_attribute_paths_and_16_elements()
{
DynamicPage page;
page.element_count = PAGE_ELEMENTS;
TEST_ASSERT_EQUAL(16, PAGE_ELEMENTS);
for (int i = 0; i < PAGE_ELEMENTS; i++)
{
page.elements[i].type = ElementType::HaValue;
strcpy(page.elements[i].entity, "sensor.weer_verwachting");
strcpy(page.elements[i].attribute, "forecast.1.templow");
}
TEST_ASSERT_NULL(validatePage(page));
strcpy(page.elements[3].attribute, "forecast/1");
TEST_ASSERT_NOT_NULL(validatePage(page));
page.elements[3].attribute[0] = '\0';
page.element_count = PAGE_ELEMENTS + 1;
TEST_ASSERT_NOT_NULL(validatePage(page));
}
void test_bar_validation()
{
DynamicPage page = barPage();
page.elements[0].entity[0] = '\0';
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_width = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_height = 20;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_max = 0;
TEST_ASSERT_NOT_NULL(validatePage(page));
page = barPage();
page.elements[0].bar_max = NAN;
TEST_ASSERT_NOT_NULL(validatePage(page));
TEST_ASSERT_TRUE(elementUsesHa(barPage().elements[0]));
}
void test_plex_user_placeholder()
{
char out[ELEMENT_ENTITY_SIZE];
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}_state", "mangavalk", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("sensor.mangavalk_state", out);
TEST_ASSERT_TRUE(expandEntity("sensor.tautulli_watching", "mangavalk", out, sizeof(out)));
TEST_ASSERT_EQUAL_STRING("sensor.tautulli_watching", out);
// No user set: not a valid entity id, so nothing is read
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}", "", out, sizeof(out)));
TEST_ASSERT_FALSE(validEntityId(out));
// Too long once filled in
char user[PLEX_USER_SIZE];
memset(user, 'a', sizeof(user) - 1);
user[sizeof(user) - 1] = '\0';
TEST_ASSERT_FALSE(expandEntity("sensor.{plex_user}_a_long_name_that_is_too_long", user, out, sizeof(out)));
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}_state"));
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}"));
TEST_ASSERT_FALSE(validEntityId("sensor.{plex_user}{plex_user}"));
TEST_ASSERT_FALSE(validEntityId("sensor.{other}_state"));
TEST_ASSERT_FALSE(validEntityId("{plex_user}")); // no domain
plexUserSlug("MangaValk", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("mangavalk", user);
plexUserSlug(" Manga Valk-2 ", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("manga_valk_2", user);
plexUserSlug("", user, sizeof(user));
TEST_ASSERT_EQUAL_STRING("", user);
char small[5];
plexUserSlug("ab cd ef", small, sizeof(small));
TEST_ASSERT_EQUAL_STRING("ab_c", small);
}
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_icon_pixels_and_hex);
RUN_TEST(test_icon_from_bitmap_keeps_its_dots_in_place);
RUN_TEST(test_page_uses_image);
RUN_TEST(test_moving_page_is_redrawn_when_due);
RUN_TEST(test_schedule_validation);
RUN_TEST(test_rule_comparisons);
RUN_TEST(test_selector_without_schedule_shows_the_chosen_page);
RUN_TEST(test_selector_rotates_the_block);
RUN_TEST(test_selector_manual_pick_until_the_next_block);
RUN_TEST(test_selector_one_block_starts_again_each_day);
RUN_TEST(test_selector_take_over_rules);
RUN_TEST(test_selector_rules_only_in_their_blocks);
RUN_TEST(test_selector_rule_can_leave_a_manual_pick_alone);
RUN_TEST(test_selector_add_to_rotation);
RUN_TEST(test_ha_auto_unit_and_absolute);
RUN_TEST(test_ha_duration);
RUN_TEST(test_ha_timestamps);
RUN_TEST(test_ha_text_map);
RUN_TEST(test_loop_stats);
RUN_TEST(test_frame_check_ok);
RUN_TEST(test_frame_check_mismatch_repairs);
RUN_TEST(test_frame_check_repairs_at_most_every_gap);
RUN_TEST(test_frame_check_no_reply);
RUN_TEST(test_frame_check_errors_and_restarts);
RUN_TEST(test_frame_check_periodic_resync);
RUN_TEST(test_bar_fill);
RUN_TEST(test_bar_render);
RUN_TEST(test_bar_validation);
RUN_TEST(test_date_days_ahead);
RUN_TEST(test_attribute_paths_and_16_elements);
RUN_TEST(test_plex_user_placeholder);
RUN_TEST(test_slow_scrolling_below_one_dot_per_second);
return UNITY_END();
}