diff --git a/esphome/core/posix_tz.cpp b/esphome/core/posix_tz.cpp new file mode 100644 index 00000000000..1de9acd3086 --- /dev/null +++ b/esphome/core/posix_tz.cpp @@ -0,0 +1,493 @@ +#include "posix_tz.h" +#include + +namespace esphome { + +namespace internal { + +bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } + +int days_in_month(int year, int month) { + static const int DAYS_PER_MONTH[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (month == 2 && is_leap_year(year)) + return 29; + return DAYS_PER_MONTH[month - 1]; +} + +// Zeller-like algorithm for day of week (0 = Sunday) +int day_of_week(int year, int month, int day) { + // Adjust for January/February + if (month < 3) { + month += 12; + year--; + } + int k = year % 100; + int j = year / 100; + int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7; + // Convert from Zeller (0=Sat) to standard (0=Sun) + return ((h + 6) % 7); +} + +void epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { + // Days since epoch + int64_t days = epoch / 86400; + int32_t remaining_secs = epoch % 86400; + if (remaining_secs < 0) { + days--; + remaining_secs += 86400; + } + + out_tm->tm_sec = remaining_secs % 60; + remaining_secs /= 60; + out_tm->tm_min = remaining_secs % 60; + out_tm->tm_hour = remaining_secs / 60; + + // Day of week (Jan 1, 1970 was Thursday = 4) + out_tm->tm_wday = static_cast((days + 4) % 7); + if (out_tm->tm_wday < 0) + out_tm->tm_wday += 7; + + // Calculate year, month, day + int year = 1970; + while (days >= (is_leap_year(year) ? 366 : 365)) { + days -= is_leap_year(year) ? 366 : 365; + year++; + } + while (days < 0) { + year--; + days += is_leap_year(year) ? 366 : 365; + } + + out_tm->tm_year = year - 1900; + out_tm->tm_yday = static_cast(days); + + int month = 1; + while (days >= days_in_month(year, month)) { + days -= days_in_month(year, month); + month++; + } + + out_tm->tm_mon = month - 1; + out_tm->tm_mday = static_cast(days) + 1; + out_tm->tm_isdst = 0; +} + +time_t tm_to_epoch_utc(const struct tm *tm) { + int year = tm->tm_year + 1900; + int month = tm->tm_mon + 1; + int day = tm->tm_mday; + + // Days from epoch to start of year + int64_t days = 0; + for (int y = 1970; y < year; y++) { + days += is_leap_year(y) ? 366 : 365; + } + + // Days from start of year to start of month + for (int m = 1; m < month; m++) { + days += days_in_month(year, m); + } + + // Days in current month + days += day - 1; + + return days * 86400 + tm->tm_hour * 3600 + tm->tm_min * 60 + tm->tm_sec; +} + +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 parse_offset(const char *&p) { + int sign = 1; + if (*p == '-') { + sign = -1; + p++; + } else if (*p == '+') { + p++; + } + + // Parse hours + int hours = 0; + while (*p && std::isdigit(static_cast(*p))) { + hours = hours * 10 + (*p - '0'); + p++; + } + + int minutes = 0; + int seconds = 0; + + // Optional :mm + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + minutes = minutes * 10 + (*p - '0'); + p++; + } + + // Optional :ss + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + seconds = seconds * 10 + (*p - '0'); + p++; + } + } + } + + return sign * (hours * 3600 + minutes * 60 + seconds); +} + +// Helper to parse the optional /time suffix +static void parse_transition_time(const char *&p, DSTRule &rule) { + rule.time_seconds = 2 * 3600; // Default 02:00 + + if (*p == '/') { + p++; + // Parse time as [+-]hh[:mm[:ss]] + int sign = 1; + if (*p == '-') { + sign = -1; + p++; + } else if (*p == '+') { + p++; + } + + int hours = 0; + while (*p && std::isdigit(static_cast(*p))) { + hours = hours * 10 + (*p - '0'); + p++; + } + + int minutes = 0; + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + minutes = minutes * 10 + (*p - '0'); + p++; + } + } + + int seconds = 0; + if (*p == ':') { + p++; + while (*p && std::isdigit(static_cast(*p))) { + seconds = seconds * 10 + (*p - '0'); + p++; + } + } + + rule.time_seconds = sign * (hours * 3600 + minutes * 60 + seconds); + } +} + +void julian_to_month_day(int julian_day, int year, 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 + static const int DAYS_BEFORE_MONTH[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; + + out_month = 1; + for (int m = 11; m >= 0; m--) { + if (julian_day > DAYS_BEFORE_MONTH[m]) { + out_month = m + 1; + out_day = julian_day - DAYS_BEFORE_MONTH[m]; + return; + } + } + out_day = julian_day; +} + +void day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) { + // Plain format: day 0-365, Feb 29 IS counted in leap years + // Day 0 = Jan 1 + int remaining = day_of_year; + out_month = 1; + + while (out_month <= 12) { + int days_this_month = days_in_month(year, out_month); + if (remaining < days_this_month) { + out_day = remaining + 1; + return; + } + remaining -= days_this_month; + out_month++; + } + + // Shouldn't reach here with valid input + out_month = 12; + out_day = 31; +} + +bool parse_dst_rule(const char *&p, DSTRule &rule) { + // Initialize defaults + rule = {}; + rule.time_seconds = 2 * 3600; // Default 02:00 + + if (*p == 'M' || *p == 'm') { + // M format: Mm.w.d (month.week.day) + rule.type = DSTRuleType::MONTH_WEEK_DAY; + p++; + + // Parse month + rule.month = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.month = rule.month * 10 + (*p - '0'); + p++; + } + if (rule.month < 1 || rule.month > 12) + return false; + + if (*p != '.') + return false; + p++; + + // Parse week (1-5, where 5 means "last") + rule.week = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.week = rule.week * 10 + (*p - '0'); + p++; + } + if (rule.week < 1 || rule.week > 5) + return false; + + if (*p != '.') + return false; + p++; + + // Parse day of week (0 = Sunday) + rule.day_of_week = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.day_of_week = rule.day_of_week * 10 + (*p - '0'); + 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 = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.day = rule.day * 10 + (*p - '0'); + 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 = 0; + while (*p && std::isdigit(static_cast(*p))) { + rule.day = rule.day * 10 + (*p - '0'); + p++; + } + if (rule.day > 365) + return false; + + } else { + return false; + } + + // Parse optional /time suffix + parse_transition_time(p, rule); + + return true; +} + +} // namespace internal + +bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { + if (!tz_string || !*tz_string) { + return false; + } + + const char *p = tz_string; + + // Initialize result + result.std_offset_seconds = 0; + result.dst_offset_seconds = 0; + result.has_dst = false; + 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) { + // No DST + result.has_dst = false; + return true; + } + + // If next char is comma, there's no DST name but there are rules (invalid) + if (*p == ',') { + return false; + } + + if (!internal::skip_tz_name(p)) { + // No valid DST name, no DST + result.has_dst = false; + return true; + } + + // We have a DST name + result.has_dst = true; + + // 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 if present + if (*p == ',') { + p++; + if (!internal::parse_dst_rule(p, result.dst_start)) { + return false; + } + + if (*p == ',') { + p++; + if (!internal::parse_dst_rule(p, result.dst_end)) { + return false; + } + } + } + + return true; +} + +time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { + int month, day; + + switch (rule.type) { + case DSTRuleType::MONTH_WEEK_DAY: { + // Find the nth occurrence of day_of_week in the given month + int first_day_of_month = internal::day_of_week(year, rule.month, 1); + + // Days until first occurrence of target day + int days_until_first = (rule.day_of_week - first_day_of_month + 7) % 7; + int first_occurrence = 1 + days_until_first; + + if (rule.week == 5) { + // "Last" occurrence - find the last one in the month + int days_in_m = internal::days_in_month(year, rule.month); + day = first_occurrence; + while (day + 7 <= days_in_m) { + day += 7; + } + } else { + // nth occurrence + day = first_occurrence + (rule.week - 1) * 7; + } + month = rule.month; + break; + } + + case DSTRuleType::JULIAN_NO_LEAP: + // J format: day 1-365, Feb 29 not counted + internal::julian_to_month_day(rule.day, year, month, day); + break; + + case DSTRuleType::DAY_OF_YEAR: + // Plain format: day 0-365, Feb 29 counted + internal::day_of_year_to_month_day(rule.day, year, month, day); + break; + } + + // Build the tm struct for this date at the transition time + struct tm transition_tm = {}; + transition_tm.tm_year = year - 1900; + transition_tm.tm_mon = month - 1; + transition_tm.tm_mday = day; + transition_tm.tm_hour = rule.time_seconds / 3600; + transition_tm.tm_min = (rule.time_seconds % 3600) / 60; + transition_tm.tm_sec = rule.time_seconds % 60; + + // Convert to UTC epoch, then add the base offset + // (transition times are specified in local time before the transition) + time_t local_epoch = internal::tm_to_epoch_utc(&transition_tm); + return local_epoch + base_offset_seconds; +} + +bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) { + if (!tz.has_dst) { + return false; + } + + // Get the year from the UTC epoch + struct tm utc_tm; + internal::epoch_to_tm_utc(utc_epoch, &utc_tm); + int year = utc_tm.tm_year + 1900; + + // Calculate DST start and end for this year + // DST start transition happens in standard time + time_t dst_start = calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds); + // DST end transition happens in daylight time + time_t dst_end = calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds); + + if (dst_start < dst_end) { + // Northern hemisphere: DST is between start and end + return (utc_epoch >= dst_start && utc_epoch < dst_end); + } else { + // Southern hemisphere: DST is outside the range (wraps around year) + return (utc_epoch >= dst_start || utc_epoch < dst_end); + } +} + +int32_t get_utc_offset(time_t utc_epoch, const ParsedTimezone &tz) { + if (is_in_dst(utc_epoch, tz)) { + return tz.dst_offset_seconds; + } + return tz.std_offset_seconds; +} + +bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { + if (!out_tm) { + return false; + } + + int32_t offset = get_utc_offset(utc_epoch, tz); + + // Apply offset (POSIX offset is positive west, so subtract to get local) + time_t local_epoch = utc_epoch - offset; + + internal::epoch_to_tm_utc(local_epoch, out_tm); + out_tm->tm_isdst = is_in_dst(utc_epoch, tz) ? 1 : 0; + + return true; +} + +} // namespace esphome diff --git a/esphome/core/posix_tz.h b/esphome/core/posix_tz.h new file mode 100644 index 00000000000..6d76c4142cf --- /dev/null +++ b/esphome/core/posix_tz.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +/// Type of DST transition rule +enum class DSTRuleType : uint8_t { + MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March) + JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted) + DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years) +}; + +/// Rule for DST transition +struct DSTRule { + DSTRuleType type; ///< Type of rule + uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY) + uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY) + uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY) + uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR) + int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM) +}; + +/// Parsed POSIX timezone information +struct ParsedTimezone { + int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west) + int32_t dst_offset_seconds; ///< DST offset from UTC in seconds + DSTRule dst_start; ///< When DST starts + DSTRule dst_end; ///< When DST ends + bool has_dst; ///< Whether this timezone has DST +}; + +/// Parse a POSIX TZ string into a ParsedTimezone struct. +/// 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); + +/// Calculate the epoch timestamp for a DST transition in a given year. +/// @param year The year (e.g., 2026) +/// @param rule The DST rule (month, week, day_of_week, time) +/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context) +/// @return Unix epoch timestamp of the transition +time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds); + +/// Check if a given UTC epoch falls within DST for the parsed timezone. +/// @param utc_epoch Unix timestamp in UTC +/// @param tz The parsed timezone +/// @return true if DST is in effect at the given time +bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz); + +/// 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 +/// @param tz The parsed timezone +/// @param[out] out_tm Output tm struct with local time +/// @return true on success +bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm); + +/// Get the current offset from UTC in seconds for a given epoch. +/// @param utc_epoch Unix timestamp in UTC +/// @param tz The parsed timezone +/// @return Offset in seconds (positive = behind UTC, negative = ahead) +int32_t get_utc_offset(time_t utc_epoch, const ParsedTimezone &tz); + +// Internal helper functions exposed for testing + +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 year The year (for leap year calculation) +/// @param[out] month Output: month 1-12 +/// @param[out] day Output: day of month +void julian_to_month_day(int julian_day, int year, int &month, int &day); + +/// Convert day of year (plain format, 0-365 counting Feb 29) to month/day +/// @param day_of_year Day number 0-365 +/// @param year The year (for leap year calculation) +/// @param[out] month Output: month 1-12 +/// @param[out] day Output: day of month +void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day); + +/// Calculate day of week for any date (0 = Sunday) +/// Uses a simplified algorithm that works for years 1970-2099 +int day_of_week(int year, int month, int day); + +/// Get the number of days in a month +int days_in_month(int year, int month); + +/// Check if a year is a leap year +bool is_leap_year(int year); + +/// Convert epoch to year/month/day/hour/min/sec (UTC) +void epoch_to_tm_utc(time_t epoch, struct tm *out_tm); + +/// Convert tm struct to epoch (UTC) +time_t tm_to_epoch_utc(const struct tm *tm); + +} // namespace internal + +} // namespace esphome diff --git a/tests/unit_tests/test_posix_tz_parser.cpp b/tests/unit_tests/test_posix_tz_parser.cpp new file mode 100644 index 00000000000..dbc0ecf380f --- /dev/null +++ b/tests/unit_tests/test_posix_tz_parser.cpp @@ -0,0 +1,884 @@ +// 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; +}