Public Access
Pages choose their own refresh effect (or the Hardware setting), used when the page appears and for its own changes. Schedule blocks that switch instantly and rules still switch instantly. The ESP32 sends page effects to the Nano with a new no-save flag (E ...,2), so page switches don't wear the Nano's EEPROM. Page format 7 (version 6 pages are converted). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2540 lines
82 KiB
C++
2540 lines
82 KiB
C++
// Tests for the ESP32 logic (src/esp32/logic), run on the PC: pio test -e test_esp32
|
|
//
|
|
// The display controller is tested with a fake power switch and fake frames that record
|
|
// what the controller asks for.
|
|
|
|
#include <unity.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "config.h"
|
|
#include "logic/frame_check.h"
|
|
#include "logic/loop_stats.h"
|
|
#include "logic/bitmap_format.h"
|
|
#include "logic/builtin_fonts.h"
|
|
#include "logic/builtin_pages.h"
|
|
#include "logic/font.h"
|
|
#include "logic/icon_format.h"
|
|
#include "logic/boot_policy.h"
|
|
#include "logic/display_controller.h"
|
|
#include "logic/page.h"
|
|
#include "logic/page_render.h"
|
|
#include "logic/page_schedule.h"
|
|
#include "logic/page_selector.h"
|
|
#include "logic/power_timer.h"
|
|
#include "logic/schedule.h"
|
|
#include "logic/settings.h"
|
|
#include "logic/status_led.h"
|
|
#include "logic/stopwatch.h"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class FakePower : public DisplayPower
|
|
{
|
|
public:
|
|
bool is_on = false;
|
|
int on_count = 0;
|
|
int off_count = 0;
|
|
|
|
bool isOn() override { return is_on; }
|
|
void on() override
|
|
{
|
|
is_on = true;
|
|
on_count++;
|
|
}
|
|
void off() override
|
|
{
|
|
is_on = false;
|
|
off_count++;
|
|
}
|
|
};
|
|
|
|
class FakeFrames : public Frames
|
|
{
|
|
public:
|
|
std::vector<std::string> shown;
|
|
uint32_t wait = 0; // what dynamicPage() returns
|
|
bool last_redraw = false;
|
|
|
|
uint32_t dynamicPage(const LocalTime &time, uint32_t, bool redraw) override
|
|
{
|
|
char s[24];
|
|
snprintf(s, sizeof(s), "clock %02d:%02d", time.hour, time.minute);
|
|
shown.push_back(s);
|
|
last_redraw = redraw;
|
|
return wait;
|
|
}
|
|
void page() override { shown.push_back("page"); }
|
|
void live() override { shown.push_back("live"); }
|
|
void noWifi(int hour, int minute) override
|
|
{
|
|
char s[16];
|
|
snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute);
|
|
shown.push_back(s);
|
|
}
|
|
void off() override { shown.push_back("off"); }
|
|
|
|
std::string last() const { return shown.empty() ? "" : shown.back(); }
|
|
};
|
|
|
|
static const Settings defaults;
|
|
|
|
static FakePower power;
|
|
static FakeFrames frames;
|
|
static DisplayController *controller;
|
|
static uint32_t now;
|
|
static bool wifi;
|
|
|
|
void setUp()
|
|
{
|
|
power = FakePower();
|
|
frames = FakeFrames();
|
|
delete controller;
|
|
controller = new DisplayController(power, frames);
|
|
now = 1000;
|
|
wifi = true;
|
|
}
|
|
|
|
void tearDown() {}
|
|
|
|
// Run the controller at a time of day; `ms` passes in steps like the real loop
|
|
static void run(int hour, int minute, uint32_t ms = 0)
|
|
{
|
|
uint32_t end = now + ms;
|
|
do
|
|
{
|
|
LocalTime time;
|
|
time.hour = hour;
|
|
time.minute = minute;
|
|
controller->update(now, true, time, wifi);
|
|
now += 100;
|
|
} while ((int32_t)(end - now) >= 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schedule
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_schedule_night_until_8()
|
|
{
|
|
TEST_ASSERT_TRUE(scheduleFor(0, 0, defaults.schedule) == ScheduleMode::Night);
|
|
TEST_ASSERT_TRUE(scheduleFor(7, 0, defaults.schedule) == ScheduleMode::Night);
|
|
TEST_ASSERT_TRUE(scheduleFor(7, 59, defaults.schedule) == ScheduleMode::Night);
|
|
}
|
|
|
|
void test_schedule_clock_from_8_to_22()
|
|
{
|
|
TEST_ASSERT_TRUE(scheduleFor(8, 0, defaults.schedule) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(12, 34, defaults.schedule) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(21, 59, defaults.schedule) == ScheduleMode::Clock);
|
|
}
|
|
|
|
void test_schedule_quarter_hours_until_23_30()
|
|
{
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 0, defaults.schedule) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 1, defaults.schedule) == ScheduleMode::Standby);
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 14, defaults.schedule) == ScheduleMode::Standby);
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 15, defaults.schedule) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 45, defaults.schedule) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 0, defaults.schedule) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 29, defaults.schedule) == ScheduleMode::Standby);
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 30, defaults.schedule) == ScheduleMode::Clock);
|
|
}
|
|
|
|
void test_schedule_night_from_23_31()
|
|
{
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 31, defaults.schedule) == ScheduleMode::Night);
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 45, defaults.schedule) == ScheduleMode::Night);
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 59, defaults.schedule) == ScheduleMode::Night);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Power timer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_timer_expires_once_after_delay()
|
|
{
|
|
PowerTimer t;
|
|
t.request(1000, 500);
|
|
TEST_ASSERT_FALSE(t.expired(1499));
|
|
TEST_ASSERT_TRUE(t.expired(1500));
|
|
TEST_ASSERT_FALSE(t.expired(1600));
|
|
}
|
|
|
|
void test_timer_keeps_earlier_deadline()
|
|
{
|
|
PowerTimer t;
|
|
t.request(0, 1000);
|
|
t.request(500, 1000); // later deadline: ignored
|
|
TEST_ASSERT_TRUE(t.expired(1000));
|
|
|
|
t.request(0, 30000);
|
|
t.request(100, 1000); // earlier deadline: used
|
|
TEST_ASSERT_TRUE(t.expired(1100));
|
|
}
|
|
|
|
void test_timer_cancel()
|
|
{
|
|
PowerTimer t;
|
|
t.request(0, 100);
|
|
t.cancel();
|
|
TEST_ASSERT_FALSE(t.pending());
|
|
TEST_ASSERT_FALSE(t.expired(1000));
|
|
}
|
|
|
|
void test_timer_across_millis_overflow()
|
|
{
|
|
PowerTimer t;
|
|
t.request(0xFFFFFF00u, 0x200); // deadline wraps past 0
|
|
TEST_ASSERT_FALSE(t.expired(0xFFFFFFFFu));
|
|
TEST_ASSERT_FALSE(t.expired(0x00000050u));
|
|
TEST_ASSERT_TRUE(t.expired(0x00000100u));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Boot policy (keeping OTA alive)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_power_on_starts_normally_and_clears_crashes()
|
|
{
|
|
BootDecision d = decideBoot(ResetKind::PowerOn, 2);
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::Normal);
|
|
TEST_ASSERT_EQUAL(0, d.crash_count);
|
|
}
|
|
|
|
void test_software_reset_starts_normally_and_keeps_count()
|
|
{
|
|
// e.g. the restart after an OTA update
|
|
BootDecision d = decideBoot(ResetKind::Software, 1);
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::Normal);
|
|
TEST_ASSERT_EQUAL(1, d.crash_count);
|
|
}
|
|
|
|
void test_other_reset_starts_normally_and_keeps_count()
|
|
{
|
|
BootDecision d = decideBoot(ResetKind::Other, 2);
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::Normal);
|
|
TEST_ASSERT_EQUAL(2, d.crash_count);
|
|
}
|
|
|
|
void test_first_crashes_give_a_panic_window()
|
|
{
|
|
BootDecision d = decideBoot(ResetKind::Crash, 0);
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::PanicWindow);
|
|
TEST_ASSERT_EQUAL(1, d.crash_count);
|
|
d = decideBoot(ResetKind::Crash, d.crash_count);
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::PanicWindow);
|
|
TEST_ASSERT_EQUAL(2, d.crash_count);
|
|
}
|
|
|
|
void test_repeated_crashes_give_safe_mode()
|
|
{
|
|
uint8_t count = 0;
|
|
BootDecision d = {BootMode::Normal, 0};
|
|
for (int i = 0; i < SAFE_MODE_CRASHES; i++)
|
|
{
|
|
d = decideBoot(ResetKind::Crash, count);
|
|
count = d.crash_count;
|
|
}
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::SafeMode);
|
|
}
|
|
|
|
void test_after_safe_mode_one_more_crash_gives_safe_mode_again()
|
|
{
|
|
uint8_t count = crashCountAfterSafeMode();
|
|
TEST_ASSERT_TRUE(decideBoot(ResetKind::Software, count).mode == BootMode::Normal);
|
|
TEST_ASSERT_TRUE(decideBoot(ResetKind::Crash, count).mode == BootMode::SafeMode);
|
|
}
|
|
|
|
void test_crash_count_does_not_overflow()
|
|
{
|
|
BootDecision d = decideBoot(ResetKind::Crash, 255);
|
|
TEST_ASSERT_EQUAL(255, d.crash_count);
|
|
TEST_ASSERT_TRUE(d.mode == BootMode::SafeMode);
|
|
}
|
|
|
|
void test_new_firmware_needs_time_and_wifi_to_be_accepted()
|
|
{
|
|
TEST_ASSERT_FALSE(shouldMarkFirmwareValid(FIRMWARE_VALID_TIME - 1, true));
|
|
TEST_ASSERT_FALSE(shouldMarkFirmwareValid(FIRMWARE_VALID_TIME, false));
|
|
TEST_ASSERT_TRUE(shouldMarkFirmwareValid(FIRMWARE_VALID_TIME, true));
|
|
}
|
|
|
|
void test_crash_count_cleared_after_running_long_enough()
|
|
{
|
|
TEST_ASSERT_FALSE(shouldClearCrashCount(CRASH_FREE_TIME - 1));
|
|
TEST_ASSERT_TRUE(shouldClearCrashCount(CRASH_FREE_TIME));
|
|
}
|
|
|
|
void test_quick_power_cycles_request_recovery()
|
|
{
|
|
for (uint8_t count = 0; count < POWER_CYCLES_FOR_RECOVERY; count++)
|
|
TEST_ASSERT_FALSE(powerCyclesRequestRecovery(count));
|
|
TEST_ASSERT_TRUE(powerCyclesRequestRecovery(POWER_CYCLES_FOR_RECOVERY));
|
|
}
|
|
|
|
void test_recovery_for_wifi_starts_clock_when_wifi_works()
|
|
{
|
|
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, 0, false, true));
|
|
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_TIME * 10, false, true));
|
|
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_MIN_TIME - 1, true, true));
|
|
TEST_ASSERT_TRUE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_MIN_TIME, true, true));
|
|
}
|
|
|
|
void test_recovery_for_other_reasons_waits_recovery_time()
|
|
{
|
|
const RecoveryReason reasons[] = {RecoveryReason::Crash, RecoveryReason::Manual,
|
|
RecoveryReason::PowerCycle, RecoveryReason::None};
|
|
for (RecoveryReason reason : reasons)
|
|
{
|
|
TEST_ASSERT_FALSE(recoveryShouldStartMain(reason, RECOVERY_TIME - 1, true, true));
|
|
TEST_ASSERT_TRUE(recoveryShouldStartMain(reason, RECOVERY_TIME, true, true));
|
|
// The time counts, not the WiFi: an OTA upload over the setup WiFi is possible
|
|
TEST_ASSERT_TRUE(recoveryShouldStartMain(reason, RECOVERY_TIME, false, true));
|
|
}
|
|
}
|
|
|
|
void test_recovery_never_starts_missing_clock_firmware()
|
|
{
|
|
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Wifi, RECOVERY_TIME, true, false));
|
|
TEST_ASSERT_FALSE(recoveryShouldStartMain(RecoveryReason::Manual, RECOVERY_TIME, true, false));
|
|
}
|
|
|
|
void test_every_recovery_reason_has_a_text()
|
|
{
|
|
for (uint8_t r = 0; r <= (uint8_t)RecoveryReason::PowerCycle; r++)
|
|
{
|
|
TEST_ASSERT_TRUE(strlen(recoveryReasonText((RecoveryReason)r)) > 0);
|
|
// fits on one line of the display
|
|
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) > 0);
|
|
TEST_ASSERT_TRUE(strlen(recoveryReasonShort((RecoveryReason)r)) <= 14);
|
|
}
|
|
TEST_ASSERT_TRUE(strcmp(recoveryReasonText(RecoveryReason::Wifi),
|
|
recoveryReasonText(RecoveryReason::Crash)) != 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status LED
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_led_off_while_display_off()
|
|
{
|
|
for (uint32_t t = 0; t < 2000; t += 50)
|
|
TEST_ASSERT_FALSE(statusLedLevel(t, true, false));
|
|
}
|
|
|
|
void test_led_blinks_slow_with_wifi_and_fast_without()
|
|
{
|
|
TEST_ASSERT_TRUE(statusLedLevel(0, true, true));
|
|
TEST_ASSERT_TRUE(statusLedLevel(499, true, true));
|
|
TEST_ASSERT_FALSE(statusLedLevel(500, true, true));
|
|
|
|
TEST_ASSERT_TRUE(statusLedLevel(0, false, true));
|
|
TEST_ASSERT_FALSE(statusLedLevel(100, false, true));
|
|
TEST_ASSERT_TRUE(statusLedLevel(200, false, true));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Display controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_nothing_happens_until_time_is_synced()
|
|
{
|
|
power.is_on = true; // showing "Connecting..."
|
|
controller->update(now, false, LocalTime(), true);
|
|
TEST_ASSERT_EQUAL(0, power.off_count);
|
|
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_daytime_powers_on_and_shows_clock()
|
|
{
|
|
run(12, 34);
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:34", frames.last().c_str());
|
|
}
|
|
|
|
void test_clock_is_only_sent_when_the_minute_changes()
|
|
{
|
|
run(12, 34, 5000);
|
|
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
|
|
run(12, 35);
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:35", frames.last().c_str());
|
|
TEST_ASSERT_EQUAL(1, power.on_count);
|
|
}
|
|
|
|
void test_night_clears_then_powers_off_after_delay()
|
|
{
|
|
run(23, 30);
|
|
run(23, 31, POWER_OFF_DELAY - 200);
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
run(23, 31, 300);
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
TEST_ASSERT_EQUAL(1, power.off_count);
|
|
}
|
|
|
|
void test_night_does_not_power_on()
|
|
{
|
|
run(3, 0, 60000);
|
|
TEST_ASSERT_EQUAL(0, power.on_count);
|
|
TEST_ASSERT_EQUAL(0, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_standby_keeps_the_last_time_and_powers_off()
|
|
{
|
|
run(22, 0);
|
|
run(22, 1, POWER_OFF_DELAY + 200);
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no off screen
|
|
}
|
|
|
|
void test_quarter_hour_powers_on_again_and_redraws()
|
|
{
|
|
run(22, 1, POWER_OFF_DELAY + 200);
|
|
run(22, 14);
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
run(22, 15);
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str());
|
|
}
|
|
|
|
void test_clock_cancels_pending_power_off()
|
|
{
|
|
run(22, 0);
|
|
run(22, 14, 10000); // power off countdown running
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
run(22, 15, POWER_OFF_DELAY);
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
TEST_ASSERT_EQUAL(0, power.off_count);
|
|
}
|
|
|
|
void test_power_switch_off_clears_and_powers_off_quickly()
|
|
{
|
|
run(12, 0);
|
|
run(22, 14, 1000); // 30 s countdown already running
|
|
controller->setPowerEnabled(false);
|
|
run(22, 14, CLEAR_BEFORE_OFF_TIME + 200);
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
}
|
|
|
|
void test_power_switch_off_waits_for_the_refresh_effect()
|
|
{
|
|
controller->setEffectTime(3000);
|
|
run(12, 0);
|
|
controller->setPowerEnabled(false);
|
|
run(12, 0, CLEAR_BEFORE_OFF_TIME + 2000);
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
|
TEST_ASSERT_TRUE(power.is_on); // the effect is still flipping dots
|
|
run(12, 0, 1400);
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
}
|
|
|
|
void test_power_switch_off_keeps_display_off_during_the_day()
|
|
{
|
|
controller->setPowerEnabled(false);
|
|
run(12, 0, 60000);
|
|
TEST_ASSERT_EQUAL(0, power.on_count);
|
|
}
|
|
|
|
void test_power_switch_on_again_shows_clock()
|
|
{
|
|
run(12, 0);
|
|
controller->setPowerEnabled(false);
|
|
run(12, 0, CLEAR_BEFORE_OFF_TIME + 200);
|
|
controller->setPowerEnabled(true);
|
|
run(12, 1);
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:01", frames.last().c_str());
|
|
}
|
|
|
|
void test_redraws_after_power_was_switched_off_elsewhere()
|
|
{
|
|
// e.g. after flashing the Nano: the Nano restarts without a frame
|
|
run(12, 0);
|
|
power.off();
|
|
run(12, 0);
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
|
}
|
|
|
|
void test_no_wifi_is_shown_after_a_minute_without_wifi()
|
|
{
|
|
run(12, 0);
|
|
wifi = false;
|
|
run(12, 0, NO_WIFI_SHOW_TIME - 200);
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
|
run(12, 1, 300);
|
|
TEST_ASSERT_EQUAL_STRING("nowifi 12:01", frames.last().c_str());
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
}
|
|
|
|
void test_no_wifi_screen_keeps_the_time_running()
|
|
{
|
|
wifi = false;
|
|
run(12, 0, NO_WIFI_SHOW_TIME + 100);
|
|
run(12, 1);
|
|
TEST_ASSERT_EQUAL_STRING("nowifi 12:01", frames.last().c_str());
|
|
int frames_before = (int)frames.shown.size();
|
|
run(12, 1, 5000);
|
|
TEST_ASSERT_EQUAL(frames_before, (int)frames.shown.size()); // only on change
|
|
}
|
|
|
|
void test_clock_returns_when_wifi_is_back()
|
|
{
|
|
wifi = false;
|
|
run(12, 0, NO_WIFI_SHOW_TIME + 100);
|
|
wifi = true;
|
|
run(12, 0);
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
|
}
|
|
|
|
void test_short_wifi_drop_does_not_change_the_display()
|
|
{
|
|
run(12, 0);
|
|
wifi = false;
|
|
run(12, 0, 10000);
|
|
wifi = true;
|
|
run(12, 0, NO_WIFI_SHOW_TIME);
|
|
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_night_without_wifi_still_powers_off()
|
|
{
|
|
run(23, 30);
|
|
wifi = false;
|
|
run(23, 31, POWER_OFF_DELAY + 200);
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_default_settings_are_valid()
|
|
{
|
|
TEST_ASSERT_NULL(validateSettings(defaults));
|
|
}
|
|
|
|
void test_settings_times_must_be_in_order()
|
|
{
|
|
Settings s;
|
|
s.schedule.late_start = 7 * 60; // before day_start
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s = Settings();
|
|
s.schedule.night_start = 21 * 60; // before late_start
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s = Settings();
|
|
s.schedule.night_start = MINUTES_PER_DAY; // never night: fine
|
|
TEST_ASSERT_NULL(validateSettings(s));
|
|
s.schedule.night_start = MINUTES_PER_DAY + 1;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
}
|
|
|
|
void test_settings_drive_ranges()
|
|
{
|
|
Settings s;
|
|
TEST_ASSERT_NULL(validateSettings(s));
|
|
s.drive.flip_gap = 5001;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s = Settings();
|
|
s.drive.pulses = 0;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s.drive.pulses = 4;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s = Settings();
|
|
s.drive.effect = DRIVE_EFFECT_COUNT;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s = Settings();
|
|
s.drive.effect_time = 99;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s.drive.effect_time = 10001;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
}
|
|
|
|
void test_effect_time_is_zero_when_instant()
|
|
{
|
|
DriveSettings d;
|
|
TEST_ASSERT_EQUAL(0, (int)effectTime(d)); // default: instant
|
|
d.effect = 1;
|
|
d.effect_time = 2500;
|
|
TEST_ASSERT_EQUAL(2500, (int)effectTime(d));
|
|
d.effect = DRIVE_EFFECT_PUSH; // can take much longer
|
|
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
|
|
d.effect = DRIVE_EFFECT_RANDOM; // can be a push
|
|
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
|
|
}
|
|
|
|
void test_settings_off_page_blank_or_bitmap_name()
|
|
{
|
|
Settings s;
|
|
TEST_ASSERT_EQUAL_STRING("", s.off_page); // default: blank
|
|
TEST_ASSERT_NULL(validateSettings(s));
|
|
strcpy(s.off_page, "Nintendo");
|
|
TEST_ASSERT_NULL(validateSettings(s));
|
|
strcpy(s.off_page, "../x");
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
memset(s.off_page, 'a', sizeof(s.off_page)); // not terminated
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
}
|
|
|
|
void test_changed_off_image_is_redrawn_while_the_display_is_on()
|
|
{
|
|
run(23, 30);
|
|
run(23, 31, 1000);
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
|
controller->pageChanged();
|
|
run(23, 31, 1000);
|
|
TEST_ASSERT_EQUAL(2, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("off")));
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
}
|
|
|
|
void test_settings_flip_time_and_port_ranges()
|
|
{
|
|
Settings s;
|
|
s.flip_time = 49;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s.flip_time = 2001;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
s = Settings();
|
|
s.home_assistant.port = 0;
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
}
|
|
|
|
void test_settings_page_must_be_a_name()
|
|
{
|
|
Settings s;
|
|
strcpy(s.page, "My bitmap");
|
|
TEST_ASSERT_NULL(validateSettings(s));
|
|
strcpy(s.page, "../evil");
|
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
|
}
|
|
|
|
void test_time_parse_and_format()
|
|
{
|
|
uint16_t m = 0;
|
|
TEST_ASSERT_TRUE(parseTime("08:30", m));
|
|
TEST_ASSERT_EQUAL(510, m);
|
|
TEST_ASSERT_TRUE(parseTime("24:00", m));
|
|
TEST_ASSERT_EQUAL(MINUTES_PER_DAY, m);
|
|
TEST_ASSERT_FALSE(parseTime("24:01", m));
|
|
TEST_ASSERT_FALSE(parseTime("12:60", m));
|
|
TEST_ASSERT_FALSE(parseTime("noon", m));
|
|
TEST_ASSERT_FALSE(parseTime("12:30x", m));
|
|
TEST_ASSERT_FALSE(parseTime(nullptr, m));
|
|
char out[6];
|
|
formatTime(23 * 60 + 31, out);
|
|
TEST_ASSERT_EQUAL_STRING("23:31", out);
|
|
formatTime(MINUTES_PER_DAY, out);
|
|
TEST_ASSERT_EQUAL_STRING("24:00", out);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bitmap format
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_bitmap_is_84_by_19_in_drawbitmap_format()
|
|
{
|
|
TEST_ASSERT_EQUAL(11, BITMAP_ROW_BYTES);
|
|
TEST_ASSERT_EQUAL(209, BITMAP_BYTES);
|
|
uint8_t b[BITMAP_BYTES] = {};
|
|
setBitmapPixel(b, 0, 0, true);
|
|
TEST_ASSERT_EQUAL_HEX8(0x80, b[0]); // leftmost dot = most significant bit
|
|
setBitmapPixel(b, 83, 18, true);
|
|
TEST_ASSERT_EQUAL_HEX8(0x10, b[18 * 11 + 10]); // x = 83: bit 3 of byte 10
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 83, 18));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 84, 0)); // outside
|
|
setBitmapPixel(b, 0, 0, false);
|
|
TEST_ASSERT_EQUAL_HEX8(0x00, b[0]);
|
|
}
|
|
|
|
void test_bitmap_hex_round_trip()
|
|
{
|
|
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
|
|
for (int i = 0; i < BITMAP_BYTES; i++)
|
|
a[i] = (uint8_t)(i * 37);
|
|
char hex[BITMAP_HEX_LENGTH + 1];
|
|
bitmapToHex(a, hex);
|
|
TEST_ASSERT_EQUAL(BITMAP_HEX_LENGTH, (int)strlen(hex));
|
|
TEST_ASSERT_TRUE(bitmapFromHex(hex, b));
|
|
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
|
|
}
|
|
|
|
void test_bitmap_hex_rejects_wrong_input()
|
|
{
|
|
uint8_t b[BITMAP_BYTES];
|
|
TEST_ASSERT_FALSE(bitmapFromHex("abcd", b));
|
|
TEST_ASSERT_FALSE(bitmapFromHex(nullptr, b));
|
|
std::string bad(BITMAP_HEX_LENGTH, 'f');
|
|
bad[100] = 'x';
|
|
TEST_ASSERT_FALSE(bitmapFromHex(bad.c_str(), b));
|
|
std::string upper(BITMAP_HEX_LENGTH, 'F');
|
|
TEST_ASSERT_TRUE(bitmapFromHex(upper.c_str(), b));
|
|
}
|
|
|
|
void test_bitmap_names()
|
|
{
|
|
TEST_ASSERT_TRUE(validBitmapName("Nintendo"));
|
|
TEST_ASSERT_TRUE(validBitmapName("Happy Birthday 2"));
|
|
TEST_ASSERT_TRUE(validBitmapName("a-b_c"));
|
|
TEST_ASSERT_FALSE(validBitmapName(""));
|
|
TEST_ASSERT_FALSE(validBitmapName(" lead"));
|
|
TEST_ASSERT_FALSE(validBitmapName("trail "));
|
|
TEST_ASSERT_FALSE(validBitmapName("../etc"));
|
|
TEST_ASSERT_FALSE(validBitmapName("a/b"));
|
|
TEST_ASSERT_TRUE(sameName("Clock", "clock"));
|
|
TEST_ASSERT_FALSE(sameName("Clock", "Clocks"));
|
|
TEST_ASSERT_TRUE(validBitmapName("123456789012345678901234"));
|
|
TEST_ASSERT_FALSE(validBitmapName("1234567890123456789012345"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schedule with settings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_schedule_disabled_is_always_on()
|
|
{
|
|
ScheduleSettings s;
|
|
s.enabled = false;
|
|
TEST_ASSERT_TRUE(scheduleFor(3, 0, s) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock);
|
|
}
|
|
|
|
void test_schedule_custom_times()
|
|
{
|
|
ScheduleSettings s;
|
|
s.day_start = 6 * 60 + 30;
|
|
s.late_start = 20 * 60;
|
|
s.night_start = 21 * 60;
|
|
TEST_ASSERT_TRUE(scheduleFor(6, 29, s) == ScheduleMode::Night);
|
|
TEST_ASSERT_TRUE(scheduleFor(6, 30, s) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(20, 1, s) == ScheduleMode::Standby);
|
|
TEST_ASSERT_TRUE(scheduleFor(20, 30, s) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(21, 0, s) == ScheduleMode::Night);
|
|
}
|
|
|
|
void test_schedule_without_quarter_hours_stays_on_until_night()
|
|
{
|
|
ScheduleSettings s;
|
|
s.quarter_hours = false;
|
|
TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock);
|
|
TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pages and live mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_bitmap_page_is_shown_once()
|
|
{
|
|
controller->setPage(Page::Bitmap);
|
|
run(12, 0);
|
|
run(12, 1, 5000);
|
|
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
|
|
TEST_ASSERT_EQUAL_STRING("page", frames.last().c_str());
|
|
}
|
|
|
|
void test_bitmap_page_redrawn_when_changed()
|
|
{
|
|
controller->setPage(Page::Bitmap);
|
|
run(12, 0);
|
|
controller->pageChanged();
|
|
run(12, 0);
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_switching_back_to_the_clock_page()
|
|
{
|
|
controller->setPage(Page::Bitmap);
|
|
run(12, 0);
|
|
controller->setPage(Page::Dynamic);
|
|
run(12, 0);
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
|
}
|
|
|
|
void test_bitmap_page_follows_the_schedule()
|
|
{
|
|
controller->setPage(Page::Bitmap);
|
|
run(23, 30);
|
|
run(23, 31, POWER_OFF_DELAY + 200);
|
|
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
}
|
|
|
|
void test_dynamic_page_redrawn_when_its_content_changes()
|
|
{
|
|
run(18, 0);
|
|
controller->pageChanged(); // e.g. a Home Assistant value changed
|
|
run(18, 0);
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_settings_change_redraws()
|
|
{
|
|
run(18, 0);
|
|
ClockSettings c;
|
|
c.no_wifi_screen = false;
|
|
controller->setSettings(defaults.schedule, c);
|
|
run(18, 0);
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_live_is_shown_right_away_even_at_night()
|
|
{
|
|
run(3, 0);
|
|
controller->showLive(now);
|
|
run(3, 0);
|
|
TEST_ASSERT_TRUE(power.is_on);
|
|
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
|
|
}
|
|
|
|
void test_live_works_before_the_time_is_synced()
|
|
{
|
|
controller->showLive(now);
|
|
controller->update(now, false, LocalTime(), true);
|
|
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
|
|
}
|
|
|
|
void test_each_live_image_is_drawn()
|
|
{
|
|
controller->showLive(now);
|
|
run(12, 0);
|
|
controller->showLive(now);
|
|
run(12, 0);
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
}
|
|
|
|
void test_stop_live_returns_to_the_page()
|
|
{
|
|
controller->showLive(now);
|
|
run(12, 0);
|
|
controller->stopLive();
|
|
run(12, 0);
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
|
TEST_ASSERT_FALSE(controller->liveActive());
|
|
}
|
|
|
|
void test_live_ends_after_timeout()
|
|
{
|
|
controller->showLive(now);
|
|
run(12, 0, LIVE_TIMEOUT + 200);
|
|
TEST_ASSERT_FALSE(controller->liveActive());
|
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
|
}
|
|
|
|
void test_live_respects_the_power_switch()
|
|
{
|
|
controller->setPowerEnabled(false);
|
|
controller->showLive(now);
|
|
run(12, 0, 5000);
|
|
TEST_ASSERT_FALSE(power.is_on);
|
|
TEST_ASSERT_EQUAL(0, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("live")));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fonts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static Font fontNamed(const char *name)
|
|
{
|
|
const BuiltinFont *b = findBuiltinFont(name);
|
|
TEST_ASSERT_NOT_NULL(b);
|
|
return builtinFont(*b);
|
|
}
|
|
|
|
void test_builtin_fonts_are_valid()
|
|
{
|
|
for (int i = 0; i < BUILTIN_FONT_COUNT; i++)
|
|
TEST_ASSERT_TRUE_MESSAGE(validFont(BUILTIN_FONTS[i].data, BUILTIN_FONTS[i].size), BUILTIN_FONTS[i].name);
|
|
TEST_ASSERT_NOT_NULL(findBuiltinFont("normal"));
|
|
TEST_ASSERT_NULL(findBuiltinFont("Comic Sans"));
|
|
TEST_ASSERT_EQUAL(2, fontNamed("Large").scale);
|
|
}
|
|
|
|
void test_font_sizes()
|
|
{
|
|
// Monospaced digits: the clock doesn't move when the time changes
|
|
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "12:34"));
|
|
TEST_ASSERT_EQUAL(29, textWidth(fontNamed("Normal"), "00:00"));
|
|
TEST_ASSERT_EQUAL(58, textWidth(fontNamed("Large"), "12:34"));
|
|
TEST_ASSERT_EQUAL(17, textWidth(fontNamed("Small"), "12:34"));
|
|
TEST_ASSERT_EQUAL(51, textWidth(fontNamed("Full height"), "12:34"));
|
|
TEST_ASSERT_EQUAL(8, fontHeight(fontNamed("Normal")));
|
|
TEST_ASSERT_EQUAL(16, fontHeight(fontNamed("Large")));
|
|
TEST_ASSERT_EQUAL(6, fontHeight(fontNamed("Small")));
|
|
TEST_ASSERT_EQUAL(19, fontHeight(fontNamed("Full height")));
|
|
// Characters that are not in the font are skipped
|
|
TEST_ASSERT_EQUAL(10, textWidth(fontNamed("Full height"), "a1b"));
|
|
TEST_ASSERT_EQUAL(0, textWidth(fontNamed("Normal"), ""));
|
|
}
|
|
|
|
void test_draw_text_uses_the_classic_glyphs()
|
|
{
|
|
uint8_t b[BITMAP_BYTES] = {};
|
|
drawText(b, fontNamed("Normal"), 10, 2, "1", DrawMode::Set);
|
|
// glcdfont '1': 0x00, 0x42, 0x7F, 0x40, 0x00 (columns, bit 0 = top)
|
|
for (int row = 0; row < 7; row++)
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2 + row));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 9));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 3));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 11, 2));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 13, 8));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 5));
|
|
}
|
|
|
|
void test_draw_text_scaled_and_inverted()
|
|
{
|
|
uint8_t b[BITMAP_BYTES];
|
|
memset(b, 0xFF, sizeof(b));
|
|
drawText(b, fontNamed("Large"), 0, 0, "1", DrawMode::Invert);
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 4, 0)); // column 2 doubled: 4 and 5
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 5, 13));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 4, 14));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
|
|
}
|
|
|
|
void test_draw_text_is_clipped()
|
|
{
|
|
uint8_t b[BITMAP_BYTES] = {};
|
|
drawText(b, fontNamed("Full height"), 80, 5, "88", DrawMode::Set); // must not write outside
|
|
drawText(b, fontNamed("Full height"), -5, -10, "88", DrawMode::Set);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 81, 6));
|
|
}
|
|
|
|
void test_invalid_fonts_are_rejected()
|
|
{
|
|
const BuiltinFont *n = findBuiltinFont("Normal");
|
|
std::vector<uint8_t> f(n->data, n->data + n->size);
|
|
TEST_ASSERT_TRUE(validFont(f.data(), f.size()));
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size() - 1));
|
|
f.push_back(0);
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
|
|
f.pop_back();
|
|
f[0] = 'X';
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
|
|
f[0] = 'F';
|
|
f[4] = 20; // too high
|
|
TEST_ASSERT_FALSE(validFont(f.data(), f.size()));
|
|
TEST_ASSERT_FALSE(validFont(nullptr, 0));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dynamic pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static DynamicPage twoSlotPage()
|
|
{
|
|
DynamicPage page;
|
|
page.slot_count = 2;
|
|
page.slots[0].start = 8 * 60;
|
|
strcpy(page.slots[0].bitmap, "Day");
|
|
page.slots[1].start = 17 * 60;
|
|
strcpy(page.slots[1].bitmap, "Evening");
|
|
return page;
|
|
}
|
|
|
|
void test_active_slot_follows_the_time()
|
|
{
|
|
DynamicPage page = twoSlotPage();
|
|
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 7 * 60)->bitmap); // from the day before
|
|
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 8 * 60)->bitmap);
|
|
TEST_ASSERT_EQUAL_STRING("Day", activeSlot(page, 17 * 60 - 1)->bitmap);
|
|
TEST_ASSERT_EQUAL_STRING("Evening", activeSlot(page, 17 * 60)->bitmap);
|
|
TEST_ASSERT_NULL(activeSlot(DynamicPage(), 600));
|
|
}
|
|
|
|
void test_page_validation()
|
|
{
|
|
DynamicPage page = twoSlotPage();
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
page.slots[1].start = 8 * 60;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // not in order
|
|
page = twoSlotPage();
|
|
page.slots[1].start = 24 * 60;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page = twoSlotPage();
|
|
strcpy(page.slots[0].bitmap, "../x");
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page = twoSlotPage();
|
|
TEST_ASSERT_EQUAL(PAGE_EFFECT_DEFAULT, page.effect);
|
|
page.effect = DRIVE_EFFECT_RANDOM;
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
page.effect = DRIVE_EFFECT_COUNT;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // unknown refresh effect
|
|
|
|
page = DynamicPage();
|
|
page.element_count = 1;
|
|
PageElement &e = page.elements[0];
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
e.type = ElementType::HaValue;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // needs an entity
|
|
strcpy(e.entity, "sensor.outside_temperature");
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
strcpy(e.attribute, "bad attr");
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
e.attribute[0] = '\0';
|
|
strcpy(e.text, "caf\xc3\xa9");
|
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // the fonts have no accents
|
|
e.text[0] = '\0';
|
|
e.decimals = 5;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
e.decimals = 1;
|
|
e.type = ElementType::Date;
|
|
e.format = DATE_FORMAT_COUNT;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page.element_count = PAGE_ELEMENTS + 1;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
|
|
TEST_ASSERT_TRUE(validEntityId("weather.home"));
|
|
TEST_ASSERT_FALSE(validEntityId("Sensor.x"));
|
|
TEST_ASSERT_FALSE(validEntityId("sensor"));
|
|
TEST_ASSERT_FALSE(validEntityId("a.b.c"));
|
|
TEST_ASSERT_FALSE(validEntityId(".x"));
|
|
}
|
|
|
|
static std::string textOf(const PageElement &e, const char *ha_value = nullptr)
|
|
{
|
|
LocalTime t; // Thursday 15 October 2026, 08:05
|
|
t.year = 2026;
|
|
t.month = 10;
|
|
t.day = 15;
|
|
t.weekday = 4;
|
|
t.hour = 8;
|
|
t.minute = 5;
|
|
char out[64];
|
|
ElementValues values;
|
|
values.ha_value = ha_value;
|
|
values.stopwatch_ms = 65000;
|
|
elementText(e, t, values, out, sizeof(out));
|
|
return out;
|
|
}
|
|
|
|
void test_clock_and_date_texts()
|
|
{
|
|
PageElement e;
|
|
TEST_ASSERT_EQUAL_STRING("08:05", textOf(e).c_str());
|
|
e.format = CLOCK_H_MM;
|
|
TEST_ASSERT_EQUAL_STRING("8:05", textOf(e).c_str());
|
|
|
|
e.type = ElementType::Date;
|
|
const char *dutch[] = {"do 15 okt", "15 okt", "15 oktober", "15-10", "15-10-2026", "donderdag", "do"};
|
|
const char *english[] = {"Thu 15 Oct", "15 Oct", "15 October", "15-10", "15-10-2026", "Thursday", "Thu"};
|
|
for (uint8_t f = 0; f < DATE_FORMAT_COUNT; f++)
|
|
{
|
|
e.format = f;
|
|
e.language = Language::Dutch;
|
|
TEST_ASSERT_EQUAL_STRING(dutch[f], textOf(e).c_str());
|
|
e.language = Language::English;
|
|
TEST_ASSERT_EQUAL_STRING(english[f], textOf(e).c_str());
|
|
}
|
|
|
|
e = PageElement();
|
|
e.type = ElementType::Text;
|
|
strcpy(e.text, "Hallo");
|
|
TEST_ASSERT_EQUAL_STRING("Hallo", textOf(e).c_str());
|
|
}
|
|
|
|
void test_home_assistant_value_texts()
|
|
{
|
|
PageElement e;
|
|
e.type = ElementType::HaValue;
|
|
strcpy(e.text, "T ");
|
|
strcpy(e.suffix, "C");
|
|
TEST_ASSERT_EQUAL_STRING("T 21.46C", textOf(e, "21.46").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, nullptr).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("T --C", textOf(e, "unavailable").c_str());
|
|
e.decimals = 1;
|
|
TEST_ASSERT_EQUAL_STRING("T 21.5C", textOf(e, "21.46").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("T 0.0C", textOf(e, "-0.04").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("T -1.0C", textOf(e, "-1").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("T onC", textOf(e, "on").c_str()); // not a number: as is
|
|
e.decimals = 0;
|
|
TEST_ASSERT_EQUAL_STRING("T 21C", textOf(e, "21.46").c_str());
|
|
}
|
|
|
|
// Backgrounds: "Full" is all dots on, "Day"/"Evening" have one dot at x 0 / x 1
|
|
class FakeResources : public PageResources
|
|
{
|
|
public:
|
|
const char *value = "21.5";
|
|
|
|
bool background(const char *name, uint8_t *bitmap) override
|
|
{
|
|
memset(bitmap, 0, BITMAP_BYTES);
|
|
if (strcmp(name, "Full") == 0)
|
|
memset(bitmap, 0xFF, BITMAP_BYTES);
|
|
else if (strcmp(name, "Day") == 0)
|
|
setBitmapPixel(bitmap, 0, 0, true);
|
|
else if (strcmp(name, "Evening") == 0)
|
|
setBitmapPixel(bitmap, 1, 0, true);
|
|
else
|
|
{
|
|
memset(bitmap, 0xAA, BITMAP_BYTES); // garbage: must be cleared
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
Font font(const char *name) override
|
|
{
|
|
const BuiltinFont *b = findBuiltinFont(name);
|
|
return b ? builtinFont(*b) : Font();
|
|
}
|
|
const char *haValue(const PageElement &) override { return value; }
|
|
};
|
|
|
|
static LocalTime at(int hour, int minute)
|
|
{
|
|
LocalTime t;
|
|
t.hour = hour;
|
|
t.minute = minute;
|
|
return t;
|
|
}
|
|
|
|
void test_render_background_follows_the_slots()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page = twoSlotPage();
|
|
uint8_t b[BITMAP_BYTES];
|
|
renderPage(page, at(9, 0), 0, res, b);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 0, 0));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 1, 0));
|
|
renderPage(page, at(18, 0), 0, res, b);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 1, 0));
|
|
strcpy(page.slots[1].bitmap, "Gone");
|
|
renderPage(page, at(18, 0), 0, res, b);
|
|
for (int i = 0; i < BITMAP_BYTES; i++)
|
|
TEST_ASSERT_EQUAL_HEX8(0, b[i]); // blank
|
|
}
|
|
|
|
void test_render_overlays()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page;
|
|
page.slot_count = 1;
|
|
strcpy(page.slots[0].bitmap, "Full");
|
|
page.element_count = 1;
|
|
PageElement &e = page.elements[0];
|
|
e.type = ElementType::Text;
|
|
strcpy(e.text, "1");
|
|
e.x = 10;
|
|
e.y = 2;
|
|
|
|
uint8_t b[BITMAP_BYTES];
|
|
e.overlay = Overlay::OnTop; // on a full background nothing changes
|
|
renderPage(page, at(12, 0), 0, res, b);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
|
|
|
|
e.overlay = Overlay::Box; // cleared area with one dot around it, then the text
|
|
renderPage(page, at(12, 0), 0, res, b);
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 9, 1));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 10, 2));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 12, 2));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 15, 10));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 16, 10));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 8, 2));
|
|
|
|
e.overlay = Overlay::Invert;
|
|
renderPage(page, at(12, 0), 0, res, b);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 2));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 12, 2));
|
|
}
|
|
|
|
void test_render_alignment_and_boxes()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page;
|
|
page.element_count = 2;
|
|
page.elements[0].x = 84;
|
|
page.elements[0].align = Align::Right;
|
|
page.elements[1].type = ElementType::HaValue;
|
|
strcpy(page.elements[1].entity, "sensor.t");
|
|
page.elements[1].x = 42;
|
|
page.elements[1].y = 11;
|
|
page.elements[1].align = Align::Center;
|
|
ElementBox boxes[2];
|
|
uint8_t b[BITMAP_BYTES];
|
|
renderPage(page, at(12, 34), 0, res, b, boxes);
|
|
TEST_ASSERT_EQUAL(84 - 29, boxes[0].x);
|
|
TEST_ASSERT_EQUAL(29, boxes[0].width);
|
|
TEST_ASSERT_EQUAL(8, boxes[0].height);
|
|
TEST_ASSERT_EQUAL(23, boxes[1].width); // "21.5"
|
|
TEST_ASSERT_EQUAL(42 - 11, boxes[1].x);
|
|
TEST_ASSERT_EQUAL(11, boxes[1].y);
|
|
}
|
|
|
|
void test_render_unknown_font_uses_normal()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
strcpy(page.elements[0].font, "Gone");
|
|
ElementBox box;
|
|
uint8_t b[BITMAP_BYTES];
|
|
renderPage(page, at(12, 34), 0, res, b, &box);
|
|
TEST_ASSERT_EQUAL(29, box.width);
|
|
}
|
|
|
|
void test_builtin_clock_page_is_the_old_clock()
|
|
{
|
|
DynamicPage page;
|
|
TEST_ASSERT_TRUE(builtinPage("clock", page)); // old settings stored "clock"
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
TEST_ASSERT_EQUAL_STRING("Pikachu", activeSlot(page, 16 * 60 + 59)->bitmap);
|
|
TEST_ASSERT_EQUAL_STRING("Pikachu yawning", activeSlot(page, 17 * 60)->bitmap);
|
|
TEST_ASSERT_EQUAL_STRING(BUILTIN_PAGE_CLOCK, builtinPageName(0));
|
|
TEST_ASSERT_FALSE(builtinPage("Pikachu", page));
|
|
|
|
// The time where the old firmware printed it: cursor 46, 4 with the classic font
|
|
FakeResources res;
|
|
TEST_ASSERT_TRUE(builtinPage(BUILTIN_PAGE_CLOCK, page));
|
|
uint8_t b[BITMAP_BYTES];
|
|
renderPage(page, at(12, 34), 0, res, b);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 4)); // the '1'
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 48, 10));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 48, 11));
|
|
}
|
|
|
|
void test_default_page_is_the_builtin_clock()
|
|
{
|
|
DynamicPage page;
|
|
TEST_ASSERT_TRUE(builtinPage(defaults.page, page));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Seconds, countdown, stopwatch, multiplier, degree sign
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_clock_with_seconds()
|
|
{
|
|
PageElement e;
|
|
e.format = CLOCK_HH_MM_SS;
|
|
TEST_ASSERT_EQUAL_STRING("08:05:00", textOf(e).c_str());
|
|
e.format = CLOCK_H_MM_SS;
|
|
TEST_ASSERT_EQUAL_STRING("8:05:00", textOf(e).c_str());
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), false));
|
|
e.format = CLOCK_HH_MM;
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false));
|
|
}
|
|
|
|
void test_countdown_target_parse_and_format()
|
|
{
|
|
uint32_t m = 0;
|
|
TEST_ASSERT_TRUE(parseTarget("2000-01-01T00:00", m));
|
|
TEST_ASSERT_EQUAL_UINT32(0, m);
|
|
TEST_ASSERT_TRUE(parseTarget("2026-12-24T18:30", m));
|
|
char out[17];
|
|
formatTarget(m, out);
|
|
TEST_ASSERT_EQUAL_STRING("2026-12-24T18:30", out);
|
|
TEST_ASSERT_FALSE(parseTarget("2026-02-31T10:00", m)); // no 31 February
|
|
TEST_ASSERT_FALSE(parseTarget("2026-12-24 18:30", m));
|
|
TEST_ASSERT_FALSE(parseTarget("1999-12-24T18:30", m));
|
|
TEST_ASSERT_FALSE(parseTarget("2026-12-24T24:00", m));
|
|
TEST_ASSERT_TRUE(parseTarget("2028-02-29T00:00", m)); // leap year
|
|
|
|
LocalTime t;
|
|
t.year = 2026;
|
|
t.month = 10;
|
|
t.day = 15;
|
|
t.hour = 8;
|
|
t.minute = 5;
|
|
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", m));
|
|
TEST_ASSERT_EQUAL_UINT32(m, minutesSince2000(t));
|
|
}
|
|
|
|
// Countdown from Thursday 15 October 2026, 08:05:00 (textOf) to `target`
|
|
static std::string countdown(const char *target, uint8_t format, Language lang = Language::Dutch)
|
|
{
|
|
PageElement e;
|
|
e.type = ElementType::Countdown;
|
|
e.format = format;
|
|
e.language = lang;
|
|
TEST_ASSERT_TRUE(parseTarget(target, e.target));
|
|
return textOf(e);
|
|
}
|
|
|
|
void test_countdown_formats()
|
|
{
|
|
TEST_ASSERT_EQUAL_STRING("12 dagen", countdown("2026-10-27T09:00", COUNTDOWN_SMART).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("1 day", countdown("2026-10-16T09:00", COUNTDOWN_SMART, Language::English).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("5 uur", countdown("2026-10-15T13:30", COUNTDOWN_SMART).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("1 hour", countdown("2026-10-15T09:30", COUNTDOWN_SMART, Language::English).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("34 min", countdown("2026-10-15T08:39", COUNTDOWN_SMART).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("1 min", countdown("2026-10-15T08:06", COUNTDOWN_SMART).c_str());
|
|
|
|
// Calendar days: tomorrow 00:30 is 1 day away
|
|
TEST_ASSERT_EQUAL_STRING("1 dag", countdown("2026-10-16T00:30", COUNTDOWN_DAYS).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("12 days", countdown("2026-10-27T01:00", COUNTDOWN_DAYS, Language::English).c_str());
|
|
|
|
TEST_ASSERT_EQUAL_STRING("12d 00:55", countdown("2026-10-27T09:00", COUNTDOWN_DAYS_TIME).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("05:25", countdown("2026-10-15T13:30", COUNTDOWN_DAYS_TIME).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("12d 00:55:00", countdown("2026-10-27T09:00", COUNTDOWN_SECONDS).c_str());
|
|
TEST_ASSERT_EQUAL_STRING("05:25:00", countdown("2026-10-15T13:30", COUNTDOWN_SECONDS).c_str());
|
|
|
|
// Passed: the suffix, or 0
|
|
TEST_ASSERT_EQUAL_STRING("0", countdown("2026-10-15T08:05", COUNTDOWN_SMART).c_str());
|
|
PageElement e;
|
|
e.type = ElementType::Countdown;
|
|
strcpy(e.text, "Disney ");
|
|
strcpy(e.suffix, "nu!");
|
|
TEST_ASSERT_TRUE(parseTarget("2026-01-01T00:00", e.target));
|
|
TEST_ASSERT_EQUAL_STRING("Disney nu!", textOf(e).c_str());
|
|
}
|
|
|
|
void test_countdown_shows_seconds_only_when_needed()
|
|
{
|
|
PageElement e;
|
|
e.type = ElementType::Countdown;
|
|
LocalTime t;
|
|
t.year = 2026;
|
|
t.month = 10;
|
|
t.day = 15;
|
|
t.hour = 8;
|
|
t.minute = 5;
|
|
t.second = 30;
|
|
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:06", e.target)); // 30 s left
|
|
e.format = COUNTDOWN_SMART;
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
|
|
char out[32];
|
|
elementText(e, t, ElementValues(), out, sizeof(out));
|
|
TEST_ASSERT_EQUAL_STRING("0:30", out);
|
|
e.format = COUNTDOWN_DAYS_TIME;
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
|
|
e.format = COUNTDOWN_SECONDS;
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, t, false));
|
|
TEST_ASSERT_TRUE(parseTarget("2026-10-15T08:05", e.target)); // passed
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, t, false));
|
|
}
|
|
|
|
void test_stopwatch()
|
|
{
|
|
Stopwatch sw;
|
|
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(5000));
|
|
sw.start(1000);
|
|
TEST_ASSERT_TRUE(sw.running());
|
|
TEST_ASSERT_EQUAL_UINT32(2000, sw.elapsed(3000));
|
|
sw.stop(4000);
|
|
TEST_ASSERT_EQUAL_UINT32(3000, sw.elapsed(9000));
|
|
sw.start(10000);
|
|
sw.start(11000); // already running: no restart
|
|
TEST_ASSERT_EQUAL_UINT32(5000, sw.elapsed(12000));
|
|
sw.reset();
|
|
TEST_ASSERT_FALSE(sw.running());
|
|
TEST_ASSERT_EQUAL_UINT32(0, sw.elapsed(20000));
|
|
sw.start(0xFFFFFF00u); // across the millis() overflow
|
|
TEST_ASSERT_EQUAL_UINT32(0x200, sw.elapsed(0x100));
|
|
|
|
PageElement e; // textOf: 65 s on the stopwatch
|
|
e.type = ElementType::Stopwatch;
|
|
e.format = STOPWATCH_M_SS;
|
|
TEST_ASSERT_EQUAL_STRING("1:05", textOf(e).c_str());
|
|
e.format = STOPWATCH_H_MM;
|
|
TEST_ASSERT_EQUAL_STRING("0:01", textOf(e).c_str());
|
|
e.format = STOPWATCH_H_MM_SS;
|
|
strcpy(e.text, "T ");
|
|
TEST_ASSERT_EQUAL_STRING("T 0:01:05", textOf(e).c_str());
|
|
TEST_ASSERT_TRUE(elementShowsSeconds(e, LocalTime(), true));
|
|
TEST_ASSERT_FALSE(elementShowsSeconds(e, LocalTime(), false)); // stopped
|
|
}
|
|
|
|
void test_home_assistant_multiplier()
|
|
{
|
|
PageElement e;
|
|
e.type = ElementType::HaValue;
|
|
e.factor = 1000;
|
|
strcpy(e.suffix, " l");
|
|
TEST_ASSERT_EQUAL_STRING("637 l", textOf(e, "0.637").c_str());
|
|
e.factor = 0.001f;
|
|
e.decimals = 1;
|
|
strcpy(e.suffix, "kWh");
|
|
TEST_ASSERT_EQUAL_STRING("26.3kWh", textOf(e, "26300").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("--kWh", textOf(e, "unknown").c_str());
|
|
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
page.elements[0] = e;
|
|
strcpy(page.elements[0].entity, "sensor.x");
|
|
page.elements[0].factor = 0;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
}
|
|
|
|
void test_degree_sign_in_the_fonts()
|
|
{
|
|
const char degree[] = {DEGREE_SIGN, 0};
|
|
TEST_ASSERT_EQUAL(4, textWidth(fontNamed("Normal"), degree));
|
|
TEST_ASSERT_EQUAL(3, textWidth(fontNamed("Small"), degree));
|
|
TEST_ASSERT_EQUAL(6, textWidth(fontNamed("Full height"), degree));
|
|
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
page.elements[0].type = ElementType::Text;
|
|
strcpy(page.elements[0].text, "17.5\x7f" "C");
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scrolling and images
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static DynamicPage scrollPage()
|
|
{
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
PageElement &e = page.elements[0];
|
|
e.type = ElementType::Text;
|
|
strcpy(e.text, "A long text that does not fit");
|
|
e.x = 10;
|
|
e.y = 2;
|
|
e.scroll_width = 30;
|
|
e.scroll_speed = 10;
|
|
return page;
|
|
}
|
|
|
|
static bool anyDot(const uint8_t *b, int x0, int x1)
|
|
{
|
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
|
for (int x = x0; x < x1; x++)
|
|
if (bitmapPixel(b, x, y))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
void test_slow_scrolling_below_one_dot_per_second()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page = scrollPage();
|
|
page.elements[0].scroll_speed = 0.25f; // a dot every 4 s
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
|
|
TEST_ASSERT_EQUAL(4000, renderPage(page, at(12, 0), 0, res, a));
|
|
TEST_ASSERT_EQUAL(1, renderPage(page, at(12, 0), 3999, res, b));
|
|
TEST_ASSERT_EQUAL(0, memcmp(a, b, BITMAP_BYTES)); // not moved yet
|
|
TEST_ASSERT_EQUAL(4000, renderPage(page, at(12, 0), 4000, res, b));
|
|
TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0); // one dot on
|
|
TEST_ASSERT_EQUAL(1500, renderPage(page, at(12, 0), 6500, res, b));
|
|
|
|
page.elements[0].scroll_speed = 0.3f; // 3333.3 ms per dot: no drift over a long time
|
|
TEST_ASSERT_EQUAL(3334, renderPage(page, at(12, 0), 0, res, a));
|
|
uint32_t t = 3000000000UL; // ~35 days of millis()
|
|
uint32_t wait = renderPage(page, at(12, 0), t, res, a);
|
|
TEST_ASSERT_TRUE(wait >= 1 && wait <= 3334);
|
|
|
|
page.elements[0].scroll_speed = 0.01f; // too slow
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page.elements[0].scroll_speed = NAN;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page.elements[0].scroll_speed = 31;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
}
|
|
|
|
void test_scrolling_text_moves_in_its_window()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page = scrollPage();
|
|
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
|
|
ElementBox box;
|
|
uint32_t wait = renderPage(page, at(12, 0), 0, res, a, &box);
|
|
TEST_ASSERT_EQUAL(100, wait); // 10 dots per second
|
|
TEST_ASSERT_EQUAL(10, box.x);
|
|
TEST_ASSERT_EQUAL(30, box.width);
|
|
TEST_ASSERT_FALSE(anyDot(a, 0, 10)); // clipped to the window
|
|
TEST_ASSERT_FALSE(anyDot(a, 40, BITMAP_WIDTH));
|
|
TEST_ASSERT_TRUE(anyDot(a, 10, 40));
|
|
|
|
TEST_ASSERT_EQUAL(60, renderPage(page, at(12, 0), 1040, res, b, &box)); // until 1100
|
|
TEST_ASSERT_TRUE(memcmp(a, b, BITMAP_BYTES) != 0);
|
|
// One round later the same image again: text width + gap
|
|
int period = textWidth(fontNamed("Normal"), page.elements[0].text) + SCROLL_GAP;
|
|
renderPage(page, at(12, 0), period * 100, res, b);
|
|
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
|
|
}
|
|
|
|
void test_short_text_in_a_window_does_not_scroll()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page = scrollPage();
|
|
strcpy(page.elements[0].text, "Hi");
|
|
page.elements[0].align = Align::Right; // right edge of the window at x 10
|
|
uint8_t b[BITMAP_BYTES];
|
|
ElementBox box;
|
|
TEST_ASSERT_EQUAL(0, renderPage(page, at(12, 0), 0, res, b, &box));
|
|
TEST_ASSERT_EQUAL(-20, box.x);
|
|
TEST_ASSERT_TRUE(anyDot(b, 0, 10)); // the text at the right of the window
|
|
}
|
|
|
|
void test_render_asks_for_the_next_second()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
uint8_t b[BITMAP_BYTES];
|
|
LocalTime t = at(12, 0);
|
|
t.millisecond = 300;
|
|
TEST_ASSERT_EQUAL(0, renderPage(page, t, 0, res, b));
|
|
page.elements[0].format = CLOCK_HH_MM_SS;
|
|
TEST_ASSERT_EQUAL(700, renderPage(page, t, 0, res, b));
|
|
}
|
|
|
|
class ImageResources : public FakeResources
|
|
{
|
|
public:
|
|
bool background(const char *name, uint8_t *bitmap) override
|
|
{
|
|
memset(bitmap, 0, BITMAP_BYTES);
|
|
if (strcmp(name, "icon sunny") == 0)
|
|
{
|
|
setBitmapPixel(bitmap, 1, 1, true);
|
|
setBitmapPixel(bitmap, 3, 2, true);
|
|
return true;
|
|
}
|
|
if (strcmp(name, "icon default") == 0)
|
|
{
|
|
setBitmapPixel(bitmap, 0, 0, true);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
void test_image_element_by_state()
|
|
{
|
|
ImageResources res;
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
PageElement &e = page.elements[0];
|
|
e.type = ElementType::Image;
|
|
strcpy(e.text, "icon ");
|
|
strcpy(e.entity, "weather.home");
|
|
e.x = 50;
|
|
e.y = 5;
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
|
|
uint8_t b[BITMAP_BYTES];
|
|
ElementBox box;
|
|
res.value = "sunny";
|
|
renderPage(page, at(12, 0), 0, res, b, &box);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 53, 7));
|
|
TEST_ASSERT_EQUAL(51, box.x);
|
|
TEST_ASSERT_EQUAL(6, box.y);
|
|
TEST_ASSERT_EQUAL(3, box.width);
|
|
TEST_ASSERT_EQUAL(2, box.height);
|
|
|
|
res.value = "rainy"; // no bitmap for it: the default
|
|
renderPage(page, at(12, 0), 0, res, b, &box);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 50, 5));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 51, 6));
|
|
|
|
e.entity[0] = '\0'; // fixed bitmap
|
|
strcpy(e.text, "icon sunny");
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
renderPage(page, at(12, 0), 0, res, b);
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 51, 6));
|
|
strcpy(e.text, "../x");
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
}
|
|
|
|
void test_icon_pixels_and_hex()
|
|
{
|
|
Icon icon;
|
|
icon.width = 11; // 2 bytes per row
|
|
icon.height = 3;
|
|
TEST_ASSERT_EQUAL(2, iconRowBytes(11));
|
|
TEST_ASSERT_EQUAL(6, iconBytes(11, 3));
|
|
setIconPixel(icon, 0, 0, true);
|
|
setIconPixel(icon, 10, 2, true);
|
|
setIconPixel(icon, 11, 0, true); // outside
|
|
TEST_ASSERT_TRUE(iconPixel(icon, 0, 0));
|
|
TEST_ASSERT_TRUE(iconPixel(icon, 10, 2));
|
|
TEST_ASSERT_FALSE(iconPixel(icon, 11, 0));
|
|
TEST_ASSERT_FALSE(iconPixel(icon, 1, 0));
|
|
|
|
char hex[BITMAP_HEX_LENGTH + 1];
|
|
iconToHex(icon, hex);
|
|
TEST_ASSERT_EQUAL_STRING("800000000020", hex);
|
|
Icon back;
|
|
TEST_ASSERT_TRUE(iconFromHex(11, 3, hex, back));
|
|
TEST_ASSERT_EQUAL(11, back.width);
|
|
TEST_ASSERT_EQUAL(3, back.height);
|
|
TEST_ASSERT_TRUE(iconPixel(back, 10, 2));
|
|
TEST_ASSERT_FALSE(iconPixel(back, 9, 2));
|
|
|
|
// Bits after the last dot of a row are cleared
|
|
TEST_ASSERT_TRUE(iconFromHex(11, 1, "ffff", back));
|
|
TEST_ASSERT_EQUAL_HEX8(0xE0, back.data[1]);
|
|
|
|
TEST_ASSERT_FALSE(iconFromHex(11, 3, "8000", back)); // too short
|
|
TEST_ASSERT_FALSE(iconFromHex(11, 3, "80000000002g", back)); // not hex
|
|
TEST_ASSERT_FALSE(iconFromHex(0, 3, "", back));
|
|
TEST_ASSERT_FALSE(iconFromHex(85, 1, "0000000000000000000000", back));
|
|
TEST_ASSERT_FALSE(iconFromHex(1, 20, "0000000000000000000000000000000000000000", back));
|
|
TEST_ASSERT_FALSE(iconFromHex(1, 1, nullptr, back));
|
|
TEST_ASSERT_TRUE(iconFromHex(84, 19, std::string(BITMAP_HEX_LENGTH, 'f').c_str(), back));
|
|
TEST_ASSERT_TRUE(iconPixel(back, 83, 18));
|
|
TEST_ASSERT_EQUAL(84, back.width);
|
|
TEST_ASSERT_TRUE(validIconSize(1, 1));
|
|
TEST_ASSERT_FALSE(validIconSize(84, 0));
|
|
}
|
|
|
|
void test_icon_from_bitmap_keeps_its_dots_in_place()
|
|
{
|
|
// Like "bg dag": a sun at x 9-19, y 0-10, nothing on the left
|
|
uint8_t bitmap[BITMAP_BYTES] = {};
|
|
setBitmapPixel(bitmap, 9, 5, true);
|
|
setBitmapPixel(bitmap, 19, 0, true);
|
|
setBitmapPixel(bitmap, 14, 10, true);
|
|
Icon icon;
|
|
iconFromBitmap(bitmap, icon);
|
|
TEST_ASSERT_EQUAL(20, icon.width);
|
|
TEST_ASSERT_EQUAL(11, icon.height);
|
|
uint8_t back[BITMAP_BYTES];
|
|
memset(back, 0xAA, sizeof(back));
|
|
iconToBitmap(icon, back);
|
|
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
|
|
|
|
// Full screen
|
|
memset(bitmap, 0, sizeof(bitmap));
|
|
setBitmapPixel(bitmap, 83, 18, true);
|
|
iconFromBitmap(bitmap, icon);
|
|
TEST_ASSERT_EQUAL(84, icon.width);
|
|
TEST_ASSERT_EQUAL(19, icon.height);
|
|
iconToBitmap(icon, back);
|
|
TEST_ASSERT_EQUAL_MEMORY(bitmap, back, BITMAP_BYTES);
|
|
|
|
// No dots: a blank dot
|
|
memset(bitmap, 0, sizeof(bitmap));
|
|
iconFromBitmap(bitmap, icon);
|
|
TEST_ASSERT_EQUAL(1, icon.width);
|
|
TEST_ASSERT_EQUAL(1, icon.height);
|
|
TEST_ASSERT_FALSE(iconPixel(icon, 0, 0));
|
|
}
|
|
|
|
void test_page_uses_image()
|
|
{
|
|
DynamicPage page;
|
|
page.slot_count = 1;
|
|
strcpy(page.slots[0].bitmap, "bg printer");
|
|
page.element_count = 3;
|
|
page.elements[0].type = ElementType::Image;
|
|
strcpy(page.elements[0].text, "plex logo");
|
|
page.elements[1].type = ElementType::Image;
|
|
strcpy(page.elements[1].text, "weer ");
|
|
strcpy(page.elements[1].entity, "weather.home");
|
|
page.elements[2].type = ElementType::Text; // texts are no images
|
|
strcpy(page.elements[2].text, "hello");
|
|
|
|
TEST_ASSERT_TRUE(pageUsesImage(page, "BG Printer"));
|
|
TEST_ASSERT_TRUE(pageUsesImage(page, "plex logo"));
|
|
TEST_ASSERT_FALSE(pageUsesImage(page, "hello"));
|
|
TEST_ASSERT_FALSE(pageUsesImage(page, "plex"));
|
|
// By state: only the default, unless all states are asked for
|
|
TEST_ASSERT_TRUE(pageUsesImage(page, "weer default"));
|
|
TEST_ASSERT_FALSE(pageUsesImage(page, "weer sunny"));
|
|
TEST_ASSERT_TRUE(pageUsesImage(page, "Weer sunny", true));
|
|
TEST_ASSERT_FALSE(pageUsesImage(page, "weer", true));
|
|
TEST_ASSERT_FALSE(pageUsesImage(page, "weerbericht", false));
|
|
TEST_ASSERT_FALSE(pageUsesImage(page, "sunny", true));
|
|
}
|
|
|
|
void test_moving_page_is_redrawn_when_due()
|
|
{
|
|
frames.wait = 250;
|
|
run(12, 0);
|
|
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
|
|
TEST_ASSERT_TRUE(frames.last_redraw);
|
|
run(12, 0, 200); // not yet (the run steps are 100 ms)
|
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
|
TEST_ASSERT_FALSE(frames.last_redraw);
|
|
frames.wait = 0; // stops moving
|
|
run(12, 0, 1000);
|
|
TEST_ASSERT_EQUAL(3, (int)frames.shown.size());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page schedule, rules and the page selector
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void setItem(ScheduleBlock &b, const char *page, uint16_t seconds)
|
|
{
|
|
strcpy(b.items[b.item_count].page, page);
|
|
b.items[b.item_count].seconds = seconds;
|
|
b.item_count++;
|
|
}
|
|
|
|
// 07:00 Weer (20 s) + Datum (10 s), with effect; 17:00 Energie, instant
|
|
static PageSchedule twoBlocks()
|
|
{
|
|
PageSchedule s;
|
|
s.block_count = 2;
|
|
s.blocks[0].start = 7 * 60;
|
|
setItem(s.blocks[0], "Weer", 20);
|
|
setItem(s.blocks[0], "Datum", 10);
|
|
s.blocks[1].start = 17 * 60;
|
|
s.blocks[1].effect = false;
|
|
setItem(s.blocks[1], "Energie", 30);
|
|
return s;
|
|
}
|
|
|
|
static TriggerRule rule(const char *page, TriggerMode mode, uint16_t seconds = 30)
|
|
{
|
|
TriggerRule r;
|
|
strcpy(r.entity, "sensor.printer");
|
|
strcpy(r.value, "printing");
|
|
strcpy(r.page, page);
|
|
r.mode = mode;
|
|
r.seconds = seconds;
|
|
return r;
|
|
}
|
|
|
|
void test_schedule_validation()
|
|
{
|
|
PageSchedule s = twoBlocks();
|
|
TEST_ASSERT_NULL(validateSchedule(s));
|
|
s.blocks[1].start = 7 * 60;
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s)); // same time
|
|
s = twoBlocks();
|
|
s.blocks[0].items[0].seconds = 4;
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s));
|
|
s = twoBlocks();
|
|
s.blocks[1].item_count = 0;
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s));
|
|
s = twoBlocks();
|
|
s.rule_count = 1;
|
|
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
|
|
TEST_ASSERT_NULL(validateSchedule(s));
|
|
s.rules[0].interval = 1;
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s));
|
|
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
|
|
strcpy(s.rules[0].entity, "printer");
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s));
|
|
s.rules[0] = rule("", TriggerMode::WhileTrue);
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s));
|
|
|
|
s = twoBlocks();
|
|
TEST_ASSERT_TRUE(scheduleUses(s, "datum"));
|
|
TEST_ASSERT_FALSE(scheduleUses(s, "Clock"));
|
|
}
|
|
|
|
void test_rule_comparisons()
|
|
{
|
|
TriggerRule r = rule("x", TriggerMode::WhileTrue);
|
|
TEST_ASSERT_TRUE(ruleMatches(r, "Printing"));
|
|
TEST_ASSERT_FALSE(ruleMatches(r, "standby"));
|
|
TEST_ASSERT_FALSE(ruleMatches(r, nullptr));
|
|
r.compare = Compare::NotEqual;
|
|
TEST_ASSERT_TRUE(ruleMatches(r, "standby"));
|
|
TEST_ASSERT_FALSE(ruleMatches(r, nullptr)); // unknown is never true
|
|
r.compare = Compare::Above;
|
|
strcpy(r.value, "1000");
|
|
TEST_ASSERT_TRUE(ruleMatches(r, "1257"));
|
|
TEST_ASSERT_FALSE(ruleMatches(r, "999.5"));
|
|
TEST_ASSERT_FALSE(ruleMatches(r, "unavailable"));
|
|
r.compare = Compare::Below;
|
|
TEST_ASSERT_TRUE(ruleMatches(r, "-3"));
|
|
}
|
|
|
|
void test_selector_without_schedule_shows_the_chosen_page()
|
|
{
|
|
PageSelector sel;
|
|
sel.setChosen("Clock");
|
|
PageChoice c = sel.update(600, 0);
|
|
TEST_ASSERT_EQUAL_STRING("Clock", c.page);
|
|
TEST_ASSERT_TRUE(c.changed);
|
|
TEST_ASSERT_FALSE(sel.update(601, 100000).changed);
|
|
sel.pick("Weer");
|
|
TEST_ASSERT_FALSE(sel.picked()); // no schedule: just the new chosen page
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(601, 100100).page);
|
|
}
|
|
|
|
void test_selector_rotates_the_block()
|
|
{
|
|
PageSchedule s = twoBlocks();
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.setChosen("Clock");
|
|
PageChoice c = sel.update(8 * 60, 0);
|
|
TEST_ASSERT_EQUAL_STRING("Weer", c.page);
|
|
TEST_ASSERT_TRUE(c.reason == ShowReason::Schedule);
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 19999).page);
|
|
c = sel.update(8 * 60, 20000);
|
|
TEST_ASSERT_EQUAL_STRING("Datum", c.page);
|
|
TEST_ASSERT_TRUE(c.changed);
|
|
TEST_ASSERT_FALSE(c.instant); // this block uses the effect
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60 + 1, 30000).page);
|
|
|
|
c = sel.update(17 * 60, 31000); // the next block
|
|
TEST_ASSERT_EQUAL_STRING("Energie", c.page);
|
|
TEST_ASSERT_TRUE(c.instant);
|
|
// Before 07:00: the last block of the day before
|
|
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(3 * 60, 40000).page);
|
|
}
|
|
|
|
void test_selector_manual_pick_until_the_next_block()
|
|
{
|
|
PageSchedule s = twoBlocks();
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.update(8 * 60, 0);
|
|
sel.pick("Grote klok");
|
|
TEST_ASSERT_TRUE(sel.picked());
|
|
TEST_ASSERT_EQUAL(17 * 60, sel.pickedUntil());
|
|
PageChoice c = sel.update(8 * 60, 1000);
|
|
TEST_ASSERT_EQUAL_STRING("Grote klok", c.page);
|
|
TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen);
|
|
TEST_ASSERT_EQUAL_STRING("Grote klok", sel.update(16 * 60 + 59, 900000).page);
|
|
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60, 901000).page);
|
|
TEST_ASSERT_FALSE(sel.picked());
|
|
|
|
sel.pick("Grote klok");
|
|
sel.backToSchedule();
|
|
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60 + 1, 902000).page);
|
|
}
|
|
|
|
void test_selector_one_block_starts_again_each_day()
|
|
{
|
|
PageSchedule s = twoBlocks();
|
|
s.block_count = 1;
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.update(8 * 60, 0);
|
|
sel.pick("Clock");
|
|
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(6 * 60 + 59, 1000).page); // next morning, before 07:00
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(7 * 60, 2000).page);
|
|
s.enabled = false; // switched off: the chosen page
|
|
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(7 * 60 + 1, 3000).page);
|
|
}
|
|
|
|
void test_selector_take_over_rules()
|
|
{
|
|
PageSchedule s = twoBlocks();
|
|
s.rule_count = 2;
|
|
s.rules[0] = rule("Deurbel", TriggerMode::ForSeconds, 30);
|
|
s.rules[1] = rule("3D printer", TriggerMode::WhileTrue);
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.update(8 * 60, 0);
|
|
sel.pick("Clock"); // a rule beats a manual pick
|
|
|
|
sel.setRuleState(1, true, 1000);
|
|
PageChoice c = sel.update(8 * 60, 1000);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", c.page);
|
|
TEST_ASSERT_TRUE(c.reason == ShowReason::Trigger);
|
|
TEST_ASSERT_EQUAL(1, c.rule);
|
|
TEST_ASSERT_TRUE(c.instant);
|
|
|
|
sel.setRuleState(0, true, 2000); // the first rule wins
|
|
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 2000).page);
|
|
sel.setRuleState(0, true, 20000); // still true: no restart
|
|
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 31999).page);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 32000).page); // 30 s over
|
|
sel.setRuleState(0, false, 33000);
|
|
sel.setRuleState(0, true, 34000); // true again: 30 s again
|
|
TEST_ASSERT_EQUAL_STRING("Deurbel", sel.update(8 * 60, 34000).page);
|
|
|
|
sel.setRuleState(0, false, 70000);
|
|
sel.setRuleState(1, false, 70000);
|
|
c = sel.update(8 * 60, 70000);
|
|
TEST_ASSERT_EQUAL_STRING("Clock", c.page); // the manual pick again
|
|
TEST_ASSERT_TRUE(c.instant); // a rule's page leaves at once
|
|
}
|
|
|
|
void test_selector_rule_can_leave_a_manual_pick_alone()
|
|
{
|
|
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
|
|
s.rule_count = 2;
|
|
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
|
|
s.rules[0].over_manual = false;
|
|
s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10);
|
|
s.rules[1].over_manual = false;
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.update(8 * 60, 0);
|
|
sel.setRuleState(0, true, 0);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 0).page); // no pick: it takes over
|
|
|
|
sel.pick("Clock");
|
|
PageChoice c = sel.update(8 * 60, 1000);
|
|
TEST_ASSERT_EQUAL_STRING("Clock", c.page); // waits while the pick lasts
|
|
TEST_ASSERT_TRUE(c.reason == ShowReason::Chosen);
|
|
sel.setRuleState(1, true, 1000);
|
|
for (uint32_t t = 1000; t < 120000; t += 5000)
|
|
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(8 * 60, t).page); // not in the rotation either
|
|
|
|
sel.backToSchedule();
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 121000).page);
|
|
|
|
// The next block ends a pick too
|
|
sel.pick("Clock");
|
|
TEST_ASSERT_EQUAL_STRING("Clock", sel.update(16 * 60, 122000).page);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 123000).page);
|
|
}
|
|
|
|
void test_selector_rules_only_in_their_blocks()
|
|
{
|
|
PageSchedule s = twoBlocks(); // 07:00 Weer + Datum, 17:00 Energie
|
|
s.rule_count = 2;
|
|
s.rules[0] = rule("3D printer", TriggerMode::WhileTrue);
|
|
s.rules[0].blocks = 1 << 1; // only from 17:00
|
|
s.rules[1] = rule("Deurbel", TriggerMode::AddToRotation, 10);
|
|
s.rules[1].blocks = 1 << 0; // only in the morning block
|
|
TEST_ASSERT_NULL(validateSchedule(s));
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.update(8 * 60, 0);
|
|
sel.setRuleState(0, true, 0);
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 1000).page); // not in this block
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(17 * 60, 2000).page);
|
|
sel.setRuleState(0, false, 3000);
|
|
sel.setRuleState(1, true, 3000);
|
|
sel.update(17 * 60, 3000);
|
|
TEST_ASSERT_EQUAL_STRING("Energie", sel.update(17 * 60, 40000).page); // not added here
|
|
|
|
// Without a running schedule the blocks don't matter
|
|
s.enabled = false;
|
|
sel.setRuleState(0, true, 50000);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel.update(8 * 60, 50000).page);
|
|
|
|
s.enabled = true;
|
|
s.rules[0].blocks = 1 << 2; // a third block doesn't exist
|
|
TEST_ASSERT_NOT_NULL(validateSchedule(s));
|
|
TEST_ASSERT_TRUE(ruleInBlock(s.rules[1], -1));
|
|
TEST_ASSERT_FALSE(ruleInBlock(s.rules[1], 1));
|
|
}
|
|
|
|
void test_selector_add_to_rotation()
|
|
{
|
|
PageSchedule s = twoBlocks();
|
|
s.rule_count = 1;
|
|
s.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15);
|
|
PageSelector sel;
|
|
sel.setSchedule(&s);
|
|
sel.update(8 * 60, 0);
|
|
sel.setRuleState(0, true, 0);
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 0).page);
|
|
TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 20000).page);
|
|
PageChoice c = sel.update(8 * 60, 30000);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", c.page);
|
|
TEST_ASSERT_TRUE(c.reason == ShowReason::Rotation);
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 45000).page);
|
|
sel.setRuleState(0, false, 46000);
|
|
TEST_ASSERT_EQUAL_STRING("Datum", sel.update(8 * 60, 65000).page);
|
|
TEST_ASSERT_EQUAL_STRING("Weer", sel.update(8 * 60, 75000).page); // printer gone
|
|
|
|
// Without a schedule the chosen page rotates with it
|
|
PageSchedule rules_only;
|
|
rules_only.rule_count = 1;
|
|
rules_only.rules[0] = rule("3D printer", TriggerMode::AddToRotation, 15);
|
|
PageSelector sel2;
|
|
sel2.setSchedule(&rules_only);
|
|
sel2.setChosen("Clock");
|
|
sel2.setRuleState(0, true, 0);
|
|
TEST_ASSERT_EQUAL_STRING("Clock", sel2.update(600, 0).page);
|
|
TEST_ASSERT_EQUAL_STRING("3D printer", sel2.update(600, CHOSEN_PAGE_SECONDS * 1000).page);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Home Assistant value conversions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static PageElement haElement(HaShow show, uint8_t format = 0)
|
|
{
|
|
PageElement e;
|
|
e.type = ElementType::HaValue;
|
|
strcpy(e.entity, "sensor.x");
|
|
e.show = show;
|
|
e.format = format;
|
|
return e;
|
|
}
|
|
|
|
void test_ha_auto_unit_and_absolute()
|
|
{
|
|
PageElement e = haElement(HaShow::Number);
|
|
e.auto_unit = true;
|
|
strcpy(e.suffix, " W");
|
|
TEST_ASSERT_EQUAL_STRING("1.3 kW", textOf(e, "1257").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("950 W", textOf(e, "950").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("-2.5 kW", textOf(e, "-2500").c_str());
|
|
e.absolute = true;
|
|
TEST_ASSERT_EQUAL_STRING("2.5 kW", textOf(e, "-2500").c_str());
|
|
strcpy(e.suffix, "Wh");
|
|
e.decimals = 2;
|
|
TEST_ASSERT_EQUAL_STRING("26.30kWh", textOf(e, "26300").c_str());
|
|
strcpy(e.suffix, "kWh");
|
|
e.decimals = DECIMALS_AS_IS;
|
|
TEST_ASSERT_EQUAL_STRING("18.6MWh", textOf(e, "18621.326").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("500.0Wh", textOf(e, "0.5").c_str()); // below 1: smaller unit
|
|
strcpy(e.suffix, "%"); // not a unit it knows
|
|
TEST_ASSERT_EQUAL_STRING("37%", textOf(e, "37").c_str());
|
|
}
|
|
|
|
void test_ha_duration()
|
|
{
|
|
PageElement e = haElement(HaShow::Duration, DURATION_PARTS);
|
|
TEST_ASSERT_EQUAL_STRING("2h 20m", textOf(e, "8452.2").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("1d 1h 1m", textOf(e, "90061").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("2d", textOf(e, "172800").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("45s", textOf(e, "45").c_str());
|
|
e.input_unit = UNIT_MINUTES;
|
|
TEST_ASSERT_EQUAL_STRING("1h 30m", textOf(e, "90").c_str());
|
|
e.input_unit = UNIT_HOURS;
|
|
e.format = DURATION_H_MM_SS;
|
|
TEST_ASSERT_EQUAL_STRING("26:30:00", textOf(e, "26.5").c_str());
|
|
e.format = DURATION_H_MM;
|
|
TEST_ASSERT_EQUAL_STRING("26:30", textOf(e, "26.5").c_str());
|
|
e.input_unit = UNIT_SECONDS;
|
|
e.format = DURATION_SMART;
|
|
TEST_ASSERT_EQUAL_STRING("2 uur", textOf(e, "8452").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("1 dag", textOf(e, "90000").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("34 min", textOf(e, "2040").c_str());
|
|
e.language = Language::English;
|
|
TEST_ASSERT_EQUAL_STRING("2 hours", textOf(e, "8452").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("--", textOf(e, "unknown").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("idle", textOf(e, "idle").c_str()); // not a number: as is
|
|
}
|
|
|
|
void test_ha_timestamps()
|
|
{
|
|
int64_t s = 0;
|
|
TEST_ASSERT_TRUE(parseTimestamp("2000-01-01T00:00:00+00:00", s));
|
|
TEST_ASSERT_EQUAL_INT64(0, s);
|
|
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T21:07:00+00:00", s));
|
|
int64_t a = s;
|
|
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T23:07:00+02:00", s));
|
|
TEST_ASSERT_EQUAL_INT64(a, s);
|
|
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15T21:07:00.515369Z", s));
|
|
TEST_ASSERT_EQUAL_INT64(a, s);
|
|
TEST_ASSERT_TRUE(parseTimestamp("2026-10-15 21:07:00", s));
|
|
TEST_ASSERT_EQUAL_INT64(a, s);
|
|
TEST_ASSERT_FALSE(parseTimestamp("21:07", s));
|
|
TEST_ASSERT_FALSE(parseTimestamp("2026-10-15T21:07:00+0200x", s));
|
|
|
|
// textOf: Thursday 15 October 2026 08:05:00 local; CEST is UTC+2
|
|
PageElement e = haElement(HaShow::Time, TIMESTAMP_H_MM);
|
|
auto at = [&](const char *value) {
|
|
LocalTime t;
|
|
t.year = 2026;
|
|
t.month = 10;
|
|
t.day = 15;
|
|
t.weekday = 4;
|
|
t.hour = 8;
|
|
t.minute = 5;
|
|
t.utc_offset = 120;
|
|
char out[64];
|
|
ElementValues values;
|
|
values.ha_value = value;
|
|
elementText(e, t, values, out, sizeof(out));
|
|
return std::string(out);
|
|
};
|
|
TEST_ASSERT_EQUAL_STRING("23:07", at("2026-10-15T21:07:00+00:00").c_str());
|
|
e.format = TIMESTAMP_WEEKDAY_H_MM;
|
|
TEST_ASSERT_EQUAL_STRING("vr 01:30", at("2026-10-15T23:30:00+00:00").c_str()); // the next day
|
|
e.format = TIMESTAMP_DATE_H_MM;
|
|
TEST_ASSERT_EQUAL_STRING("16-10 01:30", at("2026-10-15T23:30:00+00:00").c_str());
|
|
e.format = TIMESTAMP_RELATIVE;
|
|
TEST_ASSERT_EQUAL_STRING("over 34 min", at("2026-10-15T06:39:00+00:00").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("12 min geleden", at("2026-10-15T05:53:00+00:00").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("nu", at("2026-10-15T06:05:30+00:00").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("over 2 uur", at("2026-10-15T08:30:00+00:00").c_str());
|
|
e.language = Language::English;
|
|
TEST_ASSERT_EQUAL_STRING("3 days ago", at("2026-10-12T06:00:00+00:00").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("soon", at("soon").c_str()); // not a timestamp: as is
|
|
}
|
|
|
|
void test_ha_text_map()
|
|
{
|
|
PageElement e = haElement(HaShow::Map);
|
|
strcpy(e.map, "printing=Bezig, standby = Klaar ,paused=Pauze,=leeg");
|
|
strcpy(e.text, "3D ");
|
|
TEST_ASSERT_EQUAL_STRING("3D Bezig", textOf(e, "printing").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("3D Klaar", textOf(e, "Standby").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("3D Pauze", textOf(e, "paused").c_str());
|
|
TEST_ASSERT_EQUAL_STRING("3D error", textOf(e, "error").c_str()); // not in the list: as is
|
|
TEST_ASSERT_EQUAL_STRING("3D --", textOf(e, "unavailable").c_str());
|
|
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
page.elements[0] = e;
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
page.elements[0].show = HaShow::Count;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page.elements[0] = haElement(HaShow::Duration, DURATION_FORMAT_COUNT);
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
}
|
|
|
|
void test_loop_stats()
|
|
{
|
|
LoopStats stats;
|
|
TEST_ASSERT_EQUAL_UINT32(0, stats.loopsPerSecond());
|
|
// 995 passes of 10 ms (the micros() counter wraps on the way), one of them 50 ms
|
|
uint32_t t = 0xFFFFFFFFUL - 5000000UL;
|
|
stats.tick(t);
|
|
for (int i = 0; i < 995; i++)
|
|
{
|
|
t += i == 500 ? 50000 : 10000;
|
|
stats.tick(t);
|
|
}
|
|
TEST_ASSERT_EQUAL_UINT32(0, stats.loopsPerSecond()); // window not complete yet
|
|
t += 10000;
|
|
stats.tick(t);
|
|
TEST_ASSERT_EQUAL_UINT32(99, stats.loopsPerSecond());
|
|
TEST_ASSERT_EQUAL_UINT32(10040, stats.averageUs());
|
|
TEST_ASSERT_EQUAL_UINT32(50000, stats.maxUs());
|
|
TEST_ASSERT_EQUAL_UINT32(50000, stats.maxEverUs());
|
|
|
|
// Next window without the slow pass: the window max drops, the boot max stays
|
|
for (int i = 0; i < 1000; i++)
|
|
{
|
|
t += 10000;
|
|
stats.tick(t);
|
|
}
|
|
TEST_ASSERT_EQUAL_UINT32(100, stats.loopsPerSecond());
|
|
TEST_ASSERT_EQUAL_UINT32(10000, stats.maxUs());
|
|
TEST_ASSERT_EQUAL_UINT32(50000, stats.maxEverUs());
|
|
}
|
|
|
|
void test_frame_check_ok()
|
|
{
|
|
FrameCheck c;
|
|
c.reset(1000);
|
|
TEST_ASSERT_TRUE(c.due(5, 1000)); // first check right after switching on
|
|
c.sent(0x1234, 5, 1000);
|
|
TEST_ASSERT_FALSE(c.due(6, 5000)); // waiting for the answer
|
|
c.line("C 1234\r", 1010);
|
|
TEST_ASSERT_EQUAL_UINT32(1, c.stats().checks);
|
|
TEST_ASSERT_FALSE(c.resyncNow(1020, 0));
|
|
|
|
// The next one after a new frame, at most every FRAME_CHECK_INTERVAL
|
|
TEST_ASSERT_FALSE(c.due(5, 10000)); // no new frame
|
|
TEST_ASSERT_FALSE(c.due(6, 1000 + FRAME_CHECK_INTERVAL - 1));
|
|
TEST_ASSERT_TRUE(c.due(6, 1000 + FRAME_CHECK_INTERVAL));
|
|
}
|
|
|
|
void test_frame_check_mismatch_repairs()
|
|
{
|
|
FrameCheck c;
|
|
c.reset(0);
|
|
c.sent(0x1234, 1, 20000);
|
|
c.line("C 1235", 20010);
|
|
TEST_ASSERT_EQUAL_UINT32(1, c.stats().mismatches);
|
|
TEST_ASSERT_FALSE(c.due(2, 30000)); // the repair comes first
|
|
TEST_ASSERT_TRUE(c.resyncNow(20010, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(1, c.stats().repairs);
|
|
TEST_ASSERT_FALSE(c.resyncNow(20020, 0)); // once
|
|
TEST_ASSERT_TRUE(c.due(2, 30000)); // then the repaired frame is checked
|
|
}
|
|
|
|
void test_frame_check_repairs_at_most_every_gap()
|
|
{
|
|
FrameCheck c;
|
|
c.reset(0);
|
|
c.sent(1, 1, 100);
|
|
c.line("C 0002", 110);
|
|
TEST_ASSERT_FALSE(c.resyncNow(FRAME_REPAIR_MIN_GAP - 1, 0)); // switched on just now
|
|
TEST_ASSERT_TRUE(c.resyncNow(FRAME_REPAIR_MIN_GAP, 0));
|
|
}
|
|
|
|
void test_frame_check_no_reply()
|
|
{
|
|
FrameCheck c;
|
|
c.reset(0);
|
|
// Never answered (old Nano firmware, TX not wired): counted, but no repairs
|
|
c.sent(1, 1, 100);
|
|
TEST_ASSERT_FALSE(c.resyncNow(100 + FRAME_CHECK_TIMEOUT - 1, 0));
|
|
TEST_ASSERT_FALSE(c.resyncNow(100 + FRAME_CHECK_TIMEOUT + FRAME_REPAIR_MIN_GAP, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(1, c.stats().no_reply);
|
|
TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs);
|
|
|
|
// It answered before: a lost answer means lost bytes, repair
|
|
uint32_t t = 100000;
|
|
c.sent(1, 2, t);
|
|
c.line("C 0001", t + 5);
|
|
t += FRAME_CHECK_INTERVAL;
|
|
c.sent(1, 3, t);
|
|
TEST_ASSERT_TRUE(c.resyncNow(t + FRAME_CHECK_TIMEOUT, 0));
|
|
TEST_ASSERT_EQUAL_UINT32(2, c.stats().no_reply);
|
|
TEST_ASSERT_EQUAL_UINT32(1, c.stats().repairs);
|
|
// A late answer to the timed out check is ignored
|
|
c.line("C 0009", t + FRAME_CHECK_TIMEOUT + 10);
|
|
TEST_ASSERT_EQUAL_UINT32(0, c.stats().mismatches);
|
|
}
|
|
|
|
void test_frame_check_errors_and_restarts()
|
|
{
|
|
FrameCheck c;
|
|
c.reset(0);
|
|
c.line("ERR: Line too long", 10);
|
|
c.line("ERR: Invalid bitmap width (not dividable by 4) and a lot more text than fits", 20);
|
|
TEST_ASSERT_EQUAL_UINT32(2, c.stats().errors);
|
|
TEST_ASSERT_EQUAL_UINT32(sizeof(c.stats().last_error) - 1, strlen(c.stats().last_error));
|
|
TEST_ASSERT_EQUAL(0, strncmp(c.stats().last_error, "ERR: Invalid bitmap", 19));
|
|
TEST_ASSERT_FALSE(c.resyncNow(FRAME_REPAIR_MIN_GAP, 0)); // errors alone: the check decides
|
|
|
|
c.line("READY", 20000);
|
|
TEST_ASSERT_EQUAL_UINT32(1, c.stats().restarts);
|
|
TEST_ASSERT_TRUE(c.resyncNow(20000, 0));
|
|
c.line("garbage", 20010);
|
|
c.line("C 12", 20010);
|
|
TEST_ASSERT_EQUAL_UINT32(0, c.stats().checks + c.stats().mismatches);
|
|
}
|
|
|
|
void test_frame_check_periodic_resync()
|
|
{
|
|
FrameCheck c;
|
|
c.reset(1000);
|
|
TEST_ASSERT_FALSE(c.resyncNow(1000 + 600000 - 1, 600000));
|
|
TEST_ASSERT_TRUE(c.resyncNow(1000 + 600000, 600000));
|
|
TEST_ASSERT_FALSE(c.resyncNow(1000 + 600001, 600000));
|
|
TEST_ASSERT_FALSE(c.resyncNow(0xFFFFFFF0u, 0)); // 0: never
|
|
TEST_ASSERT_EQUAL_UINT32(0, c.stats().repairs); // not counted as a repair
|
|
}
|
|
|
|
static int countOn(const uint8_t *b)
|
|
{
|
|
int n = 0;
|
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
|
for (int x = 0; x < BITMAP_WIDTH; x++)
|
|
n += bitmapPixel(b, x, y);
|
|
return n;
|
|
}
|
|
|
|
static DynamicPage barPage()
|
|
{
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
PageElement &e = page.elements[0];
|
|
e.type = ElementType::Bar;
|
|
strcpy(e.entity, "sensor.{plex_user}_progress");
|
|
e.x = 10;
|
|
e.y = 14;
|
|
e.bar_width = 22; // 20 inside the frame
|
|
e.bar_height = 4;
|
|
return page;
|
|
}
|
|
|
|
void test_bar_fill()
|
|
{
|
|
PageElement e = barPage().elements[0];
|
|
TEST_ASSERT_EQUAL(0, barFill(e, nullptr, 20));
|
|
TEST_ASSERT_EQUAL(0, barFill(e, "unavailable", 20));
|
|
TEST_ASSERT_EQUAL(0, barFill(e, "-5", 20));
|
|
TEST_ASSERT_EQUAL(0, barFill(e, "0", 20));
|
|
TEST_ASSERT_EQUAL(10, barFill(e, "50", 20));
|
|
TEST_ASSERT_EQUAL(7, barFill(e, "33.3", 20));
|
|
TEST_ASSERT_EQUAL(20, barFill(e, "100", 20));
|
|
TEST_ASSERT_EQUAL(20, barFill(e, "250", 20));
|
|
e.bar_max = 1;
|
|
e.factor = 0.01f; // 0-100 as 0-1
|
|
TEST_ASSERT_EQUAL(5, barFill(e, "25", 20));
|
|
e.bar_max = 7200;
|
|
e.factor = 1;
|
|
TEST_ASSERT_EQUAL(10, barFill(e, "3600", 20)); // e.g. seconds of a 2 hour film
|
|
}
|
|
|
|
void test_bar_render()
|
|
{
|
|
FakeResources res;
|
|
DynamicPage page = barPage();
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
uint8_t b[BITMAP_BYTES];
|
|
ElementBox box;
|
|
res.value = "50";
|
|
renderPage(page, at(12, 0), 0, res, b, &box);
|
|
TEST_ASSERT_EQUAL(10, box.x);
|
|
TEST_ASSERT_EQUAL(14, box.y);
|
|
TEST_ASSERT_EQUAL(22, box.width);
|
|
TEST_ASSERT_EQUAL(4, box.height);
|
|
// Frame: 2 x 22 + 2 x 2 dots; inside 10 of 20 columns x 2 rows
|
|
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2 + 10 * 2, countOn(b));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 10, 14)); // corner
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 11, 15)); // first filled dot
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 20, 16)); // last filled dot
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 21, 16)); // empty half
|
|
|
|
res.value = nullptr; // unknown: only the frame
|
|
renderPage(page, at(12, 0), 0, res, b);
|
|
TEST_ASSERT_EQUAL(2 * 22 + 2 * 2, countOn(b));
|
|
|
|
// Without frame, right aligned, inverted over a full background
|
|
PageElement &e = page.elements[0];
|
|
e.bar_frame = false;
|
|
e.align = Align::Right;
|
|
e.x = 84;
|
|
e.overlay = Overlay::Invert;
|
|
page.slot_count = 1;
|
|
strcpy(page.slots[0].bitmap, "Full");
|
|
res.value = "100";
|
|
renderPage(page, at(12, 0), 0, res, b, &box);
|
|
TEST_ASSERT_EQUAL(62, box.x);
|
|
TEST_ASSERT_EQUAL(BITMAP_WIDTH * BITMAP_HEIGHT - 22 * 4, countOn(b));
|
|
TEST_ASSERT_FALSE(bitmapPixel(b, 83, 17));
|
|
TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17));
|
|
}
|
|
|
|
void test_date_days_ahead()
|
|
{
|
|
LocalTime t; // Thursday 31 December 2026
|
|
t.year = 2026;
|
|
t.month = 12;
|
|
t.day = 31;
|
|
t.weekday = 4;
|
|
LocalTime n = addDays(t, 1);
|
|
TEST_ASSERT_EQUAL(2027, n.year);
|
|
TEST_ASSERT_EQUAL(1, n.month);
|
|
TEST_ASSERT_EQUAL(1, n.day);
|
|
TEST_ASSERT_EQUAL(5, n.weekday);
|
|
t.year = 2028; // leap year: 28 February + 1 = 29 February
|
|
t.month = 2;
|
|
t.day = 28;
|
|
TEST_ASSERT_EQUAL(29, addDays(t, 1).day);
|
|
TEST_ASSERT_EQUAL(3, addDays(t, 2).month);
|
|
t.year = 2027;
|
|
TEST_ASSERT_EQUAL(1, addDays(t, 1).day);
|
|
|
|
DynamicPage page;
|
|
page.element_count = 1;
|
|
PageElement &e = page.elements[0];
|
|
e.type = ElementType::Date;
|
|
e.format = DATE_WEEKDAY_SHORT;
|
|
LocalTime thu;
|
|
thu.year = 2026;
|
|
thu.month = 10;
|
|
thu.day = 15;
|
|
thu.weekday = 4;
|
|
char text[32];
|
|
elementText(e, thu, ElementValues(), text, sizeof(text));
|
|
TEST_ASSERT_EQUAL_STRING("do", text);
|
|
e.day_offset = 3;
|
|
elementText(e, thu, ElementValues(), text, sizeof(text));
|
|
TEST_ASSERT_EQUAL_STRING("zo", text);
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
e.day_offset = 8;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
e.day_offset = -1;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
}
|
|
|
|
void test_attribute_paths_and_16_elements()
|
|
{
|
|
DynamicPage page;
|
|
page.element_count = PAGE_ELEMENTS;
|
|
TEST_ASSERT_EQUAL(16, PAGE_ELEMENTS);
|
|
for (int i = 0; i < PAGE_ELEMENTS; i++)
|
|
{
|
|
page.elements[i].type = ElementType::HaValue;
|
|
strcpy(page.elements[i].entity, "sensor.weer_verwachting");
|
|
strcpy(page.elements[i].attribute, "forecast.1.templow");
|
|
}
|
|
TEST_ASSERT_NULL(validatePage(page));
|
|
strcpy(page.elements[3].attribute, "forecast/1");
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page.elements[3].attribute[0] = '\0';
|
|
page.element_count = PAGE_ELEMENTS + 1;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
}
|
|
|
|
void test_bar_validation()
|
|
{
|
|
DynamicPage page = barPage();
|
|
page.elements[0].entity[0] = '\0';
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page = barPage();
|
|
page.elements[0].bar_width = 0;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page = barPage();
|
|
page.elements[0].bar_height = 20;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page = barPage();
|
|
page.elements[0].bar_max = 0;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
page = barPage();
|
|
page.elements[0].bar_max = NAN;
|
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
|
TEST_ASSERT_TRUE(elementUsesHa(barPage().elements[0]));
|
|
}
|
|
|
|
void test_plex_user_placeholder()
|
|
{
|
|
char out[ELEMENT_ENTITY_SIZE];
|
|
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}_state", "mangavalk", out, sizeof(out)));
|
|
TEST_ASSERT_EQUAL_STRING("sensor.mangavalk_state", out);
|
|
TEST_ASSERT_TRUE(expandEntity("sensor.tautulli_watching", "mangavalk", out, sizeof(out)));
|
|
TEST_ASSERT_EQUAL_STRING("sensor.tautulli_watching", out);
|
|
// No user set: not a valid entity id, so nothing is read
|
|
TEST_ASSERT_TRUE(expandEntity("sensor.{plex_user}", "", out, sizeof(out)));
|
|
TEST_ASSERT_FALSE(validEntityId(out));
|
|
// Too long once filled in
|
|
char user[PLEX_USER_SIZE];
|
|
memset(user, 'a', sizeof(user) - 1);
|
|
user[sizeof(user) - 1] = '\0';
|
|
TEST_ASSERT_FALSE(expandEntity("sensor.{plex_user}_a_long_name_that_is_too_long", user, out, sizeof(out)));
|
|
|
|
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}_state"));
|
|
TEST_ASSERT_TRUE(validEntityId("sensor.{plex_user}"));
|
|
TEST_ASSERT_FALSE(validEntityId("sensor.{plex_user}{plex_user}"));
|
|
TEST_ASSERT_FALSE(validEntityId("sensor.{other}_state"));
|
|
TEST_ASSERT_FALSE(validEntityId("{plex_user}")); // no domain
|
|
|
|
plexUserSlug("MangaValk", user, sizeof(user));
|
|
TEST_ASSERT_EQUAL_STRING("mangavalk", user);
|
|
plexUserSlug(" Manga Valk-2 ", user, sizeof(user));
|
|
TEST_ASSERT_EQUAL_STRING("manga_valk_2", user);
|
|
plexUserSlug("", user, sizeof(user));
|
|
TEST_ASSERT_EQUAL_STRING("", user);
|
|
char small[5];
|
|
plexUserSlug("ab cd ef", small, sizeof(small));
|
|
TEST_ASSERT_EQUAL_STRING("ab_c", small);
|
|
}
|
|
|
|
int main()
|
|
{
|
|
UNITY_BEGIN();
|
|
|
|
RUN_TEST(test_schedule_night_until_8);
|
|
RUN_TEST(test_schedule_clock_from_8_to_22);
|
|
RUN_TEST(test_schedule_quarter_hours_until_23_30);
|
|
RUN_TEST(test_schedule_night_from_23_31);
|
|
|
|
RUN_TEST(test_timer_expires_once_after_delay);
|
|
RUN_TEST(test_timer_keeps_earlier_deadline);
|
|
RUN_TEST(test_timer_cancel);
|
|
RUN_TEST(test_timer_across_millis_overflow);
|
|
|
|
RUN_TEST(test_power_on_starts_normally_and_clears_crashes);
|
|
RUN_TEST(test_software_reset_starts_normally_and_keeps_count);
|
|
RUN_TEST(test_other_reset_starts_normally_and_keeps_count);
|
|
RUN_TEST(test_first_crashes_give_a_panic_window);
|
|
RUN_TEST(test_repeated_crashes_give_safe_mode);
|
|
RUN_TEST(test_after_safe_mode_one_more_crash_gives_safe_mode_again);
|
|
RUN_TEST(test_crash_count_does_not_overflow);
|
|
RUN_TEST(test_new_firmware_needs_time_and_wifi_to_be_accepted);
|
|
RUN_TEST(test_crash_count_cleared_after_running_long_enough);
|
|
|
|
RUN_TEST(test_quick_power_cycles_request_recovery);
|
|
RUN_TEST(test_recovery_for_wifi_starts_clock_when_wifi_works);
|
|
RUN_TEST(test_recovery_for_other_reasons_waits_recovery_time);
|
|
RUN_TEST(test_recovery_never_starts_missing_clock_firmware);
|
|
RUN_TEST(test_every_recovery_reason_has_a_text);
|
|
|
|
RUN_TEST(test_led_off_while_display_off);
|
|
RUN_TEST(test_led_blinks_slow_with_wifi_and_fast_without);
|
|
|
|
RUN_TEST(test_nothing_happens_until_time_is_synced);
|
|
RUN_TEST(test_daytime_powers_on_and_shows_clock);
|
|
RUN_TEST(test_clock_is_only_sent_when_the_minute_changes);
|
|
RUN_TEST(test_night_clears_then_powers_off_after_delay);
|
|
RUN_TEST(test_night_does_not_power_on);
|
|
RUN_TEST(test_standby_keeps_the_last_time_and_powers_off);
|
|
RUN_TEST(test_quarter_hour_powers_on_again_and_redraws);
|
|
RUN_TEST(test_clock_cancels_pending_power_off);
|
|
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
|
|
RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect);
|
|
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
|
|
RUN_TEST(test_power_switch_on_again_shows_clock);
|
|
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
|
|
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);
|
|
RUN_TEST(test_no_wifi_screen_keeps_the_time_running);
|
|
RUN_TEST(test_clock_returns_when_wifi_is_back);
|
|
RUN_TEST(test_short_wifi_drop_does_not_change_the_display);
|
|
RUN_TEST(test_night_without_wifi_still_powers_off);
|
|
|
|
RUN_TEST(test_default_settings_are_valid);
|
|
RUN_TEST(test_settings_times_must_be_in_order);
|
|
RUN_TEST(test_settings_drive_ranges);
|
|
RUN_TEST(test_effect_time_is_zero_when_instant);
|
|
RUN_TEST(test_settings_off_page_blank_or_bitmap_name);
|
|
RUN_TEST(test_changed_off_image_is_redrawn_while_the_display_is_on);
|
|
RUN_TEST(test_settings_flip_time_and_port_ranges);
|
|
RUN_TEST(test_settings_page_must_be_a_name);
|
|
RUN_TEST(test_time_parse_and_format);
|
|
RUN_TEST(test_bitmap_is_84_by_19_in_drawbitmap_format);
|
|
RUN_TEST(test_bitmap_hex_round_trip);
|
|
RUN_TEST(test_bitmap_hex_rejects_wrong_input);
|
|
RUN_TEST(test_bitmap_names);
|
|
RUN_TEST(test_schedule_disabled_is_always_on);
|
|
RUN_TEST(test_schedule_custom_times);
|
|
RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night);
|
|
RUN_TEST(test_bitmap_page_is_shown_once);
|
|
RUN_TEST(test_bitmap_page_redrawn_when_changed);
|
|
RUN_TEST(test_switching_back_to_the_clock_page);
|
|
RUN_TEST(test_bitmap_page_follows_the_schedule);
|
|
RUN_TEST(test_dynamic_page_redrawn_when_its_content_changes);
|
|
RUN_TEST(test_settings_change_redraws);
|
|
RUN_TEST(test_live_is_shown_right_away_even_at_night);
|
|
RUN_TEST(test_live_works_before_the_time_is_synced);
|
|
RUN_TEST(test_each_live_image_is_drawn);
|
|
RUN_TEST(test_stop_live_returns_to_the_page);
|
|
RUN_TEST(test_live_ends_after_timeout);
|
|
RUN_TEST(test_live_respects_the_power_switch);
|
|
|
|
RUN_TEST(test_builtin_fonts_are_valid);
|
|
RUN_TEST(test_font_sizes);
|
|
RUN_TEST(test_draw_text_uses_the_classic_glyphs);
|
|
RUN_TEST(test_draw_text_scaled_and_inverted);
|
|
RUN_TEST(test_draw_text_is_clipped);
|
|
RUN_TEST(test_invalid_fonts_are_rejected);
|
|
RUN_TEST(test_active_slot_follows_the_time);
|
|
RUN_TEST(test_page_validation);
|
|
RUN_TEST(test_clock_and_date_texts);
|
|
RUN_TEST(test_home_assistant_value_texts);
|
|
RUN_TEST(test_render_background_follows_the_slots);
|
|
RUN_TEST(test_render_overlays);
|
|
RUN_TEST(test_render_alignment_and_boxes);
|
|
RUN_TEST(test_render_unknown_font_uses_normal);
|
|
RUN_TEST(test_builtin_clock_page_is_the_old_clock);
|
|
RUN_TEST(test_default_page_is_the_builtin_clock);
|
|
RUN_TEST(test_clock_with_seconds);
|
|
RUN_TEST(test_countdown_target_parse_and_format);
|
|
RUN_TEST(test_countdown_formats);
|
|
RUN_TEST(test_countdown_shows_seconds_only_when_needed);
|
|
RUN_TEST(test_stopwatch);
|
|
RUN_TEST(test_home_assistant_multiplier);
|
|
RUN_TEST(test_degree_sign_in_the_fonts);
|
|
RUN_TEST(test_scrolling_text_moves_in_its_window);
|
|
RUN_TEST(test_short_text_in_a_window_does_not_scroll);
|
|
RUN_TEST(test_render_asks_for_the_next_second);
|
|
RUN_TEST(test_image_element_by_state);
|
|
RUN_TEST(test_icon_pixels_and_hex);
|
|
RUN_TEST(test_icon_from_bitmap_keeps_its_dots_in_place);
|
|
RUN_TEST(test_page_uses_image);
|
|
RUN_TEST(test_moving_page_is_redrawn_when_due);
|
|
RUN_TEST(test_schedule_validation);
|
|
RUN_TEST(test_rule_comparisons);
|
|
RUN_TEST(test_selector_without_schedule_shows_the_chosen_page);
|
|
RUN_TEST(test_selector_rotates_the_block);
|
|
RUN_TEST(test_selector_manual_pick_until_the_next_block);
|
|
RUN_TEST(test_selector_one_block_starts_again_each_day);
|
|
RUN_TEST(test_selector_take_over_rules);
|
|
RUN_TEST(test_selector_rules_only_in_their_blocks);
|
|
RUN_TEST(test_selector_rule_can_leave_a_manual_pick_alone);
|
|
RUN_TEST(test_selector_add_to_rotation);
|
|
RUN_TEST(test_ha_auto_unit_and_absolute);
|
|
RUN_TEST(test_ha_duration);
|
|
RUN_TEST(test_ha_timestamps);
|
|
RUN_TEST(test_ha_text_map);
|
|
RUN_TEST(test_loop_stats);
|
|
RUN_TEST(test_frame_check_ok);
|
|
RUN_TEST(test_frame_check_mismatch_repairs);
|
|
RUN_TEST(test_frame_check_repairs_at_most_every_gap);
|
|
RUN_TEST(test_frame_check_no_reply);
|
|
RUN_TEST(test_frame_check_errors_and_restarts);
|
|
RUN_TEST(test_frame_check_periodic_resync);
|
|
RUN_TEST(test_bar_fill);
|
|
RUN_TEST(test_bar_render);
|
|
RUN_TEST(test_bar_validation);
|
|
RUN_TEST(test_date_days_ahead);
|
|
RUN_TEST(test_attribute_paths_and_16_elements);
|
|
RUN_TEST(test_plex_user_placeholder);
|
|
RUN_TEST(test_slow_scrolling_below_one_dot_per_second);
|
|
|
|
return UNITY_END();
|
|
}
|