From 8374ccf7b56735c466841e31c9e5ae80c013fb28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 16:33:59 -0600 Subject: [PATCH 1/2] [time] Remove C++ POSIX TZ string parser (bridge code) Remove the runtime POSIX TZ string parser and all associated bridge code now that timezone data is sent as pre-parsed structs via protobuf. Removed: - parse_posix_tz() and internal parsing helpers (skip_tz_name, parse_offset, parse_dst_rule, parse_uint, parse_transition_time) - RealTimeClock::set_timezone() overloads and apply_timezone_() - API connection fallback path for string-based timezone Kept: - All conversion functions (epoch_to_local_tm, is_in_dst, calculate_dst_transition) - Internal helpers used by conversion functions - localtime_r/localtime overrides - Tests for all permanent functions --- esphome/components/api/api_connection.cpp | 39 +- esphome/components/time/__init__.py | 11 +- esphome/components/time/posix_tz.cpp | 198 ----- esphome/components/time/posix_tz.h | 42 -- esphome/components/time/real_time_clock.cpp | 27 - esphome/components/time/real_time_clock.h | 28 - tests/components/time/posix_tz_parser.cpp | 757 ++++---------------- 7 files changed, 175 insertions(+), 927 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a10e53d5ee..bd62b4e31d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1113,30 +1113,23 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); #ifdef USE_TIME_TIMEZONE if (!value.timezone.empty()) { - // Check if the sender provided pre-parsed timezone data. - // If std_offset is non-zero or DST rules are present, the parsed data was populated. - // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent. const auto &pt = value.parsed_timezone; - if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) { - time::ParsedTimezone tz{}; - tz.std_offset_seconds = pt.std_offset_seconds; - tz.dst_offset_seconds = pt.dst_offset_seconds; - tz.dst_start.time_seconds = pt.dst_start.time_seconds; - tz.dst_start.day = static_cast(pt.dst_start.day); - tz.dst_start.type = static_cast(pt.dst_start.type); - tz.dst_start.month = static_cast(pt.dst_start.month); - tz.dst_start.week = static_cast(pt.dst_start.week); - tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); - tz.dst_end.time_seconds = pt.dst_end.time_seconds; - tz.dst_end.day = static_cast(pt.dst_end.day); - tz.dst_end.type = static_cast(pt.dst_end.type); - tz.dst_end.month = static_cast(pt.dst_end.month); - tz.dst_end.week = static_cast(pt.dst_end.week); - tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); - time::set_global_tz(tz); - } else { - homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); - } + time::ParsedTimezone tz{}; + tz.std_offset_seconds = pt.std_offset_seconds; + tz.dst_offset_seconds = pt.dst_offset_seconds; + tz.dst_start.time_seconds = pt.dst_start.time_seconds; + tz.dst_start.day = static_cast(pt.dst_start.day); + tz.dst_start.type = static_cast(pt.dst_start.type); + tz.dst_start.month = static_cast(pt.dst_start.month); + tz.dst_start.week = static_cast(pt.dst_start.week); + tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); + tz.dst_end.time_seconds = pt.dst_end.time_seconds; + tz.dst_end.day = static_cast(pt.dst_end.day); + tz.dst_end.type = static_cast(pt.dst_end.type); + tz.dst_end.month = static_cast(pt.dst_end.month); + tz.dst_end.week = static_cast(pt.dst_end.week); + tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); + time::set_global_tz(tz); } #endif } diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 7ffa408db9..7d63d049de 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -362,11 +362,12 @@ async def setup_time_core_(time_var, config): if CORE.is_host: # Host platform needs setenv("TZ")/tzset() for libc compatibility - cg.add(time_var.set_timezone(timezone)) - else: - # Embedded: pre-parse at codegen time, emit struct directly - parsed = parse_posix_tz_python(timezone) - _emit_parsed_timezone_fields(parsed) + cg.add(cg.RawExpression(f'setenv("TZ", "{timezone}", 1)')) + cg.add(cg.RawExpression("tzset()")) + + # Pre-parse at codegen time, emit struct directly + parsed = parse_posix_tz_python(timezone) + _emit_parsed_timezone_fields(parsed) for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index f811dd7989..e00397a0af 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -3,7 +3,6 @@ #ifdef USE_TIME_TIMEZONE #include "posix_tz.h" -#include namespace esphome::time { @@ -17,17 +16,6 @@ const ParsedTimezone &get_global_tz() { return global_tz_; } namespace internal { -// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule, -// and parse_transition_time are only used by parse_posix_tz() (bridge code). -static uint32_t parse_uint(const char *&p) { - uint32_t value = 0; - while (std::isdigit(static_cast(*p))) { - value = value * 10 + (*p - '0'); - p++; - } - return value; -} - bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } // Get days in year (avoids duplicate is_leap_year calls) @@ -121,62 +109,6 @@ void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) out_tm->tm_isdst = 0; } -bool skip_tz_name(const char *&p) { - if (*p == '<') { - // Angle-bracket quoted name: <+07>, <-03>, - p++; // skip '<' - while (*p && *p != '>') { - p++; - } - if (*p == '>') { - p++; // skip '>' - return true; - } - return false; // Unterminated - } - - // Standard name: 3+ letters - const char *start = p; - while (*p && std::isalpha(static_cast(*p))) { - p++; - } - return (p - start) >= 3; -} - -int32_t __attribute__((noinline)) parse_offset(const char *&p) { - int sign = 1; - if (*p == '-') { - sign = -1; - p++; - } else if (*p == '+') { - p++; - } - - int hours = parse_uint(p); - int minutes = 0; - int seconds = 0; - - if (*p == ':') { - p++; - minutes = parse_uint(p); - if (*p == ':') { - p++; - seconds = parse_uint(p); - } - } - - return sign * (hours * 3600 + minutes * 60 + seconds); -} - -// Helper to parse the optional /time suffix (reuses parse_offset logic) -static void parse_transition_time(const char *&p, DSTRule &rule) { - rule.time_seconds = 2 * 3600; // Default 02:00 - if (*p == '/') { - p++; - rule.time_seconds = parse_offset(p); - } -} - void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) { // J format: day 1-365, Feb 29 is NOT counted even in leap years // So day 60 is always March 1 @@ -217,59 +149,6 @@ void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int yea out_day = 31; } -bool parse_dst_rule(const char *&p, DSTRule &rule) { - rule = {}; // Zero initialize - - if (*p == 'M' || *p == 'm') { - // M format: Mm.w.d (month.week.day) - rule.type = DSTRuleType::MONTH_WEEK_DAY; - p++; - - rule.month = parse_uint(p); - if (rule.month < 1 || rule.month > 12) - return false; - - if (*p++ != '.') - return false; - - rule.week = parse_uint(p); - if (rule.week < 1 || rule.week > 5) - return false; - - if (*p++ != '.') - return false; - - rule.day_of_week = parse_uint(p); - if (rule.day_of_week > 6) - return false; - - } else if (*p == 'J' || *p == 'j') { - // J format: Jn (Julian day 1-365, not counting Feb 29) - rule.type = DSTRuleType::JULIAN_NO_LEAP; - p++; - - rule.day = parse_uint(p); - if (rule.day < 1 || rule.day > 365) - return false; - - } else if (std::isdigit(static_cast(*p))) { - // Plain number format: n (day 0-365, counting Feb 29) - rule.type = DSTRuleType::DAY_OF_YEAR; - - rule.day = parse_uint(p); - if (rule.day > 365) - return false; - - } else { - return false; - } - - // Parse optional /time suffix - parse_transition_time(p, rule); - - return true; -} - // Calculate days from Jan 1 of given year to given month/day static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) { int days = day - 1; @@ -365,83 +244,6 @@ bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone } } -// Remove before 2026.9.0: This parser is bridge code for backward compatibility with -// older Home Assistant clients that send the timezone as a POSIX TZ string instead of -// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct -// directly, this function and the parsing helpers above (skip_tz_name, parse_offset, -// parse_dst_rule, parse_transition_time) can be removed. -// See https://github.com/esphome/backlog/issues/91 -bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { - if (!tz_string || !*tz_string) { - return false; - } - - const char *p = tz_string; - - // Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false) - result.std_offset_seconds = 0; - result.dst_offset_seconds = 0; - result.dst_start = {}; - result.dst_end = {}; - - // Skip standard timezone name - if (!internal::skip_tz_name(p)) { - return false; - } - - // Parse standard offset (required) - if (!*p || (!std::isdigit(static_cast(*p)) && *p != '+' && *p != '-')) { - return false; - } - result.std_offset_seconds = internal::parse_offset(p); - - // Check for DST name - if (!*p) { - return true; // No DST - } - - // If next char is comma, there's no DST name but there are rules (invalid) - if (*p == ',') { - return false; - } - - // Check if there's something that looks like a DST name start - // (letter or angle bracket). If not, treat as trailing garbage and return success. - if (!std::isalpha(static_cast(*p)) && *p != '<') { - return true; // No DST, trailing characters ignored - } - - if (!internal::skip_tz_name(p)) { - return false; // Invalid DST name (started but malformed) - } - - // Optional DST offset (default is std - 1 hour) - if (*p && *p != ',' && (std::isdigit(static_cast(*p)) || *p == '+' || *p == '-')) { - result.dst_offset_seconds = internal::parse_offset(p); - } else { - result.dst_offset_seconds = result.std_offset_seconds - 3600; - } - - // Parse DST rules (required when DST name is present) - if (*p != ',') { - // DST name without rules - treat as no DST since we can't determine transitions - return true; - } - - p++; - if (!internal::parse_dst_rule(p, result.dst_start)) { - return false; - } - - // Second rule is required per POSIX - if (*p != ',') { - return false; - } - p++; - // has_dst() now returns true since dst_start.type was set by parse_dst_rule - return internal::parse_dst_rule(p, result.dst_end); -} - bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { if (!out_tm) { return false; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index c71ba15cd1..dbb7304474 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -36,28 +36,6 @@ struct ParsedTimezone { bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; } }; -/// Parse a POSIX TZ string into a ParsedTimezone struct. -/// -/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility). -/// This parser only exists so that older Home Assistant clients that send the timezone -/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still -/// set the timezone on the device. Once all clients are updated to send the struct -/// directly, this function and all internal parsing helpers will be removed. -/// See https://github.com/esphome/backlog/issues/91 -/// -/// Supports formats like: -/// - "EST5" (simple offset, no DST) -/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules) -/// - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times) -/// - "<+07>-7" (angle-bracket notation for special names) -/// - "IST-5:30" (half-hour offsets) -/// - "EST5EDT,J60,J300" (J-format: Julian day without leap day) -/// - "EST5EDT,60,300" (plain day number: day of year with leap day) -/// @param tz_string The POSIX TZ string to parse -/// @param result Output: the parsed timezone data -/// @return true if parsing succeeded, false on error -bool parse_posix_tz(const char *tz_string, ParsedTimezone &result); - /// Convert a UTC epoch to local time using the parsed timezone. /// This replaces libc's localtime() to avoid scanf dependency. /// @param utc_epoch Unix timestamp in UTC @@ -81,29 +59,9 @@ const ParsedTimezone &get_global_tz(); bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); // Internal helper functions exposed for testing. -// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only -// used by parse_posix_tz() which is bridge code for backward compatibility. -// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.) -// are used by the conversion functions and will stay. namespace internal { -/// Skip a timezone name (letters or <...> quoted format) -/// @param p Pointer to current position, updated on return -/// @return true if a valid name was found -bool skip_tz_name(const char *&p); - -/// Parse an offset in format [-]hh[:mm[:ss]] -/// @param p Pointer to current position, updated on return -/// @return Offset in seconds -int32_t parse_offset(const char *&p); - -/// Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time] -/// @param p Pointer to current position, updated on return -/// @param rule Output: the parsed rule -/// @return true if parsing succeeded -bool parse_dst_rule(const char *&p, DSTRule &rule); - /// Convert Julian day (J format, 1-365 not counting Feb 29) to month/day /// @param julian_day Day number 1-365 /// @param[out] month Output: month 1-12 diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index b7d4fc94aa..2b60497474 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,31 +107,4 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } -#ifdef USE_TIME_TIMEZONE -void RealTimeClock::apply_timezone_(const char *tz) { - ParsedTimezone parsed{}; - - // Handle null or empty input - use UTC - if (tz == nullptr || *tz == '\0') { - set_global_tz(parsed); - return; - } - -#ifdef USE_HOST - // On host platform, also set TZ environment variable for libc compatibility - setenv("TZ", tz, 1); - tzset(); -#endif - - // Parse the POSIX TZ string using our custom parser - if (!parse_posix_tz(tz, parsed)) { - ESP_LOGW(TAG, "Failed to parse timezone: %s", tz); - // parsed stays as default (UTC) on failure - } - - // Set global timezone for all time conversions - set_global_tz(parsed); -} -#endif - } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index f9de5f5614..e00e5b8ab8 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -22,30 +22,6 @@ class RealTimeClock : public PollingComponent { public: explicit RealTimeClock(); -#ifdef USE_TIME_TIMEZONE - /// Set the time zone from a POSIX TZ string. - void set_timezone(const char *tz) { this->apply_timezone_(tz); } - - /// Set the time zone from a character buffer with known length. - /// The buffer does not need to be null-terminated. - void set_timezone(const char *tz, size_t len) { - if (tz == nullptr) { - this->apply_timezone_(nullptr); - return; - } - // Stack buffer - TZ strings from tzdata are typically short (< 50 chars) - char buf[128]; - if (len >= sizeof(buf)) - len = sizeof(buf) - 1; - memcpy(buf, tz, len); - buf[len] = '\0'; - this->apply_timezone_(buf); - } - - /// Set the time zone from a std::string. - void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); } -#endif - /// Get the time in the currently defined timezone. ESPTime now(); @@ -65,10 +41,6 @@ class RealTimeClock : public PollingComponent { /// Report a unix epoch as current time. void synchronize_epoch_(uint32_t epoch); -#ifdef USE_TIME_TIMEZONE - void apply_timezone_(const char *tz); -#endif - LazyCallbackManager time_sync_callback_; }; diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index d1747ef5b1..1d127e1e58 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1,14 +1,7 @@ -// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime. +// Tests for time conversion functions, DST detection, and ESPTime::strptime. // -// Most tests here cover the C++ POSIX TZ string parser (parse_posix_tz), which is -// bridge code for backward compatibility — it will be removed before ESPHome 2026.9.0. -// After https://github.com/esphome/esphome/pull/14233 merges, the parser is solely -// used to handle timezone strings from Home Assistant clients older than 2026.3.0 -// that haven't been updated to send the pre-parsed ParsedTimezone protobuf struct. -// See https://github.com/esphome/backlog/issues/91 -// -// The epoch_to_local_tm, is_in_dst, and ESPTime::strptime tests cover conversion -// functions that will remain permanently. +// 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 @@ -35,435 +28,83 @@ static time_t make_utc(int year, int month, int day, int hour = 0, int min = 0, return days * 86400 + hour * 3600 + min * 60 + sec; } -// ============================================================================ -// 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()); +// 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; } -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()); +// 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; } -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()); +// 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; } -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)); -} - -// ============================================================================ -// Transition time edge cases (POSIX V3 allows -167 to +167 hours) -// ============================================================================ - -TEST(PosixTzParser, NegativeTransitionTime) { - ParsedTimezone tz; - // Negative transition time: /-1 means 11 PM (23:00) the previous day - ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1,M11.1.0/2", tz)); - EXPECT_EQ(tz.dst_start.time_seconds, -1 * 3600); // -1 hour = 11 PM previous day - EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600); -} - -TEST(PosixTzParser, NegativeTransitionTimeWithMinutes) { - ParsedTimezone tz; - // /-1:30 means 10:30 PM the previous day - ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1:30,M11.1.0", tz)); - EXPECT_EQ(tz.dst_start.time_seconds, -(1 * 3600 + 30 * 60)); -} - -TEST(PosixTzParser, LargeTransitionTime) { - ParsedTimezone tz; - // POSIX V3 allows transition times from -167 to +167 hours - // /25 means 1:00 AM the next day - ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/25,M11.1.0", tz)); - EXPECT_EQ(tz.dst_start.time_seconds, 25 * 3600); -} - -TEST(PosixTzParser, MaxTransitionTime167Hours) { - ParsedTimezone tz; - // Maximum allowed transition time per POSIX V3 - ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/167,M11.1.0", tz)); - EXPECT_EQ(tz.dst_start.time_seconds, 167 * 3600); -} - -TEST(PosixTzParser, TransitionTimeWithHoursMinutesSeconds) { - ParsedTimezone tz; - ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2:30:45,M11.1.0", tz)); - EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600 + 30 * 60 + 45); -} - -// ============================================================================ -// Invalid M format tests -// ============================================================================ - -TEST(PosixTzParser, MFormatInvalidMonth13) { - ParsedTimezone tz; - // Month must be 1-12 - EXPECT_FALSE(parse_posix_tz("EST5EDT,M13.1.0,M11.1.0", tz)); -} - -TEST(PosixTzParser, MFormatInvalidMonth0) { - ParsedTimezone tz; - // Month must be 1-12 - EXPECT_FALSE(parse_posix_tz("EST5EDT,M0.1.0,M11.1.0", tz)); -} - -TEST(PosixTzParser, MFormatInvalidWeek6) { - ParsedTimezone tz; - // Week must be 1-5 - EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.6.0,M11.1.0", tz)); -} - -TEST(PosixTzParser, MFormatInvalidWeek0) { - ParsedTimezone tz; - // Week must be 1-5 - EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.0.0,M11.1.0", tz)); -} - -TEST(PosixTzParser, MFormatInvalidDayOfWeek7) { - ParsedTimezone tz; - // Day of week must be 0-6 - EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.7,M11.1.0", tz)); -} - -TEST(PosixTzParser, MissingEndRule) { - ParsedTimezone tz; - // POSIX requires both start and end rules if any rules are specified - EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.0", tz)); -} - -TEST(PosixTzParser, MissingEndRuleJFormat) { - ParsedTimezone tz; - // POSIX requires both start and end rules if any rules are specified - EXPECT_FALSE(parse_posix_tz("EST5EDT,J60", tz)); -} - -TEST(PosixTzParser, MissingEndRulePlainDay) { - ParsedTimezone tz; - // POSIX requires both start and end rules if any rules are specified - EXPECT_FALSE(parse_posix_tz("EST5EDT,60", tz)); -} - -TEST(PosixTzParser, LowercaseMFormat) { - ParsedTimezone tz; - // Lowercase 'm' should be accepted - ASSERT_TRUE(parse_posix_tz("EST5EDT,m3.2.0,m11.1.0", tz)); - EXPECT_TRUE(tz.has_dst()); - EXPECT_EQ(tz.dst_start.month, 3); - EXPECT_EQ(tz.dst_end.month, 11); -} - -TEST(PosixTzParser, LowercaseJFormat) { - ParsedTimezone tz; - // Lowercase 'j' should be accepted - ASSERT_TRUE(parse_posix_tz("EST5EDT,j60,j305", tz)); - EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP); - EXPECT_EQ(tz.dst_start.day, 60); -} - -TEST(PosixTzParser, DstNameWithoutRules) { - ParsedTimezone tz; - // DST name present but no rules - treat as no DST since we can't determine transitions - ASSERT_TRUE(parse_posix_tz("EST5EDT", tz)); - EXPECT_FALSE(tz.has_dst()); - EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); -} - -TEST(PosixTzParser, TrailingCharactersIgnored) { - ParsedTimezone tz; - // Trailing characters after valid TZ should be ignored (parser stops at end of valid input) - // This matches libc behavior - ASSERT_TRUE(parse_posix_tz("EST5 extra garbage here", tz)); - EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); - EXPECT_FALSE(tz.has_dst()); -} - -TEST(PosixTzParser, PlainDay365LeapYear) { - // Day 365 in leap year is Dec 31 - int month, day; - internal::day_of_year_to_month_day(365, 2024, month, day); - EXPECT_EQ(month, 12); - EXPECT_EQ(day, 31); -} - -TEST(PosixTzParser, PlainDay364NonLeapYear) { - // Day 364 (0-indexed) is Dec 31 in non-leap year (last valid day) - int month, day; - internal::day_of_year_to_month_day(364, 2025, month, day); - EXPECT_EQ(month, 12); - EXPECT_EQ(day, 31); -} - -// ============================================================================ -// Large offset tests -// ============================================================================ - -TEST(PosixTzParser, MaxOffset14Hours) { - ParsedTimezone tz; - // Line Islands (Kiribati) is UTC+14, the maximum offset - ASSERT_TRUE(parse_posix_tz("<+14>-14", tz)); - EXPECT_EQ(tz.std_offset_seconds, -14 * 3600); -} - -TEST(PosixTzParser, MaxNegativeOffset12Hours) { - ParsedTimezone tz; - // Baker Island is UTC-12 - ASSERT_TRUE(parse_posix_tz("<-12>12", tz)); - EXPECT_EQ(tz.std_offset_seconds, 12 * 3600); +// 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(PosixTzParser, JulianDay60IsMarch1) { +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); @@ -471,7 +112,7 @@ TEST(PosixTzParser, JulianDay60IsMarch1) { EXPECT_EQ(day, 1); } -TEST(PosixTzParser, DayOfYear59DiffersByLeap) { +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); @@ -483,7 +124,7 @@ TEST(PosixTzParser, DayOfYear59DiffersByLeap) { EXPECT_EQ(day, 1); } -TEST(PosixTzParser, DayOfWeekKnownDates) { +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) @@ -492,56 +133,56 @@ TEST(PosixTzParser, DayOfWeekKnownDates) { EXPECT_EQ(internal::day_of_week(2026, 3, 8), 0); } -TEST(PosixTzParser, LeapYearDetection) { +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(PosixTzParser, JulianDay1IsJan1) { +TEST(PosixTz, JulianDay1IsJan1) { int month, day; internal::julian_to_month_day(1, month, day); EXPECT_EQ(month, 1); EXPECT_EQ(day, 1); } -TEST(PosixTzParser, JulianDay31IsJan31) { +TEST(PosixTz, JulianDay31IsJan31) { int month, day; internal::julian_to_month_day(31, month, day); EXPECT_EQ(month, 1); EXPECT_EQ(day, 31); } -TEST(PosixTzParser, JulianDay32IsFeb1) { +TEST(PosixTz, JulianDay32IsFeb1) { int month, day; internal::julian_to_month_day(32, month, day); EXPECT_EQ(month, 2); EXPECT_EQ(day, 1); } -TEST(PosixTzParser, JulianDay59IsFeb28) { +TEST(PosixTz, JulianDay59IsFeb28) { int month, day; internal::julian_to_month_day(59, month, day); EXPECT_EQ(month, 2); EXPECT_EQ(day, 28); } -TEST(PosixTzParser, JulianDay365IsDec31) { +TEST(PosixTz, JulianDay365IsDec31) { int month, day; internal::julian_to_month_day(365, month, day); EXPECT_EQ(month, 12); EXPECT_EQ(day, 31); } -TEST(PosixTzParser, DayOfYear0IsJan1) { +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(PosixTzParser, DaysInMonthRegular) { +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 @@ -557,19 +198,32 @@ TEST(PosixTzParser, DaysInMonthRegular) { EXPECT_EQ(internal::days_in_month(2025, 12), 31); // Dec - default case } -TEST(PosixTzParser, DaysInMonthLeapYear) { +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(PosixTzParser, DstStartUSEastern2026) { +TEST(PosixTz, DstStartUSEastern2026) { // March 8, 2026 is 2nd Sunday of March - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + auto tz = make_us_eastern(); time_t dst_start = internal::calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds); struct tm tm; @@ -582,10 +236,9 @@ TEST(PosixTzParser, DstStartUSEastern2026) { EXPECT_EQ(tm.tm_hour, 7); // 7:00 UTC = 2:00 EST } -TEST(PosixTzParser, DstEndUSEastern2026) { +TEST(PosixTz, DstEndUSEastern2026) { // November 1, 2026 is 1st Sunday of November - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); + auto tz = make_us_eastern(); time_t dst_end = internal::calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds); struct tm tm; @@ -598,7 +251,7 @@ TEST(PosixTzParser, DstEndUSEastern2026) { EXPECT_EQ(tm.tm_hour, 6); // 6:00 UTC = 2:00 EDT } -TEST(PosixTzParser, LastSundayOfMarch2026) { +TEST(PosixTz, LastSundayOfMarch2026) { // Europe: M3.5.0 = last Sunday of March = March 29, 2026 DSTRule rule{}; rule.type = DSTRuleType::MONTH_WEEK_DAY; @@ -613,7 +266,7 @@ TEST(PosixTzParser, LastSundayOfMarch2026) { EXPECT_EQ(tm.tm_wday, 0); // Sunday } -TEST(PosixTzParser, LastSundayOfOctober2026) { +TEST(PosixTz, LastSundayOfOctober2026) { // Europe: M10.5.0 = last Sunday of October = October 25, 2026 DSTRule rule{}; rule.type = DSTRuleType::MONTH_WEEK_DAY; @@ -628,7 +281,7 @@ TEST(PosixTzParser, LastSundayOfOctober2026) { EXPECT_EQ(tm.tm_wday, 0); // Sunday } -TEST(PosixTzParser, FirstSundayOfApril2026) { +TEST(PosixTz, FirstSundayOfApril2026) { // April 5, 2026 is 1st Sunday DSTRule rule{}; rule.type = DSTRuleType::MONTH_WEEK_DAY; @@ -647,46 +300,39 @@ TEST(PosixTzParser, FirstSundayOfApril2026) { // DST detection tests // ============================================================================ -TEST(PosixTzParser, IsInDstUSEasternSummer) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); - +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(PosixTzParser, IsInDstUSEasternWinter) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", 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(PosixTzParser, IsInDstNoDstTimezone) { - ParsedTimezone tz; - parse_posix_tz("IST-5:30", tz); +TEST(PosixTz, IsInDstNoDstTimezone) { + // India: IST-5:30 (no DST) + ParsedTimezone tz{}; + tz.std_offset_seconds = -(5 * 3600 + 30 * 60); + // No DST rules - // July 15, 2026 12:00 UTC time_t epoch = make_utc(2026, 7, 15, 12); 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); - +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(PosixTzParser, SouthernHemisphereDstWinter) { - ParsedTimezone tz; - parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", 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)); @@ -696,9 +342,8 @@ TEST(PosixTzParser, SouthernHemisphereDstWinter) { // epoch_to_local_tm tests // ============================================================================ -TEST(PosixTzParser, EpochToLocalBasic) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); +TEST(PosixTz, EpochToLocalBasic) { + ParsedTimezone tz{}; // UTC time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC struct tm local; @@ -709,9 +354,8 @@ TEST(PosixTzParser, EpochToLocalBasic) { EXPECT_EQ(local.tm_hour, 0); } -TEST(PosixTzParser, EpochToLocalNegativeEpoch) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); +TEST(PosixTz, EpochToLocalNegativeEpoch) { + ParsedTimezone tz{}; // UTC // Dec 31, 1969 23:59:59 UTC (1 second before epoch) time_t epoch = -1; @@ -725,15 +369,15 @@ TEST(PosixTzParser, EpochToLocalNegativeEpoch) { EXPECT_EQ(local.tm_sec, 59); } -TEST(PosixTzParser, EpochToLocalNullTmFails) { - ParsedTimezone tz; - parse_posix_tz("UTC0", tz); +TEST(PosixTz, EpochToLocalNullTmFails) { + ParsedTimezone tz{}; EXPECT_FALSE(epoch_to_local_tm(0, tz, nullptr)); } -TEST(PosixTzParser, EpochToLocalWithOffset) { - ParsedTimezone tz; - parse_posix_tz("EST5", tz); // UTC-5 +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); @@ -745,9 +389,8 @@ TEST(PosixTzParser, EpochToLocalWithOffset) { EXPECT_EQ(local.tm_isdst, 0); } -TEST(PosixTzParser, EpochToLocalDstTransition) { - ParsedTimezone tz; - parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz); +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); @@ -758,101 +401,12 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { EXPECT_EQ(local.tm_isdst, 1); } -// ============================================================================ -// Verification against libc -// ============================================================================ - -class LibcVerificationTest : public ::testing::TestWithParam> { - protected: - // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name - void SetUp() override { - // Save current TZ - const char *current_tz = getenv("TZ"); - saved_tz_ = current_tz ? current_tz : ""; - had_tz_ = current_tz != nullptr; - } - - // NOLINTNEXTLINE(readability-identifier-naming) - Google Test requires this name - void TearDown() override { - // Restore TZ - if (had_tz_) { - setenv("TZ", saved_tz_.c_str(), 1); - } else { - unsetenv("TZ"); - } - tzset(); - } - - private: - std::string saved_tz_; - bool had_tz_{false}; -}; - -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 {}; - ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &our_tm)); - - // libc implementation - setenv("TZ", tz_str, 1); - tzset(); - struct tm *libc_tm = localtime(&epoch); - ASSERT_NE(libc_tm, nullptr); - - 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); +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); @@ -863,10 +417,8 @@ TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) { 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); +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); @@ -993,7 +545,6 @@ TEST(ESPTimeStrptime, PartialTimeFails) { TEST(ESPTimeStrptime, ExtraCharactersFails) { ESPTime t{}; - // Full datetime with extra characters should fail EXPECT_FALSE(ESPTime::strptime("2026-03-15 14:30:45x", 20, t)); } @@ -1047,8 +598,7 @@ TEST(RecalcTimestampLocal, NormalTimeMatchesLibc) { const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_us_central(); set_global_tz(tz); // Test a normal time in winter (no DST) @@ -1070,8 +620,7 @@ TEST(RecalcTimestampLocal, SpringForwardSkippedHour) { const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_us_central(); set_global_tz(tz); // Test time before the transition (1:30 AM CST exists) @@ -1097,8 +646,7 @@ TEST(RecalcTimestampLocal, FallBackRepeatedHour) { const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_us_central(); set_global_tz(tz); // Test time before the transition (midnight CDT) @@ -1125,8 +673,7 @@ TEST(RecalcTimestampLocal, SouthernHemisphereDST) { const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_australia_sydney(); set_global_tz(tz); // Test winter time (July - no DST in southern hemisphere) @@ -1146,8 +693,7 @@ TEST(RecalcTimestampLocal, ExactTransitionBoundary) { const char *tz_str = "CST6CDT,M3.2.0,M11.1.0"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_us_central(); set_global_tz(tz); // 1:59:59 AM CST - last second before transition (still standard time) @@ -1172,8 +718,19 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) { const char *tz_str = "CST6CDT,M3.2.0/3,M11.1.0/3"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + 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) @@ -1193,8 +750,7 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) { const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0"; setenv("TZ", tz_str, 1); tzset(); - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_australia_sydney(); set_global_tz(tz); // Dec 31, 2025 at 23:30 - DST should be active @@ -1219,7 +775,6 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) { // ============================================================================ TEST(TimezoneOffset, NoTimezone) { - // When no timezone is set, offset should be 0 time::ParsedTimezone tz{}; set_global_tz(tz); @@ -1228,34 +783,28 @@ TEST(TimezoneOffset, NoTimezone) { } TEST(TimezoneOffset, FixedOffsetPositive) { - // India: UTC+5:30 (no DST) - const char *tz_str = "IST-5:30"; + // India: IST-5:30 (no DST) time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + tz.std_offset_seconds = -(5 * 3600 + 30 * 60); set_global_tz(tz); int32_t offset = ESPTime::timezone_offset(); - // Offset should be +5:30 = 19800 seconds (to add to UTC to get local) EXPECT_EQ(offset, 5 * 3600 + 30 * 60); } TEST(TimezoneOffset, FixedOffsetNegative) { - // US Eastern Standard Time: UTC-5 (testing without DST rules) - const char *tz_str = "EST5"; + // EST5 (no DST) time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + tz.std_offset_seconds = 5 * 3600; set_global_tz(tz); int32_t offset = ESPTime::timezone_offset(); - // Offset should be -5 hours = -18000 seconds EXPECT_EQ(offset, -5 * 3600); } TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { // US Eastern with DST - const char *tz_str = "EST5EDT,M3.2.0,M11.1.0"; - time::ParsedTimezone tz{}; - ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + auto tz = make_us_eastern(); set_global_tz(tz); // Get current time and check offset matches expected based on DST status From 199288b813781bc9a32be79c6a2c49dc84074083 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 16:41:20 -0600 Subject: [PATCH 2/2] [time] Fix test namespace for RecalcTimestampLocal and TimezoneOffset tests Move tests that use make_us_central(), set_global_tz(), ParsedTimezone, and DSTRuleType into esphome::time::testing namespace where those symbols are declared. --- tests/components/time/posix_tz_parser.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index 1d127e1e58..2748011bdd 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -565,6 +565,12 @@ TEST(ESPTimeStrptime, LeadingZeroTime) { // 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 {}; @@ -775,7 +781,7 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) { // ============================================================================ TEST(TimezoneOffset, NoTimezone) { - time::ParsedTimezone tz{}; + ParsedTimezone tz{}; set_global_tz(tz); int32_t offset = ESPTime::timezone_offset(); @@ -784,7 +790,7 @@ TEST(TimezoneOffset, NoTimezone) { TEST(TimezoneOffset, FixedOffsetPositive) { // India: IST-5:30 (no DST) - time::ParsedTimezone tz{}; + ParsedTimezone tz{}; tz.std_offset_seconds = -(5 * 3600 + 30 * 60); set_global_tz(tz); @@ -794,7 +800,7 @@ TEST(TimezoneOffset, FixedOffsetPositive) { TEST(TimezoneOffset, FixedOffsetNegative) { // EST5 (no DST) - time::ParsedTimezone tz{}; + ParsedTimezone tz{}; tz.std_offset_seconds = 5 * 3600; set_global_tz(tz); @@ -812,7 +818,7 @@ TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { int32_t offset = ESPTime::timezone_offset(); // Verify offset matches what is_in_dst says - if (time::is_in_dst(now, tz)) { + if (is_in_dst(now, tz)) { // During DST, offset should be -4 hours (EDT) EXPECT_EQ(offset, -4 * 3600); } else { @@ -821,4 +827,4 @@ TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) { } } -} // namespace esphome::testing +} // namespace esphome::time::testing