Skip to content

Support POSIX TZ environment variable strings #15792

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 324 additions & 2 deletions spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
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
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
Expand Down Expand Up @@ -254,6 +273,18 @@ class Time::Location
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(
Expand Down Expand Up @@ -322,16 +353,186 @@ class Time::Location
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, "86401") do
Location.fixed(86401)
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 "<G>"
assert_tz_raises "<GM>"
assert_tz_raises "<GMT"
assert_tz_raises "012"
assert_tz_raises "+00"
assert_tz_raises "-00"
assert_tz_raises "<$aa>"
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<GM>"
assert_tz_raises "EST5<GMT"
assert_tz_raises "EST5<$aa>"
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
Expand Down Expand Up @@ -428,6 +629,127 @@ class Time::Location
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

pending "zoneinfo + POSIX TZ string"
end
end

Expand Down
Loading
Loading