mirror of
https://github.com/esphome/esphome.git
synced 2026-02-25 04:45:29 -07:00
Move tests that use make_us_central(), set_global_tz(), ParsedTimezone, and DSTRuleType into esphome::time::testing namespace where those symbols are declared.
831 lines
26 KiB
C++
831 lines
26 KiB
C++
// Tests for time conversion functions, DST detection, and ESPTime::strptime.
|
|
//
|
|
// These tests cover the permanent timezone functions: epoch_to_local_tm, is_in_dst,
|
|
// calculate_dst_transition, and the internal helper functions they depend on.
|
|
|
|
// Enable USE_TIME_TIMEZONE for tests
|
|
#define USE_TIME_TIMEZONE
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include "esphome/components/time/posix_tz.h"
|
|
#include "esphome/core/time.h"
|
|
|
|
namespace esphome::time::testing {
|
|
|
|
// Helper to create UTC epoch from date/time components (for test readability)
|
|
static time_t make_utc(int year, int month, int day, int hour = 0, int min = 0, int sec = 0) {
|
|
int64_t days = 0;
|
|
for (int y = 1970; y < year; y++) {
|
|
days += (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 366 : 365;
|
|
}
|
|
static const int DAYS_BEFORE[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
|
|
days += DAYS_BEFORE[month - 1];
|
|
if (month > 2 && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)))
|
|
days++; // Leap year adjustment
|
|
days += day - 1;
|
|
return days * 86400 + hour * 3600 + min * 60 + sec;
|
|
}
|
|
|
|
// Helper to build a US Eastern timezone (EST5EDT,M3.2.0/2,M11.1.0/2)
|
|
static ParsedTimezone make_us_eastern() {
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = 5 * 3600;
|
|
tz.dst_offset_seconds = 4 * 3600;
|
|
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_start.month = 3;
|
|
tz.dst_start.week = 2;
|
|
tz.dst_start.day_of_week = 0;
|
|
tz.dst_start.time_seconds = 2 * 3600;
|
|
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_end.month = 11;
|
|
tz.dst_end.week = 1;
|
|
tz.dst_end.day_of_week = 0;
|
|
tz.dst_end.time_seconds = 2 * 3600;
|
|
return tz;
|
|
}
|
|
|
|
// Helper to build a US Central timezone (CST6CDT,M3.2.0,M11.1.0)
|
|
static ParsedTimezone make_us_central() {
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = 6 * 3600;
|
|
tz.dst_offset_seconds = 5 * 3600;
|
|
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_start.month = 3;
|
|
tz.dst_start.week = 2;
|
|
tz.dst_start.day_of_week = 0;
|
|
tz.dst_start.time_seconds = 2 * 3600;
|
|
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_end.month = 11;
|
|
tz.dst_end.week = 1;
|
|
tz.dst_end.day_of_week = 0;
|
|
tz.dst_end.time_seconds = 2 * 3600;
|
|
return tz;
|
|
}
|
|
|
|
// Helper to build New Zealand timezone (NZST-12NZDT,M9.5.0,M4.1.0/3)
|
|
static ParsedTimezone make_new_zealand() {
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = -12 * 3600;
|
|
tz.dst_offset_seconds = -13 * 3600;
|
|
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_start.month = 9;
|
|
tz.dst_start.week = 5;
|
|
tz.dst_start.day_of_week = 0;
|
|
tz.dst_start.time_seconds = 2 * 3600;
|
|
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_end.month = 4;
|
|
tz.dst_end.week = 1;
|
|
tz.dst_end.day_of_week = 0;
|
|
tz.dst_end.time_seconds = 3 * 3600;
|
|
return tz;
|
|
}
|
|
|
|
// Helper to build Australia/Sydney timezone (AEST-10AEDT,M10.1.0,M4.1.0)
|
|
static ParsedTimezone make_australia_sydney() {
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = -10 * 3600;
|
|
tz.dst_offset_seconds = -11 * 3600;
|
|
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_start.month = 10;
|
|
tz.dst_start.week = 1;
|
|
tz.dst_start.day_of_week = 0;
|
|
tz.dst_start.time_seconds = 2 * 3600;
|
|
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_end.month = 4;
|
|
tz.dst_end.week = 1;
|
|
tz.dst_end.day_of_week = 0;
|
|
tz.dst_end.time_seconds = 3 * 3600;
|
|
return tz;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper function tests
|
|
// ============================================================================
|
|
|
|
TEST(PosixTz, JulianDay60IsMarch1) {
|
|
// J60 is always March 1 (J format ignores leap years by design)
|
|
int month, day;
|
|
internal::julian_to_month_day(60, month, day);
|
|
EXPECT_EQ(month, 3);
|
|
EXPECT_EQ(day, 1);
|
|
}
|
|
|
|
TEST(PosixTz, DayOfYear59DiffersByLeap) {
|
|
int month, day;
|
|
// Day 59 in leap year is Feb 29
|
|
internal::day_of_year_to_month_day(59, 2024, month, day);
|
|
EXPECT_EQ(month, 2);
|
|
EXPECT_EQ(day, 29);
|
|
// Day 59 in non-leap year is March 1
|
|
internal::day_of_year_to_month_day(59, 2025, month, day);
|
|
EXPECT_EQ(month, 3);
|
|
EXPECT_EQ(day, 1);
|
|
}
|
|
|
|
TEST(PosixTz, DayOfWeekKnownDates) {
|
|
// January 1, 1970 was Thursday (4)
|
|
EXPECT_EQ(internal::day_of_week(1970, 1, 1), 4);
|
|
// January 1, 2000 was Saturday (6)
|
|
EXPECT_EQ(internal::day_of_week(2000, 1, 1), 6);
|
|
// March 8, 2026 is Sunday (0) - US DST start
|
|
EXPECT_EQ(internal::day_of_week(2026, 3, 8), 0);
|
|
}
|
|
|
|
TEST(PosixTz, LeapYearDetection) {
|
|
EXPECT_FALSE(internal::is_leap_year(1900)); // Divisible by 100 but not 400
|
|
EXPECT_TRUE(internal::is_leap_year(2000)); // Divisible by 400
|
|
EXPECT_TRUE(internal::is_leap_year(2024)); // Divisible by 4
|
|
EXPECT_FALSE(internal::is_leap_year(2025)); // Not divisible by 4
|
|
}
|
|
|
|
TEST(PosixTz, JulianDay1IsJan1) {
|
|
int month, day;
|
|
internal::julian_to_month_day(1, month, day);
|
|
EXPECT_EQ(month, 1);
|
|
EXPECT_EQ(day, 1);
|
|
}
|
|
|
|
TEST(PosixTz, JulianDay31IsJan31) {
|
|
int month, day;
|
|
internal::julian_to_month_day(31, month, day);
|
|
EXPECT_EQ(month, 1);
|
|
EXPECT_EQ(day, 31);
|
|
}
|
|
|
|
TEST(PosixTz, JulianDay32IsFeb1) {
|
|
int month, day;
|
|
internal::julian_to_month_day(32, month, day);
|
|
EXPECT_EQ(month, 2);
|
|
EXPECT_EQ(day, 1);
|
|
}
|
|
|
|
TEST(PosixTz, JulianDay59IsFeb28) {
|
|
int month, day;
|
|
internal::julian_to_month_day(59, month, day);
|
|
EXPECT_EQ(month, 2);
|
|
EXPECT_EQ(day, 28);
|
|
}
|
|
|
|
TEST(PosixTz, JulianDay365IsDec31) {
|
|
int month, day;
|
|
internal::julian_to_month_day(365, month, day);
|
|
EXPECT_EQ(month, 12);
|
|
EXPECT_EQ(day, 31);
|
|
}
|
|
|
|
TEST(PosixTz, DayOfYear0IsJan1) {
|
|
int month, day;
|
|
internal::day_of_year_to_month_day(0, 2025, month, day);
|
|
EXPECT_EQ(month, 1);
|
|
EXPECT_EQ(day, 1);
|
|
}
|
|
|
|
TEST(PosixTz, DaysInMonthRegular) {
|
|
// Test all 12 months to ensure switch coverage
|
|
EXPECT_EQ(internal::days_in_month(2025, 1), 31); // Jan - default case
|
|
EXPECT_EQ(internal::days_in_month(2025, 2), 28); // Feb - case 2
|
|
EXPECT_EQ(internal::days_in_month(2025, 3), 31); // Mar - default case
|
|
EXPECT_EQ(internal::days_in_month(2025, 4), 30); // Apr - case 4
|
|
EXPECT_EQ(internal::days_in_month(2025, 5), 31); // May - default case
|
|
EXPECT_EQ(internal::days_in_month(2025, 6), 30); // Jun - case 6
|
|
EXPECT_EQ(internal::days_in_month(2025, 7), 31); // Jul - default case
|
|
EXPECT_EQ(internal::days_in_month(2025, 8), 31); // Aug - default case
|
|
EXPECT_EQ(internal::days_in_month(2025, 9), 30); // Sep - case 9
|
|
EXPECT_EQ(internal::days_in_month(2025, 10), 31); // Oct - default case
|
|
EXPECT_EQ(internal::days_in_month(2025, 11), 30); // Nov - case 11
|
|
EXPECT_EQ(internal::days_in_month(2025, 12), 31); // Dec - default case
|
|
}
|
|
|
|
TEST(PosixTz, DaysInMonthLeapYear) {
|
|
EXPECT_EQ(internal::days_in_month(2024, 2), 29);
|
|
EXPECT_EQ(internal::days_in_month(2025, 2), 28);
|
|
}
|
|
|
|
TEST(PosixTz, PlainDay365LeapYear) {
|
|
int month, day;
|
|
internal::day_of_year_to_month_day(365, 2024, month, day);
|
|
EXPECT_EQ(month, 12);
|
|
EXPECT_EQ(day, 31);
|
|
}
|
|
|
|
TEST(PosixTz, PlainDay364NonLeapYear) {
|
|
int month, day;
|
|
internal::day_of_year_to_month_day(364, 2025, month, day);
|
|
EXPECT_EQ(month, 12);
|
|
EXPECT_EQ(day, 31);
|
|
}
|
|
|
|
// ============================================================================
|
|
// DST transition calculation tests
|
|
// ============================================================================
|
|
|
|
TEST(PosixTz, DstStartUSEastern2026) {
|
|
// March 8, 2026 is 2nd Sunday of March
|
|
auto tz = make_us_eastern();
|
|
|
|
time_t dst_start = internal::calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds);
|
|
struct tm tm;
|
|
internal::epoch_to_tm_utc(dst_start, &tm);
|
|
|
|
// At 2:00 AM EST (UTC-5), so 7:00 AM UTC
|
|
EXPECT_EQ(tm.tm_year + 1900, 2026);
|
|
EXPECT_EQ(tm.tm_mon + 1, 3); // March
|
|
EXPECT_EQ(tm.tm_mday, 8); // 8th
|
|
EXPECT_EQ(tm.tm_hour, 7); // 7:00 UTC = 2:00 EST
|
|
}
|
|
|
|
TEST(PosixTz, DstEndUSEastern2026) {
|
|
// November 1, 2026 is 1st Sunday of November
|
|
auto tz = make_us_eastern();
|
|
|
|
time_t dst_end = internal::calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds);
|
|
struct tm tm;
|
|
internal::epoch_to_tm_utc(dst_end, &tm);
|
|
|
|
// At 2:00 AM EDT (UTC-4), so 6:00 AM UTC
|
|
EXPECT_EQ(tm.tm_year + 1900, 2026);
|
|
EXPECT_EQ(tm.tm_mon + 1, 11); // November
|
|
EXPECT_EQ(tm.tm_mday, 1); // 1st
|
|
EXPECT_EQ(tm.tm_hour, 6); // 6:00 UTC = 2:00 EDT
|
|
}
|
|
|
|
TEST(PosixTz, LastSundayOfMarch2026) {
|
|
// Europe: M3.5.0 = last Sunday of March = March 29, 2026
|
|
DSTRule rule{};
|
|
rule.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
rule.month = 3;
|
|
rule.week = 5;
|
|
rule.day_of_week = 0;
|
|
rule.time_seconds = 2 * 3600;
|
|
time_t transition = internal::calculate_dst_transition(2026, rule, 0);
|
|
struct tm tm;
|
|
internal::epoch_to_tm_utc(transition, &tm);
|
|
EXPECT_EQ(tm.tm_mday, 29);
|
|
EXPECT_EQ(tm.tm_wday, 0); // Sunday
|
|
}
|
|
|
|
TEST(PosixTz, LastSundayOfOctober2026) {
|
|
// Europe: M10.5.0 = last Sunday of October = October 25, 2026
|
|
DSTRule rule{};
|
|
rule.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
rule.month = 10;
|
|
rule.week = 5;
|
|
rule.day_of_week = 0;
|
|
rule.time_seconds = 3 * 3600;
|
|
time_t transition = internal::calculate_dst_transition(2026, rule, 0);
|
|
struct tm tm;
|
|
internal::epoch_to_tm_utc(transition, &tm);
|
|
EXPECT_EQ(tm.tm_mday, 25);
|
|
EXPECT_EQ(tm.tm_wday, 0); // Sunday
|
|
}
|
|
|
|
TEST(PosixTz, FirstSundayOfApril2026) {
|
|
// April 5, 2026 is 1st Sunday
|
|
DSTRule rule{};
|
|
rule.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
rule.month = 4;
|
|
rule.week = 1;
|
|
rule.day_of_week = 0;
|
|
rule.time_seconds = 0;
|
|
time_t transition = internal::calculate_dst_transition(2026, rule, 0);
|
|
struct tm tm;
|
|
internal::epoch_to_tm_utc(transition, &tm);
|
|
EXPECT_EQ(tm.tm_mday, 5);
|
|
EXPECT_EQ(tm.tm_wday, 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// DST detection tests
|
|
// ============================================================================
|
|
|
|
TEST(PosixTz, IsInDstUSEasternSummer) {
|
|
auto tz = make_us_eastern();
|
|
// July 4, 2026 12:00 UTC - definitely in DST
|
|
time_t summer = make_utc(2026, 7, 4, 12);
|
|
EXPECT_TRUE(is_in_dst(summer, tz));
|
|
}
|
|
|
|
TEST(PosixTz, IsInDstUSEasternWinter) {
|
|
auto tz = make_us_eastern();
|
|
// January 15, 2026 12:00 UTC - definitely not in DST
|
|
time_t winter = make_utc(2026, 1, 15, 12);
|
|
EXPECT_FALSE(is_in_dst(winter, tz));
|
|
}
|
|
|
|
TEST(PosixTz, IsInDstNoDstTimezone) {
|
|
// India: IST-5:30 (no DST)
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = -(5 * 3600 + 30 * 60);
|
|
// No DST rules
|
|
|
|
time_t epoch = make_utc(2026, 7, 15, 12);
|
|
EXPECT_FALSE(is_in_dst(epoch, tz));
|
|
}
|
|
|
|
TEST(PosixTz, SouthernHemisphereDstSummer) {
|
|
auto tz = make_new_zealand();
|
|
// December 15, 2025 12:00 UTC - summer in NZ, should be in DST
|
|
time_t nz_summer = make_utc(2025, 12, 15, 12);
|
|
EXPECT_TRUE(is_in_dst(nz_summer, tz));
|
|
}
|
|
|
|
TEST(PosixTz, SouthernHemisphereDstWinter) {
|
|
auto tz = make_new_zealand();
|
|
// July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST
|
|
time_t nz_winter = make_utc(2026, 7, 15, 12);
|
|
EXPECT_FALSE(is_in_dst(nz_winter, tz));
|
|
}
|
|
|
|
// ============================================================================
|
|
// epoch_to_local_tm tests
|
|
// ============================================================================
|
|
|
|
TEST(PosixTz, EpochToLocalBasic) {
|
|
ParsedTimezone tz{}; // UTC
|
|
|
|
time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC
|
|
struct tm local;
|
|
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
|
|
EXPECT_EQ(local.tm_year, 70);
|
|
EXPECT_EQ(local.tm_mon, 0);
|
|
EXPECT_EQ(local.tm_mday, 1);
|
|
EXPECT_EQ(local.tm_hour, 0);
|
|
}
|
|
|
|
TEST(PosixTz, EpochToLocalNegativeEpoch) {
|
|
ParsedTimezone tz{}; // UTC
|
|
|
|
// Dec 31, 1969 23:59:59 UTC (1 second before epoch)
|
|
time_t epoch = -1;
|
|
struct tm local;
|
|
ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local));
|
|
EXPECT_EQ(local.tm_year, 69); // 1969
|
|
EXPECT_EQ(local.tm_mon, 11); // December
|
|
EXPECT_EQ(local.tm_mday, 31);
|
|
EXPECT_EQ(local.tm_hour, 23);
|
|
EXPECT_EQ(local.tm_min, 59);
|
|
EXPECT_EQ(local.tm_sec, 59);
|
|
}
|
|
|
|
TEST(PosixTz, EpochToLocalNullTmFails) {
|
|
ParsedTimezone tz{};
|
|
EXPECT_FALSE(epoch_to_local_tm(0, tz, nullptr));
|
|
}
|
|
|
|
TEST(PosixTz, EpochToLocalWithOffset) {
|
|
// EST5 (UTC-5, no DST)
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = 5 * 3600;
|
|
|
|
// Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST
|
|
time_t utc_epoch = make_utc(2026, 1, 1, 5);
|
|
|
|
struct tm local;
|
|
ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local));
|
|
EXPECT_EQ(local.tm_hour, 0); // Midnight EST
|
|
EXPECT_EQ(local.tm_mday, 1);
|
|
EXPECT_EQ(local.tm_isdst, 0);
|
|
}
|
|
|
|
TEST(PosixTz, EpochToLocalDstTransition) {
|
|
auto tz = make_us_eastern();
|
|
|
|
// July 4, 2026 16:00 UTC = 12:00 EDT (noon)
|
|
time_t utc_epoch = make_utc(2026, 7, 4, 16);
|
|
|
|
struct tm local;
|
|
ASSERT_TRUE(epoch_to_local_tm(utc_epoch, tz, &local));
|
|
EXPECT_EQ(local.tm_hour, 12); // Noon EDT
|
|
EXPECT_EQ(local.tm_isdst, 1);
|
|
}
|
|
|
|
// ============================================================================
|
|
// DST boundary edge cases
|
|
// ============================================================================
|
|
|
|
TEST(PosixTz, DstBoundaryJustBeforeSpringForward) {
|
|
auto tz = make_us_eastern();
|
|
|
|
// March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward)
|
|
time_t before_epoch = make_utc(2026, 3, 8, 6, 59, 59);
|
|
EXPECT_FALSE(is_in_dst(before_epoch, tz));
|
|
|
|
// March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started)
|
|
time_t after_epoch = make_utc(2026, 3, 8, 7);
|
|
EXPECT_TRUE(is_in_dst(after_epoch, tz));
|
|
}
|
|
|
|
TEST(PosixTz, DstBoundaryJustBeforeFallBack) {
|
|
auto tz = make_us_eastern();
|
|
|
|
// November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back)
|
|
time_t before_epoch = make_utc(2026, 11, 1, 5, 59, 59);
|
|
EXPECT_TRUE(is_in_dst(before_epoch, tz));
|
|
|
|
// November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended)
|
|
time_t after_epoch = make_utc(2026, 11, 1, 6);
|
|
EXPECT_FALSE(is_in_dst(after_epoch, tz));
|
|
}
|
|
|
|
} // namespace esphome::time::testing
|
|
|
|
// ============================================================================
|
|
// ESPTime::strptime tests (replaces sscanf-based parsing)
|
|
// ============================================================================
|
|
|
|
namespace esphome::testing {
|
|
|
|
TEST(ESPTimeStrptime, FullDateTime) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("2026-03-15 14:30:45", 19, t));
|
|
EXPECT_EQ(t.year, 2026);
|
|
EXPECT_EQ(t.month, 3);
|
|
EXPECT_EQ(t.day_of_month, 15);
|
|
EXPECT_EQ(t.hour, 14);
|
|
EXPECT_EQ(t.minute, 30);
|
|
EXPECT_EQ(t.second, 45);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, DateTimeNoSeconds) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("2026-03-15 14:30", 16, t));
|
|
EXPECT_EQ(t.year, 2026);
|
|
EXPECT_EQ(t.month, 3);
|
|
EXPECT_EQ(t.day_of_month, 15);
|
|
EXPECT_EQ(t.hour, 14);
|
|
EXPECT_EQ(t.minute, 30);
|
|
EXPECT_EQ(t.second, 0);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, DateOnly) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("2026-03-15", 10, t));
|
|
EXPECT_EQ(t.year, 2026);
|
|
EXPECT_EQ(t.month, 3);
|
|
EXPECT_EQ(t.day_of_month, 15);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, TimeWithSeconds) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("14:30:45", 8, t));
|
|
EXPECT_EQ(t.hour, 14);
|
|
EXPECT_EQ(t.minute, 30);
|
|
EXPECT_EQ(t.second, 45);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, TimeNoSeconds) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("14:30", 5, t));
|
|
EXPECT_EQ(t.hour, 14);
|
|
EXPECT_EQ(t.minute, 30);
|
|
EXPECT_EQ(t.second, 0);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, Midnight) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("00:00:00", 8, t));
|
|
EXPECT_EQ(t.hour, 0);
|
|
EXPECT_EQ(t.minute, 0);
|
|
EXPECT_EQ(t.second, 0);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, EndOfDay) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("23:59:59", 8, t));
|
|
EXPECT_EQ(t.hour, 23);
|
|
EXPECT_EQ(t.minute, 59);
|
|
EXPECT_EQ(t.second, 59);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, LeapYearDate) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("2024-02-29", 10, t));
|
|
EXPECT_EQ(t.year, 2024);
|
|
EXPECT_EQ(t.month, 2);
|
|
EXPECT_EQ(t.day_of_month, 29);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, NewYearsEve) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("2026-12-31 23:59:59", 19, t));
|
|
EXPECT_EQ(t.year, 2026);
|
|
EXPECT_EQ(t.month, 12);
|
|
EXPECT_EQ(t.day_of_month, 31);
|
|
EXPECT_EQ(t.hour, 23);
|
|
EXPECT_EQ(t.minute, 59);
|
|
EXPECT_EQ(t.second, 59);
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, EmptyStringFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime("", 0, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, NullInputFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime(nullptr, 0, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, InvalidFormatFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime("not-a-date", 10, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, PartialDateFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime("2026-03", 7, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, PartialTimeFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime("14:", 3, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, ExtraCharactersFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime("2026-03-15 14:30:45x", 20, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, WrongSeparatorFails) {
|
|
ESPTime t{};
|
|
EXPECT_FALSE(ESPTime::strptime("2026/03/15", 10, t));
|
|
}
|
|
|
|
TEST(ESPTimeStrptime, LeadingZeroTime) {
|
|
ESPTime t{};
|
|
ASSERT_TRUE(ESPTime::strptime("01:05:09", 8, t));
|
|
EXPECT_EQ(t.hour, 1);
|
|
EXPECT_EQ(t.minute, 5);
|
|
EXPECT_EQ(t.second, 9);
|
|
}
|
|
|
|
// ============================================================================
|
|
// recalc_timestamp_local() tests - verify behavior matches libc mktime()
|
|
// ============================================================================
|
|
|
|
} // namespace esphome::testing
|
|
|
|
namespace esphome::time::testing {
|
|
|
|
using esphome::ESPTime;
|
|
|
|
// Helper to call libc mktime with same fields
|
|
static time_t libc_mktime(int year, int month, int day, int hour, int min, int sec) {
|
|
struct tm tm {};
|
|
tm.tm_year = year - 1900;
|
|
tm.tm_mon = month - 1;
|
|
tm.tm_mday = day;
|
|
tm.tm_hour = hour;
|
|
tm.tm_min = min;
|
|
tm.tm_sec = sec;
|
|
tm.tm_isdst = -1; // Let libc determine DST
|
|
return mktime(&tm);
|
|
}
|
|
|
|
// Helper to create ESPTime and call recalc_timestamp_local
|
|
static time_t esptime_recalc_local(int year, int month, int day, int hour, int min, int sec) {
|
|
ESPTime t{};
|
|
t.year = year;
|
|
t.month = month;
|
|
t.day_of_month = day;
|
|
t.hour = hour;
|
|
t.minute = min;
|
|
t.second = sec;
|
|
t.day_of_week = 1; // Placeholder for fields_in_range()
|
|
t.day_of_year = 1;
|
|
t.recalc_timestamp_local();
|
|
return t.timestamp;
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, NormalTimeMatchesLibc) {
|
|
// Set timezone to US Central (CST6CDT)
|
|
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
auto tz = make_us_central();
|
|
set_global_tz(tz);
|
|
|
|
// Test a normal time in winter (no DST)
|
|
// January 15, 2026 at 10:30:00 CST
|
|
time_t libc_result = libc_mktime(2026, 1, 15, 10, 30, 0);
|
|
time_t esp_result = esptime_recalc_local(2026, 1, 15, 10, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Test a normal time in summer (DST active)
|
|
// July 15, 2026 at 10:30:00 CDT
|
|
libc_result = libc_mktime(2026, 7, 15, 10, 30, 0);
|
|
esp_result = esptime_recalc_local(2026, 7, 15, 10, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, SpringForwardSkippedHour) {
|
|
// Set timezone to US Central (CST6CDT)
|
|
// DST starts March 8, 2026 at 2:00 AM -> clocks jump to 3:00 AM
|
|
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
auto tz = make_us_central();
|
|
set_global_tz(tz);
|
|
|
|
// Test time before the transition (1:30 AM CST exists)
|
|
time_t libc_result = libc_mktime(2026, 3, 8, 1, 30, 0);
|
|
time_t esp_result = esptime_recalc_local(2026, 3, 8, 1, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Test time after the transition (3:30 AM CDT exists)
|
|
libc_result = libc_mktime(2026, 3, 8, 3, 30, 0);
|
|
esp_result = esptime_recalc_local(2026, 3, 8, 3, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Test the skipped hour (2:30 AM doesn't exist - gets normalized)
|
|
// Both implementations should produce the same result
|
|
libc_result = libc_mktime(2026, 3, 8, 2, 30, 0);
|
|
esp_result = esptime_recalc_local(2026, 3, 8, 2, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, FallBackRepeatedHour) {
|
|
// Set timezone to US Central (CST6CDT)
|
|
// DST ends November 1, 2026 at 2:00 AM -> clocks fall back to 1:00 AM
|
|
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
auto tz = make_us_central();
|
|
set_global_tz(tz);
|
|
|
|
// Test time before the transition (midnight CDT)
|
|
time_t libc_result = libc_mktime(2026, 11, 1, 0, 30, 0);
|
|
time_t esp_result = esptime_recalc_local(2026, 11, 1, 0, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Test time well after the transition (3:00 AM CST)
|
|
libc_result = libc_mktime(2026, 11, 1, 3, 0, 0);
|
|
esp_result = esptime_recalc_local(2026, 11, 1, 3, 0, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Test the repeated hour (1:30 AM occurs twice)
|
|
// libc behavior varies by platform for this edge case, so we verify our
|
|
// consistent behavior: prefer standard time (later UTC timestamp)
|
|
esp_result = esptime_recalc_local(2026, 11, 1, 1, 30, 0);
|
|
time_t std_interpretation = esptime_recalc_local(2026, 11, 1, 2, 30, 0) - 3600; // 2:30 CST - 1 hour
|
|
EXPECT_EQ(esp_result, std_interpretation);
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, SouthernHemisphereDST) {
|
|
// Set timezone to Australia/Sydney (AEST-10AEDT,M10.1.0,M4.1.0)
|
|
// DST starts first Sunday of October, ends first Sunday of April
|
|
const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
auto tz = make_australia_sydney();
|
|
set_global_tz(tz);
|
|
|
|
// Test winter time (July - no DST in southern hemisphere)
|
|
time_t libc_result = libc_mktime(2026, 7, 15, 10, 30, 0);
|
|
time_t esp_result = esptime_recalc_local(2026, 7, 15, 10, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Test summer time (January - DST active in southern hemisphere)
|
|
libc_result = libc_mktime(2026, 1, 15, 10, 30, 0);
|
|
esp_result = esptime_recalc_local(2026, 1, 15, 10, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, ExactTransitionBoundary) {
|
|
// Test exact boundary of spring forward transition
|
|
// Mar 8, 2026 at 2:00 AM CST -> 3:00 AM CDT (clocks skip forward)
|
|
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
auto tz = make_us_central();
|
|
set_global_tz(tz);
|
|
|
|
// 1:59:59 AM CST - last second before transition (still standard time)
|
|
time_t libc_result = libc_mktime(2026, 3, 8, 1, 59, 59);
|
|
time_t esp_result = esptime_recalc_local(2026, 3, 8, 1, 59, 59);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// 3:00:00 AM CDT - first second after transition (now DST)
|
|
libc_result = libc_mktime(2026, 3, 8, 3, 0, 0);
|
|
esp_result = esptime_recalc_local(2026, 3, 8, 3, 0, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Verify the gap: 3:00 AM CDT should be exactly 1 second after 1:59:59 AM CST
|
|
time_t before_transition = esptime_recalc_local(2026, 3, 8, 1, 59, 59);
|
|
time_t after_transition = esptime_recalc_local(2026, 3, 8, 3, 0, 0);
|
|
EXPECT_EQ(after_transition - before_transition, 1);
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, NonDefaultTransitionTime) {
|
|
// Test DST transition at 3:00 AM instead of default 2:00 AM
|
|
// Using custom transition time: CST6CDT,M3.2.0/3,M11.1.0/3
|
|
const char *tz_str = "CST6CDT,M3.2.0/3,M11.1.0/3";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = 6 * 3600;
|
|
tz.dst_offset_seconds = 5 * 3600;
|
|
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_start.month = 3;
|
|
tz.dst_start.week = 2;
|
|
tz.dst_start.day_of_week = 0;
|
|
tz.dst_start.time_seconds = 3 * 3600;
|
|
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
|
|
tz.dst_end.month = 11;
|
|
tz.dst_end.week = 1;
|
|
tz.dst_end.day_of_week = 0;
|
|
tz.dst_end.time_seconds = 3 * 3600;
|
|
set_global_tz(tz);
|
|
|
|
// 2:30 AM should still be standard time (transition at 3:00 AM)
|
|
time_t libc_result = libc_mktime(2026, 3, 8, 2, 30, 0);
|
|
time_t esp_result = esptime_recalc_local(2026, 3, 8, 2, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// 4:00 AM should be DST (after 3:00 AM transition)
|
|
libc_result = libc_mktime(2026, 3, 8, 4, 0, 0);
|
|
esp_result = esptime_recalc_local(2026, 3, 8, 4, 0, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
}
|
|
|
|
TEST(RecalcTimestampLocal, YearBoundaryDST) {
|
|
// Test southern hemisphere DST across year boundary
|
|
// Australia/Sydney: DST active from October to April (spans Jan 1)
|
|
const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0";
|
|
setenv("TZ", tz_str, 1);
|
|
tzset();
|
|
auto tz = make_australia_sydney();
|
|
set_global_tz(tz);
|
|
|
|
// Dec 31, 2025 at 23:30 - DST should be active
|
|
time_t libc_result = libc_mktime(2025, 12, 31, 23, 30, 0);
|
|
time_t esp_result = esptime_recalc_local(2025, 12, 31, 23, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Jan 1, 2026 at 00:30 - DST should still be active
|
|
libc_result = libc_mktime(2026, 1, 1, 0, 30, 0);
|
|
esp_result = esptime_recalc_local(2026, 1, 1, 0, 30, 0);
|
|
EXPECT_EQ(esp_result, libc_result);
|
|
|
|
// Verify both are in DST (11 hour offset from UTC, not 10)
|
|
// The timestamps should be 1 hour apart
|
|
time_t dec31 = esptime_recalc_local(2025, 12, 31, 23, 30, 0);
|
|
time_t jan1 = esptime_recalc_local(2026, 1, 1, 0, 30, 0);
|
|
EXPECT_EQ(jan1 - dec31, 3600); // 1 hour difference
|
|
}
|
|
|
|
// ============================================================================
|
|
// ESPTime::timezone_offset() tests
|
|
// ============================================================================
|
|
|
|
TEST(TimezoneOffset, NoTimezone) {
|
|
ParsedTimezone tz{};
|
|
set_global_tz(tz);
|
|
|
|
int32_t offset = ESPTime::timezone_offset();
|
|
EXPECT_EQ(offset, 0);
|
|
}
|
|
|
|
TEST(TimezoneOffset, FixedOffsetPositive) {
|
|
// India: IST-5:30 (no DST)
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = -(5 * 3600 + 30 * 60);
|
|
set_global_tz(tz);
|
|
|
|
int32_t offset = ESPTime::timezone_offset();
|
|
EXPECT_EQ(offset, 5 * 3600 + 30 * 60);
|
|
}
|
|
|
|
TEST(TimezoneOffset, FixedOffsetNegative) {
|
|
// EST5 (no DST)
|
|
ParsedTimezone tz{};
|
|
tz.std_offset_seconds = 5 * 3600;
|
|
set_global_tz(tz);
|
|
|
|
int32_t offset = ESPTime::timezone_offset();
|
|
EXPECT_EQ(offset, -5 * 3600);
|
|
}
|
|
|
|
TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) {
|
|
// US Eastern with DST
|
|
auto tz = make_us_eastern();
|
|
set_global_tz(tz);
|
|
|
|
// Get current time and check offset matches expected based on DST status
|
|
time_t now = ::time(nullptr);
|
|
int32_t offset = ESPTime::timezone_offset();
|
|
|
|
// Verify offset matches what is_in_dst says
|
|
if (is_in_dst(now, tz)) {
|
|
// During DST, offset should be -4 hours (EDT)
|
|
EXPECT_EQ(offset, -4 * 3600);
|
|
} else {
|
|
// During standard time, offset should be -5 hours (EST)
|
|
EXPECT_EQ(offset, -5 * 3600);
|
|
}
|
|
}
|
|
|
|
} // namespace esphome::time::testing
|