From 1efcf6db36409dd868a697f21c740e45469005e6 Mon Sep 17 00:00:00 2001 From: Phil Ross Date: Tue, 3 Nov 2020 09:28:14 +0000 Subject: [PATCH] Support "slim" zoneinfo files produced by default by zic >= 2020b. Use the POSIX-style TZ string to calculate transitions after the last defined transition contained within the file. At the moment these transitions are generated when the file is loaded. In a later release this will likely be changed to calculate transitions on demand. Resolves #120. --- lib/tzinfo.rb | 3 + lib/tzinfo/annual_rules.rb | 71 ++ .../data_sources/posix_time_zone_parser.rb | 181 +++++ .../data_sources/zoneinfo_data_source.rb | 5 +- lib/tzinfo/data_sources/zoneinfo_reader.rb | 209 ++++- lib/tzinfo/transition_rule.rb | 455 +++++++++++ .../data_sources/tc_posix_time_zone_parser.rb | 281 +++++++ test/data_sources/tc_zoneinfo_data_source.rb | 10 +- test/data_sources/tc_zoneinfo_reader.rb | 757 +++++++++++++++++- test/tc_annual_rules.rb | 98 +++ test/tc_transition_rule.rb | 563 +++++++++++++ 11 files changed, 2609 insertions(+), 24 deletions(-) create mode 100644 lib/tzinfo/annual_rules.rb create mode 100644 lib/tzinfo/data_sources/posix_time_zone_parser.rb create mode 100644 lib/tzinfo/transition_rule.rb create mode 100644 test/data_sources/tc_posix_time_zone_parser.rb create mode 100644 test/tc_annual_rules.rb create mode 100644 test/tc_transition_rule.rb diff --git a/lib/tzinfo.rb b/lib/tzinfo.rb index 6949127b..df447a9d 100644 --- a/lib/tzinfo.rb +++ b/lib/tzinfo.rb @@ -25,6 +25,8 @@ module TZInfo require_relative 'tzinfo/timezone_offset' require_relative 'tzinfo/timezone_transition' +require_relative 'tzinfo/transition_rule' +require_relative 'tzinfo/annual_rules' require_relative 'tzinfo/data_sources' require_relative 'tzinfo/data_sources/timezone_info' @@ -35,6 +37,7 @@ module TZInfo require_relative 'tzinfo/data_sources/country_info' +require_relative 'tzinfo/data_sources/posix_time_zone_parser' require_relative 'tzinfo/data_sources/zoneinfo_reader' require_relative 'tzinfo/data_source' diff --git a/lib/tzinfo/annual_rules.rb b/lib/tzinfo/annual_rules.rb new file mode 100644 index 00000000..c73e38b6 --- /dev/null +++ b/lib/tzinfo/annual_rules.rb @@ -0,0 +1,71 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +module TZInfo + # A set of rules that define when transitions occur in time zones with + # annually occurring daylight savings time. + # + # @private + class AnnualRules #:nodoc: + # @return [TimezoneOffset] the standard offset that applies when daylight + # savings time is not in force. + attr_reader :std_offset + + # @return [TimezoneOffset] the offset that applies when daylight savings + # time is in force. + attr_reader :dst_offset + + # @return [TransitionRule] the rule that determines when daylight savings + # time starts. + attr_reader :dst_start_rule + + # @return [TransitionRule] the rule that determines when daylight savings + # time ends. + attr_reader :dst_end_rule + + # Initializes a new {AnnualRules} instance. + # + # @param std_offset [TimezoneOffset] the standard offset that applies when + # daylight savings time is not in force. + # @param dst_offset [TimezoneOffset] the offset that applies when daylight + # savings time is in force. + # @param dst_start_rule [TransitionRule] the rule that determines when + # daylight savings time starts. + # @param dst_end_rule [TransitionRule] the rule that determines when daylight + # savings time ends. + def initialize(std_offset, dst_offset, dst_start_rule, dst_end_rule) + @std_offset = std_offset + @dst_offset = dst_offset + @dst_start_rule = dst_start_rule + @dst_end_rule = dst_end_rule + end + + # Returns the transitions between standard and daylight savings time for a + # given year. The results are ordered by time of occurrence (earliest to + # latest). + # + # @param year [Integer] the year to calculate transitions for. + # @return [Array] the transitions for the year. + def transitions(year) + start_dst = apply_rule(@dst_start_rule, @std_offset, @dst_offset, year) + end_dst = apply_rule(@dst_end_rule, @dst_offset, @std_offset, year) + + end_dst.timestamp_value < start_dst.timestamp_value ? [end_dst, start_dst] : [start_dst, end_dst] + end + + private + + # Applies a given rule between offsets on a year. + # + # @param rule [TransitionRule] the rule to apply. + # @param from_offset [TimezoneOffset] the offset the rule transitions from. + # @param to_offset [TimezoneOffset] the offset the rule transitions to. + # @param year [Integer] the year when the transition occurs. + # @return [TimezoneTransition] the transition determined by the rule. + def apply_rule(rule, from_offset, to_offset, year) + at = rule.at(from_offset, year) + TimezoneTransition.new(to_offset, from_offset, at.value) + end + end + private_constant :AnnualRules +end diff --git a/lib/tzinfo/data_sources/posix_time_zone_parser.rb b/lib/tzinfo/data_sources/posix_time_zone_parser.rb new file mode 100644 index 00000000..b3d2b2e3 --- /dev/null +++ b/lib/tzinfo/data_sources/posix_time_zone_parser.rb @@ -0,0 +1,181 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +require 'strscan' + +module TZInfo + # Use send as a workaround for erroneous 'wrong number of arguments' errors + # with JRuby 9.0.5.0 when calling methods with Java implementations. See #114. + send(:using, UntaintExt) if TZInfo.const_defined?(:UntaintExt) + + module DataSources + # An {InvalidPosixTimeZone} exception is raised if an invalid POSIX-style + # time zone string is encountered. + # + # @private + class InvalidPosixTimeZone < StandardError #:nodoc: + end + private_constant :InvalidPosixTimeZone + + # A parser for POSIX-style TZ strings used in zoneinfo files and specified + # by tzfile.5 and tzset.3. + # + # @private + class PosixTimeZoneParser #:nodoc: + # Initializes a new {PosixTimeZoneParser}. + # + # @param string_deduper [StringDeduper] a {StringDeduper} instance to use + # to dedupe abbreviations. + def initialize(string_deduper) + @string_deduper = string_deduper + end + + # Parses a POSIX-style TZ string. + # + # @param tz_string [String] the string to parse. + # @return [Object] either a {TimezoneOffset} for a constantly applied + # offset or an {AnnualRules} instance representing the rules. + # @raise [InvalidPosixTimeZone] if `tz_string` is not a `String`. + # @raise [InvalidPosixTimeZone] if `tz_string` is is not valid. + def parse(tz_string) + raise InvalidPosixTimeZone unless tz_string.kind_of?(String) + return nil if tz_string.empty? + + s = StringScanner.new(tz_string) + check_scan(s, /([^-+,\d<][^-+,\d]*) | <([^>]+)>/x) + std_abbrev = @string_deduper.dedupe((s[1] || s[2]).untaint) + check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/) + std_offset = get_offset_from_hms(s[1], s[2], s[3]) + + if s.scan(/([^-+,\d<][^-+,\d]*) | <([^>]+)>/x) + dst_abbrev = @string_deduper.dedupe((s[1] || s[2]).untaint) + + if s.scan(/([-+]?\d+)(?::(\d+)(?::(\d+))?)?/) + dst_offset = get_offset_from_hms(s[1], s[2], s[3]) + else + # POSIX is negative for ahead of UTC. + dst_offset = std_offset - 3600 + end + + dst_difference = std_offset - dst_offset + + start_rule = parse_rule(s, 'start') + end_rule = parse_rule(s, 'end') + + raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." if s.rest? + + if start_rule.is_always_first_day_of_year? && start_rule.transition_at == 0 && + end_rule.is_always_last_day_of_year? && end_rule.transition_at == 86400 + dst_difference + # Constant daylight savings time. + # POSIX is negative for ahead of UTC. + TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev) + else + AnnualRules.new( + TimezoneOffset.new(-std_offset, 0, std_abbrev), + TimezoneOffset.new(-std_offset, dst_difference, dst_abbrev), + start_rule, + end_rule) + end + elsif !s.rest? + # Constant standard time. + # POSIX is negative for ahead of UTC. + TimezoneOffset.new(-std_offset, 0, std_abbrev) + else + raise InvalidPosixTimeZone, "Expected the end of a POSIX-style time zone string but found '#{s.rest}'." + end + end + + private + + # Parses a rule. + # + # @param s [StringScanner] the `StringScanner` to read the rule from. + # @param type [String] the type of rule (either `'start'` or `'end'`). + # @raise [InvalidPosixTimeZone] if the rule is not valid. + # @return [TransitionRule] the parsed rule. + def parse_rule(s, type) + check_scan(s, /,(?: (?: J(\d+) ) | (\d+) | (?: M(\d+)\.(\d)\.(\d) ) )/x) + julian_day_of_year = s[1] + absolute_day_of_year = s[2] + month = s[3] + week = s[4] + day_of_week = s[5] + + if s.scan(/\//) + check_scan(s, /([-+]?\d+)(?::(\d+)(?::(\d+))?)?/) + transition_at = get_seconds_after_midnight_from_hms(s[1], s[2], s[3]) + else + transition_at = 7200 + end + + begin + if julian_day_of_year + JulianDayOfYearTransitionRule.new(julian_day_of_year.to_i, transition_at) + elsif absolute_day_of_year + AbsoluteDayOfYearTransitionRule.new(absolute_day_of_year.to_i, transition_at) + elsif week == '5' + LastDayOfMonthTransitionRule.new(month.to_i, day_of_week.to_i, transition_at) + else + DayOfMonthTransitionRule.new(month.to_i, week.to_i, day_of_week.to_i, transition_at) + end + rescue ArgumentError => e + raise InvalidPosixTimeZone, "Invalid #{type} rule in POSIX-style time zone string: #{e}" + end + end + + # Returns an offset in seconds from hh:mm:ss values. The value can be + # negative. -02:33:12 would represent 2 hours, 33 minutes and 12 seconds + # ahead of UTC. + # + # @param h [String] the hours. + # @param m [String] the minutes. + # @param s [String] the seconds. + # @return [Integer] the offset. + # @raise [InvalidPosixTimeZone] if the mm and ss values are greater than + # 59. + def get_offset_from_hms(h, m, s) + h = h.to_i + m = m.to_i + s = s.to_i + raise InvalidPosixTimeZone, "Invalid minute #{m} in offset for POSIX-style time zone string." if m > 59 + raise InvalidPosixTimeZone, "Invalid second #{s} in offset for POSIX-style time zone string." if s > 59 + magnitude = (h.abs * 60 + m) * 60 + s + h < 0 ? -magnitude : magnitude + end + + # Returns the seconds from midnight from hh:mm:ss values. Hours can exceed + # 24 for a time on the following day. Hours can be negative to subtract + # hours from midnight on the given day. -02:33:12 represents 22:33:12 on + # the prior day. + # + # @param h [String] the hour. + # @param m [String] the minutes past the hour. + # @param s [String] the seconds past the minute. + # @return [Integer] the number of seconds after midnight. + # @raise [InvalidPosixTimeZone] if the mm and ss values are greater than + # 59. + def get_seconds_after_midnight_from_hms(h, m, s) + h = h.to_i + m = m.to_i + s = s.to_i + raise InvalidPosixTimeZone, "Invalid minute #{m} in time for POSIX-style time zone string." if m > 59 + raise InvalidPosixTimeZone, "Invalid second #{s} in time for POSIX-style time zone string." if s > 59 + (h * 3600) + m * 60 + s + end + + # Scans for a pattern and raises an exception if the pattern does not + # match the input. + # + # @param s [StringScanner] the `StringScanner` to scan. + # @param pattern [Regexp] the pattern to match. + # @return [String] the result of the scan. + # @raise [InvalidPosixTimeZone] if the pattern does not match the input. + def check_scan(s, pattern) + result = s.scan(pattern) + raise InvalidPosixTimeZone, "Expected '#{s.rest}' to match #{pattern} in POSIX-style time zone string." unless result + result + end + end + private_constant :PosixTimeZoneParser + end +end diff --git a/lib/tzinfo/data_sources/zoneinfo_data_source.rb b/lib/tzinfo/data_sources/zoneinfo_data_source.rb index f14a4838..cb76c3a2 100644 --- a/lib/tzinfo/data_sources/zoneinfo_data_source.rb +++ b/lib/tzinfo/data_sources/zoneinfo_data_source.rb @@ -237,7 +237,10 @@ def initialize(zoneinfo_dir = nil, alternate_iso3166_tab_path = nil) @timezone_identifiers = load_timezone_identifiers.freeze @countries = load_countries(iso3166_tab_path, zone_tab_path).freeze @country_codes = @countries.keys.sort!.freeze - @zoneinfo_reader = ZoneinfoReader.new(ConcurrentStringDeduper.new) + + string_deduper = ConcurrentStringDeduper.new + posix_tz_parser = PosixTimeZoneParser.new(string_deduper) + @zoneinfo_reader = ZoneinfoReader.new(posix_tz_parser, string_deduper) end # Returns a frozen `Array` of all the available time zone identifiers. The diff --git a/lib/tzinfo/data_sources/zoneinfo_reader.rb b/lib/tzinfo/data_sources/zoneinfo_reader.rb index c1e78207..ffa83ea3 100644 --- a/lib/tzinfo/data_sources/zoneinfo_reader.rb +++ b/lib/tzinfo/data_sources/zoneinfo_reader.rb @@ -14,11 +14,20 @@ class InvalidZoneinfoFile < StandardError # Reads compiled zoneinfo TZif (\0, 2 or 3) files. class ZoneinfoReader #:nodoc: + # The year to generate transitions up to. + # + # @private + GENERATE_UP_TO = Time.now.utc.year + 100 + private_constant :GENERATE_UP_TO + # Initializes a new {ZoneinfoReader}. # + # @param posix_tz_parser [PosixTimeZoneParser] a {PosixTimeZoneParser} + # instance to use to parse POSIX-style TZ strings. # @param string_deduper [StringDeduper] a {StringDeduper} instance to use - # when deduping abbreviations. - def initialize(string_deduper) + # to dedupe abbreviations. + def initialize(posix_tz_parser, string_deduper) + @posix_tz_parser = posix_tz_parser @string_deduper = string_deduper end @@ -163,6 +172,168 @@ def derive_offsets(transitions, offsets) first_offset_index end + # Determines if the offset from a transition matches the offset from a + # rule. This is a looser match than equality, not requiring that the + # base_utc_offset and std_offset both match (which have to be derived for + # transitions, but are known for rules. + # + # @param offset [TimezoneOffset] an offset from a transition. + # @param rule_offset [TimezoneOffset] an offset from a rule. + # @return [Boolean] whether the offsets match. + def offset_matches_rule?(offset, rule_offset) + offset.observed_utc_offset == rule_offset.observed_utc_offset && + offset.dst? == rule_offset.dst? && + offset.abbreviation == rule_offset.abbreviation + end + + # Apply the rules from the TZ string when there were no defined + # transitions. Checks for a matching offset. Returns the rules-based + # constant offset or generates transitions from 1970 until 100 years into + # the future (at the time of loading zoneinfo_reader.rb). + # + # @param file [IO] the file being processed. + # @param first_offset [TimezoneOffset] the first offset included in the + # file that would normally apply without the rules. + # @param rules [Object] a {TimezoneOffset} specifying a constant offset or + # {AnnualRules} instance specfying transitions. + # @return [Object] either a {TimezoneOffset} or an `Array` of + # {TimezoneTransition}s. + # @raise [InvalidZoneinfoFile] if the first offset does not match the + # rules. + def apply_rules_without_transitions(file, first_offset, rules) + if rules.kind_of?(TimezoneOffset) + unless offset_matches_rule?(first_offset, rules) + raise InvalidZoneinfoFile, "Constant offset POSIX-style TZ string does not match constant offset in file '#{file.path}'." + end + rules + else + transitions = 1970.upto(GENERATE_UP_TO).flat_map {|y| rules.transitions(y) } + first_transition = transitions[0] + + unless offset_matches_rule?(first_offset, first_transition.previous_offset) + # Not transitioning from the designated first offset. + + if offset_matches_rule?(first_offset, first_transition.offset) + # Skip an unnecessary transition to the first offset. + transitions.shift + else + # The initial offset doesn't match the ongoing rules. Replace the + # previous offset of the first transition. + transitions[0] = TimezoneTransition.new(first_transition.offset, first_offset, first_transition.timestamp_value) + end + end + + transitions + end + end + + # Finds an offset that is equivalent to the one specified in the given + # `Array`. Matching is performed with {TimezoneOffset#==}. + # + # @param offsets [Array] an `Array` to search. + # @param offset [TimezoneOffset] the offset to search for. + # @return [TimezoneOffset] the matching offset from `offsets` or `nil` + # if not found. + def find_existing_offset(offsets, offset) + offsets.find {|o| o == offset } + end + + # Returns a new AnnualRules instance with standard and daylight savings + # offsets replaced with equivalents from an array. This reduces the memory + # requirement for loaded time zones by reusing offsets for rule-generated + # transitions. + # + # @param offsets [Array] an `Array` to search for + # equivalent offsets. + # @param annual_rules [AnnualRules] the {AnnualRules} instance to check. + # @return [AnnualRules] either a new {AnnualRules} instance with either + # the {AnnualRules#std_offset std_offset} or {AnnualRules#dst_offset + # dst_offset} replaced, or the original instance if no equivalent for + # either {AnnualRules#std_offset std_offset} or {AnnualRules#dst_offset + # dst_offset} could be found. + def replace_with_existing_offsets(offsets, annual_rules) + existing_std_offset = find_existing_offset(offsets, annual_rules.std_offset) + existing_dst_offset = find_existing_offset(offsets, annual_rules.dst_offset) + if existing_std_offset || existing_dst_offset + AnnualRules.new(existing_std_offset || annual_rules.std_offset, existing_dst_offset || annual_rules.dst_offset, + annual_rules.dst_start_rule, annual_rules.dst_end_rule) + else + annual_rules + end + end + + # Validates the offset indicated to be observed by the rules before the + # first generated transition against the offset of the last defined + # transition. + # + # Fix the last defined transition if it differ on just base/std offsets + # (which are derived). Raise an error if the observed UTC offset or + # abbreviations differ. + # + # @param file [IO] the file being processed. + # @param last_defined [TimezoneTransition] the last defined transition in + # the file. + # @param first_rule_offset [TimezoneOffset] the offset the rules indicate + # is observed prior to the first rules generated transition. + # @return [TimezoneTransition] the last defined transition (either the + # original instance or a replacement). + # @raise [InvalidZoneinfoFile] if the offset of {last_defined} and + # {first_rule_offset} do not match. + def validate_and_fix_last_defined_transition_offset(file, last_defined, first_rule_offset) + offset_of_last_defined = last_defined.offset + + if offset_of_last_defined == first_rule_offset + last_defined + else + if offset_matches_rule?(offset_of_last_defined, first_rule_offset) + # The same overall offset, but differing in the base or std + # offset (which are derived). Correct by using the rule. + TimezoneTransition.new(first_rule_offset, last_defined.previous_offset, last_defined.timestamp_value) + else + raise InvalidZoneinfoFile, "The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{file.path}'." + end + end + end + + # Apply the rules from the TZ string when there were defined + # transitions. Checks for a matching offset with the last transition. + # Redefines the last transition if required and if the rules don't + # specific a constant offset, generates transitions until 100 years into + # the future (at the time of loading zoneinfo_reader.rb). + # + # @param file [IO] the file being processed. + # @param transitions [Array] the defined transitions. + # @param offsets [Array] the offsets used by the defined + # transitions. + # @param rules [Object] a {TimezoneOffset} specifying a constant offset or + # {AnnualRules} instance specfying transitions. + # @raise [InvalidZoneinfoFile] if the first offset does not match the + # rules. + # @raise [InvalidZoneinfoFile] if the previous offset of the first + # generated transition does not match the offset of the last defined + # transition. + def apply_rules_with_transitions(file, transitions, offsets, rules) + last_defined = transitions[-1] + + if rules.kind_of?(TimezoneOffset) + transitions[-1] = validate_and_fix_last_defined_transition_offset(file, last_defined, rules) + else + last_year = last_defined.local_end_at.to_time.year + + if last_year <= GENERATE_UP_TO + rules = replace_with_existing_offsets(offsets, rules) + + generated = rules.transitions(last_year).find_all {|t| t.timestamp_value > last_defined.timestamp_value } + + (last_year + 1).upto(GENERATE_UP_TO).flat_map {|y| rules.transitions(y) } + + unless generated.empty? + transitions[-1] = validate_and_fix_last_defined_transition_offset(file, last_defined, generated[0].previous_offset) + transitions.concat(generated) + end + end + end + end + # Parses a zoneinfo file and returns either a {TimezoneOffset} that is # constantly observed or an `Array` of {TimezoneTransition}s. # @@ -171,7 +342,7 @@ def derive_offsets(transitions, offsets) # {TimezoneTransition}s. # @raise [InvalidZoneinfoFile] if the file is not a valid zoneinfo file. def parse(file) - magic, version, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt = + magic, version, ttisutccnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt = check_read(file, 44).unpack('a4 a x15 NNNNNN') if magic != 'TZif' @@ -180,11 +351,11 @@ def parse(file) if version == '2' || version == '3' # Skip the first 32-bit section and read the header of the second 64-bit section - file.seek(timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + ttisgmtcnt + ttisstdcnt, IO::SEEK_CUR) + file.seek(timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + ttisstdcnt + ttisutccnt, IO::SEEK_CUR) prev_version = version - magic, version, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt = + magic, version, ttisutccnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt = check_read(file, 44).unpack('a4 a x15 NNNNNN') unless magic == 'TZif' && (version == prev_version) @@ -229,6 +400,23 @@ def parse(file) abbrev = check_read(file, charcnt) + if using_64bit + # Skip to the POSIX-style TZ string. + file.seek(ttisstdcnt + ttisutccnt, IO::SEEK_CUR) # + leapcnt * 8, but leapcnt is checked above and guaranteed to be 0. + tz_string_start = check_read(file, 1) + raise InvalidZoneinfoFile, "Expected newline starting POSIX-style TZ string in file '#{file.path}'." unless tz_string_start == "\n" + tz_string = file.readline("\n").force_encoding(Encoding::UTF_8) + raise InvalidZoneinfoFile, "Expected newline ending POSIX-style TZ string in file '#{file.path}'." unless tz_string.chomp!("\n") + + begin + rules = @posix_tz_parser.parse(tz_string) + rescue InvalidPosixTimeZone => e + raise InvalidZoneinfoFile, "Failed to parse POSIX-style TZ string in file '#{file.path}': #{e}" + end + else + rules = nil + end + # Derive the offsets from standard time (std_offset). first_offset_index = derive_offsets(transitions, offsets) @@ -266,12 +454,16 @@ def parse(file) if transitions.empty? - first_offset + if rules + apply_rules_without_transitions(file, first_offset, rules) + else + first_offset + end else previous_offset = first_offset previous_at = nil - transitions.map do |t| + transitions = transitions.map do |t| offset = offsets[t[:offset]] at = t[:at] raise InvalidZoneinfoFile, "Transition at #{at} is not later than the previous transition at #{previous_at} in file '#{file.path}'." if previous_at && previous_at >= at @@ -280,6 +472,9 @@ def parse(file) previous_at = at tt end + + apply_rules_with_transitions(file, transitions, offsets, rules) if rules + transitions end end end diff --git a/lib/tzinfo/transition_rule.rb b/lib/tzinfo/transition_rule.rb new file mode 100644 index 00000000..58ef5684 --- /dev/null +++ b/lib/tzinfo/transition_rule.rb @@ -0,0 +1,455 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +module TZInfo + # Base class for rules definining the transition between standard and daylight + # savings time. + # + # @abstract + # @private + class TransitionRule #:nodoc: + # Returns the number of seconds after midnight local time on the day + # identified by the rule at which the transition occurs. Can be negative to + # denote a time on the prior day. Can be greater than or equal to 86,400 to + # denote a time of the following day. + # + # @return [Integer] the time in seconds after midnight local time at which + # the transition occurs. + attr_reader :transition_at + + # Initializes a new {TransitionRule}. + # + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + def initialize(transition_at) + raise ArgumentError, 'Invalid transition_at' unless transition_at.kind_of?(Integer) + @transition_at = transition_at + end + + # Calculates the time of the transition from a given offset on a given year. + # + # @param offset [TimezoneOffset] the current offset at the time the rule + # will transition. + # @param year [Integer] the year in which the transition occurs (local + # time). + # @return [TimestampWithOffset] the time at which the transition occurs. + def at(offset, year) + day = get_day(offset, year) + TimestampWithOffset.set_timezone_offset(Timestamp.for(day + @transition_at), offset) + end + + # Determines if this {TransitionRule} is equal to another instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {TransitionRule} with the same + # {transition_at} as this {TransitionRule}, otherwise `false`. + def ==(r) + r.kind_of?(TransitionRule) && @transition_at == r.transition_at + end + alias eql? == + + # @return [Integer] a hash based on {hash_args} (defaulting to + # {transition_at}). + def hash + hash_args.hash + end + + protected + + # @return [Array] an `Array` of parameters that will influence the output of + # {hash}. + def hash_args + [@transition_at] + end + end + private_constant :TransitionRule + + # A base class for transition rules that activate based on an integer day of + # the year. + # + # @abstract + # @private + class DayOfYearTransitionRule < TransitionRule #:nodoc: + # Initializes a new {DayOfYearTransitionRule}. + # + # @param day [Integer] the day of the year on which the transition occurs. + # The precise meaning is defined by subclasses. + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + # @raise [ArgumentError] if `day` is not an `Integer`. + def initialize(day, transition_at) + super(transition_at) + raise ArgumentError, 'Invalid day' unless day.kind_of?(Integer) + @seconds = day * 86400 + end + + # Determines if this {DayOfYearTransitionRule} is equal to another instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {DayOfYearTransitionRule} with the + # same {transition_at} and day as this {DayOfYearTransitionRule}, + # otherwise `false`. + def ==(r) + super(r) && r.kind_of?(DayOfYearTransitionRule) && @seconds == r.seconds + end + alias eql? == + + protected + + # @return [Integer] the day multipled by the number of seconds in a day. + attr_reader :seconds + + # (see TransitionRule#hash_args) + def hash_args + [@seconds] + super + end + end + private_constant :DayOfYearTransitionRule + + # Defines transitions that occur on the zero-based nth day of the year. + # + # Day 0 is 1 January. + # + # Leap days are counted. Day 59 will be 29 February on a leap year and 1 March + # on a non-leap year. Day 365 will be 31 December on a leap year and 1 January + # the following year on a non-leap year. + # + # @private + class AbsoluteDayOfYearTransitionRule < DayOfYearTransitionRule #:nodoc: + # Initializes a new {AbsoluteDayOfYearTransitionRule}. + # + # @param day [Integer] the zero-based day of the year on which the + # transition occurs (0 to 365 inclusive). + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + # @raise [ArgumentError] if `day` is not an `Integer`. + # @raise [ArgumentError] if `day` is less than 0 or greater than 365. + def initialize(day, transition_at = 0) + super(day, transition_at) + raise ArgumentError, 'Invalid day' unless day >= 0 && day <= 365 + end + + # @return [Boolean] `true` if the day specified by this transition is the + # first in the year (a day number of 0), otherwise `false`. + def is_always_first_day_of_year? + seconds == 0 + end + + # @return [Boolean] `false`. + def is_always_last_day_of_year? + false + end + + # Determines if this {AbsoluteDayOfYearTransitionRule} is equal to another + # instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {AbsoluteDayOfYearTransitionRule} + # with the same {transition_at} and day as this + # {AbsoluteDayOfYearTransitionRule}, otherwise `false`. + def ==(r) + super(r) && r.kind_of?(AbsoluteDayOfYearTransitionRule) + end + alias eql? == + + protected + + # Returns a `Time` representing midnight local time on the day specified by + # the rule for the given offset and year. + # + # @param offset [TimezoneOffset] the current offset at the time of the + # transition. + # @param year [Integer] the year in which the transition occurs. + # @return [Time] midnight local time on the day specified by the rule for + # the given offset and year. + def get_day(offset, year) + Time.new(year, 1, 1, 0, 0, 0, offset.observed_utc_offset) + seconds + end + + # (see TransitionRule#hash_args) + def hash_args + [AbsoluteDayOfYearTransitionRule] + super + end + end + + # Defines transitions that occur on the one-based nth Julian day of the year. + # + # Leap days are not counted. Day 1 is 1 January. Day 60 is always 1 March. + # Day 365 is always 31 December. + # + # @private + class JulianDayOfYearTransitionRule < DayOfYearTransitionRule #:nodoc: + # The 60 days in seconds. + LEAP = 60 * 86400 + private_constant :LEAP + + # The length of a non-leap year in seconds. + YEAR = 365 * 86400 + private_constant :YEAR + + # Initializes a new {JulianDayOfYearTransitionRule}. + # + # @param day [Integer] the one-based Julian day of the year on which the + # transition occurs (1 to 365 inclusive). + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + # @raise [ArgumentError] if `day` is not an `Integer`. + # @raise [ArgumentError] if `day` is less than 1 or greater than 365. + def initialize(day, transition_at = 0) + super(day, transition_at) + raise ArgumentError, 'Invalid day' unless day >= 1 && day <= 365 + end + + # @return [Boolean] `true` if the day specified by this transition is the + # first in the year (a day number of 1), otherwise `false`. + def is_always_first_day_of_year? + seconds == 86400 + end + + # @return [Boolean] `true` if the day specified by this transition is the + # last in the year (a day number of 365), otherwise `false`. + def is_always_last_day_of_year? + seconds == YEAR + end + + # Determines if this {JulianDayOfYearTransitionRule} is equal to another + # instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {JulianDayOfYearTransitionRule} with + # the same {transition_at} and day as this + # {JulianDayOfYearTransitionRule}, otherwise `false`. + def ==(r) + super(r) && r.kind_of?(JulianDayOfYearTransitionRule) + end + alias eql? == + + protected + + # Returns a `Time` representing midnight local time on the day specified by + # the rule for the given offset and year. + # + # @param offset [TimezoneOffset] the current offset at the time of the + # transition. + # @param year [Integer] the year in which the transition occurs. + # @return [Time] midnight local time on the day specified by the rule for + # the given offset and year. + def get_day(offset, year) + # Returns 1 March on non-leap years. + leap = Time.new(year, 2, 29, 0, 0, 0, offset.observed_utc_offset) + diff = seconds - LEAP + diff += 86400 if diff >= 0 && leap.mday == 29 + leap + diff + end + + # (see TransitionRule#hash_args) + def hash_args + [JulianDayOfYearTransitionRule] + super + end + end + private_constant :JulianDayOfYearTransitionRule + + # A base class for rules that transition on a particular day of week of a + # given week (subclasses specify which week of the month). + # + # @abstract + # @private + class DayOfWeekTransitionRule < TransitionRule #:nodoc: + # Initializes a new {DayOfWeekTransitionRule}. + # + # @param month [Integer] the month of the year when the transition occurs. + # @param day_of_week [Integer] the day of the week when the transition + # occurs. 0 is Sunday, 6 is Saturday. + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + # @raise [ArgumentError] if `month` is not an `Integer`. + # @raise [ArgumentError] if `month` is less than 1 or greater than 12. + # @raise [ArgumentError] if `day_of_week` is not an `Integer`. + # @raise [ArgumentError] if `day_of_week` is less than 0 or greater than 6. + def initialize(month, day_of_week, transition_at) + super(transition_at) + raise ArgumentError, 'Invalid month' unless month.kind_of?(Integer) && month >= 1 && month <= 12 + raise ArgumentError, 'Invalid day_of_week' unless day_of_week.kind_of?(Integer) && day_of_week >= 0 && day_of_week <= 6 + @month = month + @day_of_week = day_of_week + end + + # @return [Boolean] `false`. + def is_always_first_day_of_year? + false + end + + # @return [Boolean] `false`. + def is_always_last_day_of_year? + false + end + + # Determines if this {DayOfWeekTransitionRule} is equal to another + # instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {DayOfWeekTransitionRule} with the + # same {transition_at}, month and day of week as this + # {DayOfWeekTransitionRule}, otherwise `false`. + def ==(r) + super(r) && r.kind_of?(DayOfWeekTransitionRule) && @month == r.month && @day_of_week == r.day_of_week + end + alias eql? == + + protected + + # @return [Integer] the month of the year (1 to 12). + attr_reader :month + + # @return [Integer] the day of the week (0 to 6 for Sunday to Monday). + attr_reader :day_of_week + + # (see TransitionRule#hash_args) + def hash_args + [@month, @day_of_week] + super + end + end + private_constant :DayOfWeekTransitionRule + + # A rule that transitions on the nth occurrence of a particular day of week + # of a calendar month. + # + # @private + class DayOfMonthTransitionRule < DayOfWeekTransitionRule #:nodoc: + # Initializes a new {DayOfMonthTransitionRule}. + # + # @param month [Integer] the month of the year when the transition occurs. + # @param week [Integer] the week of the month when the transition occurs (1 + # to 4). + # @param day_of_week [Integer] the day of the week when the transition + # occurs. 0 is Sunday, 6 is Saturday. + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + # @raise [ArgumentError] if `month` is not an `Integer`. + # @raise [ArgumentError] if `month` is less than 1 or greater than 12. + # @raise [ArgumentError] if `week` is not an `Integer`. + # @raise [ArgumentError] if `week` is less than 1 or greater than 4. + # @raise [ArgumentError] if `day_of_week` is not an `Integer`. + # @raise [ArgumentError] if `day_of_week` is less than 0 or greater than 6. + def initialize(month, week, day_of_week, transition_at = 0) + super(month, day_of_week, transition_at) + raise ArgumentError, 'Invalid week' unless week.kind_of?(Integer) && week >= 1 && week <= 4 + @offset_start = (week - 1) * 7 + 1 + end + + # Determines if this {DayOfMonthTransitionRule} is equal to another + # instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {DayOfMonthTransitionRule} with the + # same {transition_at}, month, week and day of week as this + # {DayOfMonthTransitionRule}, otherwise `false`. + def ==(r) + super(r) && r.kind_of?(DayOfMonthTransitionRule) && @offset_start == r.offset_start + end + alias eql? == + + protected + + # @return [Integer] the day the week starts on for a month starting on a + # Sunday. + attr_reader :offset_start + + # Returns a `Time` representing midnight local time on the day specified by + # the rule for the given offset and year. + # + # @param offset [TimezoneOffset] the current offset at the time of the + # transition. + # @param year [Integer] the year in which the transition occurs. + # @return [Time] midnight local time on the day specified by the rule for + # the given offset and year. + def get_day(offset, year) + candidate = Time.new(year, month, @offset_start, 0, 0, 0, offset.observed_utc_offset) + diff = day_of_week - candidate.wday + + if diff < 0 + candidate + (7 + diff) * 86400 + elsif diff > 0 + candidate + diff * 86400 + else + candidate + end + end + + # (see TransitionRule#hash_args) + def hash_args + [@offset_start] + super + end + end + private_constant :DayOfMonthTransitionRule + + # A rule that transitions on the last occurrence of a particular day of week + # of a calendar month. + # + # @private + class LastDayOfMonthTransitionRule < DayOfWeekTransitionRule #:nodoc: + # Initializes a new {LastDayOfMonthTransitionRule}. + # + # @param month [Integer] the month of the year when the transition occurs. + # @param day_of_week [Integer] the day of the week when the transition + # occurs. 0 is Sunday, 6 is Saturday. + # @param transition_at [Integer] the time in seconds after midnight local + # time at which the transition occurs. + # @raise [ArgumentError] if `transition_at` is not an `Integer`. + # @raise [ArgumentError] if `month` is not an `Integer`. + # @raise [ArgumentError] if `month` is less than 1 or greater than 12. + # @raise [ArgumentError] if `day_of_week` is not an `Integer`. + # @raise [ArgumentError] if `day_of_week` is less than 0 or greater than 6. + def initialize(month, day_of_week, transition_at = 0) + super(month, day_of_week, transition_at) + end + + # Determines if this {LastDayOfMonthTransitionRule} is equal to another + # instance. + # + # @param r [Object] the instance to test for equality. + # @return [Boolean] `true` if `r` is a {LastDayOfMonthTransitionRule} with + # the same {transition_at}, month and day of week as this + # {LastDayOfMonthTransitionRule}, otherwise `false`. + def ==(r) + super(r) && r.kind_of?(LastDayOfMonthTransitionRule) + end + alias eql? == + + protected + + # Returns a `Time` representing midnight local time on the day specified by + # the rule for the given offset and year. + # + # @param offset [TimezoneOffset] the current offset at the time of the + # transition. + # @param year [Integer] the year in which the transition occurs. + # @return [Time] midnight local time on the day specified by the rule for + # the given offset and year. + def get_day(offset, year) + next_month = month + 1 + if next_month == 13 + year += 1 + next_month = 1 + end + + candidate = Time.new(year, next_month, 1, 0, 0, 0, offset.observed_utc_offset) - 86400 + diff = candidate.wday - day_of_week + + if diff < 0 + candidate - (diff + 7) * 86400 + elsif diff > 0 + candidate - diff * 86400 + else + candidate + end + end + end + private_constant :LastDayOfMonthTransitionRule +end diff --git a/test/data_sources/tc_posix_time_zone_parser.rb b/test/data_sources/tc_posix_time_zone_parser.rb new file mode 100644 index 00000000..d6c8c352 --- /dev/null +++ b/test/data_sources/tc_posix_time_zone_parser.rb @@ -0,0 +1,281 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +require_relative '../test_utils' + +include TZInfo + +# Use send as a workaround for erroneous 'wrong number of arguments' errors with +# JRuby 9.0.5.0 when calling methods with Java implementations. See #114. +send(:using, TestUtils::TaintExt) if TestUtils.const_defined?(:TaintExt) + +module DataSources + class TCPosixTimeZoneParser < Minitest::Test + HOUR = 3600 + MINUTE = 60 + + class << self + private + + def append_time_to_rule(day_rule, time) + time ? "#{day_rule}/#{time}" : day_rule + end + + def define_invalid_dst_rule_tests(type, rule) + define_method "test_#{type}_dst_start_rule_for_invalid_#{rule}" do + tz_string = "STD-1DST,#{rule},300" + assert_raises(InvalidPosixTimeZone) { @parser.parse(tz_string) } + end + + define_method "test_#{type}_dst_end_rule_for_invalid_#{rule}" do + tz_string = "STD-1DST,60,#{rule}" + assert_raises(InvalidPosixTimeZone) { @parser.parse(tz_string) } + end + end + end + + def setup + @string_deduper = StringDeduper.new + @parser = PosixTimeZoneParser.new(@string_deduper) + end + + def test_empty_rule_returns_nil + result = @parser.parse('') + assert_nil(result) + end + + ABBREVIATIONS_WITH_OFFSETS = [ + ['UTC0', 'UTC', 0], + ['U0', 'U', 0], + ['West1', 'West', -HOUR], + ['East-1', 'East', HOUR], + ['<-05>5', '-05', -5 * HOUR], + ['<+12>-12', '+12', 12 * HOUR], + ['HMM2:30', 'HMM', -(2 * HOUR + 30 * MINUTE)], + ['HHMM02:30', 'HHMM', -(2 * HOUR + 30 * MINUTE)], + ['HHMM+02:30', 'HHMM', -(2 * HOUR + 30 * MINUTE)], + ['HHMSS-12:5:50', 'HHMSS', 12 * HOUR + 5 * MINUTE + 50], + ['HHMMSS-12:05:50', 'HHMMSS', 12 * HOUR + 5 * MINUTE + 50], + ['HHMMS-12:05:7', 'HHMMS', 12 * HOUR + 5 * MINUTE + 7] + ] + + ABBREVIATIONS_WITH_OFFSETS.each do |(tz_string, expected_abbrev, expected_base_offset)| + define_method "test_std_only_returns_std_offset_#{tz_string}" do + result = @parser.parse(tz_string) + expected = TimezoneOffset.new(expected_base_offset, 0, expected_abbrev) + assert_equal(expected, result) + end + end + + ABBREVIATIONS_WITH_OFFSETS.each do |(abbrev_and_offset, expected_abbrev, expected_base_offset)| + define_method "test_std_offset_#{abbrev_and_offset}" do + result = @parser.parse(abbrev_and_offset + 'DST,60,300') + expected_std_offset = TimezoneOffset.new(expected_base_offset, 0, expected_abbrev) + assert_equal(expected_std_offset, result.std_offset) + end + end + + [ + ['Zero0One-1', 'One', 0, HOUR], + ['Zero0One', 'One', 0, HOUR], + ['Z0O', 'O', 0, HOUR], + ['West1WestS0', 'WestS', -HOUR, HOUR], + ['West1WestS', 'WestS', -HOUR, HOUR], + ['East-1EastS-2', 'EastS', HOUR, HOUR], + ['East-1EastS', 'EastS', HOUR, HOUR], + ['Neg2NegS3', 'NegS', -2 * HOUR, -HOUR], + ['<-05>5<-04>4', '-04', -5 * HOUR, HOUR], + ['STD5<-04>4', '-04', -5 * HOUR, HOUR], + ['<+12>-12<+13>-13', '+13', 12 * HOUR, HOUR], + ['STD-12<+13>-13', '+13', 12 * HOUR, HOUR], + ['HMM2:30SHMM1:15', 'SHMM', -(2 * HOUR + 30 * MINUTE), HOUR + 15 * MINUTE], + ['HHMM02:30SHHMM01:15', 'SHHMM', -(2 * HOUR + 30 * MINUTE), HOUR + 15 * MINUTE], + ['HHMM+02:30SHHMM+01:15', 'SHHMM', -(2 * HOUR + 30 * MINUTE), HOUR + 15 * MINUTE], + ['HHMSS-12:5:50SHHMSS-13:4:30', 'SHHMSS', 12 * HOUR + 5 * MINUTE + 50, 58 * MINUTE + 40], + ['HHMMSS-12:05:50SHHMMSS-13:04:30', 'SHHMMSS', 12 * HOUR + 5 * MINUTE + 50, 58 * MINUTE + 40], + ['HHMMS-12:05:7SHHMMSS-13:06:8', 'SHHMMSS', 12 * HOUR + 5 * MINUTE + 7, HOUR + MINUTE + 1], + ].each do |(abbrevs_and_offsets, expected_abbrev, expected_base_offset, expected_std_offset)| + define_method "test_dst_offset_#{abbrevs_and_offsets}" do + result = @parser.parse(abbrevs_and_offsets + ',60,300') + expected_dst_offset = TimezoneOffset.new(expected_base_offset, expected_std_offset, expected_abbrev) + assert_equal(expected_dst_offset, result.dst_offset) + end + end + + ['01:-1', '01:60', '01:00:-1', '01:00:60'].each do |abbrev_and_offset| + ['', 'DST,60,300'].each do |dst_suffix| + tz_string = abbrev_and_offset + dst_suffix + define_method "test_std_offset_invalid_#{tz_string}" do + assert_raises(InvalidPosixTimeZone) { @parser.parse(tz_string) } + end + end + + tz_string = "STD1#{abbrev_and_offset},60,300" + define_method "test_dst_offset_invalid_#{tz_string}" do + assert_raises(InvalidPosixTimeZone) { @parser.parse(tz_string) } + end + end + + [ + [nil, 2 * HOUR], + ['2', 2 * HOUR], + ['+2', 2 * HOUR], + ['-2', -2 * HOUR], + ['2:3:4', 2 * HOUR + 3 * MINUTE + 4], + ['02:03:04', 2 * HOUR + 3 * MINUTE + 4], + ['-2:3:4', -2 * HOUR + 3 * MINUTE + 4], # 22:03:04 on the day prior to the one specified + ['-02:03:04', -2 * HOUR + 3 * MINUTE + 4], + ['167', 167 * HOUR], + ['-167', -167 * HOUR] + ].each do |(time, expected_offset_from_midnight)| + [ + ['J1', 1], + ['J365', 365] + ].each do |(julian_day_rule, expected_julian_day)| + rule = append_time_to_rule(julian_day_rule, time) + + define_method "test_julian_day_dst_start_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,#{rule},300") + expected_dst_start_rule = JulianDayOfYearTransitionRule.new(expected_julian_day, expected_offset_from_midnight) + assert_equal(expected_dst_start_rule, result.dst_start_rule) + end + + define_method "test_julian_day_dst_end_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,60,#{rule}") + expected_dst_end_rule = JulianDayOfYearTransitionRule.new(expected_julian_day, expected_offset_from_midnight) + assert_equal(expected_dst_end_rule, result.dst_end_rule) + end + end + + [ + ['0', 0], + ['365', 365] + ].each do |(absolute_day_rule, expected_day)| + rule = append_time_to_rule(absolute_day_rule, time) + + define_method "test_absolute_day_dst_start_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,#{rule},J300") + expected_dst_start_rule = AbsoluteDayOfYearTransitionRule.new(expected_day, expected_offset_from_midnight) + assert_equal(expected_dst_start_rule, result.dst_start_rule) + end + + define_method "test_absolute_day_dst_end_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,J60,#{rule}") + expected_dst_end_rule = AbsoluteDayOfYearTransitionRule.new(expected_day, expected_offset_from_midnight) + assert_equal(expected_dst_end_rule, result.dst_end_rule) + end + end + + [ + ['M1.1.0', 1, 1, 0], + ['M12.4.6', 12, 4, 6] + ].each do |(day_of_month_rule, expected_month, expected_week, expected_day_of_week)| + rule = append_time_to_rule(day_of_month_rule, time) + + define_method "test_day_of_month_dst_start_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,#{rule},300") + expected_dst_start_rule = DayOfMonthTransitionRule.new(expected_month, expected_week, expected_day_of_week, expected_offset_from_midnight) + assert_equal(expected_dst_start_rule, result.dst_start_rule) + end + + define_method "test_day_of_month_dst_end_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,60,#{rule}") + expected_dst_end_rule = DayOfMonthTransitionRule.new(expected_month, expected_week, expected_day_of_week, expected_offset_from_midnight) + assert_equal(expected_dst_end_rule, result.dst_end_rule) + end + end + + [ + ['M1.5.0', 1, 0], + ['M12.5.6', 12, 6] + ].each do |(last_day_of_month_rule, expected_month, expected_day_of_week)| + rule = append_time_to_rule(last_day_of_month_rule, time) + + define_method "test_last_day_of_month_dst_start_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,#{rule},300") + expected_dst_start_rule = LastDayOfMonthTransitionRule.new(expected_month, expected_day_of_week, expected_offset_from_midnight) + assert_equal(expected_dst_start_rule, result.dst_start_rule) + end + + define_method "test_last_day_of_month_dst_end_rule_for_#{rule}" do + result = @parser.parse("STD-1DST,60,#{rule}") + expected_dst_end_rule = LastDayOfMonthTransitionRule.new(expected_month, expected_day_of_week, expected_offset_from_midnight) + assert_equal(expected_dst_end_rule, result.dst_end_rule) + end + end + end + + ['J0', 'J366'].each do |julian_day_rule| + define_invalid_dst_rule_tests('julian_day', julian_day_rule) + end + + ['-1', '366'].each do |absolute_day_rule| + define_invalid_dst_rule_tests('absolute_day', absolute_day_rule) + end + + ['M0,1,0', 'M13,1,0', 'M6,0,0', 'M6,6,0', 'M6,1,-1', 'M6,1,7'].each do |day_of_month_rule| + define_invalid_dst_rule_tests('day_of_month', day_of_month_rule) + end + + ['M0,5,0', 'M13,5,0', 'M6,5,-1', 'M6,5,7'].each do |last_day_of_month_rule| + define_invalid_dst_rule_tests('last_day_of_month', last_day_of_month_rule) + end + + def test_invalid_dst_start_rule + assert_raises(InvalidPosixTimeZone) { @parser.parse('STD1DST,X60,300') } + end + + def test_invalid_dst_end_rule + assert_raises(InvalidPosixTimeZone) { @parser.parse('STD1DST,60,X300') } + end + + [ + ['STD5DST,0/0,J365/25', 'DST', -5 * HOUR, HOUR], + ['STD-5DST,0/0,J365/25', 'DST', 5 * HOUR, HOUR], + ['STD5DST3,0/0,J365/26', 'DST', -5 * HOUR, 2 * HOUR], + ['STD5DST6,0/0,J365/23', 'DST', -5 * HOUR, -HOUR], + ['STD5DST,J1/0,J365/25', 'DST', -5 * HOUR, HOUR], + ['Winter5Summer,0/0,J365/25', 'Summer', -5 * HOUR, HOUR], + ['<-05>5<-06>,0/0,J365/25', '-06', -5 * HOUR, HOUR] + ].each do |(tz_string, expected_abbrev, expected_base_offset, expected_std_offset)| + define_method "test_dst_only_returns_continuous_offset_for_#{tz_string}" do + result = @parser.parse(tz_string) + expected = TimezoneOffset.new(expected_base_offset, expected_std_offset, expected_abbrev) + assert_equal(expected, result) + end + end + + def test_returns_deduped_strings + std = @string_deduper.dedupe('STD'.dup) + dst = @string_deduper.dedupe('DST'.dup) + + result = @parser.parse('STD1DST,60,300') + + assert_same(std, result.std_offset.abbreviation) + assert_same(dst, result.dst_offset.abbreviation) + end + + def test_parses_tainted_string_in_safe_mode_and_returns_untainted_abbreviations + safe_test(unavailable: :skip) do + result = @parser.parse('STD1DST,60,300'.dup.taint) + + refute(result.std_offset.abbreviation.tainted?) + refute(result.dst_offset.abbreviation.tainted?) + end + end + + ['STD1', 'STD1DST,60,300'].each do |tz_string| + tz_string += "-" + define_method "test_content_after_end_for_#{tz_string}" do + error = assert_raises(InvalidPosixTimeZone) { @parser.parse(tz_string) } + assert_equal("Expected the end of a POSIX-style time zone string but found '-'.", error.message) + end + end + + ['X', 0].each do |invalid_tz_string| + define_method "test_invalid_tz_string_#{invalid_tz_string}" do + assert_raises(InvalidPosixTimeZone) { @parser.parse(invalid_tz_string) } + end + end + end +end diff --git a/test/data_sources/tc_zoneinfo_data_source.rb b/test/data_sources/tc_zoneinfo_data_source.rb index d19aae34..e4af667f 100644 --- a/test/data_sources/tc_zoneinfo_data_source.rb +++ b/test/data_sources/tc_zoneinfo_data_source.rb @@ -1488,7 +1488,7 @@ def test_inspect assert_equal("#", @data_source.inspect) end - def test_strings_deduped + def test_country_info_strings_deduped Dir.mktmpdir('tzinfo_test') do |dir| File.open(File.join(dir, 'iso3166.tab'), 'w', external_encoding: Encoding::UTF_8) do |iso3166| iso3166.puts("FC\tFake Country") @@ -1517,6 +1517,14 @@ def test_strings_deduped assert_same(data_source.timezone_identifiers[0], fc.zones[1].identifier) end + + def test_timezone_abbreviations_deduped + london = @data_source.load_timezone_info('Europe/London').transitions[0].previous_offset + new_york = @data_source.load_timezone_info('America/New_York').transitions[0].previous_offset + + assert_equal('LMT', london) + assert_same(london, new_york) + end end private diff --git a/test/data_sources/tc_zoneinfo_reader.rb b/test/data_sources/tc_zoneinfo_reader.rb index 6693988a..be8741c8 100644 --- a/test/data_sources/tc_zoneinfo_reader.rb +++ b/test/data_sources/tc_zoneinfo_reader.rb @@ -12,6 +12,16 @@ module DataSources class TCZoneinfoReader < Minitest::Test + class FakePosixTimeZoneParser + def initialize(&block) + @on_parse = block + end + + def parse(tz_string) + @on_parse.call(tz_string) + end + end + MIN_FORMAT = 1 MAX_FORMAT = 3 @@ -39,13 +49,15 @@ def pack_int64_signed_network_order(values) pack_int64_network_order(values.collect {|value| value < 0 ? value + 0x10000000000000000 : value}) end - def write_tzif(format, offsets, transitions, leaps = [], options = {}) + def write_tzif(format, offsets, transitions, tz_string, leaps, options = {}) # Options for testing malformed zoneinfo files. magic = options[:magic] section2_magic = options[:section2_magic] abbrev_separator = options[:abbrev_separator] || "\0" abbrev_offset_base = options[:abbrev_offset_base] || 0 + omit_tz_string_start_new_line = options[:omit_tz_string_start_new_line] + omit_tz_string_end_new_line = options[:omit_tz_string_end_new_line] unless magic if format == 1 @@ -148,26 +160,48 @@ def write_tzif(format, offsets, transitions, leaps = [], options = {}) file.write("\0" * offsets.length * 2) end - # Empty POSIX timezone string - file.write("\n\n") + file.write("\n") unless omit_tz_string_start_new_line + file.write(tz_string.encode(Encoding::UTF_8)) + file.write("\n") unless omit_tz_string_end_new_line end file.flush - yield file.path, format + yield file.path end end - def tzif_test(offsets, transitions, leaps = [], options = {}, &block) - min_format = options[:min_format] || MIN_FORMAT + def tzif_test(offsets, transitions, options = {}, &block) + rules = options[:rules] + tz_string = options[:tz_string] || (rules ? "TEST_TZ_STRING_#{rand(1000000)}" : '') + leaps = options[:leaps] || [] + min_format = options[:min_format] || (tz_string.empty? ? MIN_FORMAT : 2) min_format.upto(MAX_FORMAT) do |format| - write_tzif(format, offsets, transitions, leaps, options, &block) + write_tzif(format, offsets, transitions, tz_string, leaps, options) do |path| + if format >= 2 + @tz_parse_result = rules + @expect_tz_string = tz_string + end + begin + yield path, format + ensure + @tz_parse_result = nil + @expect_tz_string = nil + end + end end end def setup - @reader = ZoneinfoReader.new(StringDeduper.new) + @expect_tz_string = nil + @tz_parse_result = nil + @posix_tz_parser = FakePosixTimeZoneParser.new do |tz_string| + raise "Unexpected tz_string passed to PosixTimeZoneParser: #{tz_string}" unless tz_string == @expect_tz_string + raise InvalidPosixTimeZone, 'FakePosixTimeZoneParser Failure.' if @tz_parse_result == :fail + @tz_parse_result + end + @reader = ZoneinfoReader.new(@posix_tz_parser, StringDeduper.new) end def test_read @@ -322,7 +356,7 @@ def test_read_with_leap_seconds offsets = [{gmtoff: -0, isdst: false, abbrev: 'LMT'}] leaps = [{at: Time.utc(1972,6,30,23,59,60), seconds: 1}] - tzif_test(offsets, [], leaps) do |path, format| + tzif_test(offsets, [], leaps: leaps) do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("The file '#{path}' contains leap second data. TZInfo requires zoneinfo files that omit leap seconds.", error.message) end @@ -332,7 +366,7 @@ def test_read_invalid_magic ['tzif2', '12345'].each do |magic| offsets = [{gmtoff: -12094, isdst: false, abbrev: 'LT'}] - tzif_test(offsets, [], [], magic: magic) do |path, format| + tzif_test(offsets, [], magic: magic) do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("The file '#{path}' does not start with the expected header.", error.message) end @@ -342,7 +376,7 @@ def test_read_invalid_magic def test_read_invalid_version offsets = [{gmtoff: -12094, isdst: false, abbrev: 'LT'}] - tzif_test(offsets, [], [], magic: 'TZif4') do |path, format| + tzif_test(offsets, [], magic: 'TZif4') do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("The file '#{path}' contains a version of the zoneinfo format that is not currently supported.", error.message) end @@ -352,7 +386,7 @@ def test_read_invalid_section2_magic ['TZif4', 'tzif2', '12345'].each do |section2_magic| offsets = [{gmtoff: -12094, isdst: false, abbrev: 'LT'}] - tzif_test(offsets, [], [], min_format: 2, section2_magic: section2_magic) do |path, format| + tzif_test(offsets, [], min_format: 2, section2_magic: section2_magic) do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("The file '#{path}' contains an invalid 64-bit section header.", error.message) end @@ -366,7 +400,7 @@ def test_read_mismatched_section2_magic [minus_one, plus_one].each do |section2_magic| offsets = [{gmtoff: -12094, isdst: false, abbrev: 'LT'}] - tzif_test(offsets, [], [], min_format: 2, section2_magic: section2_magic) do |path, format| + tzif_test(offsets, [], min_format: 2, section2_magic: section2_magic) do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("The file '#{path}' contains an invalid 64-bit section header.", error.message) end @@ -391,7 +425,7 @@ def test_read_missing_abbrev_null_termination transitions = [ {at: Time.utc(2000, 1, 1), offset_index: 1}] - tzif_test(offsets, transitions, [], abbrev_separator: '^') do |path, format| + tzif_test(offsets, transitions, abbrev_separator: '^') do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("Missing abbreviation null terminator in file '#{path}'.", error.message) end @@ -405,7 +439,7 @@ def test_read_out_of_range_abbrev_offsets transitions = [ {at: Time.utc(2000, 1, 1), offset_index: 1}] - tzif_test(offsets, transitions, [], abbrev_offset_base: 8) do |path, format| + tzif_test(offsets, transitions, abbrev_offset_base: 8) do |path, format| error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } assert_equal("Abbreviation index is out of range in file '#{path}'.", error.message) end @@ -1294,5 +1328,698 @@ def test_read_returns_deduped_strings assert_same(abbreviations[0], abbreviations[1]) end end + + def test_read_invalid_tz_string + offsets = [{gmtoff: 0, isdst: false, abbrev: 'UTC'}] + + tzif_test(offsets, [], rules: :fail) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("Failed to parse POSIX-style TZ string in file '#{path}': FakePosixTimeZoneParser Failure.", error.message) + end + end + + def test_read_tz_string_missing_start_newline + offsets = [{gmtoff: 0, isdst: false, abbrev: 'UTC'}] + rules = TimezoneOffset.new(0, 0, 'UTC') + + tzif_test(offsets, [], rules: rules, omit_tz_string_start_new_line: true) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("Expected newline starting POSIX-style TZ string in file '#{path}'.", error.message) + end + end + + def test_read_tz_string_missing_end_newline + offsets = [{gmtoff: 0, isdst: false, abbrev: 'UTC'}] + rules = TimezoneOffset.new(0, 0, 'UTC') + + tzif_test(offsets, [], rules: rules, omit_tz_string_end_new_line: true) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("Expected newline ending POSIX-style TZ string in file '#{path}'.", error.message) + end + end + + [ + [false, 1, 0, 'TEST'], + [false, 0, 1, 'TEST'], + [false, 0, 0, 'TEST2'], + [false, -1, 1, 'TEST'], + [true, 0, 0, 'TEST'] + ].each do |(isdst, base_utc_offset, std_offset, abbreviation)| + define_method "test_read_tz_string_does_not_match_#{isdst ? 'dst' : 'std'}_constant_offset_#{base_utc_offset}_#{std_offset}_#{abbreviation}" do + offsets = [{gmtoff: 0, isdst: isdst, abbrev: 'TEST'}] + rules = TimezoneOffset.new(base_utc_offset, std_offset, abbreviation) + + tzif_test(offsets, [], rules: rules) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("Constant offset POSIX-style TZ string does not match constant offset in file '#{path}'.", error.message) + end + end + end + + [ + [3601, 'XST'], + [3600, 'YST'] + ].each do |(base_utc_offset, abbreviation)| + define_method "test_read_tz_string_does_not_match_final_std_transition_offset_#{base_utc_offset}_#{abbreviation}" do + offsets = [ + {gmtoff: 3542, isdst: false, abbrev: 'LMT'}, + {gmtoff: 3600, isdst: false, abbrev: 'XST'}, + {gmtoff: 7200, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 3542), offset_index: 1}, + {at: Time.new(1981, 4, 10, 2, 0, 0, 3600), offset_index: 2}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 7200), offset_index: 1} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(base_utc_offset, 0, abbreviation), + TimezoneOffset.new(3600, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 7200), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{path}'.", error.message) + end + end + end + + [ + [3601, 0, 'XST'], + [3600, 1, 'XST'], + [3600, 0, 'YST'] + ].each do |(base_utc_offset, std_offset, abbreviation)| + define_method "test_read_tz_string_does_not_match_final_dst_transition_offset_#{base_utc_offset}_#{std_offset}_#{abbreviation}" do + offsets = [ + {gmtoff: 3542, isdst: false, abbrev: 'LMT'}, + {gmtoff: 3600, isdst: false, abbrev: 'XST'}, + {gmtoff: 7200, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 3542), offset_index: 1}, + {at: Time.new(1981, 4, 10, 2, 0, 0, 3600), offset_index: 2} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(3600, 0, 'XST'), + TimezoneOffset.new(base_utc_offset, std_offset, abbreviation), + JulianDayOfYearTransitionRule.new(100, 7200), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{path}'.", error.message) + end + end + end + + [ + [3600, 0], + [3600, 3600], + [10800, -3600] + ].each do |(base_utc_offset, std_offset)| + rules = TimezoneOffset.new(base_utc_offset, std_offset, 'TEST') + + define_method "test_read_tz_string_uses_constant_offset_with_no_transitions_#{base_utc_offset}_#{std_offset}" do + offsets = [{gmtoff: base_utc_offset + std_offset, isdst: std_offset != 0, abbrev: 'TEST'}] + + tzif_test(offsets, [], rules: rules) do |path, format| + assert_equal(rules, @reader.read(path)) + end + end + + define_method "test_read_tz_string_uses_constant_offset_after_last_transition_#{base_utc_offset}_#{std_offset}" do + offsets = [ + {gmtoff: 3542, isdst: false, abbrev: 'LMT'}, + {gmtoff: base_utc_offset + std_offset, isdst: std_offset != 0, abbrev: 'TEST'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 3542), offset_index: 1} + ] + + o0 = TimezoneOffset.new(3542, 0, 'LMT') + t0 = TimezoneTransition.new(rules, o0, Time.new(1971, 1, 2, 2, 0, 0, 3542).to_i) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal([t0], @reader.read(path)) + end + end + end + + def test_read_tz_string_uses_rules_to_generate_all_transitions_when_none_defined + offsets = [{gmtoff: 7200, isdst: false, abbrev: 'XST'}] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7200, 0, 'XST') + o1 = TimezoneOffset.new(7200, 3600, 'XDT') + + t = 1970.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o1, o0, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o0, o1, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + + tzif_test(offsets, [], rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_uses_rules_to_generate_all_transitions_when_none_defined_omitting_first_if_matches_first_offset + offsets = [{gmtoff: 10800, isdst: true, abbrev: 'XDT'}] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7200, 3600, 'XDT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1970, 10, 27, 2, 0, 0, 10800).to_i) + tn = 1971.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o0, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o0, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, [], rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_uses_rules_to_generate_all_transitions_when_none_defined_with_previous_offset_of_first_matching_first_offset + offsets = [{gmtoff: 7142, isdst: false, abbrev: 'LMT'}] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o2, o0, Time.new(1970, 4, 10, 1, 0, 0, 7200).to_i) + t1 = TimezoneTransition.new(o1, o2, Time.new(1970, 10, 27, 2, 0, 0, 10800).to_i) + tn = 1971.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o2, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o2, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0, t1] + tn + + tzif_test(offsets, [], rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_uses_rules_to_generate_all_transitions_when_none_defined_correcting_initial_offset + offsets = [{gmtoff: 10800, isdst: true, abbrev: 'XDDT'}] + + rules = AnnualRules.new( + TimezoneOffset.new(3600, 0, 'XST'), + TimezoneOffset.new(3600, 7200, 'XDDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(3600, 7200, 'XDDT') + o1 = TimezoneOffset.new(3600, 0, 'XST') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1970, 10, 27, 2, 0, 0, 10800).to_i) + tn = 1971.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o0, o1, Time.new(year, 4, 10, 1, 0, 0, 3600).to_i), + TimezoneTransition.new(o1, o0, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, [], rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_extends_transitions_starting_from_std_to_dst_following_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 10800), offset_index: 1} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1971, 1, 2, 2, 0, 0, 7142).to_i) + tn = 1981.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o2, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o2, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_extends_transitions_starting_from_dst_to_std_same_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 10800), offset_index: 1}, + {at: Time.new(1982, 4, 10, 1, 0, 0, 7200), offset_index: 2} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1971, 1, 2, 2, 0, 0, 7142).to_i) + tn = 1981.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o2, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o2, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_extends_transitions_starting_from_dst_to_std_following_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 1, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 10, 27, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1982, 4, 10, 2, 0, 0, 10800), offset_index: 1}, + {at: Time.new(1982, 10, 27, 1, 0, 0, 7200), offset_index: 2} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(300, 3600), + JulianDayOfYearTransitionRule.new(100, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1971, 1, 2, 1, 0, 0, 7142).to_i) + t1 = TimezoneTransition.new(o2, o1, Time.new(1981, 10, 27, 1, 0, 0, 7200).to_i) + tn = 1982.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o1, o2, Time.new(year, 4, 10, 2, 0, 0, 10800).to_i), + TimezoneTransition.new(o2, o1, Time.new(year, 10, 27, 1, 0, 0, 7200).to_i) + ] + end + t = [t0, t1] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_extends_transitions_starting_from_std_to_dst_same_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 1, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 10, 27, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1982, 4, 10, 2, 0, 0, 10800), offset_index: 1}, + {at: Time.new(1982, 10, 27, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1983, 4, 10, 2, 0, 0, 10800), offset_index: 1} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(300, 3600), + JulianDayOfYearTransitionRule.new(100, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1971, 1, 2, 1, 0, 0, 7142).to_i) + t1 = TimezoneTransition.new(o2, o1, Time.new(1981, 10, 27, 1, 0, 0, 7200).to_i) + tn = 1982.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o1, o2, Time.new(year, 4, 10, 2, 0, 0, 10800).to_i), + TimezoneTransition.new(o2, o1, Time.new(year, 10, 27, 1, 0, 0, 7200).to_i) + ] + end + t = [t0, t1] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_extends_transitions_negative_dst + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: true, abbrev: 'XDT'}, + {gmtoff: 10800, isdst: false, abbrev: 'XST'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 10800), offset_index: 1} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(10800, 0, 'XST'), + TimezoneOffset.new(10800, -3600, 'XDT'), + JulianDayOfYearTransitionRule.new(300, 7200), + JulianDayOfYearTransitionRule.new(100, 3600) + ) + + o0 = TimezoneOffset.new( 7142, 0, 'LMT') + o1 = TimezoneOffset.new(10800, -3600, 'XDT') + o2 = TimezoneOffset.new(10800, 0, 'XST') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1971, 1, 2, 2, 0, 0, 7142).to_i) + tn = 1981.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o2, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o2, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_extends_single_transition_in_final_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + generate_up_to = ZoneinfoReader.const_get(:GENERATE_UP_TO) + + transitions = [ + {at: Time.new( 1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(generate_up_to - 1, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(generate_up_to - 1, 10, 27, 2, 0, 0, 10800), offset_index: 1}, + {at: Time.new(generate_up_to, 4, 10, 1, 0, 0, 7200), offset_index: 2} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1971, 1, 2, 2, 0, 0, 7142).to_i) + tn = (generate_up_to - 1).upto(generate_up_to).flat_map do |year| + [ + TimezoneTransition.new(o2, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o2, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_adds_nothing_if_transitions_up_to_final_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + generate_up_to = ZoneinfoReader.const_get(:GENERATE_UP_TO) + + transitions = [ + {at: Time.new( 1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(generate_up_to, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(generate_up_to, 10, 27, 2, 0, 0, 10800), offset_index: 1}, + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new( 1971, 1, 2, 2, 0, 0, 7142).to_i) + t1 = TimezoneTransition.new(o2, o1, Time.new(generate_up_to, 4, 10, 1, 0, 0, 7200).to_i) + t2 = TimezoneTransition.new(o1, o2, Time.new(generate_up_to, 10, 27, 2, 0, 0, 10800).to_i) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal([t0,t1,t2], @reader.read(path)) + end + end + + def test_read_tz_string_corrects_offset_of_final_transition_same_year + offsets = [ + {gmtoff: 3542, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: true, abbrev: 'XDT'}] + + transitions = [{at: Time.new(2000, 4, 10, 1, 0, 0, 3542), offset_index: 1}] + + rules = AnnualRules.new( + TimezoneOffset.new(3600, 0, 'XST'), + TimezoneOffset.new(3600, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(3542, 0, 'LMT') + o1 = TimezoneOffset.new(3600, 3600, 'XDT') # without tz_string would be 3542, 3658, 'XDT' + o2 = TimezoneOffset.new(3600, 0, 'XST') + + t0 = TimezoneTransition.new(o1, o0, Time.new(2000, 4, 10, 1, 0, 0, 3542).to_i) + t1 = TimezoneTransition.new(o2, o1, Time.new(2000, 10, 27, 2, 0, 0, 7200).to_i) + tn = 2001.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o1, o2, Time.new(year, 4, 10, 1, 0, 0, 3600).to_i), + TimezoneTransition.new(o2, o1, Time.new(year, 10, 27, 2, 0, 0, 7200).to_i) + ] + end + t = [t0, t1] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_specifies_transition_to_offset_of_final_transition_same_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 10800), offset_index: 1}, + {at: Time.new(1982, 4, 10, 1, 0, 0, 7200), offset_index: 2} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(101, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{path}'.", error.message) + end + end + + def test_read_tz_string_specifies_transition_to_offset_of_final_transition_following_year + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 7200), offset_index: 2}, + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(299, 7200) + ) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + error = assert_raises(InvalidZoneinfoFile) { @reader.read(path) } + assert_equal("The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{path}'.", error.message) + end + end + + def test_read_tz_string_generates_from_last_transition_if_before_1970 + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1961, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1962, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1962, 10, 27, 2, 0, 0, 10800), offset_index: 1} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + o0 = TimezoneOffset.new(7142, 0, 'LMT') + o1 = TimezoneOffset.new(7200, 0, 'XST') + o2 = TimezoneOffset.new(7200, 3600, 'XDT') + + t0 = TimezoneTransition.new(o1, o0, Time.new(1961, 1, 2, 2, 0, 0, 7142).to_i) + tn = 1962.upto(ZoneinfoReader.const_get(:GENERATE_UP_TO)).flat_map do |year| + [ + TimezoneTransition.new(o2, o1, Time.new(year, 4, 10, 1, 0, 0, 7200).to_i), + TimezoneTransition.new(o1, o2, Time.new(year, 10, 27, 2, 0, 0, 10800).to_i) + ] + end + t = [t0] + tn + + tzif_test(offsets, transitions, rules: rules) do |path, format| + assert_equal(t, @reader.read(path)) + end + end + + def test_read_tz_string_reuses_offset_instances_when_adding_to_exsisting_transitions + offsets = [ + {gmtoff: 7142, isdst: false, abbrev: 'LMT'}, + {gmtoff: 7200, isdst: false, abbrev: 'XST'}, + {gmtoff: 10800, isdst: true, abbrev: 'XDT'} + ] + + transitions = [ + {at: Time.new(1971, 1, 2, 2, 0, 0, 7142), offset_index: 1}, + {at: Time.new(1981, 4, 10, 1, 0, 0, 7200), offset_index: 2}, + {at: Time.new(1981, 10, 27, 2, 0, 0, 10800), offset_index: 1} + ] + + rules = AnnualRules.new( + TimezoneOffset.new(7200, 0, 'XST'), + TimezoneOffset.new(7200, 3600, 'XDT'), + JulianDayOfYearTransitionRule.new(100, 3600), + JulianDayOfYearTransitionRule.new(300, 7200) + ) + + tzif_test(offsets, transitions, rules: rules) do |path, format| + transitions = @reader.read(path) + xst = transitions[0].offset + xdt = transitions[1].offset + assert_equal(TimezoneOffset.new(7200, 0, 'XST'), xst) + assert_equal(TimezoneOffset.new(7200, 3600, 'XDT'), xdt) + + 1.upto((transitions.length - 1) / 2) do |i| # 2, 4, 6, ... + assert_same(xst, transitions[i * 2].offset) + assert_same(xdt, transitions[i * 2].previous_offset) + end + + 1.upto(transitions.length / 2 - 1) {|i| assert_same(xdt, transitions[i * 2 + 1].offset) } # 3, 5, 7, ... + 1.upto(transitions.length / 2) {|i| assert_same(xst, transitions[i * 2 - 1].previous_offset) } # 1, 3, 5, ... + end + end + + def test_read_tz_string_as_utf8 + offsets = [{gmtoff: 3600, isdst: false, abbrev: 'áccént'}] + rules = TimezoneOffset.new(3600, 0, 'áccént') + + tzif_test(offsets, [], tz_string: '<áccént>1', rules: rules) do |path, format| + # FakePosixTimeZoneParser will test that the tz_string matches. + assert_same(rules, @reader.read(path)) + end + end end end diff --git a/test/tc_annual_rules.rb b/test/tc_annual_rules.rb new file mode 100644 index 00000000..25af4bc1 --- /dev/null +++ b/test/tc_annual_rules.rb @@ -0,0 +1,98 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +require_relative 'test_utils' + +include TZInfo + +class TCAnnualRules < Minitest::Test + + def test_initialize + std_offset = TimezoneOffset.new(0, 0, 'GMT') + dst_offset = TimezoneOffset.new(0, 3600, 'BST') + dst_start_rule = FakeAlwaysDateAdjustmentRule.new(3, 1) + dst_end_rule = FakeAlwaysDateAdjustmentRule.new(10, 1) + + rules = AnnualRules.new(std_offset, dst_offset, dst_start_rule, dst_end_rule) + assert_same(std_offset, rules.std_offset) + assert_same(dst_offset, rules.dst_offset) + assert_same(dst_start_rule, rules.dst_start_rule) + assert_same(dst_end_rule, rules.dst_end_rule) + end + + [2020, 2021].each do |year| + define_method "test_transitions_for_dst_mid_year_#{year}" do + std_offset = TimezoneOffset.new(3600, 0, 'TEST') + dst_offset = TimezoneOffset.new(3600, 3600, 'TESTS') + + rules = AnnualRules.new( + std_offset, + dst_offset, + FakeAlwaysDateAdjustmentRule.new(3, 21), + FakeAlwaysDateAdjustmentRule.new(10, 22) + ) + + result = rules.transitions(year) + + expected = [ + TimezoneTransition.new(dst_offset, std_offset, Time.new(year, 3, 21, 0, 0, 0, 3600).to_i), + TimezoneTransition.new(std_offset, dst_offset, Time.new(year, 10, 22, 0, 0, 0, 7200).to_i) + ] + + assert_equal(expected, result) + end + + define_method "test_transitions_for_dst_start_and_end_of_year_#{year}" do + std_offset = TimezoneOffset.new(3600, 0, 'TEST') + dst_offset = TimezoneOffset.new(3600, 3600, 'TESTS') + + rules = AnnualRules.new( + std_offset, + dst_offset, + FakeAlwaysDateAdjustmentRule.new(10, 22), + FakeAlwaysDateAdjustmentRule.new(3, 21) + ) + + result = rules.transitions(year) + + expected = [ + TimezoneTransition.new(std_offset, dst_offset, Time.new(year, 3, 21, 0, 0, 0, 7200).to_i), + TimezoneTransition.new(dst_offset, std_offset, Time.new(year, 10, 22, 0, 0, 0, 3600).to_i) + ] + + assert_equal(expected, result) + end + + define_method "test_transitions_for_negative_dst_start_and_end_of_year_#{year}" do + std_offset = TimezoneOffset.new(7200, 0, 'TEST') + dst_offset = TimezoneOffset.new(7200, -3600, 'TESTW') + + rules = AnnualRules.new( + std_offset, + dst_offset, + FakeAlwaysDateAdjustmentRule.new(10, 22), + FakeAlwaysDateAdjustmentRule.new(3, 21) + ) + + result = rules.transitions(year) + + expected = [ + TimezoneTransition.new(std_offset, dst_offset, Time.new(year, 3, 21, 0, 0, 0, 3600).to_i), + TimezoneTransition.new(dst_offset, std_offset, Time.new(year, 10, 22, 0, 0, 0, 7200).to_i) + ] + + assert_equal(expected, result) + end + end + + class FakeAlwaysDateAdjustmentRule + def initialize(month, day) + @month = month + @day = day + end + + def at(offset, year) + TimestampWithOffset.for(Time.new(year, @month, @day, 0, 0, 0, offset.observed_utc_offset)).set_timezone_offset(offset) + end + end +end diff --git a/test/tc_transition_rule.rb b/test/tc_transition_rule.rb new file mode 100644 index 00000000..838455f7 --- /dev/null +++ b/test/tc_transition_rule.rb @@ -0,0 +1,563 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +require_relative 'test_utils' + +include TZInfo + +class TCTransitionRule < Minitest::Test + [-1, 0, 1].each do |transition_at| + define_method "test_transition_at_#{transition_at}" do + rule = TestTransitionRule.new(transition_at) + assert_equal(transition_at, rule.transition_at) + end + end + + [ + [-1, 19, 23, 59, 59], + [0, 20, 0, 0, 0], + [1, 20, 0, 0, 1], + [60, 20, 0, 1, 0], + [86399, 20, 23, 59, 59], + [86400, 21, 0, 0, 0] + ].each do |(transition_at, expected_day, expected_hour, expected_minute, expected_second)| + define_method "test_at_with_transition_at_#{transition_at}" do + offset = TimezoneOffset.new(0, 0, 'TEST') + rule = TestTransitionRule.new(transition_at) do |o, y| + assert_same(offset, o) + assert_equal(2020, y) + Time.new(2020, 3, 20, 0, 0, 0, 0) + end + + result = rule.at(offset, 2020) + + # Construct Timestamp from a value instead of using Timestamp.for. + # JRuby would consider a Time with a 0 offset to be UTC. + assert_equal_with_offset(Timestamp.new(Time.new(2020, 3, expected_day, expected_hour, expected_minute, expected_second, 0).to_i, 0, 0), result) + assert_same(offset, result.timezone_offset) + end + end + + [-7200, 0, 7200].each do |offset_seconds| + define_method "test_at_with_offset_#{offset_seconds}" do + offset = TimezoneOffset.new(offset_seconds, 0, 'TEST') + rule = TestTransitionRule.new(3600) do |o, y| + assert_same(offset, o) + assert_equal(2020, y) + Time.new(2020, 3, 20, 0, 0, 0, offset_seconds) + end + + result = rule.at(offset, 2020) + + # Construct Timestamp from a value instead of using Timestamp.for. + # JRuby would consider a Time with a 0 offset to be UTC. + assert_equal_with_offset(Timestamp.new(Time.new(2020, 3, 20, 1, 0, 0, offset_seconds).to_i, 0, offset_seconds), result) + assert_same(offset, result.timezone_offset) + end + end + + [2020, 2021].each do |year| + define_method "test_at_with_year_#{year}" do + offset = TimezoneOffset.new(0, 0, 'TEST') + rule = TestTransitionRule.new(3600) do |o, y| + assert_same(offset, o) + assert_equal(year, y) + Time.new(year, 3, 20, 0, 0, 0, 0) + end + + result = rule.at(offset, year) + + # Construct Timestamp from a value instead of using Timestamp.for. + # JRuby would consider a Time with a 0 offset to be UTC. + assert_equal_with_offset(Timestamp.new(Time.new(year, 3, 20, 1, 0, 0, 0).to_i, 0, 0), result) + assert_same(offset, result.timezone_offset) + end + end + + class TestTransitionRule < TransitionRule + def initialize(transition_at, &block) + super(transition_at) + @get_day = block + end + + protected + + def get_day(offset, year) + @get_day.call(offset, year) + end + end +end + +module BaseTransitionRuleTestHelper + def test_invalid_transition_at + error = assert_raises(ArgumentError) { create_with_transition_at('0') } + assert_match(/\btransition_at(\b.*)?/, error.message) + end + + [:==, :eql?].each do |method| + define_method "test_not_equal_by_transition_at_with_#{method}" do + rule1 = create_with_transition_at(0) + rule2 = create_with_transition_at(1) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + + define_method "test_not_equal_to_other_with_#{method}" do + rule = create_with_transition_at(0) + assert_equal(false, rule.public_send(method, Object.new)) + end + end +end + +class TCAbsoluteDayOfYearTransitionRule < Minitest::Test + include BaseTransitionRuleTestHelper + + [-1, 366, '0'].each do |value| + define_method "test_invalid_day_#{value}" do + error = assert_raises(ArgumentError) { AbsoluteDayOfYearTransitionRule.new(value) } + assert_match(/\bday\b/, error.message) + end + end + + [ + [2020, 0, 1, 1], + [2020, 58, 2, 28], + [2020, 59, 2, 29], + [2020, 365, 12, 31], + [2021, 59, 3, 1], + [2021, 365, 13, 1], + [2100, 59, 3, 1], + [2100, 365, 13, 1], + [2000, 59, 2, 29], + [2000, 365, 12, 31] + ].each do |(year, day, expected_month, expected_day)| + define_method "test_day_#{day}_of_year_#{year}" do + rule = AbsoluteDayOfYearTransitionRule.new(day, 3600) + offset = TimezoneOffset.new(7200, 0, 'TEST') + + result = rule.at(offset, year) + + expected_year = year + if expected_month == 13 + expected_year += 1 + expected_month = 1 + end + + assert_equal_with_offset(Timestamp.for(Time.new(expected_year, expected_month, expected_day, 1, 0, 0, 7200)), result) + assert_same(offset, result.timezone_offset) + end + end + + def test_day_0_is_always_first_day_of_year + rule = AbsoluteDayOfYearTransitionRule.new(0) + assert_equal(true, rule.is_always_first_day_of_year?) + end + + [1, 365].each do |day| + define_method "test_day_#{day}_is_not_always_first_day_of_year" do + rule = AbsoluteDayOfYearTransitionRule.new(day) + assert_equal(false, rule.is_always_first_day_of_year?) + end + + define_method "test_day_#{day}_is_not_always_last_day_of_year" do + rule = AbsoluteDayOfYearTransitionRule.new(day) + assert_equal(false, rule.is_always_last_day_of_year?) + end + end + + [:==, :eql?].each do |method| + [ + [0, 0], + [0, 3600], + [365, 0] + ].each do |(day, transition_at)| + define_method "test_equal_for_day_#{day}_and_transition_at_#{transition_at}_with_#{method}" do + rule1 = AbsoluteDayOfYearTransitionRule.new(day, transition_at) + rule2 = AbsoluteDayOfYearTransitionRule.new(day, transition_at) + assert_equal(true, rule1.public_send(method, rule2)) + assert_equal(true, rule2.public_send(method, rule1)) + end + end + + define_method "test_not_equal_by_day_with_#{method}" do + rule1 = AbsoluteDayOfYearTransitionRule.new(0, 3600) + rule2 = AbsoluteDayOfYearTransitionRule.new(1, 3600) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + + define_method "test_not_equal_to_julian_with_#{method}" do + rule1 = AbsoluteDayOfYearTransitionRule.new(1, 0) + rule2 = JulianDayOfYearTransitionRule.new(1, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + end + + [ + [0, 0], + [0, 3600], + [365, 0] + ].each do |(day, transition_at)| + define_method "test_hash_for_day_#{day}_and_transition_at_#{transition_at}" do + rule = AbsoluteDayOfYearTransitionRule.new(day, transition_at) + expected = [AbsoluteDayOfYearTransitionRule, day * 86400, transition_at].hash + assert_equal(expected, rule.hash) + end + end + + protected + + def create_with_transition_at(transition_at) + AbsoluteDayOfYearTransitionRule.new(1, transition_at) + end +end + +class TCJulianDayOfYearTransitionRule < Minitest::Test + include BaseTransitionRuleTestHelper + + [0, 366, '1'].each do |value| + define_method "test_invalid_day_#{value}" do + error = assert_raises(ArgumentError) { JulianDayOfYearTransitionRule.new(value) } + assert_match(/\bday\b/, error.message) + end + end + + [2020, 2021, 2100, 2000].each do |year| + [ + [1, 1, 1], + [59, 2, 28], + [60, 3, 1], + [365, 12, 31] + ].each do |(day, expected_month, expected_day)| + define_method "test_day_#{day}_of_year_#{year}" do + rule = JulianDayOfYearTransitionRule.new(day, 3600) + offset = TimezoneOffset.new(7200, 0, 'TEST') + + result = rule.at(offset, year) + + assert_equal_with_offset(Timestamp.for(Time.new(year, expected_month, expected_day, 1, 0, 0, 7200)), result) + assert_same(offset, result.timezone_offset) + end + end + end + + def test_day_1_is_always_first_day_of_year + rule = JulianDayOfYearTransitionRule.new(1) + assert_equal(true, rule.is_always_first_day_of_year?) + end + + [2, 365].each do |day| + define_method "test_day_#{day}_is_not_always_first_day_of_year" do + rule = JulianDayOfYearTransitionRule.new(day) + assert_equal(false, rule.is_always_first_day_of_year?) + end + end + + def test_day_365_is_always_last_day_of_year + rule = JulianDayOfYearTransitionRule.new(365) + assert_equal(true, rule.is_always_last_day_of_year?) + end + + [1, 364].each do |day| + define_method "test_day_#{day}_is_not_always_last_day_of_year" do + rule = JulianDayOfYearTransitionRule.new(day) + assert_equal(false, rule.is_always_last_day_of_year?) + end + end + + [:==, :eql?].each do |method| + [ + [1, 0], + [1, 3600], + [365, 0] + ].each do |(day, transition_at)| + define_method "test_equal_for_day_#{day}_and_transition_at_#{transition_at}_with_#{method}" do + rule1 = JulianDayOfYearTransitionRule.new(day, transition_at) + rule2 = JulianDayOfYearTransitionRule.new(day, transition_at) + assert_equal(true, rule1.public_send(method, rule2)) + assert_equal(true, rule2.public_send(method, rule1)) + end + end + + define_method "test_not_equal_by_day_with_#{method}" do + rule1 = JulianDayOfYearTransitionRule.new(1, 0) + rule2 = JulianDayOfYearTransitionRule.new(2, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + + define_method "test_not_equal_to_absolute_with_#{method}" do + rule1 = JulianDayOfYearTransitionRule.new(1, 0) + rule2 = AbsoluteDayOfYearTransitionRule.new(1, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + end + + [ + [1, 0], + [1, 3600], + [365, 0] + ].each do |(day, transition_at)| + define_method "test_hash_for_day_#{day}_and_transition_at_#{transition_at}" do + rule = JulianDayOfYearTransitionRule.new(day, transition_at) + expected = [JulianDayOfYearTransitionRule, day * 86400, transition_at].hash + assert_equal(expected, rule.hash) + end + end + + protected + + def create_with_transition_at(transition_at) + JulianDayOfYearTransitionRule.new(1, transition_at) + end +end + +module DayOfWeekTransitionRuleTestHelper + [-1, 0, 13, '1'].each do |month| + define_method "test_invalid_month_#{month}" do + error = assert_raises(ArgumentError) { create_with_month_and_day_of_week(month, 0) } + assert_match(/\bmonth\b/, error.message) + end + end + + [-1, 7, '0'].each do |day_of_week| + define_method "test_invalid_day_of_week_#{day_of_week}" do + error = assert_raises(ArgumentError) { create_with_month_and_day_of_week(1, day_of_week) } + assert_match(/\bday_of_week\b/, error.message) + end + end + + def test_is_not_always_first_day_of_year + rule = create_with_month_and_day_of_week(1, 0) + assert_equal(false, rule.is_always_first_day_of_year?) + end + + def test_is_not_always_last_day_of_year + rule = create_with_month_and_day_of_week(12, 6) + assert_equal(false, rule.is_always_last_day_of_year?) + end + + [:==, :eql?].each do |method| + define_method "test_not_equal_by_month_with_#{method}" do + rule1 = create_with_month_and_day_of_week(1, 0) + rule2 = create_with_month_and_day_of_week(2, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + + define_method "test_not_equal_by_day_of_week_with_#{method}" do + rule1 = create_with_month_and_day_of_week(1, 0) + rule2 = create_with_month_and_day_of_week(1, 1) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + end +end + +class TCDayOfMonthTransitionRule < Minitest::Test + include BaseTransitionRuleTestHelper + include DayOfWeekTransitionRuleTestHelper + + [-1, 0, 5, '1'].each do |week| + define_method "test_invalid_week_#{week}" do + error = assert_raises(ArgumentError) { DayOfMonthTransitionRule.new(1, week, 0) } + assert_match(/\bweek\b/, error.message) + end + end + + [ + # All possible first week start days. + [2020, 3, [1, 2, 3, 4, 5, 6, 7]], + [2021, 3, [7, 1, 2, 3, 4, 5, 6]], + [2022, 3, [6, 7, 1, 2, 3, 4, 5]], + [2023, 3, [5, 6, 7, 1, 2, 3, 4]], + [2018, 3, [4, 5, 6, 7, 1, 2, 3]], + [2024, 3, [3, 4, 5, 6, 7, 1, 2]], + [2025, 3, [2, 3, 4, 5, 6, 7, 1]], + + # All possible months. + [2019, 1, [6]], + [2019, 2, [3]], + [2019, 3, [3]], + [2019, 4, [7]], + [2019, 5, [5]], + [2019, 6, [2]], + [2019, 7, [7]], + [2019, 8, [4]], + [2019, 9, [1]], + [2019, 10, [6]], + [2019, 11, [3]], + [2019, 12, [1]] + ].each do |(year, month, days)| + days.each_with_index do |expected_day, day_of_week| + (1..4).each do |week| + define_method "test_month_#{month}_week_#{week}_and_day_of_week_#{day_of_week}_year_#{year}" do + rule = DayOfMonthTransitionRule.new(month, week, day_of_week, 3600) + offset = TimezoneOffset.new(7200, 0, 'TEST') + + result = rule.at(offset, year) + + assert_equal_with_offset(Timestamp.for(Time.new(year, month, expected_day + (week - 1) * 7, 1, 0, 0, 7200)), result) + assert_same(offset, result.timezone_offset) + end + end + end + end + + [:==, :eql?].each do |method| + [ + [1, 1, 0, 0], + [1, 1, 0, 1], + [1, 1, 1, 0], + [1, 2, 0, 0], + [2, 1, 0, 0] + ].each do |(month, week, day_of_week, transition_at)| + define_method "test_equal_for_month_#{month}_week_#{week}_day_of_week_#{day_of_week}_and_transition_at_#{transition_at}_with_#{method}" do + rule1 = DayOfMonthTransitionRule.new(month, week, day_of_week, transition_at) + rule2 = DayOfMonthTransitionRule.new(month, week, day_of_week, transition_at) + assert_equal(true, rule1.public_send(method, rule2)) + assert_equal(true, rule2.public_send(method, rule1)) + end + end + + define_method "test_not_equal_by_week_with_#{method}" do + rule1 = DayOfMonthTransitionRule.new(1, 1, 0, 0) + rule2 = DayOfMonthTransitionRule.new(1, 2, 0, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + + define_method "test_not_equal_to_last_day_of_month_with_#{method}" do + rule1 = DayOfMonthTransitionRule.new(1, 1, 0, 0) + rule2 = LastDayOfMonthTransitionRule.new(1, 0, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + end + + [ + [1, 1, 0, 0], + [1, 1, 0, 1], + [1, 1, 1, 0], + [1, 2, 0, 0], + [2, 1, 0, 0] + ].each do |(month, week, day_of_week, transition_at)| + define_method "test_hash_for_month_#{month}_week_#{week}_day_of_week_#{day_of_week}_and_transition_at_#{transition_at}" do + rule = DayOfMonthTransitionRule.new(month, week, day_of_week, transition_at) + expected = [(week - 1) * 7 + 1, month, day_of_week, transition_at].hash + assert_equal(expected, rule.hash) + end + end + + protected + + def create_with_transition_at(transition_at) + DayOfMonthTransitionRule.new(1, 1, 0, transition_at) + end + + def create_with_month_and_day_of_week(month, day_of_week) + DayOfMonthTransitionRule.new(month, 1, day_of_week) + end +end + +class TCLastDayOfMonthTransitionRule < Minitest::Test + include BaseTransitionRuleTestHelper + include DayOfWeekTransitionRuleTestHelper + + [ + # All possible last days. + [2021, 10, [31, 25, 26, 27, 28, 29, 30]], + [2022, 10, [30, 31, 25, 26, 27, 28, 29]], + [2023, 10, [29, 30, 31, 25, 26, 27, 28]], + [2018, 10, [28, 29, 30, 31, 25, 26, 27]], + [2024, 10, [27, 28, 29, 30, 31, 25, 26]], + [2025, 10, [26, 27, 28, 29, 30, 31, 25]], + [2026, 10, [25, 26, 27, 28, 29, 30, 31]], + + # All possible months. + [2020, 1, [26]], + [2020, 2, [23]], + [2020, 3, [29]], + [2020, 4, [26]], + [2020, 5, [31]], + [2020, 6, [28]], + [2020, 7, [26]], + [2020, 8, [30]], + [2020, 9, [27]], + [2020, 10, [25]], + [2020, 11, [29]], + [2020, 12, [27]] + ].each do |(year, month, days)| + days.each_with_index do |expected_day, day_of_week| + define_method "test_month_#{month}_day_of_week_#{day_of_week}_year_#{year}" do + rule = LastDayOfMonthTransitionRule.new(month, day_of_week, 7200) + offset = TimezoneOffset.new(7200, 3600, 'TEST') + + result = rule.at(offset, year) + + assert_equal_with_offset(Timestamp.for(Time.new(year, month, expected_day, 2, 0, 0, 10800)), result) + assert_same(offset, result.timezone_offset) + end + end + end + + [[2020, 6, 29], [2021, 0, 28], [2000, 2, 29], [2100, 0, 28]].each do |(year, day_of_week, expected_day)| + define_method "test_#{expected_day == 29 ? '' : 'non_'}leap_year_#{year}" do + rule = LastDayOfMonthTransitionRule.new(2, day_of_week, 7200) + offset = TimezoneOffset.new(7200, 3600, 'TEST') + + result = rule.at(offset, year) + + assert_equal_with_offset(Timestamp.for(Time.new(year, 2, expected_day, 2, 0, 0, 10800)), result) + assert_same(offset, result.timezone_offset) + end + end + + [:==, :eql?].each do |method| + [ + [1, 0, 0], + [1, 0, 1], + [1, 1, 0], + [2, 0, 0] + ].each do |(month, day_of_week, transition_at)| + define_method "test_equal_for_month_#{month}_day_of_week_#{day_of_week}_and_transition_at_#{transition_at}_with_#{method}" do + rule1 = LastDayOfMonthTransitionRule.new(month, day_of_week, transition_at) + rule2 = LastDayOfMonthTransitionRule.new(month, day_of_week, transition_at) + assert_equal(true, rule1.public_send(method, rule2)) + assert_equal(true, rule2.public_send(method, rule1)) + end + end + + define_method "test_not_equal_to_day_of_month_with_#{method}" do + rule1 = LastDayOfMonthTransitionRule.new(1, 0, 0) + rule2 = DayOfMonthTransitionRule.new(1, 1, 0, 0) + assert_equal(false, rule1.public_send(method, rule2)) + assert_equal(false, rule2.public_send(method, rule1)) + end + end + + [ + [1, 0, 0], + [1, 0, 1], + [1, 1, 0], + [2, 0, 0] + ].each do |(month, day_of_week, transition_at)| + define_method "test_hash_for_month_#{month}_day_of_week_#{day_of_week}_and_transition_at_#{transition_at}" do + rule = LastDayOfMonthTransitionRule.new(month, day_of_week, transition_at) + expected = [month, day_of_week, transition_at].hash + assert_equal(expected, rule.hash) + end + end + + protected + + def create_with_transition_at(transition_at) + LastDayOfMonthTransitionRule.new(1, 0, transition_at) + end + + def create_with_month_and_day_of_week(month, day_of_week) + LastDayOfMonthTransitionRule.new(month, day_of_week) + end +end