// 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 #include #include #include #include #include #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 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 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_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_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_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_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_plex_user_placeholder); RUN_TEST(test_slow_scrolling_below_one_dot_per_second); return UNITY_END(); }