2026-10-13 00:56:32 +02:00
|
|
|
// 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>
|
2026-10-13 01:21:04 +02:00
|
|
|
#include <cstring>
|
2026-10-13 00:56:32 +02:00
|
|
|
#include <string>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
#include "config.h"
|
2026-10-16 15:20:26 +02:00
|
|
|
#include "logic/frame_check.h"
|
|
|
|
|
#include "logic/loop_stats.h"
|
2026-10-13 01:52:34 +02:00
|
|
|
#include "logic/bitmap_format.h"
|
2026-10-15 19:43:59 +02:00
|
|
|
#include "logic/builtin_fonts.h"
|
|
|
|
|
#include "logic/builtin_pages.h"
|
|
|
|
|
#include "logic/font.h"
|
2026-10-13 01:05:06 +02:00
|
|
|
#include "logic/boot_policy.h"
|
2026-10-13 00:56:32 +02:00
|
|
|
#include "logic/display_controller.h"
|
2026-10-15 19:43:59 +02:00
|
|
|
#include "logic/page.h"
|
|
|
|
|
#include "logic/page_render.h"
|
2026-10-15 21:36:56 +02:00
|
|
|
#include "logic/page_schedule.h"
|
|
|
|
|
#include "logic/page_selector.h"
|
2026-10-13 00:56:32 +02:00
|
|
|
#include "logic/power_timer.h"
|
|
|
|
|
#include "logic/schedule.h"
|
2026-10-13 01:52:34 +02:00
|
|
|
#include "logic/settings.h"
|
2026-10-13 00:56:32 +02:00
|
|
|
#include "logic/status_led.h"
|
2026-10-15 21:01:14 +02:00
|
|
|
#include "logic/stopwatch.h"
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
2026-10-15 21:01:14 +02:00
|
|
|
uint32_t wait = 0; // what dynamicPage() returns
|
|
|
|
|
bool last_redraw = false;
|
2026-10-13 00:56:32 +02:00
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
uint32_t dynamicPage(const LocalTime &time, uint32_t, bool redraw) override
|
2026-10-13 00:56:32 +02:00
|
|
|
{
|
2026-10-13 01:52:34 +02:00
|
|
|
char s[24];
|
2026-10-15 19:43:59 +02:00
|
|
|
snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute);
|
2026-10-13 00:56:32 +02:00
|
|
|
shown.push_back(s);
|
2026-10-15 21:01:14 +02:00
|
|
|
last_redraw = redraw;
|
|
|
|
|
return wait;
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
2026-10-13 01:52:34 +02:00
|
|
|
void page() override { shown.push_back("page"); }
|
|
|
|
|
void live() override { shown.push_back("live"); }
|
2026-10-13 01:34:04 +02:00
|
|
|
void noWifi(int hour, int minute) override
|
|
|
|
|
{
|
|
|
|
|
char s[16];
|
|
|
|
|
snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute);
|
|
|
|
|
shown.push_back(s);
|
|
|
|
|
}
|
2026-10-15 01:29:01 +02:00
|
|
|
void off() override { shown.push_back("off"); }
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
std::string last() const { return shown.empty() ? "" : shown.back(); }
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-13 01:52:34 +02:00
|
|
|
static const Settings defaults;
|
|
|
|
|
|
2026-10-13 00:56:32 +02:00
|
|
|
static FakePower power;
|
|
|
|
|
static FakeFrames frames;
|
|
|
|
|
static DisplayController *controller;
|
|
|
|
|
static uint32_t now;
|
2026-10-13 01:34:04 +02:00
|
|
|
static bool wifi;
|
2026-10-13 00:56:32 +02:00
|
|
|
|
|
|
|
|
void setUp()
|
|
|
|
|
{
|
|
|
|
|
power = FakePower();
|
|
|
|
|
frames = FakeFrames();
|
|
|
|
|
delete controller;
|
|
|
|
|
controller = new DisplayController(power, frames);
|
|
|
|
|
now = 1000;
|
2026-10-13 01:34:04 +02:00
|
|
|
wifi = true;
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
{
|
2026-10-15 19:43:59 +02:00
|
|
|
LocalTime time;
|
|
|
|
|
time.hour = hour;
|
|
|
|
|
time.minute = minute;
|
|
|
|
|
controller->update(now, true, time, wifi);
|
2026-10-13 00:56:32 +02:00
|
|
|
now += 100;
|
|
|
|
|
} while ((int32_t)(end - now) >= 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Schedule
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
void test_schedule_night_until_8()
|
|
|
|
|
{
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_schedule_clock_from_8_to_22()
|
|
|
|
|
{
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_schedule_quarter_hours_until_23_30()
|
|
|
|
|
{
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_schedule_night_from_23_31()
|
|
|
|
|
{
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 01:05:06 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 01:21:04 +02:00
|
|
|
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++)
|
2026-10-13 01:34:04 +02:00
|
|
|
{
|
2026-10-13 01:21:04 +02:00
|
|
|
TEST_ASSERT_TRUE(strlen(recoveryReasonText((RecoveryReason)r)) > 0);
|
2026-10-13 01:34:04 +02:00
|
|
|
// fits on one line of the display
|
|
|
|
|
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) > 0);
|
|
|
|
|
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) <= 14);
|
|
|
|
|
}
|
2026-10-13 01:21:04 +02:00
|
|
|
TEST_ASSERT_TRUE(strcmp(recoveryReasonText(RecoveryReason::Wifi),
|
|
|
|
|
recoveryReasonText(RecoveryReason::Crash)) != 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 00:56:32 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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..."
|
2026-10-15 19:43:59 +02:00
|
|
|
controller->update(now, false, LocalTime(), true);
|
2026-10-13 00:56:32 +02:00
|
|
|
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);
|
2026-10-15 01:29:01 +02:00
|
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
2026-10-13 00:56:32 +02:00
|
|
|
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);
|
2026-10-15 19:43:59 +02:00
|
|
|
TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no off screen
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-15 19:43:59 +02:00
|
|
|
TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str());
|
2026-10-13 00:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-15 01:29:01 +02:00
|
|
|
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);
|
2026-10-13 00:56:32 +02:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 01:34:04 +02:00
|
|
|
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);
|
2026-10-15 01:29:01 +02:00
|
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
2026-10-13 01:34:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-13 01:52:34 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 01:29:01 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 01:52:34 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
void test_settings_page_must_be_a_name()
|
2026-10-13 01:52:34 +02:00
|
|
|
{
|
|
|
|
|
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"));
|
2026-10-15 19:43:59 +02:00
|
|
|
TEST_ASSERT_TRUE(sameName("Clock", "clock"));
|
|
|
|
|
TEST_ASSERT_FALSE(sameName("Clock", "Clocks"));
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-15 19:43:59 +02:00
|
|
|
controller->setPage(Page::Dynamic);
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-15 01:29:01 +02:00
|
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
2026-10-13 01:52:34 +02:00
|
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
void test_dynamic_page_redrawn_when_its_content_changes()
|
2026-10-13 01:52:34 +02:00
|
|
|
{
|
2026-10-15 19:43:59 +02:00
|
|
|
run(18, 0);
|
|
|
|
|
controller->pageChanged(); // e.g. a Home Assistant value changed
|
|
|
|
|
run(18, 0);
|
|
|
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
2026-10-13 01:52:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_settings_change_redraws()
|
|
|
|
|
{
|
|
|
|
|
run(18, 0);
|
|
|
|
|
ClockSettings c;
|
2026-10-15 19:43:59 +02:00
|
|
|
c.no_wifi_screen = false;
|
2026-10-13 01:52:34 +02:00
|
|
|
controller->setSettings(defaults.schedule, c);
|
|
|
|
|
run(18, 0);
|
2026-10-15 19:43:59 +02:00
|
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
2026-10-13 01:52:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-15 19:43:59 +02:00
|
|
|
controller->update(now, false, LocalTime(), true);
|
2026-10-13 01:52:34 +02:00
|
|
|
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")));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Fonts
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static Font fontNamed(const char *name)
|
|
|
|
|
{
|
|
|
|
|
const BuiltinFont *b = findBuiltinFont(name);
|
|
|
|
|
TEST_ASSERT_NOT_NULL(b);
|
|
|
|
|
return builtinFont(*b);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_builtin_fonts_are_valid()
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
|
|
|
|
|
TEST_ASSERT_TRUE_MESSAGE(validFont(BUILTIN_FONTS[i].data, BUILTIN_FONTS[i].size), BUILTIN_FONTS[i].name);
|
|
|
|
|
TEST_ASSERT_NOT_NULL(findBuiltinFont("normal"));
|
|
|
|
|
TEST_ASSERT_NULL(findBuiltinFont("Comic Sans"));
|
|
|
|
|
TEST_ASSERT_EQUAL(2, fontNamed("Large").scale);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_font_sizes()
|
|
|
|
|
{
|
|
|
|
|
// Monospaced digits: the clock doesn't move when the time changes
|
|
|
|
|
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "12:34"));
|
|
|
|
|
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "00:00"));
|
|
|
|
|
TEST_ASSERT_EQUAL(58, textWidth(fontNamed("Large"), "12:34"));
|
|
|
|
|
TEST_ASSERT_EQUAL(17, textWidth(fontNamed("Small"), "12:34"));
|
|
|
|
|
TEST_ASSERT_EQUAL(51, textWidth(fontNamed("Full height"), "12:34"));
|
|
|
|
|
TEST_ASSERT_EQUAL(8, fontHeight(fontNamed("Normal")));
|
|
|
|
|
TEST_ASSERT_EQUAL(16, fontHeight(fontNamed("Large")));
|
|
|
|
|
TEST_ASSERT_EQUAL(6, fontHeight(fontNamed("Small")));
|
|
|
|
|
TEST_ASSERT_EQUAL(19, fontHeight(fontNamed("Full height")));
|
|
|
|
|
// Characters that are not in the font are skipped
|
|
|
|
|
TEST_ASSERT_EQUAL(10, textWidth(fontNamed("Full height"), "a1b"));
|
|
|
|
|
TEST_ASSERT_EQUAL(0, textWidth(fontNamed("Normal"), ""));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_draw_text_uses_the_classic_glyphs()
|
|
|
|
|
{
|
|
|
|
|
uint8_t b[BITMAP_BYTES] = {};
|
|
|
|
|
drawText(b, fontNamed("Normal"), 10, 2, "1", DrawMode::Set);
|
|
|
|
|
// glcdfont '1': 0x00, 0x42, 0x7F, 0x40, 0x00 (columns, bit 0 = top)
|
|
|
|
|
for (int row = 0; row < 7; row++)
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2 + row));
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 9));
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 3));
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 11, 2));
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 13, 8));
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_draw_text_scaled_and_inverted()
|
|
|
|
|
{
|
|
|
|
|
uint8_t b[BITMAP_BYTES];
|
|
|
|
|
memset(b, 0xFF, sizeof(b));
|
|
|
|
|
drawText(b, fontNamed("Large"), 0, 0, "1", DrawMode::Invert);
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 4, 0)); // column 2 doubled: 4 and 5
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 5, 13));
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 4, 14));
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_draw_text_is_clipped()
|
|
|
|
|
{
|
|
|
|
|
uint8_t b[BITMAP_BYTES] = {};
|
|
|
|
|
drawText(b, fontNamed("Full height"), 80, 5, "88", DrawMode::Set); // must not write outside
|
|
|
|
|
drawText(b, fontNamed("Full height"), -5, -10, "88", DrawMode::Set);
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 81, 6));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_invalid_fonts_are_rejected()
|
|
|
|
|
{
|
|
|
|
|
const BuiltinFont *n = findBuiltinFont("Normal");
|
|
|
|
|
std::vector<uint8_t> f(n->data, n->data + n->size);
|
|
|
|
|
TEST_ASSERT_TRUE(validFont(f.data(), f.size()));
|
|
|
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size() - 1));
|
|
|
|
|
f.push_back(0);
|
|
|
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
|
|
|
|
|
f.pop_back();
|
|
|
|
|
f[0] = 'X';
|
|
|
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
|
|
|
|
|
f[0] = 'F';
|
|
|
|
|
f[4] = 20; // too high
|
|
|
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
|
|
|
|
|
TEST_ASSERT_FALSE(validFont(nullptr, 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Dynamic pages
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static DynamicPage twoSlotPage()
|
|
|
|
|
{
|
|
|
|
|
DynamicPage page;
|
|
|
|
|
page.slot_count = 2;
|
|
|
|
|
page.slots[0].start = 8 * 60;
|
|
|
|
|
strcpy(page.slots[0].bitmap, "Day");
|
|
|
|
|
page.slots[1].start = 17 * 60;
|
|
|
|
|
strcpy(page.slots[1].bitmap, "Evening");
|
|
|
|
|
return page;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_active_slot_follows_the_time()
|
|
|
|
|
{
|
|
|
|
|
DynamicPage page = twoSlotPage();
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 7 * 60)->bitmap); // from the day before
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 8 * 60)->bitmap);
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 17 * 60 - 1)->bitmap);
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 17 * 60)->bitmap);
|
|
|
|
|
TEST_ASSERT_NULL(activeSlot(DynamicPage(), 600));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_page_validation()
|
|
|
|
|
{
|
|
|
|
|
DynamicPage page = twoSlotPage();
|
|
|
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
|
page.slots[1].start = 8 * 60;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // not in order
|
|
|
|
|
page = twoSlotPage();
|
|
|
|
|
page.slots[1].start = 24 * 60;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
page = twoSlotPage();
|
|
|
|
|
strcpy(page.slots[0].bitmap, "../x");
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
|
|
|
|
|
page = DynamicPage();
|
|
|
|
|
page.element_count = 1;
|
|
|
|
|
PageElement &e = page.elements[0];
|
|
|
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
|
e.type = ElementType::HaValue;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // needs an entity
|
|
|
|
|
strcpy(e.entity, "sensor.outside_temperature");
|
|
|
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
|
strcpy(e.attribute, "bad attr");
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
e.attribute[0] = '\0';
|
|
|
|
|
strcpy(e.text, "caf\xc3\xa9");
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // the fonts have no accents
|
|
|
|
|
e.text[0] = '\0';
|
|
|
|
|
e.decimals = 5;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
e.decimals = 1;
|
|
|
|
|
e.type = ElementType::Date;
|
|
|
|
|
e.format = DATE_FORMAT_COUNT;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
page.element_count = PAGE_ELEMENTS + 1;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT_TRUE(validEntityId("weather.home"));
|
|
|
|
|
TEST_ASSERT_FALSE(validEntityId("Sensor.x"));
|
|
|
|
|
TEST_ASSERT_FALSE(validEntityId("sensor"));
|
|
|
|
|
TEST_ASSERT_FALSE(validEntityId("a.b.c"));
|
|
|
|
|
TEST_ASSERT_FALSE(validEntityId(".x"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string textOf(const PageElement &e, const char *ha_value = nullptr)
|
|
|
|
|
{
|
|
|
|
|
LocalTime t; // Thursday 15 October 2026, 08:05
|
|
|
|
|
t.year = 2026;
|
|
|
|
|
t.month = 10;
|
|
|
|
|
t.day = 15;
|
|
|
|
|
t.weekday = 4;
|
|
|
|
|
t.hour = 8;
|
|
|
|
|
t.minute = 5;
|
|
|
|
|
char out[64];
|
2026-10-15 21:01:14 +02:00
|
|
|
ElementValues values;
|
|
|
|
|
values.ha_value = ha_value;
|
|
|
|
|
values.stopwatch_ms = 65000;
|
|
|
|
|
elementText(e, t, values, out, sizeof(out));
|
2026-10-15 19:43:59 +02:00
|
|
|
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];
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(9, 0), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0));
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(18, 0), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0));
|
|
|
|
|
strcpy(page.slots[1].bitmap, "Gone");
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(18, 0), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
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
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(12, 0), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
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
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(12, 0), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
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;
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(12, 0), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
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];
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(12, 34), 0, res, b, boxes);
|
2026-10-15 19:43:59 +02:00
|
|
|
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];
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(12, 34), 0, res, b, &box);
|
2026-10-15 19:43:59 +02:00
|
|
|
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];
|
2026-10-15 21:01:14 +02:00
|
|
|
renderPage(page, at(12, 34), 0, res, b);
|
2026-10-15 19:43:59 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:01:14 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Seconds, countdown, stopwatch, multiplier, degree sign
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
void test_clock_with_seconds()
|
|
|
|
|
{
|
|
|
|
|
PageElement e;
|
|
|
|
|
e.format = CLOCK_HH_MM_SS;
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("08:05:00", textOf(e).c_str());
|
|
|
|
|
e.format = CLOCK_H_MM_SS;
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("8:05:00", textOf(e).c_str());
|
|
|
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), false));
|
|
|
|
|
e.format = CLOCK_HH_MM;
|
|
|
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_countdown_target_parse_and_format()
|
|
|
|
|
{
|
|
|
|
|
uint32_t m = 0;
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2000-01-01T00:00", m));
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(0, m);
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2026-12-24T18:30", m));
|
|
|
|
|
char out[17];
|
|
|
|
|
formatTarget(m, out);
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("2026-12-24T18:30", out);
|
|
|
|
|
TEST_ASSERT_FALSE(parseTarget("2026-02-31T10:00", m)); // no 31 February
|
|
|
|
|
TEST_ASSERT_FALSE(parseTarget("2026-12-24 18:30", m));
|
|
|
|
|
TEST_ASSERT_FALSE(parseTarget("1999-12-24T18:30", m));
|
|
|
|
|
TEST_ASSERT_FALSE(parseTarget("2026-12-24T24:00", m));
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2028-02-29T00:00", m)); // leap year
|
|
|
|
|
|
|
|
|
|
LocalTime t;
|
|
|
|
|
t.year = 2026;
|
|
|
|
|
t.month = 10;
|
|
|
|
|
t.day = 15;
|
|
|
|
|
t.hour = 8;
|
|
|
|
|
t.minute = 5;
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", m));
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(m, minutesSince2000(t));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Countdown from Thursday 15 October 2026, 08:05:00 (textOf) to `target`
|
|
|
|
|
static std::string countdown(const char *target, uint8_t format, Language lang = Language::Dutch)
|
|
|
|
|
{
|
|
|
|
|
PageElement e;
|
|
|
|
|
e.type = ElementType::Countdown;
|
|
|
|
|
e.format = format;
|
|
|
|
|
e.language = lang;
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget(target, e.target));
|
|
|
|
|
return textOf(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_countdown_formats()
|
|
|
|
|
{
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("12 dagen", countdown("2026-10-27T09:00", COUNTDOWN_SMART).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("1 day", countdown("2026-10-16T09:00", COUNTDOWN_SMART, Language::English).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("5 uur", countdown("2026-10-15T13:30", COUNTDOWN_SMART).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("1 hour", countdown("2026-10-15T09:30", COUNTDOWN_SMART, Language::English).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("34 min", countdown("2026-10-15T08:39", COUNTDOWN_SMART).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("1 min", countdown("2026-10-15T08:06", COUNTDOWN_SMART).c_str());
|
|
|
|
|
|
|
|
|
|
// Calendar days: tomorrow 00:30 is 1 day away
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("1 dag", countdown("2026-10-16T00:30", COUNTDOWN_DAYS).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("12 days", countdown("2026-10-27T01:00", COUNTDOWN_DAYS, Language::English).c_str());
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("12d 00:55", countdown("2026-10-27T09:00", COUNTDOWN_DAYS_TIME).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("05:25", countdown("2026-10-15T13:30", COUNTDOWN_DAYS_TIME).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("12d 00:55:00", countdown("2026-10-27T09:00", COUNTDOWN_SECONDS).c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("05:25:00", countdown("2026-10-15T13:30", COUNTDOWN_SECONDS).c_str());
|
|
|
|
|
|
|
|
|
|
// Passed: the suffix, or 0
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("0", countdown("2026-10-15T08:05", COUNTDOWN_SMART).c_str());
|
|
|
|
|
PageElement e;
|
|
|
|
|
e.type = ElementType::Countdown;
|
|
|
|
|
strcpy(e.text, "Disney ");
|
|
|
|
|
strcpy(e.suffix, "nu!");
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2026-01-01T00:00", e.target));
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("Disney nu!", textOf(e).c_str());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_countdown_shows_seconds_only_when_needed()
|
|
|
|
|
{
|
|
|
|
|
PageElement e;
|
|
|
|
|
e.type = ElementType::Countdown;
|
|
|
|
|
LocalTime t;
|
|
|
|
|
t.year = 2026;
|
|
|
|
|
t.month = 10;
|
|
|
|
|
t.day = 15;
|
|
|
|
|
t.hour = 8;
|
|
|
|
|
t.minute = 5;
|
|
|
|
|
t.second = 30;
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:06", e.target)); // 30 s left
|
|
|
|
|
e.format = COUNTDOWN_SMART;
|
|
|
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
|
|
|
|
|
char out[32];
|
|
|
|
|
elementText(e, t, ElementValues(), out, sizeof(out));
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("0:30", out);
|
|
|
|
|
e.format = COUNTDOWN_DAYS_TIME;
|
|
|
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
|
|
|
|
|
e.format = COUNTDOWN_SECONDS;
|
|
|
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
|
|
|
|
|
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", e.target)); // passed
|
|
|
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_stopwatch()
|
|
|
|
|
{
|
|
|
|
|
Stopwatch sw;
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(5000));
|
|
|
|
|
sw.start(1000);
|
|
|
|
|
TEST_ASSERT_TRUE(sw.running());
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(2000, sw.elapsed(3000));
|
|
|
|
|
sw.stop(4000);
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(3000, sw.elapsed(9000));
|
|
|
|
|
sw.start(10000);
|
|
|
|
|
sw.start(11000); // already running: no restart
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(5000, sw.elapsed(12000));
|
|
|
|
|
sw.reset();
|
|
|
|
|
TEST_ASSERT_FALSE(sw.running());
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(20000));
|
|
|
|
|
sw.start(0xFFFFFF00u); // across the millis() overflow
|
|
|
|
|
TEST_ASSERT_EQUAL_UINT32(0x200, sw.elapsed(0x100));
|
|
|
|
|
|
|
|
|
|
PageElement e; // textOf: 65 s on the stopwatch
|
|
|
|
|
e.type = ElementType::Stopwatch;
|
|
|
|
|
e.format = STOPWATCH_M_SS;
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("1:05", textOf(e).c_str());
|
|
|
|
|
e.format = STOPWATCH_H_MM;
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("0:01", textOf(e).c_str());
|
|
|
|
|
e.format = STOPWATCH_H_MM_SS;
|
|
|
|
|
strcpy(e.text, "T ");
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("T 0:01:05", textOf(e).c_str());
|
|
|
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
|
|
|
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false)); // stopped
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_home_assistant_multiplier()
|
|
|
|
|
{
|
|
|
|
|
PageElement e;
|
|
|
|
|
e.type = ElementType::HaValue;
|
|
|
|
|
e.factor = 1000;
|
|
|
|
|
strcpy(e.suffix, " l");
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("637 l", textOf(e, "0.637").c_str());
|
|
|
|
|
e.factor = 0.001f;
|
|
|
|
|
e.decimals = 1;
|
|
|
|
|
strcpy(e.suffix, "kWh");
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("26.3kWh", textOf(e, "26300").c_str());
|
|
|
|
|
TEST_ASSERT_EQUAL_STRING("--kWh", textOf(e, "unknown").c_str());
|
|
|
|
|
|
|
|
|
|
DynamicPage page;
|
|
|
|
|
page.element_count = 1;
|
|
|
|
|
page.elements[0] = e;
|
|
|
|
|
strcpy(page.elements[0].entity, "sensor.x");
|
|
|
|
|
page.elements[0].factor = 0;
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_degree_sign_in_the_fonts()
|
|
|
|
|
{
|
|
|
|
|
const char degree[] = {DEGREE_SIGN, 0};
|
|
|
|
|
TEST_ASSERT_EQUAL(4, textWidth(fontNamed("Normal"), degree));
|
|
|
|
|
TEST_ASSERT_EQUAL(3, textWidth(fontNamed("Small"), degree));
|
|
|
|
|
TEST_ASSERT_EQUAL(6, textWidth(fontNamed("Full height"), degree));
|
|
|
|
|
|
|
|
|
|
DynamicPage page;
|
|
|
|
|
page.element_count = 1;
|
|
|
|
|
page.elements[0].type = ElementType::Text;
|
|
|
|
|
strcpy(page.elements[0].text, "17.5\x7f" "C");
|
|
|
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Scrolling and images
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
static DynamicPage scrollPage()
|
|
|
|
|
{
|
|
|
|
|
DynamicPage page;
|
|
|
|
|
page.element_count = 1;
|
|
|
|
|
PageElement &e = page.elements[0];
|
|
|
|
|
e.type = ElementType::Text;
|
|
|
|
|
strcpy(e.text, "A long text that does not fit");
|
|
|
|
|
e.x = 10;
|
|
|
|
|
e.y = 2;
|
|
|
|
|
e.scroll_width = 30;
|
|
|
|
|
e.scroll_speed = 10;
|
|
|
|
|
return page;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool anyDot(const uint8_t *b, int x0, int x1)
|
|
|
|
|
{
|
|
|
|
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
|
|
|
|
for (int x = x0; x < x1; x++)
|
|
|
|
|
if (bitmapPixel(b, x, y))
|
|
|
|
|
return true;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_scrolling_text_moves_in_its_window()
|
|
|
|
|
{
|
|
|
|
|
FakeResources res;
|
|
|
|
|
DynamicPage page = scrollPage();
|
|
|
|
|
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
|
|
|
|
|
ElementBox box;
|
|
|
|
|
uint32_t wait = renderPage(page, at(12, 0), 0, res, a, &box);
|
|
|
|
|
TEST_ASSERT_EQUAL(100, wait); // 10 dots per second
|
|
|
|
|
TEST_ASSERT_EQUAL(10, box.x);
|
|
|
|
|
TEST_ASSERT_EQUAL(30, box.width);
|
|
|
|
|
TEST_ASSERT_FALSE(anyDot(a, 0, 10)); // clipped to the window
|
|
|
|
|
TEST_ASSERT_FALSE(anyDot(a, 40, BITMAP_WIDTH));
|
|
|
|
|
TEST_ASSERT_TRUE(anyDot(a, 10, 40));
|
|
|
|
|
|
|
|
|
|
TEST_ASSERT_EQUAL(60, renderPage(page, at(12, 0), 1040, res, b, &box)); // until 1100
|
|
|
|
|
TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0);
|
|
|
|
|
// One round later the same image again: text width + gap
|
|
|
|
|
int period = textWidth(fontNamed("Normal"), page.elements[0].text) + SCROLL_GAP;
|
|
|
|
|
renderPage(page, at(12, 0), period * 100, res, b);
|
|
|
|
|
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_short_text_in_a_window_does_not_scroll()
|
|
|
|
|
{
|
|
|
|
|
FakeResources res;
|
|
|
|
|
DynamicPage page = scrollPage();
|
|
|
|
|
strcpy(page.elements[0].text, "Hi");
|
|
|
|
|
page.elements[0].align = Align::Right; // right edge of the window at x 10
|
|
|
|
|
uint8_t b[BITMAP_BYTES];
|
|
|
|
|
ElementBox box;
|
|
|
|
|
TEST_ASSERT_EQUAL(0, renderPage(page, at(12, 0), 0, res, b, &box));
|
|
|
|
|
TEST_ASSERT_EQUAL(-20, box.x);
|
|
|
|
|
TEST_ASSERT_TRUE(anyDot(b, 0, 10)); // the text at the right of the window
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_render_asks_for_the_next_second()
|
|
|
|
|
{
|
|
|
|
|
FakeResources res;
|
|
|
|
|
DynamicPage page;
|
|
|
|
|
page.element_count = 1;
|
|
|
|
|
uint8_t b[BITMAP_BYTES];
|
|
|
|
|
LocalTime t = at(12, 0);
|
|
|
|
|
t.millisecond = 300;
|
|
|
|
|
TEST_ASSERT_EQUAL(0, renderPage(page, t, 0, res, b));
|
|
|
|
|
page.elements[0].format = CLOCK_HH_MM_SS;
|
|
|
|
|
TEST_ASSERT_EQUAL(700, renderPage(page, t, 0, res, b));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class ImageResources : public FakeResources
|
|
|
|
|
{
|
|
|
|
|
public:
|
|
|
|
|
bool background(const char *name, uint8_t *bitmap) override
|
|
|
|
|
{
|
|
|
|
|
memset(bitmap, 0, BITMAP_BYTES);
|
|
|
|
|
if (strcmp(name, "icon sunny") == 0)
|
|
|
|
|
{
|
|
|
|
|
setBitmapPixel(bitmap, 1, 1, true);
|
|
|
|
|
setBitmapPixel(bitmap, 3, 2, true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (strcmp(name, "icon default") == 0)
|
|
|
|
|
{
|
|
|
|
|
setBitmapPixel(bitmap, 0, 0, true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void test_image_element_by_state()
|
|
|
|
|
{
|
|
|
|
|
ImageResources res;
|
|
|
|
|
DynamicPage page;
|
|
|
|
|
page.element_count = 1;
|
|
|
|
|
PageElement &e = page.elements[0];
|
|
|
|
|
e.type = ElementType::Image;
|
|
|
|
|
strcpy(e.text, "icon ");
|
|
|
|
|
strcpy(e.entity, "weather.home");
|
|
|
|
|
e.x = 50;
|
|
|
|
|
e.y = 5;
|
|
|
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
|
|
|
|
|
|
uint8_t b[BITMAP_BYTES];
|
|
|
|
|
ElementBox box;
|
|
|
|
|
res.value = "sunny";
|
|
|
|
|
renderPage(page, at(12, 0), 0, res, b, &box);
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 53, 7));
|
|
|
|
|
TEST_ASSERT_EQUAL(51, box.x);
|
|
|
|
|
TEST_ASSERT_EQUAL(6, box.y);
|
|
|
|
|
TEST_ASSERT_EQUAL(3, box.width);
|
|
|
|
|
TEST_ASSERT_EQUAL(2, box.height);
|
|
|
|
|
|
|
|
|
|
res.value = "rainy"; // no bitmap for it: the default
|
|
|
|
|
renderPage(page, at(12, 0), 0, res, b, &box);
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 50, 5));
|
|
|
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 51, 6));
|
|
|
|
|
|
|
|
|
|
e.entity[0] = '\0'; // fixed bitmap
|
|
|
|
|
strcpy(e.text, "icon sunny");
|
|
|
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
|
renderPage(page, at(12, 0), 0, res, b);
|
|
|
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
|
|
|
|
|
strcpy(e.text, "../x");
|
|
|
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void test_moving_page_is_redrawn_when_due()
|
|
|
|
|
{
|
|
|
|
|
frames.wait = 250;
|
|
|
|
|
run(12, 0);
|
|
|
|
|
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
|
|
|
|
|
TEST_ASSERT_TRUE(frames.last_redraw);
|
|
|
|
|
run(12, 0, 200); // not yet (the run steps are 100 ms)
|
|
|
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
|
|
|
TEST_ASSERT_FALSE(frames.last_redraw);
|
|
|
|
|
frames.wait = 0; // stops moving
|
|
|
|
|
run(12, 0, 1000);
|
|
|
|
|
TEST_ASSERT_EQUAL(3, (int)frames.shown.size());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:36:56 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 22:25:00 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:36:56 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 21:50:19 +02:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 15:20:26 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 00:56:32 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-10-13 01:05:06 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-10-13 01:21:04 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-10-13 00:56:32 +02:00
|
|
|
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);
|
2026-10-15 01:29:01 +02:00
|
|
|
RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect);
|
2026-10-13 00:56:32 +02:00
|
|
|
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);
|
2026-10-13 01:34:04 +02:00
|
|
|
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);
|
2026-10-13 00:56:32 +02:00
|
|
|
|
2026-10-13 01:52:34 +02:00
|
|
|
RUN_TEST(test_default_settings_are_valid);
|
|
|
|
|
RUN_TEST(test_settings_times_must_be_in_order);
|
2026-10-15 01:29:01 +02:00
|
|
|
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);
|
2026-10-13 01:52:34 +02:00
|
|
|
RUN_TEST(test_settings_flip_time_and_port_ranges);
|
2026-10-15 19:43:59 +02:00
|
|
|
RUN_TEST(test_settings_page_must_be_a_name);
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
2026-10-15 19:43:59 +02:00
|
|
|
RUN_TEST(test_dynamic_page_redrawn_when_its_content_changes);
|
2026-10-13 01:52:34 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-10-15 19:43:59 +02:00
|
|
|
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);
|
2026-10-15 21:01:14 +02:00
|
|
|
RUN_TEST(test_clock_with_seconds);
|
|
|
|
|
RUN_TEST(test_countdown_target_parse_and_format);
|
|
|
|
|
RUN_TEST(test_countdown_formats);
|
|
|
|
|
RUN_TEST(test_countdown_shows_seconds_only_when_needed);
|
|
|
|
|
RUN_TEST(test_stopwatch);
|
|
|
|
|
RUN_TEST(test_home_assistant_multiplier);
|
|
|
|
|
RUN_TEST(test_degree_sign_in_the_fonts);
|
|
|
|
|
RUN_TEST(test_scrolling_text_moves_in_its_window);
|
|
|
|
|
RUN_TEST(test_short_text_in_a_window_does_not_scroll);
|
|
|
|
|
RUN_TEST(test_render_asks_for_the_next_second);
|
|
|
|
|
RUN_TEST(test_image_element_by_state);
|
|
|
|
|
RUN_TEST(test_moving_page_is_redrawn_when_due);
|
2026-10-15 21:36:56 +02:00
|
|
|
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);
|
2026-10-15 22:25:00 +02:00
|
|
|
RUN_TEST(test_selector_rules_only_in_their_blocks);
|
2026-10-15 21:36:56 +02:00
|
|
|
RUN_TEST(test_selector_add_to_rotation);
|
2026-10-15 21:50:19 +02:00
|
|
|
RUN_TEST(test_ha_auto_unit_and_absolute);
|
|
|
|
|
RUN_TEST(test_ha_duration);
|
|
|
|
|
RUN_TEST(test_ha_timestamps);
|
|
|
|
|
RUN_TEST(test_ha_text_map);
|
2026-10-16 15:20:26 +02:00
|
|
|
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);
|
2026-10-15 19:43:59 +02:00
|
|
|
|
2026-10-13 00:56:32 +02:00
|
|
|
return UNITY_END();
|
|
|
|
|
}
|