require "../spec_helper" require "../../support/time" private def assert_tz_boundaries(tz : String, t0 : Time, t1 : Time, t2 : Time, t3 : Time, *, file = __FILE__, line = __LINE__) location = Time::Location.posix_tz("Local", tz) std_zone = location.zones.find(&.dst?.!).should_not be_nil, file: file, line: line dst_zone = location.zones.find(&.dst?).should_not be_nil, file: file, line: line assert_tz_boundaries(location, std_zone, dst_zone, t0, t1, t2, t3, file: file, line: line) end private def assert_tz_boundaries( location : Time::Location, std_zone : Time::Location::Zone, dst_zone : Time::Location::Zone, t0 : Time, t1 : Time, t2 : Time, t3 : Time, *, file = __FILE__, line = __LINE__, ) t0, t1, t2, t3 = t0.to_unix, t1.to_unix, t2.to_unix, t3.to_unix location.lookup_with_boundaries(t1 - 1).should eq({std_zone, {t0, t1}}), file: file, line: line location.lookup_with_boundaries(t1).should eq({dst_zone, {t1, t2}}), file: file, line: line location.lookup_with_boundaries(t1 + (t2 - t1) // 2).should eq({dst_zone, {t1, t2}}), file: file, line: line location.lookup_with_boundaries(t2 - 1).should eq({dst_zone, {t1, t2}}), file: file, line: line location.lookup_with_boundaries(t2).should eq({std_zone, {t2, t3}}), file: file, line: line end private def assert_tz_raises(str, *, file = __FILE__, line = __LINE__) expect_raises(ArgumentError, "Invalid TZ string: #{str}", file: file, line: line) do Time::Location.posix_tz("", str) end end class Time::Location describe Time::Location do describe ".load" do it "loads Europe/Berlin" do with_zoneinfo do location = Location.load("Europe/Berlin") location.name.should eq "Europe/Berlin" standard_time = location.lookup(Time.utc(2017, 11, 22)) standard_time.name.should eq "CET" standard_time.offset.should eq 3600 standard_time.dst?.should be_false summer_time = location.lookup(Time.utc(2017, 10, 22)) summer_time.name.should eq "CEST" summer_time.offset.should eq 7200 summer_time.dst?.should be_true location.utc?.should be_false location.fixed?.should be_false with_tz("UTC") do location.local?.should be_false end with_tz("Europe/Berlin") do location.local?.should be_true end Location.load("Europe/Berlin", {ZONEINFO_ZIP}).should eq location end end {% if flag?(:win32) %} it "maps IANA timezone identifier to Windows name (#13166)" do location = Location.load("Europe/Berlin") location.name.should eq "Europe/Berlin" location.utc?.should be_false location.fixed?.should be_false end it "uses IANA zone names for Windows system time zones (#15911)" do location = Location.load("Europe/Lisbon") std_zone = location.zones.find(&.dst?.!).should_not be_nil dst_zone = location.zones.find(&.dst?).should_not be_nil std_zone.name.should eq("WET") dst_zone.name.should eq("WEST") end {% end %} it "invalid timezone identifier" do with_zoneinfo(datapath("zoneinfo")) do expect_raises(InvalidLocationNameError, "Foobar/Baz") do Location.load("Foobar/Baz") end end Location.load?("Foobar/Baz", [datapath("zoneinfo")]).should be_nil end it "name is folder" do Location.load?("Foo", [datapath("zoneinfo")]).should be_nil end it "invalid zone file" do expect_raises(Time::Location::InvalidTZDataError) do Location.load?("Foo/invalid", [datapath("zoneinfo")]) end end it "treats UTC as special case" do with_zoneinfo do Location.load("UTC").should eq Location::UTC Location.load("").should eq Location::UTC Location.load("Etc/UTC").should eq Location::UTC end with_zoneinfo("nonexistent_zipfile.zip") do Location.load("UTC").should eq Location::UTC Location.load("").should eq Location::UTC Location.load("Etc/UTC").should eq Location::UTC end end it "treats GMT as special case" do with_zoneinfo do Location.load("GMT").should eq Location::UTC Location.load("Etc/GMT").should eq Location::UTC end with_zoneinfo("nonexistent_zipfile.zip") do Location.load("GMT").should eq Location::UTC Location.load("Etc/GMT").should eq Location::UTC end end describe "validating name" do it "absolute path" do with_zoneinfo do expect_raises(InvalidLocationNameError) do Location.load("/America/New_York") end expect_raises(InvalidLocationNameError) do Location.load("\\Zulu") end end end it "dot dot" do with_zoneinfo do expect_raises(InvalidLocationNameError) do Location.load("../zoneinfo/America/New_York") end expect_raises(InvalidLocationNameError) do Location.load("a..") end end end end context "with $TZDIR" do it "loads from custom directory" do with_zoneinfo(datapath("zoneinfo")) do location = Location.load("Foo/Bar") location.name.should eq "Foo/Bar" end end it "loads from custom zipfile" do with_zoneinfo(ZONEINFO_ZIP) do location = Location.load("Asia/Jerusalem") location.not_nil!.name.should eq "Asia/Jerusalem" end end it "raises if not available" do with_zoneinfo(ZONEINFO_ZIP) do expect_raises(InvalidLocationNameError) do Location.load("Foo/Bar") end Location.load?("Foo/Bar", Crystal::System::Time.zone_sources).should be_nil end end it "does not fall back to default sources" do with_zoneinfo(datapath("zoneinfo")) do expect_raises(InvalidLocationNameError) do Location.load("Europe/Berlin") end end with_zoneinfo("nonexistent_zipfile.zip") do expect_raises(InvalidLocationNameError) do Location.load("Europe/Berlin") end end end it "caches result" do with_zoneinfo do location = Location.load("Europe/Berlin") Location.load("Europe/Berlin").should be location end end it "loads new data if file was changed" do zoneinfo_path = datapath("zoneinfo") with_zoneinfo(zoneinfo_path) do location1 = Location.load("Foo/Bar") File.touch(File.join(zoneinfo_path, "Foo/Bar")) location2 = Location.load("Foo/Bar") location1.should eq location2 location1.should_not be location2 end end it "loads new data if ZIP file was changed" do with_zoneinfo(ZONEINFO_ZIP) do location1 = Location.load("Europe/Berlin") File.touch(ZONEINFO_ZIP) location2 = Location.load("Europe/Berlin") location1.should eq location2 location1.should_not be location2 end end end context "with $ZONEINFO" do it "loads from custom directory" do with_env("ZONEINFO": datapath("zoneinfo")) do Time::Location.__clear_location_cache location = Location.load("Foo/Bar") location.name.should eq "Foo/Bar" end end end end describe ".load?" do it "loads Europe/Berlin" do with_zoneinfo do Time::Location.load?("Europe/Berlin").should eq Time::Location.load("Europe/Berlin") end end it "returns nil if unavailable" do Location.load?("Foobar/Baz").should be_nil with_zoneinfo(datapath("zoneinfo")) do Location.load?("Foobar/Baz").should be_nil end end it "invalid zone file" do expect_raises(Time::Location::InvalidTZDataError) do Location.load?("Foo/invalid", [datapath("zoneinfo")]) end end it "treats UTC as special case" do with_zoneinfo do Location.load?("UTC").should eq Location::UTC Location.load?("").should eq Location::UTC Location.load?("Etc/UTC").should eq Location::UTC end end describe "raises on invalid location name" do it "absolute path" do with_zoneinfo do expect_raises(InvalidLocationNameError) do Location.load?("/America/New_York") end expect_raises(InvalidLocationNameError) do Location.load?("\\Zulu") end end end it "dot dot" do with_zoneinfo do expect_raises(InvalidLocationNameError) do Location.load?("../zoneinfo/America/New_York") end expect_raises(InvalidLocationNameError) do Location.load?("a..") end end end end it "caches result" do with_zoneinfo do location = Location.load?("Europe/Berlin") Location.load?("Europe/Berlin").should be location end end end describe ".load_android" do it "loads Europe/Berlin" do Location.__clear_location_cache location = Location.load_android("Europe/Berlin", {datapath("android_tzdata")}).should_not be_nil location.name.should eq "Europe/Berlin" standard_time = location.lookup(Time.utc(2017, 11, 22)) standard_time.name.should eq "CET" standard_time.offset.should eq 3600 standard_time.dst?.should be_false summer_time = location.lookup(Time.utc(2017, 10, 22)) summer_time.name.should eq "CEST" summer_time.offset.should eq 7200 summer_time.dst?.should be_true location.utc?.should be_false location.fixed?.should be_false end it "loads new data if tzdata file was changed" do tzdata_path = datapath("android_tzdata") Location.__clear_location_cache location1 = Location.load_android("Europe/Berlin", {tzdata_path}) File.touch(tzdata_path) location2 = Location.load_android("Europe/Berlin", {tzdata_path}) location1.should eq location2 location1.should_not be location2 end end it "UTC" do location = Location::UTC location.name.should eq "UTC" location.utc?.should be_true location.fixed?.should be_true # this could fail if no source for localtime is available unless Location.local.utc? location.local?.should be_false end zone = location.lookup(Time.utc) zone.name.should eq "UTC" zone.offset.should eq 0 zone.dst?.should be_false end it ".local" do with_zoneinfo do Location.local.should eq Location.load_local end Location.local = Location::UTC Location.local.should be Location::UTC end describe ".load_local" do context "with unset TZ" do it "is #local?" do with_tz(nil) do Location.load_local.local?.should be_true end end it "derives location name from system (e.g. /etc/localtime)" do with_tz(nil) do local = Location.load_local # This expectation might fail on unix in case /etc/localtime is not # a symlink into the zoneinfo database. local.name.should_not eq "Local" local.should eq Location.load(local.name) end end end it "with TZ" do with_tz("Europe/Berlin") do with_zoneinfo do Location.load_local.name.should eq "Europe/Berlin" end end with_tz("Foo/Bar") do with_zoneinfo(datapath("zoneinfo")) do Location.load_local.name.should eq "Foo/Bar" end end end it "with empty TZ" do with_zoneinfo do with_tz("") do Location.load_local.utc?.should be_true end end end it "with POSIX TZ string" do with_tz("EST5EDT,M3.2.0,M11.1.0") do location = Location.load_local location.name.should eq("Local") location.zones.should eq [ Location::Zone.new("EST", -18000, false), Location::Zone.new("EDT", -14400, true), ] location.transitions.should be_empty end end {% if flag?(:win32) %} it "loads time zone information from registry" do info = LibC::DYNAMIC_TIME_ZONE_INFORMATION.new( bias: -60, standardBias: 0, daylightBias: -60, standardDate: LibC::SYSTEMTIME.new(wYear: 0, wMonth: 10, wDayOfWeek: 0, wDay: 5, wHour: 3, wMinute: 0, wSecond: 0, wMilliseconds: 0), daylightDate: LibC::SYSTEMTIME.new(wYear: 0, wMonth: 3, wDayOfWeek: 0, wDay: 5, wHour: 2, wMinute: 0, wSecond: 0, wMilliseconds: 0), ) info.standardName.to_slice.copy_from "Central Europe Standard Time".to_utf16 info.daylightName.to_slice.copy_from "Central Europe Summer Time".to_utf16 info.timeZoneKeyName.to_slice.copy_from "Central Europe Standard Time".to_utf16 with_system_time_zone(info) do location = Location.load_local std_zone = Time::Location::Zone.new("CET", 3600, false) dst_zone = Time::Location::Zone.new("CEST", 7200, true) location.zones.should eq [std_zone, dst_zone] location.lookup(Time.utc(2000, 10, 29, 0, 59, 59)).should eq(dst_zone) location.lookup(Time.utc(2000, 10, 29, 1, 0, 0)).should eq(std_zone) location.lookup(Time.utc(2001, 3, 25, 0, 59, 59)).should eq(std_zone) location.lookup(Time.utc(2001, 3, 25, 1, 0, 0)).should eq(dst_zone) location.lookup(Time.utc(3000, 10, 26, 0, 59, 59)).should eq(dst_zone) location.lookup(Time.utc(3000, 10, 26, 1, 0, 0)).should eq(std_zone) location.lookup(Time.utc(3001, 3, 29, 0, 59, 59)).should eq(std_zone) location.lookup(Time.utc(3001, 3, 29, 1, 0, 0)).should eq(dst_zone) end end it "loads time zone without DST (#13502)" do info = LibC::DYNAMIC_TIME_ZONE_INFORMATION.new(bias: -480) info.standardName.to_slice.copy_from "China Standard Time".to_utf16 info.daylightName.to_slice.copy_from "China Daylight Time".to_utf16 info.timeZoneKeyName.to_slice.copy_from "China Standard Time".to_utf16 with_system_time_zone(info) do location = Location.load_local location.zones.should eq [Time::Location::Zone.new("CST", 28800, false)] end end {% end %} end describe ".fixed" do it "without name" do location = Location.fixed -9012 location.name.should eq "-02:30:12" location.zones.should eq [Zone.new(nil, -9012, false)] location.transitions.size.should eq 0 location.utc?.should be_false location.fixed?.should be_true location.local?.should be_false end it "with name" do location = Location.fixed "Fixed", 1800 location.name.should eq "Fixed" location.zones.should eq [Zone.new("Fixed", 1800, false)] location.transitions.size.should eq 0 location.utc?.should be_false location.fixed?.should be_true location.local?.should be_false end it "positive" do location = Location.fixed 8000 location.name.should eq "+02:13:20" location.zones.first.offset.should eq 8000 end it "negative" do location = Location.fixed -7539 location.name.should eq "-02:05:39" location.zones.first.offset.should eq -7539 end it "exactly 24 hours" do location = Location.fixed 86400 location.name.should eq "+24:00" location.zones.first.offset.should eq 86400 location = Location.fixed -86400 location.name.should eq "-24:00" location.zones.first.offset.should eq -86400 end it "raises if offset to large" do expect_raises(InvalidTimezoneOffsetError, "93600") do Location.fixed(93600) end expect_raises(InvalidTimezoneOffsetError, "-90000") do Location.fixed(-90000) end end end describe ".tz" do it "parses string with standard time only" do location = Location.posix_tz("America/New_York", "EST5") location.name.should eq("America/New_York") location.zones.should eq [ Location::Zone.new("EST", -18000, false), ] location.transitions.should be_empty end it "parses string with both standard time and DST" do location = Location.posix_tz("America/New_York", "EST5EDT,M3.2.0,M11.1.0") location.name.should eq("America/New_York") location.zones.should eq [ Location::Zone.new("EST", -18000, false), Location::Zone.new("EDT", -14400, true), ] location.transitions.should be_empty location = Location.posix_tz("America/New_York", "EST5EDT-24:59:59,M3.2.0,M11.1.0") location.name.should eq("America/New_York") location.zones.should eq [ Location::Zone.new("EST", -18000, false), Location::Zone.new("EDT", 89999, true), ] location.transitions.should be_empty location = Location.posix_tz("America/New_York", "EST-24:59:59EDT,M3.2.0,M11.1.0") location.name.should eq("America/New_York") location.zones.should eq [ Location::Zone.new("EST", 89999, false), Location::Zone.new("EDT", 93599, true), ] location.transitions.should be_empty end it "parses string with all-year DST" do location = Location.posix_tz("America/New_York", "EST5EDT,0/0,J365/25") location.name.should eq("America/New_York") location.zones.should eq [ Location::Zone.new("EDT", -14400, true), ] location.transitions.should be_empty location = Location.posix_tz("America/New_York", "XXX-6EDT-4:30:10,J1/0,J365/22:30:10") location.name.should eq("America/New_York") location.zones.should eq [ Location::Zone.new("EDT", 16210, true), ] location.transitions.should be_empty end it "errors on invalid TZ strings" do # std assert_tz_raises "" assert_tz_raises "G" assert_tz_raises "GM" assert_tz_raises "<>" assert_tz_raises "" assert_tz_raises "" assert_tz_raises "" assert_tz_raises "?" assert_tz_raises ":foobar" assert_tz_raises "/foo/bar" assert_tz_raises "Europe/Berlin" # std offset assert_tz_raises "EST" assert_tz_raises "EST " assert_tz_raises "EST 5" assert_tz_raises "EST25" assert_tz_raises "EST123" assert_tz_raises "EST00123" assert_tz_raises "EST-25" assert_tz_raises "EST-123" assert_tz_raises "EST-00123" assert_tz_raises "EST4:" assert_tz_raises "EST4:60" assert_tz_raises "EST4:+30" assert_tz_raises "EST4:-01" assert_tz_raises "EST4:20:" assert_tz_raises "EST4:20:60" assert_tz_raises "EST4:20:+30" assert_tz_raises "EST4:20:-01" # dst assert_tz_raises "EST5 " assert_tz_raises "EST5G" assert_tz_raises "EST5GM" assert_tz_raises "EST5<>" assert_tz_raises "EST5" assert_tz_raises "EST5" assert_tz_raises "EST5+00" assert_tz_raises "EST5-00" # dst offset assert_tz_raises "EST5EDT4:" assert_tz_raises "EST5EDT4:60" assert_tz_raises "EST5EDT4:+30" assert_tz_raises "EST5EDT4:-01" assert_tz_raises "EST5EDT4:20:" assert_tz_raises "EST5EDT4:20:60" assert_tz_raises "EST5EDT4:20:+30" assert_tz_raises "EST5EDT4:20:-01" # start assert_tz_raises "EST5EDT" assert_tz_raises "EST5EDT," assert_tz_raises "EST5EDT,A" assert_tz_raises "EST5EDT,J0" assert_tz_raises "EST5EDT,J366" assert_tz_raises "EST5EDT,-1" assert_tz_raises "EST5EDT,366" assert_tz_raises "EST5EDT,M3" assert_tz_raises "EST5EDT,M3." assert_tz_raises "EST5EDT,M3.2" assert_tz_raises "EST5EDT,M3.2." assert_tz_raises "EST5EDT,M0.2.0" assert_tz_raises "EST5EDT,M13.2.0" assert_tz_raises "EST5EDT,M3.0.0" assert_tz_raises "EST5EDT,M3.6.0" assert_tz_raises "EST5EDT,M3.2.-1" assert_tz_raises "EST5EDT,M3.2.7" assert_tz_raises "EST5EDT,M3.2.0/" assert_tz_raises "EST5EDT,M3.2.0/168" assert_tz_raises "EST5EDT,M3.2.0/-168" # end assert_tz_raises "EST5EDT,M3.2.0" assert_tz_raises "EST5EDT,M3.2.0," assert_tz_raises "EST5EDT,M3.2.0,A" assert_tz_raises "EST5EDT,M3.2.0,J0" assert_tz_raises "EST5EDT,M3.2.0,J366" assert_tz_raises "EST5EDT,M3.2.0,-1" assert_tz_raises "EST5EDT,M3.2.0,366" assert_tz_raises "EST5EDT,M3.2.0,M11" assert_tz_raises "EST5EDT,M3.2.0,M11." assert_tz_raises "EST5EDT,M3.2.0,M11.1" assert_tz_raises "EST5EDT,M3.2.0,M11.1." assert_tz_raises "EST5EDT,M3.2.0,M0.1.0" assert_tz_raises "EST5EDT,M3.2.0,M13.1.0" assert_tz_raises "EST5EDT,M3.2.0,M11.0.0" assert_tz_raises "EST5EDT,M3.2.0,M11.6.0" assert_tz_raises "EST5EDT,M3.2.0,M11.1.-1" assert_tz_raises "EST5EDT,M3.2.0,M11.1.7" assert_tz_raises "EST5EDT,M3.2.0,M11.1.0/" assert_tz_raises "EST5EDT,M3.2.0,M11.1.0/168" assert_tz_raises "EST5EDT,M3.2.0,M11.1.0/-168" # trailing characters assert_tz_raises "EST5EDT,M3.2.0,M11.1.0 " assert_tz_raises "EST5EDT,M3.2.0/2,M11.1.0/2 " end end describe "#lookup" do it "looks up" do with_zoneinfo do location = Location.load("Europe/Berlin") zone, range = location.lookup_with_boundaries(Time.utc(2017, 11, 23, 22, 6, 12).to_unix) zone.should eq Zone.new("CET", 3600, false) range.should eq({1509238800_i64, 1521939600_i64}) end end it "handles dst change" do with_zoneinfo do location = Location.load("Europe/Berlin") time = Time.utc(2017, 10, 29, 1, 0, 0) summer = location.lookup(time - 1.second) summer.name.should eq "CEST" summer.offset.should eq 2 * SECONDS_PER_HOUR summer.dst?.should be_true winter = location.lookup(time) winter.name.should eq "CET" winter.offset.should eq 1 * SECONDS_PER_HOUR winter.dst?.should be_false last_ns = location.lookup(time - 1.nanosecond) last_ns.name.should eq "CEST" last_ns.offset.should eq 2 * SECONDS_PER_HOUR last_ns.dst?.should be_true end end it "handles value after last transition" do with_zoneinfo do location = Location.load("America/Argentina/Buenos_Aires") zone = location.lookup(Time.utc(5000, 1, 1)) zone.name.should eq "-03" zone.offset.should eq -3 * 3600 end end # Test that we get the correct results for times before the first # transition time. To do this we explicitly check early dates in a # couple of specific timezones. context "first zone" do it "PST8PDT" do with_zoneinfo do location = Location.load("PST8PDT") zone1 = location.lookup(-1633269601) zone2 = location.lookup(-1633269601 + 1) zone1.name.should eq "PST" zone1.offset.should eq -8 * SECONDS_PER_HOUR zone2.name.should eq "PDT" zone2.offset.should eq -7 * SECONDS_PER_HOUR end end it "Pacific/Fakaofo" do with_zoneinfo do location = Location.load("Pacific/Fakaofo") zone1 = location.lookup(1325242799) zone2 = location.lookup(1325242799 + 1) zone1.name.should eq "-11" zone1.offset.should eq -11 * SECONDS_PER_HOUR zone2.name.should eq "+13" zone2.offset.should eq 13 * SECONDS_PER_HOUR end end end it "caches last zone" do with_zoneinfo do location = Time::Location.load("Europe/Berlin") location.@cached_range.should eq({Int64::MIN, Int64::MIN}) location.@cached_zone.should eq Zone.new("LMT", 3208, false) expected_zone = Zone.new("CET", 3600, false) location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq expected_zone location.@cached_range.should eq({1509238800_i64, 1521939600_i64}) location.@cached_zone.should eq expected_zone end end it "reads from cache" do with_zoneinfo do location = Location.load("Europe/Berlin") location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq Zone.new("CET", 3600, false) cached_zone = Zone.new("MyZone", 1234, true) location.__cached_zone = cached_zone location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq cached_zone end end context "TZ string" do it "looks up location with standard time only" do location = Location.posix_tz("Local", "EST5") zone, range = location.lookup_with_boundaries(Time.utc(2025, 1, 1, 22, 6, 12).to_unix) zone.should eq(Zone.new("EST", -18000, false)) range.should eq({Int64::MIN, Int64::MAX}) end it "looks up location with all-year DST" do location = Location.posix_tz("Local", "EST5EDT4,0/0,J365/25") zone, range = location.lookup_with_boundaries(Time.utc(2025, 1, 1, 22, 6, 12).to_unix) zone.should eq(Zone.new("EDT", -14400, true)) range.should eq({Int64::MIN, Int64::MAX}) end context "transition dates" do it "supports one-based ordinal days" do assert_tz_boundaries "EST5EDT4,J1/2,J365/2", Time.utc(2025, 12, 31, 6, 0, 0), Time.utc(2026, 1, 1, 7, 0, 0), Time.utc(2026, 12, 31, 6, 0, 0), Time.utc(2027, 1, 1, 7, 0, 0) assert_tz_boundaries "EST5EDT4,J1/2,J365/2", Time.utc(2027, 12, 31, 6, 0, 0), Time.utc(2028, 1, 1, 7, 0, 0), Time.utc(2028, 12, 31, 6, 0, 0), Time.utc(2029, 1, 1, 7, 0, 0) end it "excludes Feb 29 if one-based" do assert_tz_boundaries "EST5EDT4,J59/2,J60/2", Time.utc(2027, 3, 1, 6, 0, 0), Time.utc(2028, 2, 28, 7, 0, 0), Time.utc(2028, 3, 1, 6, 0, 0), Time.utc(2029, 2, 28, 7, 0, 0) end it "supports zero-based ordinal days" do assert_tz_boundaries "EST5EDT4,50/2,280/2", Time.utc(2025, 10, 8, 6, 0, 0), Time.utc(2026, 2, 20, 7, 0, 0), Time.utc(2026, 10, 8, 6, 0, 0), Time.utc(2027, 2, 20, 7, 0, 0) assert_tz_boundaries "EST5EDT4,50/2,280/2", Time.utc(2027, 10, 8, 6, 0, 0), Time.utc(2028, 2, 20, 7, 0, 0), Time.utc(2028, 10, 7, 6, 0, 0), Time.utc(2029, 2, 20, 7, 0, 0) end it "includes Feb 29 if zero-based" do assert_tz_boundaries "EST5EDT4,59/2,60/2", Time.utc(2027, 3, 2, 6, 0, 0), Time.utc(2028, 2, 29, 7, 0, 0), Time.utc(2028, 3, 1, 6, 0, 0), Time.utc(2029, 3, 1, 7, 0, 0) end it "supports month + week + day of week" do tz = "EST5EDT4,M3.2.0/2,M11.1.0/2" trans = [ {Time.utc(2020, 11, 1, 6, 0, 0), Time.utc(2021, 3, 14, 7, 0, 0)}, {Time.utc(2021, 11, 7, 6, 0, 0), Time.utc(2022, 3, 13, 7, 0, 0)}, {Time.utc(2022, 11, 6, 6, 0, 0), Time.utc(2023, 3, 12, 7, 0, 0)}, {Time.utc(2023, 11, 5, 6, 0, 0), Time.utc(2024, 3, 10, 7, 0, 0)}, {Time.utc(2024, 11, 3, 6, 0, 0), Time.utc(2025, 3, 9, 7, 0, 0)}, {Time.utc(2025, 11, 2, 6, 0, 0), Time.utc(2026, 3, 8, 7, 0, 0)}, {Time.utc(2026, 11, 1, 6, 0, 0), Time.utc(2027, 3, 14, 7, 0, 0)}, {Time.utc(2027, 11, 7, 6, 0, 0), Time.utc(2028, 3, 12, 7, 0, 0)}, {Time.utc(2028, 11, 5, 6, 0, 0), Time.utc(2029, 3, 11, 7, 0, 0)}, ] trans.each_cons_pair do |(t0, t1), (t2, t3)| assert_tz_boundaries(tz, t0, t1, t2, t3) end end it "handles time zone differences other than 1 hour" do assert_tz_boundaries "EST4:30EDT-1:23:45,M3.2.0,M11.1.0", Time.utc(2024, 11, 3, 0, 36, 15), Time.utc(2025, 3, 9, 6, 30, 0), Time.utc(2025, 11, 2, 0, 36, 15), Time.utc(2026, 3, 8, 6, 30, 0) end it "defaults transition times to 02:00:00 local time" do assert_tz_boundaries "EST5EDT,M3.2.0,M11.1.0", Time.utc(2024, 11, 3, 6, 0, 0), Time.utc(2025, 3, 9, 7, 0, 0), Time.utc(2025, 11, 2, 6, 0, 0), Time.utc(2026, 3, 8, 7, 0, 0) end it "supports transition times from -167 to 167 hours" do assert_tz_boundaries "EST5EDT,M3.2.0/-167,M11.1.0/167", Time.utc(2024, 11, 10, 3, 0, 0), Time.utc(2025, 3, 2, 6, 0, 0), Time.utc(2025, 11, 9, 3, 0, 0), Time.utc(2026, 3, 1, 6, 0, 0) end it "handles years beginning and ending in DST" do tz = "AEST-10AEDT,M10.1.0,M4.1.0/3" trans = [ {Time.utc(2020, 4, 4, 16, 0, 0), Time.utc(2020, 10, 3, 16, 0, 0)}, {Time.utc(2021, 4, 3, 16, 0, 0), Time.utc(2021, 10, 2, 16, 0, 0)}, {Time.utc(2022, 4, 2, 16, 0, 0), Time.utc(2022, 10, 1, 16, 0, 0)}, {Time.utc(2023, 4, 1, 16, 0, 0), Time.utc(2023, 9, 30, 16, 0, 0)}, {Time.utc(2024, 4, 6, 16, 0, 0), Time.utc(2024, 10, 5, 16, 0, 0)}, {Time.utc(2025, 4, 5, 16, 0, 0), Time.utc(2025, 10, 4, 16, 0, 0)}, {Time.utc(2026, 4, 4, 16, 0, 0), Time.utc(2026, 10, 3, 16, 0, 0)}, {Time.utc(2027, 4, 3, 16, 0, 0), Time.utc(2027, 10, 2, 16, 0, 0)}, {Time.utc(2028, 4, 1, 16, 0, 0), Time.utc(2028, 9, 30, 16, 0, 0)}, {Time.utc(2029, 3, 31, 16, 0, 0), Time.utc(2029, 10, 6, 16, 0, 0)}, ] trans.each_cons_pair do |(t0, t1), (t2, t3)| assert_tz_boundaries(tz, t0, t1, t2, t3) end end it "handles very distant years" do assert_tz_boundaries "EST5EDT4,M3.2.0/2,M11.1.0/2", Time.utc(1583, 11, 6, 6, 0, 0), Time.utc(1584, 3, 11, 7, 0, 0), Time.utc(1584, 11, 4, 6, 0, 0), Time.utc(1585, 3, 10, 7, 0, 0) assert_tz_boundaries "EST5EDT4,M3.2.0/2,M11.1.0/2", Time.utc(3332, 11, 2, 6, 0, 0), Time.utc(3333, 3, 8, 7, 0, 0), Time.utc(3333, 11, 1, 6, 0, 0), Time.utc(3334, 3, 14, 7, 0, 0) end end end context "zoneinfo + POSIX TZ string" do it "looks up location beyond last transition time" do with_zoneinfo do # "CET-1CEST,M3.5.0,M10.5.0/3" # last transition is in year 2037 location = Location.load("Europe/Berlin") Time.unix(location.@transitions.last.when).year.should eq(2037) assert_tz_boundaries location, Zone.new("CET", 3600, false), Zone.new("CEST", 7200, true), Time.utc(2037, 10, 25, 1, 0, 0), Time.utc(2038, 3, 28, 1, 0, 0), Time.utc(2038, 10, 31, 1, 0, 0), Time.utc(2039, 3, 27, 1, 0, 0) assert_tz_boundaries location, Zone.new("CET", 3600, false), Zone.new("CEST", 7200, true), Time.utc(3003, 10, 30, 1, 0, 0), Time.utc(3004, 3, 25, 1, 0, 0), Time.utc(3004, 10, 28, 1, 0, 0), Time.utc(3005, 3, 31, 1, 0, 0) end end it "looks up location if TZ string has no transitions" do with_zoneinfo do # Paraguay stopped observing DST since 2024 location = Location.load("America/Asuncion") zone, range = location.lookup_with_boundaries(Time.utc(2024, 10, 15, 2, 59, 59).to_unix) zone.should eq(Zone.new("-03", -10800, true)) range.should eq({Time.utc(2024, 10, 6, 4, 0, 0).to_unix, Time.utc(2024, 10, 15, 3, 0, 0).to_unix}) zone, range = location.lookup_with_boundaries(Time.utc(2024, 10, 15, 3, 0, 0).to_unix) zone.should eq(Zone.new("-03", -10800, false)) range.should eq({Time.utc(2024, 10, 15, 3, 0, 0).to_unix, Int64::MAX}) end end end end end describe Time::Location::Zone do it "#inspect" do Time::Location::Zone.new("CET", 3600, false).inspect.should eq "Time::Location::Zone(CET +01:00 (3600s) STD)" Time::Location::Zone.new("CEST", 7200, true).inspect.should eq "Time::Location::Zone(CEST +02:00 (7200s) DST)" Time::Location::Zone.new(nil, 9000, true).inspect.should eq "Time::Location::Zone(+02:30 (9000s) DST)" Time::Location::Zone.new(nil, 9012, true).inspect.should eq "Time::Location::Zone(+02:30:12 (9012s) DST)" end it "#name" do Time::Location::Zone.new("CEST", 7200, true).name.should eq "CEST" Time::Location::Zone.new(nil, 9000, true).name.should eq "+02:30" end end end