diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp new file mode 100644 index 0000000000..e977ca53e9 --- /dev/null +++ b/tests/components/time/posix_tz_parser.cpp @@ -0,0 +1,678 @@ +// Tests for the POSIX TZ parser implementation +// This verifies our custom parser produces identical results to libc's +// tzset()/localtime() implementation. The custom parser avoids pulling in scanf (~7.6KB). + +#include +#include +#include +#include "esphome/core/posix_tz.h" + +namespace esphome::time::testing { + +// ============================================================================ +// Basic TZ string parsing tests +// ============================================================================ + +TEST(PosixTzParser, ParseSimpleOffsetEST5) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); // +5 hours (west of UTC) + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseNegativeOffsetCET) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CET-1", tz)); + EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); // -1 hour (east of UTC) + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseExplicitPositiveOffset) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("TEST+5", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseZeroOffset) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 0); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseUSEasternWithDST) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_start.week, 2); + EXPECT_EQ(tz.dst_start.day_of_week, 0); // Sunday + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // Default 2:00 AM + EXPECT_EQ(tz.dst_end.month, 11); + EXPECT_EQ(tz.dst_end.week, 1); + EXPECT_EQ(tz.dst_end.day_of_week, 0); +} + +TEST(PosixTzParser, ParseUSCentralWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz)); + EXPECT_EQ(tz.std_offset_seconds, 6 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // 2:00 AM + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, ParseEuropeBerlin) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -2 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 3); + EXPECT_EQ(tz.dst_start.week, 5); // Last week + EXPECT_EQ(tz.dst_end.month, 10); + EXPECT_EQ(tz.dst_end.week, 5); // Last week + EXPECT_EQ(tz.dst_end.time_seconds, 3 * 3600); // 3:00 AM +} + +TEST(PosixTzParser, ParseNewZealand) { + ParsedTimezone tz; + // Southern hemisphere - DST starts in Sept, ends in April + ASSERT_TRUE(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -12 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -13 * 3600); // Default: STD - 1hr + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 9); // September + EXPECT_EQ(tz.dst_end.month, 4); // April +} + +TEST(PosixTzParser, ParseExplicitDstOffset) { + ParsedTimezone tz; + // Some places have non-standard DST offsets + ASSERT_TRUE(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); + EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); + EXPECT_TRUE(tz.has_dst); +} + +// ============================================================================ +// Angle-bracket notation tests (espressif/newlib-esp32#8) +// ============================================================================ + +TEST(PosixTzParser, ParseAngleBracketPositive) { + // Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+07>-7", tz)); + EXPECT_EQ(tz.std_offset_seconds, -7 * 3600); // -7 = 7 hours east of UTC + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseAngleBracketNegative) { + // <-03>3 means UTC-3 (name is "-03", offset is 3 hours west) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<-03>3", tz)); + EXPECT_EQ(tz.std_offset_seconds, 3 * 3600); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseAngleBracketWithDST) { + // <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); + EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); + EXPECT_EQ(tz.dst_offset_seconds, -11 * 3600); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.month, 10); + EXPECT_EQ(tz.dst_end.month, 4); +} + +TEST(PosixTzParser, ParseAngleBracketNamed) { + // -10 (Australian Eastern Standard Time) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("-10", tz)); + EXPECT_EQ(tz.std_offset_seconds, -10 * 3600); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseAngleBracketWithMinutes) { + // <+0545>-5:45 (Nepal) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+0545>-5:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); + EXPECT_FALSE(tz.has_dst); +} + +// ============================================================================ +// Half-hour and unusual offset tests +// ============================================================================ + +TEST(PosixTzParser, ParseOffsetWithMinutesIndia) { + ParsedTimezone tz; + // India: UTC+5:30 + ASSERT_TRUE(parse_posix_tz("IST-5:30", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 30 * 60)); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseOffsetWithMinutesNepal) { + ParsedTimezone tz; + // Nepal: UTC+5:45 + ASSERT_TRUE(parse_posix_tz("NPT-5:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60)); + EXPECT_FALSE(tz.has_dst); +} + +TEST(PosixTzParser, ParseOffsetWithSeconds) { + ParsedTimezone tz; + // Unusual but valid: offset with seconds + ASSERT_TRUE(parse_posix_tz("TEST-1:30:30", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(1 * 3600 + 30 * 60 + 30)); +} + +TEST(PosixTzParser, ParseChathamIslands) { + // Chatham Islands: UTC+12:45 with DST + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); + EXPECT_EQ(tz.std_offset_seconds, -(12 * 3600 + 45 * 60)); + EXPECT_EQ(tz.dst_offset_seconds, -(13 * 3600 + 45 * 60)); + EXPECT_TRUE(tz.has_dst); +} + +// ============================================================================ +// Invalid input tests +// ============================================================================ + +TEST(PosixTzParser, ParseEmptyStringFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("", tz)); +} + +TEST(PosixTzParser, ParseNullFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz(nullptr, tz)); +} + +TEST(PosixTzParser, ParseShortNameFails) { + ParsedTimezone tz; + // TZ name must be at least 3 characters + EXPECT_FALSE(parse_posix_tz("AB5", tz)); +} + +TEST(PosixTzParser, ParseMissingOffsetFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("EST", tz)); +} + +TEST(PosixTzParser, ParseUnterminatedBracketFails) { + ParsedTimezone tz; + EXPECT_FALSE(parse_posix_tz("<+07-7", tz)); // Missing closing > +} + +// ============================================================================ +// J-format and plain day number tests +// ============================================================================ + +TEST(PosixTzParser, ParseJFormatBasic) { + ParsedTimezone tz; + // J format: Julian day 1-365, not counting Feb 29 + ASSERT_TRUE(parse_posix_tz("EST5EDT,J60,J305", tz)); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_start.day, 60); // March 1 + EXPECT_EQ(tz.dst_end.type, DSTRuleType::JULIAN_NO_LEAP); + EXPECT_EQ(tz.dst_end.day, 305); // November 1 +} + +TEST(PosixTzParser, ParseJFormatWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,J60/2,J305/2", tz)); + EXPECT_EQ(tz.dst_start.day, 60); + EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); + EXPECT_EQ(tz.dst_end.day, 305); + EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); +} + +TEST(PosixTzParser, ParsePlainDayNumber) { + ParsedTimezone tz; + // Plain format: day 0-365, counting Feb 29 in leap years + ASSERT_TRUE(parse_posix_tz("EST5EDT,59,304", tz)); + EXPECT_TRUE(tz.has_dst); + EXPECT_EQ(tz.dst_start.type, DSTRuleType::DAY_OF_YEAR); + EXPECT_EQ(tz.dst_start.day, 59); + EXPECT_EQ(tz.dst_end.type, DSTRuleType::DAY_OF_YEAR); + EXPECT_EQ(tz.dst_end.day, 304); +} + +TEST(PosixTzParser, JFormatInvalidDayZero) { + ParsedTimezone tz; + // J format day must be 1-365, not 0 + EXPECT_FALSE(parse_posix_tz("EST5EDT,J0,J305", tz)); +} + +TEST(PosixTzParser, JFormatInvalidDay366) { + ParsedTimezone tz; + // J format day must be 1-365 + EXPECT_FALSE(parse_posix_tz("EST5EDT,J366,J305", tz)); +} + +TEST(PosixTzParser, ParsePlainDayNumberWithTime) { + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,59/3,304/1:30", tz)); + EXPECT_EQ(tz.dst_start.day, 59); + EXPECT_EQ(tz.dst_start.time_seconds, 3 * 3600); + EXPECT_EQ(tz.dst_end.day, 304); + EXPECT_EQ(tz.dst_end.time_seconds, 1 * 3600 + 30 * 60); +} + +TEST(PosixTzParser, PlainDayInvalidDay366) { + ParsedTimezone tz; + // Plain format day must be 0-365 + EXPECT_FALSE(parse_posix_tz("EST5EDT,366,304", tz)); +} + +// ============================================================================ +// Helper function tests +// ============================================================================ + +TEST(PosixTzParser, JulianDay60IsMarch1) { + // J60 is always March 1, regardless of leap year + int month, day; + internal::julian_to_month_day(60, 2024, month, day); // Leap year + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); + internal::julian_to_month_day(60, 2025, month, day); // Non-leap year + EXPECT_EQ(month, 3); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, 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(PosixTzParser, 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(PosixTzParser, 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(PosixTzParser, JulianDay1IsJan1) { + int month, day; + internal::julian_to_month_day(1, 2025, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, JulianDay365IsDec31) { + int month, day; + internal::julian_to_month_day(365, 2025, month, day); + EXPECT_EQ(month, 12); + EXPECT_EQ(day, 31); +} + +TEST(PosixTzParser, DayOfYear0IsJan1) { + int month, day; + internal::day_of_year_to_month_day(0, 2025, month, day); + EXPECT_EQ(month, 1); + EXPECT_EQ(day, 1); +} + +TEST(PosixTzParser, DaysInMonthRegular) { + EXPECT_EQ(internal::days_in_month(2025, 1), 31); + EXPECT_EQ(internal::days_in_month(2025, 2), 28); + EXPECT_EQ(internal::days_in_month(2025, 4), 30); + EXPECT_EQ(internal::days_in_month(2025, 12), 31); +} + +TEST(PosixTzParser, DaysInMonthLeapYear) { + EXPECT_EQ(internal::days_in_month(2024, 2), 29); + EXPECT_EQ(internal::days_in_month(2025, 2), 28); +} + +// ============================================================================ +// DST transition calculation tests +// ============================================================================ + +TEST(PosixTzParser, DstStartUSEastern2026) { + // March 8, 2026 is 2nd Sunday of March + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + time_t dst_start = 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(PosixTzParser, DstEndUSEastern2026) { + // November 1, 2026 is 1st Sunday of November + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + time_t dst_end = 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(PosixTzParser, 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 = 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(PosixTzParser, 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 = 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(PosixTzParser, 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 = 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(PosixTzParser, IsInDstUSEasternSummer) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 12:00 UTC - definitely in DST + struct tm july4 {}; + july4.tm_hour = 12; + july4.tm_mday = 4; + july4.tm_mon = 6; + july4.tm_year = 126; + time_t summer = internal::tm_to_epoch_utc(&july4); + EXPECT_TRUE(is_in_dst(summer, tz)); +} + +TEST(PosixTzParser, IsInDstUSEasternWinter) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // January 15, 2026 12:00 UTC - definitely not in DST + struct tm jan15 {}; + jan15.tm_hour = 12; + jan15.tm_mday = 15; + jan15.tm_mon = 0; + jan15.tm_year = 126; + time_t winter = internal::tm_to_epoch_utc(&jan15); + EXPECT_FALSE(is_in_dst(winter, tz)); +} + +TEST(PosixTzParser, IsInDstNoDstTimezone) { + ParsedTimezone tz; + parse_posix_tz("IST-5:30", tz); + + struct tm anytime {}; + anytime.tm_hour = 12; + anytime.tm_mday = 15; + anytime.tm_mon = 6; + anytime.tm_year = 126; + time_t epoch = internal::tm_to_epoch_utc(&anytime); + EXPECT_FALSE(is_in_dst(epoch, tz)); +} + +TEST(PosixTzParser, SouthernHemisphereDstSummer) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // December 15, 2025 12:00 UTC - summer in NZ, should be in DST + struct tm dec15 {}; + dec15.tm_hour = 12; + dec15.tm_mday = 15; + dec15.tm_mon = 11; + dec15.tm_year = 125; + time_t nz_summer = internal::tm_to_epoch_utc(&dec15); + EXPECT_TRUE(is_in_dst(nz_summer, tz)); +} + +TEST(PosixTzParser, SouthernHemisphereDstWinter) { + ParsedTimezone tz; + parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); + + // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST + struct tm july15 {}; + july15.tm_hour = 12; + july15.tm_mday = 15; + july15.tm_mon = 6; + july15.tm_year = 126; + time_t nz_winter = internal::tm_to_epoch_utc(&july15); + EXPECT_FALSE(is_in_dst(nz_winter, tz)); +} + +// ============================================================================ +// epoch_to_local_tm tests +// ============================================================================ + +TEST(PosixTzParser, EpochToLocalBasic) { + ParsedTimezone tz; + parse_posix_tz("UTC0", tz); + + 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(PosixTzParser, EpochToLocalWithOffset) { + ParsedTimezone tz; + parse_posix_tz("EST5", tz); // UTC-5 + + // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST + struct tm utc_tm {}; + utc_tm.tm_hour = 5; + utc_tm.tm_mday = 1; + utc_tm.tm_mon = 0; + utc_tm.tm_year = 126; + time_t utc_epoch = internal::tm_to_epoch_utc(&utc_tm); + + 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(PosixTzParser, EpochToLocalDstTransition) { + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // July 4, 2026 16:00 UTC = 12:00 EDT (noon) + struct tm july4_utc {}; + july4_utc.tm_hour = 16; + july4_utc.tm_mday = 4; + july4_utc.tm_mon = 6; + july4_utc.tm_year = 126; + time_t utc_epoch = internal::tm_to_epoch_utc(&july4_utc); + + 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); +} + +// ============================================================================ +// Verification against libc +// ============================================================================ + +class LibcVerificationTest : public ::testing::TestWithParam> {}; + +TEST_P(LibcVerificationTest, MatchesLibc) { + auto [tz_str, epoch] = GetParam(); + + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + + // Our implementation + struct tm our_tm; + epoch_to_local_tm(epoch, tz, &our_tm); + + // libc implementation + setenv("TZ", tz_str, 1); + tzset(); + struct tm *libc_tm = localtime(&epoch); + + EXPECT_EQ(our_tm.tm_year, libc_tm->tm_year); + EXPECT_EQ(our_tm.tm_mon, libc_tm->tm_mon); + EXPECT_EQ(our_tm.tm_mday, libc_tm->tm_mday); + EXPECT_EQ(our_tm.tm_hour, libc_tm->tm_hour); + EXPECT_EQ(our_tm.tm_min, libc_tm->tm_min); + EXPECT_EQ(our_tm.tm_sec, libc_tm->tm_sec); + EXPECT_EQ(our_tm.tm_isdst, libc_tm->tm_isdst); +} + +INSTANTIATE_TEST_SUITE_P(USEastern, LibcVerificationTest, + ::testing::Values(std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple("EST5EDT,M3.2.0/2,M11.1.0/2", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AngleBracket, LibcVerificationTest, + ::testing::Values(std::make_tuple("<+07>-7", 1704067200), + std::make_tuple("<+07>-7", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(India, LibcVerificationTest, + ::testing::Values(std::make_tuple("IST-5:30", 1704067200), + std::make_tuple("IST-5:30", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(NewZealand, LibcVerificationTest, + ::testing::Values(std::make_tuple("NZST-12NZDT,M9.5.0,M4.1.0/3", 1704067200), + std::make_tuple("NZST-12NZDT,M9.5.0,M4.1.0/3", 1720000000))); + +INSTANTIATE_TEST_SUITE_P(USCentral, LibcVerificationTest, + ::testing::Values(std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1704067200), + std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1720000000), + std::make_tuple("CST6CDT,M3.2.0/2,M11.1.0/2", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(EuropeBerlin, LibcVerificationTest, + ::testing::Values(std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1704067200), + std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1720000000), + std::make_tuple("CET-1CEST,M3.5.0,M10.5.0/3", 1735689600))); + +INSTANTIATE_TEST_SUITE_P(AustraliaSydney, LibcVerificationTest, + ::testing::Values(std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1704067200), + std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1720000000), + std::make_tuple("AEST-10AEDT,M10.1.0,M4.1.0/3", 1735689600))); + +// ============================================================================ +// DST boundary edge cases +// ============================================================================ + +TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { + // Test 1 second before DST starts + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) + struct tm before {}; + before.tm_sec = 59; + before.tm_min = 59; + before.tm_hour = 6; + before.tm_mday = 8; + before.tm_mon = 2; + before.tm_year = 126; + time_t before_epoch = internal::tm_to_epoch_utc(&before); + 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) + struct tm after {}; + after.tm_hour = 7; + after.tm_mday = 8; + after.tm_mon = 2; + after.tm_year = 126; + time_t after_epoch = internal::tm_to_epoch_utc(&after); + EXPECT_TRUE(is_in_dst(after_epoch, tz)); +} + +TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) { + // Test 1 second before DST ends + ParsedTimezone tz; + parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + + // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) + struct tm before {}; + before.tm_sec = 59; + before.tm_min = 59; + before.tm_hour = 5; + before.tm_mday = 1; + before.tm_mon = 10; + before.tm_year = 126; + time_t before_epoch = internal::tm_to_epoch_utc(&before); + 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) + struct tm after {}; + after.tm_hour = 6; + after.tm_mday = 1; + after.tm_mon = 10; + after.tm_year = 126; + time_t after_epoch = internal::tm_to_epoch_utc(&after); + EXPECT_FALSE(is_in_dst(after_epoch, tz)); +} + +} // namespace esphome::time::testing diff --git a/tests/unit_tests/test_posix_tz_parser.cpp b/tests/unit_tests/test_posix_tz_parser.cpp deleted file mode 100644 index dbc0ecf380..0000000000 --- a/tests/unit_tests/test_posix_tz_parser.cpp +++ /dev/null @@ -1,884 +0,0 @@ -// Test POSIX TZ parser implementation -// Compile with: g++ -std=gnu++20 -I../../esphome/core -o test_posix_tz_parser test_posix_tz_parser.cpp -// ../../esphome/core/posix_tz.cpp && ./test_posix_tz_parser -// -// This test verifies our custom POSIX TZ parser produces identical results to libc's -// tzset()/localtime() implementation. The custom parser avoids pulling in scanf (~7.6KB). -// -// Key test cases include: -// - Angle-bracket timezone notation (<+07>-7) - see espressif/newlib-esp32#8 -// - Half-hour offsets (IST-5:30) -// - Southern hemisphere DST (start month > end month) -// - DST transition boundary conditions - -#include -#include -#include -#include -#include - -// Include the implementation directly for standalone compilation -#include "../../esphome/core/posix_tz.h" -#include "../../esphome/core/posix_tz.cpp" - -using namespace esphome; - -#define TEST(name) static void test_##name() -#define RUN_TEST(name) \ - do { \ - printf(" " #name "..."); \ - fflush(stdout); \ - test_##name(); \ - printf(" OK\n"); \ - } while (0) - -// ============================================================================ -// Basic TZ string parsing tests -// ============================================================================ - -TEST(parse_simple_offset_est5) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5", tz)); - assert(tz.std_offset_seconds == 5 * 3600); // +5 hours (west of UTC) - assert(!tz.has_dst); -} - -TEST(parse_negative_offset_cet) { - ParsedTimezone tz; - assert(parse_posix_tz("CET-1", tz)); - assert(tz.std_offset_seconds == -1 * 3600); // -1 hour (east of UTC) - assert(!tz.has_dst); -} - -TEST(parse_explicit_positive_offset) { - ParsedTimezone tz; - assert(parse_posix_tz("TEST+5", tz)); - assert(tz.std_offset_seconds == 5 * 3600); - assert(!tz.has_dst); -} - -TEST(parse_zero_offset) { - ParsedTimezone tz; - assert(parse_posix_tz("UTC0", tz)); - assert(tz.std_offset_seconds == 0); - assert(!tz.has_dst); -} - -TEST(parse_us_eastern_with_dst) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz)); - assert(tz.std_offset_seconds == 5 * 3600); - assert(tz.dst_offset_seconds == 4 * 3600); // Default: STD - 1hr - assert(tz.has_dst); - assert(tz.dst_start.month == 3); - assert(tz.dst_start.week == 2); - assert(tz.dst_start.day_of_week == 0); // Sunday - assert(tz.dst_start.time_seconds == 2 * 3600); // Default 2:00 AM - assert(tz.dst_end.month == 11); - assert(tz.dst_end.week == 1); - assert(tz.dst_end.day_of_week == 0); -} - -TEST(parse_us_central_with_time) { - ParsedTimezone tz; - assert(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz)); - assert(tz.std_offset_seconds == 6 * 3600); - assert(tz.dst_offset_seconds == 5 * 3600); - assert(tz.dst_start.time_seconds == 2 * 3600); // 2:00 AM - assert(tz.dst_end.time_seconds == 2 * 3600); -} - -TEST(parse_europe_berlin) { - ParsedTimezone tz; - assert(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz)); - assert(tz.std_offset_seconds == -1 * 3600); - assert(tz.dst_offset_seconds == -2 * 3600); // Default: STD - 1hr - assert(tz.has_dst); - assert(tz.dst_start.month == 3); - assert(tz.dst_start.week == 5); // Last week - assert(tz.dst_end.month == 10); - assert(tz.dst_end.week == 5); // Last week - assert(tz.dst_end.time_seconds == 3 * 3600); // 3:00 AM -} - -TEST(parse_new_zealand) { - ParsedTimezone tz; - // Southern hemisphere - DST starts in Sept, ends in April - assert(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz)); - assert(tz.std_offset_seconds == -12 * 3600); - assert(tz.dst_offset_seconds == -13 * 3600); // Default: STD - 1hr - assert(tz.has_dst); - assert(tz.dst_start.month == 9); // September - assert(tz.dst_end.month == 4); // April -} - -TEST(parse_explicit_dst_offset) { - ParsedTimezone tz; - // Some places have non-standard DST offsets - assert(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz)); - assert(tz.std_offset_seconds == 5 * 3600); - assert(tz.dst_offset_seconds == 4 * 3600); - assert(tz.has_dst); -} - -// ============================================================================ -// Angle-bracket notation tests (espressif/newlib-esp32#8) -// ============================================================================ - -TEST(parse_angle_bracket_positive) { - // Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east) - ParsedTimezone tz; - assert(parse_posix_tz("<+07>-7", tz)); - assert(tz.std_offset_seconds == -7 * 3600); // -7 = 7 hours east of UTC - assert(!tz.has_dst); -} - -TEST(parse_angle_bracket_negative) { - // <-03>3 means UTC-3 (name is "-03", offset is 3 hours west) - ParsedTimezone tz; - assert(parse_posix_tz("<-03>3", tz)); - assert(tz.std_offset_seconds == 3 * 3600); - assert(!tz.has_dst); -} - -TEST(parse_angle_bracket_with_dst) { - // <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style) - ParsedTimezone tz; - assert(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz)); - assert(tz.std_offset_seconds == -10 * 3600); - assert(tz.dst_offset_seconds == -11 * 3600); - assert(tz.has_dst); - assert(tz.dst_start.month == 10); - assert(tz.dst_end.month == 4); -} - -TEST(parse_angle_bracket_named) { - // -10 (Australian Eastern Standard Time) - ParsedTimezone tz; - assert(parse_posix_tz("-10", tz)); - assert(tz.std_offset_seconds == -10 * 3600); - assert(!tz.has_dst); -} - -TEST(parse_angle_bracket_with_minutes) { - // <+0545>-5:45 (Nepal) - ParsedTimezone tz; - assert(parse_posix_tz("<+0545>-5:45", tz)); - assert(tz.std_offset_seconds == -(5 * 3600 + 45 * 60)); - assert(!tz.has_dst); -} - -// ============================================================================ -// Half-hour and unusual offset tests -// ============================================================================ - -TEST(parse_offset_with_minutes_india) { - ParsedTimezone tz; - // India: UTC+5:30 - assert(parse_posix_tz("IST-5:30", tz)); - assert(tz.std_offset_seconds == -(5 * 3600 + 30 * 60)); - assert(!tz.has_dst); -} - -TEST(parse_offset_with_minutes_nepal) { - ParsedTimezone tz; - // Nepal: UTC+5:45 - assert(parse_posix_tz("NPT-5:45", tz)); - assert(tz.std_offset_seconds == -(5 * 3600 + 45 * 60)); - assert(!tz.has_dst); -} - -TEST(parse_offset_with_seconds) { - ParsedTimezone tz; - // Unusual but valid: offset with seconds - assert(parse_posix_tz("TEST-1:30:30", tz)); - assert(tz.std_offset_seconds == -(1 * 3600 + 30 * 60 + 30)); -} - -TEST(parse_chatham_islands) { - // Chatham Islands: UTC+12:45 with DST - ParsedTimezone tz; - assert(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz)); - assert(tz.std_offset_seconds == -(12 * 3600 + 45 * 60)); - assert(tz.dst_offset_seconds == -(13 * 3600 + 45 * 60)); - assert(tz.has_dst); -} - -// ============================================================================ -// Invalid input tests -// ============================================================================ - -TEST(parse_empty_string_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz("", tz)); -} - -TEST(parse_null_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz(nullptr, tz)); -} - -TEST(parse_short_name_fails) { - ParsedTimezone tz; - // TZ name must be at least 3 characters - assert(!parse_posix_tz("AB5", tz)); -} - -TEST(parse_missing_offset_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz("EST", tz)); -} - -TEST(parse_unterminated_bracket_fails) { - ParsedTimezone tz; - assert(!parse_posix_tz("<+07-7", tz)); // Missing closing > -} - -// ============================================================================ -// J-format and plain day number tests -// ============================================================================ - -TEST(parse_j_format_basic) { - ParsedTimezone tz; - // J format: Julian day 1-365, not counting Feb 29 - assert(parse_posix_tz("EST5EDT,J60,J305", tz)); - assert(tz.has_dst); - assert(tz.dst_start.type == DSTRuleType::JULIAN_NO_LEAP); - assert(tz.dst_start.day == 60); // March 1 - assert(tz.dst_end.type == DSTRuleType::JULIAN_NO_LEAP); - assert(tz.dst_end.day == 305); // November 1 -} - -TEST(parse_j_format_with_time) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5EDT,J60/2,J305/2", tz)); - assert(tz.dst_start.day == 60); - assert(tz.dst_start.time_seconds == 2 * 3600); - assert(tz.dst_end.day == 305); - assert(tz.dst_end.time_seconds == 2 * 3600); -} - -TEST(parse_plain_day_number) { - ParsedTimezone tz; - // Plain format: day 0-365, counting Feb 29 in leap years - assert(parse_posix_tz("EST5EDT,59,304", tz)); - assert(tz.has_dst); - assert(tz.dst_start.type == DSTRuleType::DAY_OF_YEAR); - assert(tz.dst_start.day == 59); // Feb 29 or March 1 depending on leap year - assert(tz.dst_end.type == DSTRuleType::DAY_OF_YEAR); - assert(tz.dst_end.day == 304); -} - -TEST(parse_plain_day_number_with_time) { - ParsedTimezone tz; - assert(parse_posix_tz("EST5EDT,59/3,304/1:30", tz)); - assert(tz.dst_start.day == 59); - assert(tz.dst_start.time_seconds == 3 * 3600); - assert(tz.dst_end.day == 304); - assert(tz.dst_end.time_seconds == 1 * 3600 + 30 * 60); -} - -TEST(j_format_invalid_day_zero) { - ParsedTimezone tz; - // J format day must be 1-365, not 0 - assert(!parse_posix_tz("EST5EDT,J0,J305", tz)); -} - -TEST(j_format_invalid_day_366) { - ParsedTimezone tz; - // J format day must be 1-365 - assert(!parse_posix_tz("EST5EDT,J366,J305", tz)); -} - -TEST(plain_day_invalid_day_366) { - ParsedTimezone tz; - // Plain format day must be 0-365 - assert(!parse_posix_tz("EST5EDT,366,304", tz)); -} - -// ============================================================================ -// Julian day to month/day conversion tests -// ============================================================================ - -TEST(julian_day_60_is_march_1) { - // J60 is always March 1, regardless of leap year - int month, day; - internal::julian_to_month_day(60, 2024, month, day); // Leap year - assert(month == 3 && day == 1); - internal::julian_to_month_day(60, 2025, month, day); // Non-leap year - assert(month == 3 && day == 1); -} - -TEST(julian_day_1_is_jan_1) { - int month, day; - internal::julian_to_month_day(1, 2025, month, day); - assert(month == 1 && day == 1); -} - -TEST(julian_day_365_is_dec_31) { - int month, day; - internal::julian_to_month_day(365, 2025, month, day); - assert(month == 12 && day == 31); -} - -TEST(day_of_year_59_differs_by_leap) { - int month, day; - // Day 59 in leap year is Feb 29 - internal::day_of_year_to_month_day(59, 2024, month, day); - assert(month == 2 && day == 29); - // Day 59 in non-leap year is March 1 - internal::day_of_year_to_month_day(59, 2025, month, day); - assert(month == 3 && day == 1); -} - -TEST(day_of_year_0_is_jan_1) { - int month, day; - internal::day_of_year_to_month_day(0, 2025, month, day); - assert(month == 1 && day == 1); -} - -// ============================================================================ -// Day of week calculation tests -// ============================================================================ - -TEST(day_of_week_known_dates) { - // January 1, 1970 was Thursday (4) - assert(internal::day_of_week(1970, 1, 1) == 4); - - // July 4, 1776 was Thursday (4) - assert(internal::day_of_week(1776, 7, 4) == 4); - - // January 1, 2000 was Saturday (6) - assert(internal::day_of_week(2000, 1, 1) == 6); - - // September 11, 2001 was Tuesday (2) - assert(internal::day_of_week(2001, 9, 11) == 2); - - // March 8, 2026 is Sunday (0) - US DST start - assert(internal::day_of_week(2026, 3, 8) == 0); - - // November 1, 2026 is Sunday (0) - US DST end - assert(internal::day_of_week(2026, 11, 1) == 0); -} - -TEST(leap_year_detection) { - assert(!internal::is_leap_year(1900)); // Divisible by 100 but not 400 - assert(internal::is_leap_year(2000)); // Divisible by 400 - assert(internal::is_leap_year(2024)); // Divisible by 4 - assert(!internal::is_leap_year(2025)); // Not divisible by 4 - assert(internal::is_leap_year(2028)); -} - -TEST(days_in_month_regular) { - assert(internal::days_in_month(2025, 1) == 31); - assert(internal::days_in_month(2025, 2) == 28); - assert(internal::days_in_month(2025, 4) == 30); - assert(internal::days_in_month(2025, 12) == 31); -} - -TEST(days_in_month_leap_year) { - assert(internal::days_in_month(2024, 2) == 29); - assert(internal::days_in_month(2025, 2) == 28); -} - -// ============================================================================ -// DST transition calculation tests -// ============================================================================ - -TEST(dst_start_us_eastern_2026) { - // March 8, 2026 is 2nd Sunday of March - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - time_t dst_start = 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 - assert(tm.tm_year + 1900 == 2026); - assert(tm.tm_mon + 1 == 3); // March - assert(tm.tm_mday == 8); // 8th - assert(tm.tm_hour == 7); // 7:00 UTC = 2:00 EST -} - -TEST(dst_end_us_eastern_2026) { - // November 1, 2026 is 1st Sunday of November - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - time_t dst_end = 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 - assert(tm.tm_year + 1900 == 2026); - assert(tm.tm_mon + 1 == 11); // November - assert(tm.tm_mday == 1); // 1st - assert(tm.tm_hour == 6); // 6:00 UTC = 2:00 EDT -} - -TEST(last_sunday_of_march_2026) { - // 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 = calculate_dst_transition(2026, rule, 0); - struct tm tm; - internal::epoch_to_tm_utc(transition, &tm); - assert(tm.tm_mday == 29); - assert(tm.tm_wday == 0); // Sunday -} - -TEST(last_sunday_of_october_2026) { - // 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 = calculate_dst_transition(2026, rule, 0); - struct tm tm; - internal::epoch_to_tm_utc(transition, &tm); - assert(tm.tm_mday == 25); - assert(tm.tm_wday == 0); // Sunday -} - -TEST(first_sunday_of_april_2026) { - // 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 = calculate_dst_transition(2026, rule, 0); - struct tm tm; - internal::epoch_to_tm_utc(transition, &tm); - assert(tm.tm_mday == 5); - assert(tm.tm_wday == 0); -} - -// ============================================================================ -// is_in_dst tests -// ============================================================================ - -TEST(is_in_dst_us_eastern_summer) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // July 4, 2026 12:00 UTC - definitely in DST - struct tm july4 {}; - july4.tm_sec = 0; - july4.tm_min = 0; - july4.tm_hour = 12; - july4.tm_mday = 4; - july4.tm_mon = 6; - july4.tm_year = 126; - time_t summer = internal::tm_to_epoch_utc(&july4); - assert(is_in_dst(summer, tz) == true); -} - -TEST(is_in_dst_us_eastern_winter) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // January 15, 2026 12:00 UTC - definitely not in DST - struct tm jan15 {}; - jan15.tm_sec = 0; - jan15.tm_min = 0; - jan15.tm_hour = 12; - jan15.tm_mday = 15; - jan15.tm_mon = 0; - jan15.tm_year = 126; - time_t winter = internal::tm_to_epoch_utc(&jan15); - assert(is_in_dst(winter, tz) == false); -} - -TEST(is_in_dst_no_dst_timezone) { - ParsedTimezone tz; - parse_posix_tz("IST-5:30", tz); - - struct tm anytime {}; - anytime.tm_sec = 0; - anytime.tm_min = 0; - anytime.tm_hour = 12; - anytime.tm_mday = 15; - anytime.tm_mon = 6; - anytime.tm_year = 126; - time_t epoch = internal::tm_to_epoch_utc(&anytime); - assert(is_in_dst(epoch, tz) == false); -} - -TEST(southern_hemisphere_dst_summer) { - ParsedTimezone tz; - parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); - - // December 15, 2025 12:00 UTC - summer in NZ, should be in DST - struct tm dec15 {}; - dec15.tm_sec = 0; - dec15.tm_min = 0; - dec15.tm_hour = 12; - dec15.tm_mday = 15; - dec15.tm_mon = 11; - dec15.tm_year = 125; - time_t nz_summer = internal::tm_to_epoch_utc(&dec15); - assert(is_in_dst(nz_summer, tz) == true); -} - -TEST(southern_hemisphere_dst_winter) { - ParsedTimezone tz; - parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz); - - // July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST - struct tm july15 {}; - july15.tm_sec = 0; - july15.tm_min = 0; - july15.tm_hour = 12; - july15.tm_mday = 15; - july15.tm_mon = 6; - july15.tm_year = 126; - time_t nz_winter = internal::tm_to_epoch_utc(&july15); - assert(is_in_dst(nz_winter, tz) == false); -} - -// ============================================================================ -// epoch_to_local_tm tests -// ============================================================================ - -TEST(epoch_to_local_basic) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); - - time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC - struct tm local; - assert(epoch_to_local_tm(epoch, tz, &local)); - assert(local.tm_year == 70); - assert(local.tm_mon == 0); - assert(local.tm_mday == 1); - assert(local.tm_hour == 0); -} - -TEST(epoch_to_local_with_offset) { - ParsedTimezone tz; - parse_posix_tz("EST5", tz); // UTC-5 - - // Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST - struct tm utc_tm {}; - utc_tm.tm_sec = 0; - utc_tm.tm_min = 0; - utc_tm.tm_hour = 5; - utc_tm.tm_mday = 1; - utc_tm.tm_mon = 0; - utc_tm.tm_year = 126; - time_t utc_epoch = internal::tm_to_epoch_utc(&utc_tm); - - struct tm local; - assert(epoch_to_local_tm(utc_epoch, tz, &local)); - assert(local.tm_hour == 0); // Midnight EST - assert(local.tm_mday == 1); - assert(local.tm_isdst == 0); -} - -TEST(epoch_to_local_dst_transition) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // July 4, 2026 16:00 UTC = 12:00 EDT (noon) - struct tm july4_utc {}; - july4_utc.tm_sec = 0; - july4_utc.tm_min = 0; - july4_utc.tm_hour = 16; - july4_utc.tm_mday = 4; - july4_utc.tm_mon = 6; - july4_utc.tm_year = 126; - time_t utc_epoch = internal::tm_to_epoch_utc(&july4_utc); - - struct tm local; - assert(epoch_to_local_tm(utc_epoch, tz, &local)); - assert(local.tm_hour == 12); // Noon EDT - assert(local.tm_isdst == 1); -} - -// ============================================================================ -// Verification against libc (run on desktop only) -// ============================================================================ - -// Helper to compare our implementation against libc -static bool verify_against_libc(const char *tz_str, time_t epoch) { - ParsedTimezone tz; - if (!parse_posix_tz(tz_str, tz)) { - printf("Failed to parse TZ: %s\n", tz_str); - return false; - } - - // Our implementation - struct tm our_tm; - epoch_to_local_tm(epoch, tz, &our_tm); - - // libc implementation - setenv("TZ", tz_str, 1); - tzset(); - struct tm *libc_tm = localtime(&epoch); - - bool match = - (our_tm.tm_year == libc_tm->tm_year && our_tm.tm_mon == libc_tm->tm_mon && our_tm.tm_mday == libc_tm->tm_mday && - our_tm.tm_hour == libc_tm->tm_hour && our_tm.tm_min == libc_tm->tm_min && our_tm.tm_sec == libc_tm->tm_sec && - our_tm.tm_isdst == libc_tm->tm_isdst); - - if (!match) { - printf("\nMismatch for TZ=%s epoch=%ld\n", tz_str, (long) epoch); - printf(" Our: %04d-%02d-%02d %02d:%02d:%02d DST=%d\n", our_tm.tm_year + 1900, our_tm.tm_mon + 1, our_tm.tm_mday, - our_tm.tm_hour, our_tm.tm_min, our_tm.tm_sec, our_tm.tm_isdst); - printf(" libc: %04d-%02d-%02d %02d:%02d:%02d DST=%d\n", libc_tm->tm_year + 1900, libc_tm->tm_mon + 1, - libc_tm->tm_mday, libc_tm->tm_hour, libc_tm->tm_min, libc_tm->tm_sec, libc_tm->tm_isdst); - } - - return match; -} - -TEST(verify_us_eastern_multiple_epochs) { - const char *tz_str = "EST5EDT,M3.2.0/2,M11.1.0/2"; - // Test various dates throughout the year - time_t epochs[] = { - 1704067200, // Jan 1, 2024 00:00 UTC - 1711900800, // March 31, 2024 12:00 UTC (after DST start) - 1720000000, // July 3, 2024 (summer) - 1730419200, // Nov 1, 2024 00:00 UTC (DST end day) - 1735689600, // Jan 1, 2025 00:00 UTC - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_us_central_multiple_epochs) { - const char *tz_str = "CST6CDT,M3.2.0/2,M11.1.0/2"; - time_t epochs[] = { - 1704067200, 1711900800, 1720000000, 1730419200, 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_europe_berlin_multiple_epochs) { - const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0/3"; - time_t epochs[] = { - 1704067200, 1711900800, 1720000000, 1730419200, 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_angle_bracket_notation) { - // This was the bug in espressif/newlib-esp32#8 - const char *tz_str = "<+07>-7"; - time_t epochs[] = { - 1704067200, - 1720000000, - 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_india_half_hour) { - const char *tz_str = "IST-5:30"; - time_t epochs[] = { - 1704067200, - 1720000000, - 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_new_zealand_southern_hemisphere) { - const char *tz_str = "NZST-12NZDT,M9.5.0,M4.1.0/3"; - time_t epochs[] = { - 1704067200, // Jan (NZ summer, DST) - 1720000000, // July (NZ winter, no DST) - 1735689600, // Jan (NZ summer, DST) - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -TEST(verify_australia_sydney) { - const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0/3"; - time_t epochs[] = { - 1704067200, - 1720000000, - 1735689600, - }; - for (time_t epoch : epochs) { - assert(verify_against_libc(tz_str, epoch)); - } -} - -// ============================================================================ -// DST boundary edge cases -// ============================================================================ - -TEST(dst_boundary_just_before_spring_forward) { - // Test 1 second before DST starts - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward) - struct tm before {}; - before.tm_sec = 59; - before.tm_min = 59; - before.tm_hour = 6; - before.tm_mday = 8; - before.tm_mon = 2; - before.tm_year = 126; - time_t before_epoch = internal::tm_to_epoch_utc(&before); - assert(is_in_dst(before_epoch, tz) == false); - - // March 8, 2026 07:00:00 UTC = 02:00:00 EST -> 03:00:00 EDT (DST started) - struct tm after {}; - after.tm_sec = 0; - after.tm_min = 0; - after.tm_hour = 7; - after.tm_mday = 8; - after.tm_mon = 2; - after.tm_year = 126; - time_t after_epoch = internal::tm_to_epoch_utc(&after); - assert(is_in_dst(after_epoch, tz) == true); -} - -TEST(dst_boundary_just_before_fall_back) { - // Test 1 second before DST ends - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - - // November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back) - struct tm before {}; - before.tm_sec = 59; - before.tm_min = 59; - before.tm_hour = 5; - before.tm_mday = 1; - before.tm_mon = 10; - before.tm_year = 126; - time_t before_epoch = internal::tm_to_epoch_utc(&before); - assert(is_in_dst(before_epoch, tz) == true); - - // November 1, 2026 06:00:00 UTC = 02:00:00 EDT -> 01:00:00 EST (DST ended) - struct tm after {}; - after.tm_sec = 0; - after.tm_min = 0; - after.tm_hour = 6; - after.tm_mday = 1; - after.tm_mon = 10; - after.tm_year = 126; - time_t after_epoch = internal::tm_to_epoch_utc(&after); - assert(is_in_dst(after_epoch, tz) == false); -} - -// ============================================================================ -// Main -// ============================================================================ - -int main() { - printf("POSIX TZ Parser Unit Tests\n"); - printf("==========================\n\n"); - - printf("Basic TZ string parsing:\n"); - RUN_TEST(parse_simple_offset_est5); - RUN_TEST(parse_negative_offset_cet); - RUN_TEST(parse_explicit_positive_offset); - RUN_TEST(parse_zero_offset); - RUN_TEST(parse_us_eastern_with_dst); - RUN_TEST(parse_us_central_with_time); - RUN_TEST(parse_europe_berlin); - RUN_TEST(parse_new_zealand); - RUN_TEST(parse_explicit_dst_offset); - - printf("\nAngle-bracket notation (espressif/newlib-esp32#8):\n"); - RUN_TEST(parse_angle_bracket_positive); - RUN_TEST(parse_angle_bracket_negative); - RUN_TEST(parse_angle_bracket_with_dst); - RUN_TEST(parse_angle_bracket_named); - RUN_TEST(parse_angle_bracket_with_minutes); - - printf("\nHalf-hour and unusual offsets:\n"); - RUN_TEST(parse_offset_with_minutes_india); - RUN_TEST(parse_offset_with_minutes_nepal); - RUN_TEST(parse_offset_with_seconds); - RUN_TEST(parse_chatham_islands); - - printf("\nInvalid input handling:\n"); - RUN_TEST(parse_empty_string_fails); - RUN_TEST(parse_null_fails); - RUN_TEST(parse_short_name_fails); - RUN_TEST(parse_missing_offset_fails); - RUN_TEST(parse_unterminated_bracket_fails); - - printf("\nJ-format and plain day number:\n"); - RUN_TEST(parse_j_format_basic); - RUN_TEST(parse_j_format_with_time); - RUN_TEST(parse_plain_day_number); - RUN_TEST(parse_plain_day_number_with_time); - RUN_TEST(j_format_invalid_day_zero); - RUN_TEST(j_format_invalid_day_366); - RUN_TEST(plain_day_invalid_day_366); - - printf("\nJulian day to month/day conversion:\n"); - RUN_TEST(julian_day_60_is_march_1); - RUN_TEST(julian_day_1_is_jan_1); - RUN_TEST(julian_day_365_is_dec_31); - RUN_TEST(day_of_year_59_differs_by_leap); - RUN_TEST(day_of_year_0_is_jan_1); - - printf("\nDay of week calculation:\n"); - RUN_TEST(day_of_week_known_dates); - RUN_TEST(leap_year_detection); - RUN_TEST(days_in_month_regular); - RUN_TEST(days_in_month_leap_year); - - printf("\nDST transition calculation:\n"); - RUN_TEST(dst_start_us_eastern_2026); - RUN_TEST(dst_end_us_eastern_2026); - RUN_TEST(last_sunday_of_march_2026); - RUN_TEST(last_sunday_of_october_2026); - RUN_TEST(first_sunday_of_april_2026); - - printf("\nis_in_dst tests:\n"); - RUN_TEST(is_in_dst_us_eastern_summer); - RUN_TEST(is_in_dst_us_eastern_winter); - RUN_TEST(is_in_dst_no_dst_timezone); - RUN_TEST(southern_hemisphere_dst_summer); - RUN_TEST(southern_hemisphere_dst_winter); - - printf("\nepoch_to_local_tm tests:\n"); - RUN_TEST(epoch_to_local_basic); - RUN_TEST(epoch_to_local_with_offset); - RUN_TEST(epoch_to_local_dst_transition); - - printf("\nDST boundary edge cases:\n"); - RUN_TEST(dst_boundary_just_before_spring_forward); - RUN_TEST(dst_boundary_just_before_fall_back); - - printf("\nVerification against libc:\n"); - RUN_TEST(verify_us_eastern_multiple_epochs); - RUN_TEST(verify_us_central_multiple_epochs); - RUN_TEST(verify_europe_berlin_multiple_epochs); - RUN_TEST(verify_angle_bracket_notation); - RUN_TEST(verify_india_half_hour); - RUN_TEST(verify_new_zealand_southern_hemisphere); - RUN_TEST(verify_australia_sydney); - - printf("\n==========================\n"); - printf("All tests passed!\n"); - - return 0; -}