diff --git a/.gitignore b/.gitignore index daf913b1..0d58f8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,11 @@ *.so # Folders -_obj -_test +.idea/ +_obj/ +_test/ +reports/ +vendor/ # Architecture specific extensions/prefixes *.[568vq] @@ -22,3 +25,4 @@ _testmain.go *.exe *.test *.prof +*.out diff --git a/.travis.yml b/.travis.yml index 878e3a0c..e77cc767 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,13 @@ language: go go: - - tip + - '1.17' install: - go get -t -v ./... - go get github.com/mattn/goveralls script: - - go test -v -covermode=count -coverprofile=coverage.out ./... - - go tool cover -func=coverage.out - - $(go env GOPATH | awk 'BEGIN{FS=":"} {print $1}')/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN + - ./build+test.sh amd64 + - ./build+test.sh 386 -env: - secure: "kcksCWXVeZKmFUWcyi2S/j87iwUmXMxZXxA2DG9ymc11QP43QoPNSG9pBjA/DDjvzt4WdKIFphTrxVfvawii/9j3oXA1aPmAcHGu87i4iOVg4IIZ4bPZLfUo0e7s6XP5FakzegYvPP6HWV5Xr5h+Q6osrjq3czOnPY+rVII6MRrxXMOfsqo8HEER+YIOOD6vj5LV2/quY8d0XHtThqgGvQ1cz4OB3vbd4KFBl48kmfXKefTrRG1NoqoQMMpwUVzU395JIEAg1eWbGkquhWU5v13gRwk3VMVWF75jZna8TSiqWha0P5iQdaED30kNCz3poIaBI1MLdxktJxwUQJZ5AaYIMCxh7ZCiW0FXTYCRu3EoeYusTPMLqy1ghK+gIlA46sNd26cKk5/OngXRrHo/J0aF5NWjydlk5FLHfKm9ih/Y426M9nV2zYNQAcVKgO8zVNb2IkJ3e7aTB2NH4DpkvjSV4D4hlnmW9xxmo14TKF+gXJ9Hw9ssKbigRHoL6S92aQHcpkdjGGnI5YSTy1fZh/nIE3HDmx+hcK4/ZtPHj9KnXopKYxBGyNswN5Eko+q6h3BB/Q7LwALtEexdDbznwsRmcZXJsOU4chvcjAKgIAi6cbeTwq8kG5E/w8TTY2wGeRm2ZFysQu6Jf8hgTZDQTV343RG80STWeUbiD0o7/WY=" \ No newline at end of file diff --git a/LICENSE b/LICENSE index f642e17e..b0280bc6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015 The Go Authors. All rights reserved. +Copyright (c) 2015 The Go Authors & Rick Beton. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are @@ -24,4 +24,4 @@ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index e29f2852..52bcde8c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,39 @@ # date -[![GoDoc](https://img.shields.io/badge/api-Godoc-blue.svg?style=flat-square)](https://godoc.org/github.com/fxtlabs/date) -[![Build Status](https://api.travis-ci.org/fxtlabs/date.svg?branch=master)](https://travis-ci.org/fxtlabs/date) -[![Coverage Status](https://coveralls.io/repos/fxtlabs/date/badge.svg?branch=master&service=github)](https://coveralls.io/github/fxtlabs/date?branch=master) +[![GoDoc](https://img.shields.io/badge/api-Godoc-blue.svg)](https://pkg.go.dev/github.com/rickb777/date) +[![Build Status](https://api.travis-ci.org/rickb777/date.svg?branch=master)](https://travis-ci.org/rickb777/date/builds) +[![Coverage Status](https://coveralls.io/repos/rickb777/date/badge.svg?branch=master&service=github)](https://coveralls.io/github/rickb777/date?branch=master) +[![Go Report Card](https://goreportcard.com/badge/github.com/rickb777/date)](https://goreportcard.com/report/github.com/rickb777/date) +[![Issues](https://img.shields.io/github/issues/rickb777/date.svg)](https://github.com/rickb777/date/issues) Package `date` provides functionality for working with dates. This package introduces a light-weight `Date` type that is storage-efficient -and covenient for calendrical calculations and date parsing and formatting +and convenient for calendrical calculations and date parsing and formatting (including years outside the [0,9999] interval). -See [package documentation](https://godoc.org/github.com/fxtlabs/date) for +It also provides + + * `clock.Clock` which expresses a wall-clock style hours-minutes-seconds with millisecond precision. + * `period.Period` which expresses a period corresponding to the ISO-8601 form (e.g. "PT30S"). + * `timespan.DateRange` which expresses a period between two dates. + * `timespan.TimeSpan` which expresses a duration of time between two instants. + * `view.VDate` which wraps `Date` for use in templates etc. + +See [package documentation](https://godoc.org/github.com/rickb777/date) for full documentation and examples. ## Installation - go get -u github.com/fxtlabs/date + go get -u github.com/rickb777/date + +or + + dep ensure -add github.com/rickb777/date + +## Status + +This library has been in reliable production use for some time. Versioning follows the well-known semantic version pattern. ## Credits @@ -25,3 +43,5 @@ many of the `Date` methods are implemented using the corresponding methods of the `time.Time` type and much of the documentation is copied directly from that package. +The original [Good Work](https://github.com/fxtlabs/date) on which this was +based was done by Filippo Tampieri at Fxtlabs. diff --git a/build+test.sh b/build+test.sh new file mode 100755 index 00000000..b07e45b3 --- /dev/null +++ b/build+test.sh @@ -0,0 +1,46 @@ +#!/bin/bash -e +cd "$(dirname $0)" +PATH=$HOME/go/bin:$PATH +unset GOPATH +export GOARCH=${1} + +function v +{ + echo + echo $@ + $@ +} + +if ! type -p goveralls; then + v go install github.com/mattn/goveralls +fi + +if ! type -p shadow; then + v go get golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow + v go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow +fi + +if ! type -p goreturns; then + v go get github.com/sqs/goreturns + v go install github.com/sqs/goreturns +fi + +echo date... +v go test -v -covermode=count -coverprofile=date.out . +v go tool cover -func=date.out +#[ -z "$COVERALLS_TOKEN" ] || goveralls -coverprofile=date.out -service=travis-ci -repotoken $COVERALLS_TOKEN + +for d in clock period timespan view; do + echo $d... + v go test -v -covermode=count -coverprofile=$d.out ./$d + v go tool cover -func=$d.out + #[ -z "$COVERALLS_TOKEN" ] || goveralls -coverprofile=$d.out -service=travis-ci -repotoken $COVERALLS_TOKEN +done + +v goreturns -l -w *.go */*.go + +v go vet ./... + +v shadow ./... + +v go install ./datetool diff --git a/clock/clock.go b/clock/clock.go new file mode 100644 index 00000000..81d018c7 --- /dev/null +++ b/clock/clock.go @@ -0,0 +1,182 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package clock specifies a time of day with resolution to the nearest millisecond. +package clock + +import ( + "math" + "time" +) + +// Clock specifies a time of day. It complements the existing time.Duration, applying +// that to the time since midnight (on some arbitrary day in some arbitrary timezone). +// The resolution is to the nearest millisecond, unlike time.Duration (which has nanosecond +// resolution). +// +// It is not intended that Clock be used to represent periods greater than 24 hours nor +// negative values. However, for such lengths of time, a fixed 24 hours per day +// is assumed and a modulo operation Mod24 is provided to discard whole multiples of 24 hours. +// +// Clock is a type of integer (actually int32), so values can be compared and sorted as +// per other integers. The constants Second, Minute, Hour and Day can be added and subtracted +// with obvious outcomes. +// +// See https://en.wikipedia.org/wiki/ISO_8601#Times +type Clock int32 + +// Common durations - second, minute, hour and day. +const ( + // Second is one second; it has a similar meaning to time.Second. + Second Clock = Clock(time.Second / time.Millisecond) + + // Minute is one minute; it has a similar meaning to time.Minute. + Minute Clock = Clock(time.Minute / time.Millisecond) + + // Hour is one hour; it has a similar meaning to time.Hour. + Hour Clock = Clock(time.Hour / time.Millisecond) + + // Day is a fixed period of 24 hours. This does not take account of daylight savings, + // so is not fully general. + Day Clock = Clock(time.Hour * 24 / time.Millisecond) +) + +// Midnight is the zero value of a Clock. +const Midnight Clock = 0 + +// Noon is at 12pm. +const Noon Clock = Hour * 12 + +// Undefined is provided because the zero value of a Clock *is* defined (i.e. Midnight). +// So a special value is chosen, which is math.MinInt32. +const Undefined Clock = Clock(math.MinInt32) + +// New returns a new Clock with specified hour, minute, second and millisecond. +func New(hour, minute, second, millisec int) Clock { + hx := Clock(hour) * Hour + mx := Clock(minute) * Minute + sx := Clock(second) * Second + return Clock(hx + mx + sx + Clock(millisec)) +} + +// NewAt returns a new Clock with specified hour, minute, second and millisecond. +func NewAt(t time.Time) Clock { + hour, minute, second := t.Clock() + hx := Clock(hour) * Hour + mx := Clock(minute) * Minute + sx := Clock(second) * Second + ms := Clock(t.Nanosecond() / int(time.Millisecond)) + return Clock(hx + mx + sx + ms) +} + +// SinceMidnight returns a new Clock based on a duration since some arbitrary midnight. +func SinceMidnight(d time.Duration) Clock { + return Clock(d / time.Millisecond) +} + +// DurationSinceMidnight convert a clock to a time.Duration since some arbitrary midnight. +func (c Clock) DurationSinceMidnight() time.Duration { + return time.Duration(c) * time.Millisecond +} + +// Add returns a new Clock offset from this clock specified hour, minute, second and millisecond. +// The parameters can be negative. +// +// If required, use Mod24() to correct any overflow or underflow. +func (c Clock) Add(h, m, s, ms int) Clock { + hx := Clock(h) * Hour + mx := Clock(m) * Minute + sx := Clock(s) * Second + return c + hx + mx + sx + Clock(ms) +} + +// AddDuration returns a new Clock offset from this clock by a duration. +// The parameters can be negative. +// +// If required, use Mod24() to correct any overflow or underflow. +// +// AddDuration is also useful for adding period.Period values. +func (c Clock) AddDuration(d time.Duration) Clock { + return c + Clock(d/time.Millisecond) +} + +// ModSubtract returns the duration between two clock times. +// +// If c2 is before c (i.e. c2 < c), the result is the duration computed from c - c2. +// +// But if c is before c2, it is assumed that c is after midnight and c2 is before midnight. The +// result is the sum of the evening time from c2 to midnight with the morning time from midnight to c. +// This is the same as Mod24(c - c2). +func (c Clock) ModSubtract(c2 Clock) time.Duration { + ms := c - c2 + return ms.Mod24().DurationSinceMidnight() +} + +// IsInOneDay tests whether a clock time is in the range 0 to 24 hours, inclusive. Inside this +// range, a Clock is generally well-behaved. But outside it, there may be errors due to daylight +// savings. Note that 24:00:00 is included as a special case as per ISO-8601 definition of midnight. +func (c Clock) IsInOneDay() bool { + return Midnight <= c && c <= Day +} + +// IsMidnight tests whether a clock time is midnight. This is shorthand for c.Mod24() == 0. +// For large values, this assumes that every day has 24 hours. +func (c Clock) IsMidnight() bool { + return c.Mod24() == Midnight +} + +// Mod24 calculates the remainder vs 24 hours using Euclidean division, in which the result +// will be less than 24 hours and is never negative. Note that this imposes the assumption that +// every day has 24 hours (not correct when daylight saving changes in any timezone). +// +// https://en.wikipedia.org/wiki/Modulo_operation +func (c Clock) Mod24() Clock { + if Midnight <= c && c < Day { + return c + } + if c < Midnight { + q := 1 - c/Day + m := c + (q * Day) + if m == Day { + m = Midnight + } + return m + } + q := c / Day + return c - (q * Day) +} + +// Days gets the number of whole days represented by the Clock, assuming that each day is a fixed +// 24 hour period. Negative values are treated so that the range -23h59m59s to -1s is fully +// enclosed in a day numbered -1, and so on. This means that the result is zero only for the +// clock range 0s to 23h59m59s, for which IsInOneDay() returns true. +func (c Clock) Days() int { + if c < Midnight { + return int(c/Day) - 1 + } + return int(c / Day) +} + +// Hours gets the clock-face number of hours (calculated from the modulo time, see Mod24). +func (c Clock) Hours() int { + return int(clockHours(c.Mod24())) +} + +// Minutes gets the clock-face number of minutes (calculated from the modulo time, see Mod24). +// For example, for 22:35 this will return 35. +func (c Clock) Minutes() int { + return int(clockMinutes(c.Mod24())) +} + +// Seconds gets the clock-face number of seconds (calculated from the modulo time, see Mod24). +// For example, for 10:20:30 this will return 30. +func (c Clock) Seconds() int { + return int(clockSeconds(c.Mod24())) +} + +// Millisec gets the clock-face number of milliseconds (calculated from the modulo time, see Mod24). +// For example, for 10:20:30.456 this will return 456. +func (c Clock) Millisec() int { + return int(clockMillisec(c.Mod24())) +} diff --git a/clock/clock_test.go b/clock/clock_test.go new file mode 100644 index 00000000..0d629167 --- /dev/null +++ b/clock/clock_test.go @@ -0,0 +1,363 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package clock + +import ( + "testing" + "time" +) + +func TestClockHoursMinutesSeconds(t *testing.T) { + cases := []struct { + in Clock + h, m, s, ms int + }{ + {New(0, 0, 0, 0), 0, 0, 0, 0}, + {New(1, 2, 3, 4), 1, 2, 3, 4}, + {New(23, 59, 59, 999), 23, 59, 59, 999}, + {New(0, 0, 0, -1), 23, 59, 59, 999}, + {NewAt(time.Date(2015, 12, 4, 18, 50, 42, 173444111, time.UTC)), 18, 50, 42, 173}, + } + for i, x := range cases { + h := x.in.Hours() + m := x.in.Minutes() + s := x.in.Seconds() + ms := x.in.Millisec() + if h != x.h || m != x.m || s != x.s || ms != x.ms { + t.Errorf("%d: got %02d:%02d:%02d.%03d, want %v (%d)", i, h, m, s, ms, x.in, x.in) + } + } +} + +func TestClockSinceMidnight(t *testing.T) { + cases := []struct { + in Clock + d time.Duration + }{ + {New(1, 2, 3, 4), time.Hour + 2*time.Minute + 3*time.Second + 4}, + {New(23, 59, 59, 999), 23*time.Hour + 59*time.Minute + 59*time.Second + 999}, + } + for i, x := range cases { + d := x.in.DurationSinceMidnight() + c2 := SinceMidnight(d) + if c2 != x.in { + t.Errorf("%d: got %v, want %v (%d)", i, c2, x.in, x.in) + } + } +} + +func TestClockIsInOneDay(t *testing.T) { + cases := []struct { + in Clock + want bool + }{ + {New(0, 0, 0, 0), true}, + {New(24, 0, 0, 0), true}, + {New(-24, 0, 0, 0), false}, + {New(48, 0, 0, 0), false}, + {New(0, 0, 0, 1), true}, + {New(2, 0, 0, 1), true}, + {New(-1, 0, 0, 0), false}, + {New(0, 0, 0, -1), false}, + } + for _, x := range cases { + got := x.in.IsInOneDay() + if got != x.want { + t.Errorf("%v got %v, want %v", x.in, x.in.IsInOneDay(), x.want) + } + } +} + +func TestClockAdd(t *testing.T) { + cases := []struct { + h, m, s, ms int + in, want Clock + }{ + {0, 0, 0, 0, 2 * Hour, New(2, 0, 0, 0)}, + {0, 0, 0, 1, 2 * Hour, New(2, 0, 0, 1)}, + {0, 0, 0, -1, 2 * Hour, New(1, 59, 59, 999)}, + {0, 0, 1, 0, 2 * Hour, New(2, 0, 1, 0)}, + {0, 0, -1, 0, 2 * Hour, New(1, 59, 59, 0)}, + {0, 1, 0, 0, 2 * Hour, New(2, 1, 0, 0)}, + {0, -1, 0, 0, 2 * Hour, New(1, 59, 0, 0)}, + {1, 0, 0, 0, 2 * Hour, New(3, 0, 0, 0)}, + {-1, 0, 0, 0, 2 * Hour, New(1, 0, 0, 0)}, + {-2, 0, 0, 0, 2 * Hour, New(0, 0, 0, 0)}, + {-2, 0, -1, -1, 2 * Hour, New(0, 0, -1, -1)}, + } + for i, x := range cases { + got := x.in.Add(x.h, x.m, x.s, x.ms) + if got != x.want { + t.Errorf("%d: %d %d %d.%d: got %v, want %v", i, x.h, x.m, x.s, x.ms, got, x.want) + } + } +} + +func TestClockAddDuration(t *testing.T) { + cases := []struct { + d time.Duration + in, want Clock + }{ + {0, 2 * Hour, New(2, 0, 0, 0)}, + {1, 2 * Hour, New(2, 0, 0, 0)}, + {time.Millisecond, 2 * Hour, New(2, 0, 0, 1)}, + {-time.Second, 2 * Hour, New(1, 59, 59, 0)}, + {7 * time.Minute, 2 * Hour, New(2, 7, 0, 0)}, + } + for i, x := range cases { + got := x.in.AddDuration(x.d) + if got != x.want { + t.Errorf("%d: %d: got %v, want %v", i, x.d, got, x.want) + } + } +} + +func TestClockSubtract(t *testing.T) { + cases := []struct { + c1, c2 Clock + want time.Duration + }{ + {New(1, 2, 3, 4), New(1, 2, 3, 4), 0 * time.Hour}, + {New(2, 0, 0, 0), New(0, 0, 0, 0), 2 * time.Hour}, + {New(0, 0, 0, 0), New(2, 0, 0, 0), 22 * time.Hour}, + {New(1, 0, 0, 0), New(23, 0, 0, 0), 2 * time.Hour}, + {New(23, 0, 0, 0), New(1, 0, 0, 0), 22 * time.Hour}, + {New(1, 2, 3, 5), New(1, 2, 3, 4), 1 * time.Millisecond}, + {New(1, 2, 3, 4), New(1, 2, 3, 5), 24*time.Hour - 1*time.Millisecond}, + } + for i, x := range cases { + got := x.c1.ModSubtract(x.c2) + if got != x.want { + t.Errorf("%d: %v - %v: got %v, want %v", i, x.c1, x.c2, got, x.want) + } + } +} + +func TestClockIsMidnight(t *testing.T) { + cases := []struct { + in Clock + want bool + }{ + {New(0, 0, 0, 0), true}, + {Day, true}, + {24 * Hour, true}, + {New(24, 0, 0, 0), true}, + {New(-24, 0, 0, 0), true}, + {New(-48, 0, 0, 0), true}, + {New(48, 0, 0, 0), true}, + {New(0, 0, 0, 1), false}, + {New(2, 0, 0, 1), false}, + {New(-1, 0, 0, 0), false}, + {New(0, 0, 0, -1), false}, + } + for i, x := range cases { + got := x.in.IsMidnight() + if got != x.want { + t.Errorf("%d: %v got %v, want %v, %d", i, x.in, x.in.IsMidnight(), x.want, x.in.Mod24()) + } + } +} + +func TestClockMod(t *testing.T) { + cases := []struct { + h, want Clock + }{ + {0, 0}, + {1 * Hour, 1 * Hour}, + {2 * Hour, 2 * Hour}, + {23 * Hour, 23 * Hour}, + {24 * Hour, 0}, + {-24 * Hour, 0}, + {-48 * Hour, 0}, + {25 * Hour, Hour}, + {49 * Hour, Hour}, + {-1 * Hour, 23 * Hour}, + {-23 * Hour, Hour}, + {New(0, 0, 0, 1), 1}, + {New(0, 0, 1, 0), Second}, + {New(0, 0, 0, -1), New(23, 59, 59, 999)}, + } + for i, x := range cases { + clock := x.h + got := clock.Mod24() + if got != x.want { + t.Errorf("%d: %dh: got %#v, want %#v", i, x.h, got, x.want) + } + } +} + +func TestClockDays(t *testing.T) { + cases := []struct { + h, days int + }{ + {0, 0}, + {1, 0}, + {23, 0}, + {24, 1}, + {25, 1}, + {48, 2}, + {49, 2}, + {-1, -1}, + {-23, -1}, + {-24, -2}, + } + for i, x := range cases { + clock := Clock(x.h) * Hour + if clock.Days() != x.days { + t.Errorf("%d: %dh: got %v, want %v", i, x.h, clock.Days(), x.days) + } + } +} + +func TestClockString(t *testing.T) { + cases := []struct { + h, m, s, ms Clock + hh, hhmm, hhmmss, str, h12, hmm12, hmmss12 string + }{ + {0, 0, 0, 0, "00", "00:00", "00:00:00", "00:00:00.000", "12am", "12:00am", "12:00:00am"}, + {0, 0, 0, 1, "00", "00:00", "00:00:00", "00:00:00.001", "12am", "12:00am", "12:00:00am"}, + {0, 0, 1, 0, "00", "00:00", "00:00:01", "00:00:01.000", "12am", "12:00am", "12:00:01am"}, + {0, 1, 0, 0, "00", "00:01", "00:01:00", "00:01:00.000", "12am", "12:01am", "12:01:00am"}, + {1, 0, 0, 0, "01", "01:00", "01:00:00", "01:00:00.000", "1am", "1:00am", "1:00:00am"}, + {1, 2, 3, 4, "01", "01:02", "01:02:03", "01:02:03.004", "1am", "1:02am", "1:02:03am"}, + {11, 0, 0, 0, "11", "11:00", "11:00:00", "11:00:00.000", "11am", "11:00am", "11:00:00am"}, + {12, 0, 0, 0, "12", "12:00", "12:00:00", "12:00:00.000", "12pm", "12:00pm", "12:00:00pm"}, + {13, 0, 0, 0, "13", "13:00", "13:00:00", "13:00:00.000", "1pm", "1:00pm", "1:00:00pm"}, + {24, 0, 0, 0, "24", "24:00", "24:00:00", "24:00:00.000", "12am", "12:00am", "12:00:00am"}, + {24, 0, 0, 1, "00", "00:00", "00:00:00", "00:00:00.001", "12am", "12:00am", "12:00:00am"}, + {-1, 0, 0, 0, "23", "23:00", "23:00:00", "23:00:00.000", "11pm", "11:00pm", "11:00:00pm"}, + {-1, -1, -1, -1, "22", "22:58", "22:58:58", "22:58:58.999", "10pm", "10:58pm", "10:58:58pm"}, + } + for _, x := range cases { + d := Clock(x.h*Hour + x.m*Minute + x.s*Second + x.ms) + if d.Hh() != x.hh { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.Hh(), x.hh, d) + } + if d.HhMm() != x.hhmm { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.HhMm(), x.hhmm, d) + } + if d.HhMmSs() != x.hhmmss { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.HhMmSs(), x.hhmmss, d) + } + if d.String() != x.str { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.String(), x.str, d) + } + if d.Hh12() != x.h12 { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.Hh12(), x.h12, d) + } + if d.HhMm12() != x.hmm12 { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.HhMm12(), x.hmm12, d) + } + if d.HhMmSs12() != x.hmmss12 { + t.Errorf("%d, %d, %d, %d, got %v, want %v (%d)", x.h, x.m, x.s, x.ms, d.HhMmSs12(), x.hmmss12, d) + } + } +} + +func TestClockParseGoods(t *testing.T) { + cases := []struct { + str string + want Clock + }{ + {"00", New(0, 0, 0, 0)}, + {"01", New(1, 0, 0, 0)}, + {"23", New(23, 0, 0, 0)}, + {"00:00", New(0, 0, 0, 0)}, + {"00:01", New(0, 1, 0, 0)}, + {"01:00", New(1, 0, 0, 0)}, + {"01:02", New(1, 2, 0, 0)}, + {"23:59", New(23, 59, 0, 0)}, + {"0911", New(9, 11, 0, 0)}, + {"1024", New(10, 24, 0, 0)}, + {"2359", New(23, 59, 0, 0)}, + {"00:00:00", New(0, 0, 0, 0)}, + {"00:00:01", New(0, 0, 1, 0)}, + {"00:01:00", New(0, 1, 0, 0)}, + {"01:00:00", New(1, 0, 0, 0)}, + {"01:02:03", New(1, 2, 3, 0)}, + {"23:59:59", New(23, 59, 59, 0)}, + {"235959", New(23, 59, 59, 0)}, + {"00:00:00.000", New(0, 0, 0, 0)}, + {"00:00:00.001", New(0, 0, 0, 1)}, + {"00:00:01.000", New(0, 0, 1, 0)}, + {"00:01:00.000", New(0, 1, 0, 0)}, + {"01:00:00.000", New(1, 0, 0, 0)}, + {"01:02:03.004", New(1, 2, 3, 4)}, + {"01:02:03.04", New(1, 2, 3, 40)}, + {"01:02:03.4", New(1, 2, 3, 400)}, + {"23:59:59.999", New(23, 59, 59, 999)}, + {"0am", New(0, 0, 0, 0)}, + {"00am", New(0, 0, 0, 0)}, + {"12am", New(0, 0, 0, 0)}, + {"12pm", New(12, 0, 0, 0)}, + {"12:01am", New(0, 1, 0, 0)}, + {"12:01pm", New(12, 1, 0, 0)}, + {"12:01:02am", New(0, 1, 2, 0)}, + {"12:01:02pm", New(12, 1, 2, 0)}, + {"1am", New(1, 0, 0, 0)}, + {"1pm", New(13, 0, 0, 0)}, + {"1:00am", New(1, 0, 0, 0)}, + {"1:23am", New(1, 23, 0, 0)}, + {"1:23pm", New(13, 23, 0, 0)}, + {"01:23pm", New(13, 23, 0, 0)}, + {"1:00:00am", New(1, 0, 0, 0)}, + {"1:02:03pm", New(13, 2, 3, 0)}, + {"01:02:03pm", New(13, 2, 3, 0)}, + {"1:02:03.004pm", New(13, 2, 3, 4)}, + {"01:02:03.004pm", New(13, 2, 3, 4)}, + {"1:20:30.04pm", New(13, 20, 30, 40)}, + {"1:20:30.4pm", New(13, 20, 30, 400)}, + {"1:20:30.pm", New(13, 20, 30, 0)}, + } + for _, x := range cases { + str := MustParse(x.str) + if str != x.want { + t.Errorf("%s, got %v, want %v", x.str, str, x.want) + } + } +} + +func TestClockParseBads(t *testing.T) { + cases := []struct { + str string + }{ + {"0"}, + {"0:01"}, + {"0:00:01"}, + {"hh"}, + {"00-00"}, + {"00:00-00"}, + {"00:00:00-"}, + {"00:00:00-0"}, + {"00:00:00-00"}, + {"00:00:00-000"}, + {"00:mm"}, + {"00:00:ss"}, + {"00:00:00.xxx"}, + {"01-02:03.004"}, + {"01:02-03.04"}, + {"01:02:03-4"}, + {"12xm"}, + {"12-01am"}, + {"12:01-02am"}, + {"ham"}, + {"hham"}, + {"1xm"}, + {"1-00am"}, + {"1:00-00am"}, + {"1:02:03-4pm"}, + {"1:02:03-04pm"}, + {"1:02:03-004pm"}, + {"1:02:03.0045pm"}, + } + for _, x := range cases { + c, err := Parse(x.str) + if err == nil { + t.Errorf("%s, got %#v, want err", x.str, c) + // } else { + // println(err.Error()) + } + } +} diff --git a/clock/format.go b/clock/format.go new file mode 100644 index 00000000..8db9eda6 --- /dev/null +++ b/clock/format.go @@ -0,0 +1,106 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package clock + +import "fmt" + +func clockHours(cm Clock) Clock { + return (cm / Hour) +} + +func clockHours12(cm Clock) (Clock, string) { + h := clockHours(cm) + if h < 1 { + return 12, "am" + } else if h > 12 { + return h - 12, "pm" + } else if h == 12 { + return 12, "pm" + } + return h, "am" +} + +func clockMinutes(cm Clock) Clock { + return (cm % Hour) / Minute +} + +func clockSeconds(cm Clock) Clock { + return (cm % Minute) / Second +} + +func clockMillisec(cm Clock) Clock { + return cm % Second +} + +// Hh gets the clock-face number of hours as a two-digit string. +// It is calculated from the modulo time; see Mod24. +// Note the special case of midnight at the end of a day is "24". +func (c Clock) Hh() string { + if c == Day { + return "24" + } + cm := c.Mod24() + return fmt.Sprintf("%02d", clockHours(cm)) +} + +// HhMm gets the clock-face number of hours and minutes as a five-character ISO-8601 time string. +// It is calculated from the modulo time; see Mod24. +// Note the special case of midnight at the end of a day is "24:00". +func (c Clock) HhMm() string { + if c == Day { + return "24:00" + } + cm := c.Mod24() + return fmt.Sprintf("%02d:%02d", clockHours(cm), clockMinutes(cm)) +} + +// HhMmSs gets the clock-face number of hours, minutes, seconds as an eight-character ISO-8601 time string. +// It is calculated from the modulo time; see Mod24. +// Note the special case of midnight at the end of a day is "24:00:00". +func (c Clock) HhMmSs() string { + if c == Day { + return "24:00:00" + } + cm := c.Mod24() + return fmt.Sprintf("%02d:%02d:%02d", clockHours(cm), clockMinutes(cm), clockSeconds(cm)) +} + +// Hh12 gets the clock-face number of hours as a one- or two-digit string, followed by am or pm. +// Remember that midnight is 12am, noon is 12pm. +// It is calculated from the modulo time; see Mod24. +func (c Clock) Hh12() string { + cm := c.Mod24() + h, sfx := clockHours12(cm) + return fmt.Sprintf("%d%s", h, sfx) +} + +// HhMm12 gets the clock-face number of hours and minutes, followed by am or pm. +// Remember that midnight is 12am, noon is 12pm. +// It is calculated from the modulo time; see Mod24. +func (c Clock) HhMm12() string { + cm := c.Mod24() + h, sfx := clockHours12(cm) + return fmt.Sprintf("%d:%02d%s", h, clockMinutes(cm), sfx) +} + +// HhMmSs12 gets the clock-face number of hours, minutes and seconds, followed by am or pm. +// Remember that midnight is 12am, noon is 12pm. +// It is calculated from the modulo time; see Mod24. +func (c Clock) HhMmSs12() string { + cm := c.Mod24() + h, sfx := clockHours12(cm) + return fmt.Sprintf("%d:%02d:%02d%s", h, clockMinutes(cm), clockSeconds(cm), sfx) +} + +// String gets the clock-face number of hours, minutes, seconds and milliseconds as a 12-character ISO-8601 +// time string (calculated from the modulo time, see Mod24), specified to the nearest millisecond. +// Note the special case of midnight at the end of a day is "24:00:00.000". +func (c Clock) String() string { + if c == Day { + return "24:00:00.000" + } + cm := c.Mod24() + return fmt.Sprintf("%02d:%02d:%02d.%03d", clockHours(cm), clockMinutes(cm), clockSeconds(cm), clockMillisec(cm)) +} diff --git a/clock/marshal.go b/clock/marshal.go new file mode 100644 index 00000000..6121b71a --- /dev/null +++ b/clock/marshal.go @@ -0,0 +1,43 @@ +package clock + +import ( + "errors" +) + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (c Clock) MarshalBinary() ([]byte, error) { + enc := []byte{ + byte(c >> 24), + byte(c >> 16), + byte(c >> 8), + byte(c), + } + return enc, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (c *Clock) UnmarshalBinary(data []byte) error { + if len(data) == 0 { + return errors.New("Clock.UnmarshalBinary: no data") + } + if len(data) != 4 { + return errors.New("Clock.UnmarshalBinary: invalid length") + } + + *c = Clock(data[3]) | Clock(data[2])<<8 | Clock(data[1])<<16 | Clock(data[0])<<24 + return nil +} + +// MarshalText implements the encoding.TextMarshaler interface. +func (c Clock) MarshalText() ([]byte, error) { + return []byte(c.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +func (c *Clock) UnmarshalText(data []byte) (err error) { + clock, err := Parse(string(data)) + if err == nil { + *c = clock + } + return err +} diff --git a/clock/marshal_test.go b/clock/marshal_test.go new file mode 100644 index 00000000..1a9fa655 --- /dev/null +++ b/clock/marshal_test.go @@ -0,0 +1,149 @@ +package clock + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "strings" + "testing" +) + +func TestGobEncoding(t *testing.T) { + var b bytes.Buffer + encoder := gob.NewEncoder(&b) + decoder := gob.NewDecoder(&b) + cases := []Clock{ + New(-1, -1, -1, -1), + New(0, 0, 0, 0), + New(12, 40, 40, 80), + New(13, 55, 0, 20), + New(16, 20, 0, 0), + New(20, 60, 59, 59), + New(24, 0, 0, 0), + New(24, 0, 0, 1), + } + for _, c := range cases { + var clock Clock + err := encoder.Encode(&c) + if err != nil { + t.Errorf("Gob(%v) encode error %v", c, err) + } else { + err = decoder.Decode(&clock) + if err != nil { + t.Errorf("Gob(%v) decode error %v", c, err) + } else if clock != c { + t.Errorf("Gob(%v) decode got %v", c, clock) + } + } + } +} + +func TestJSONMarshalling(t *testing.T) { + cases := []struct { + value Clock + want string + }{ + {New(-1, -1, -1, -1), `"22:58:58.999"`}, + {New(0, 0, 0, 0), `"00:00:00.000"`}, + {New(12, 40, 40, 80), `"12:40:40.080"`}, + {New(13, 55, 0, 20), `"13:55:00.020"`}, + {New(16, 20, 0, 0), `"16:20:00.000"`}, + {New(20, 60, 59, 59), `"21:00:59.059"`}, + {New(24, 0, 0, 0), `"24:00:00.000"`}, + {New(24, 0, 0, 1), `"00:00:00.001"`}, + } + for _, c := range cases { + bb, err := json.Marshal(c.value) + if err != nil { + t.Errorf("JSON(%v) marshal error %v", c, err) + } else if string(bb) != c.want { + t.Errorf("JSON(%v) == %v, want %v", c.value, string(bb), c.want) + } + } +} + +func TestJSONUnmarshalling(t *testing.T) { + cases := []struct { + values []string + want Clock + }{ + {[]string{`"22:58:58.999"`, `"10:58:58.999pm"`}, New(-1, -1, -1, -1)}, + {[]string{`"00:00:00.000"`, `"00:00:00.000AM"`}, New(0, 0, 0, 0)}, + {[]string{`"12:40:40.080"`, `"12:40:40.080PM"`}, New(12, 40, 40, 80)}, + {[]string{`"13:55:00.020"`, `"01:55:00.020PM"`}, New(13, 55, 0, 20)}, + {[]string{`"16:20:00.000"`, `"04:20:00.000pm"`}, New(16, 20, 0, 0)}, + {[]string{`"21:00:59.059"`, `"09:00:59.059PM"`}, New(20, 60, 59, 59)}, + {[]string{`"24:00:00.000"`, `"00:00:00.000am"`}, New(24, 0, 0, 0)}, + {[]string{`"00:00:00.001"`, `"00:00:00.001AM"`}, New(24, 0, 0, 1)}, + } + + for _, c := range cases { + for _, v := range c.values { + var clock Clock + err := json.Unmarshal([]byte(v), &clock) + if err != nil { + t.Errorf("JSON(%v) unmarshal error %v", v, err) + } else if c.want.Mod24() != clock.Mod24() { + t.Errorf("JSON(%v) == %v, want %v", v, clock, c.want) + } + } + } +} + +func TestBinaryMarshalling(t *testing.T) { + cases := []Clock{ + New(-1, -1, -1, -1), + New(0, 0, 0, 0), + New(12, 40, 40, 80), + New(13, 55, 0, 20), + New(16, 20, 0, 0), + New(20, 60, 59, 59), + New(24, 0, 0, 0), + New(24, 0, 0, 1), + } + for _, c := range cases { + bb, err := c.MarshalBinary() + if err != nil { + t.Errorf("Binary(%v) marshal error %v", c, err) + } else { + var clock Clock + err = clock.UnmarshalBinary(bb) + if err != nil { + t.Errorf("Binary(% v) unmarshal error %v", c, err) + } else if clock.Mod24() != c.Mod24() { + t.Errorf("Binary(%v) unmarshal got %v", c, clock) + } + } + } +} + +func TestBinaryUnmarshallingErrors(t *testing.T) { + var c Clock + err1 := c.UnmarshalBinary([]byte{}) + if err1 == nil { + t.Errorf("unmarshal no empty data error") + } + + err2 := c.UnmarshalBinary([]byte("12345")) + if err2 == nil { + t.Errorf("unmarshal no wrong length error") + } +} + +func TestInvalidClockText(t *testing.T) { + cases := []struct { + value string + want string + }{ + {`not-a-clock`, `clock.Clock: cannot parse not-a-clock`}, + {`00:50:100.0`, `clock.Clock: cannot parse 00:50:100.0`}, + {`24:00:00.0pM`, `clock.Clock: cannot parse 24:00:00.0pM: strconv.Atoi: parsing "0pM": invalid syntax`}, + } + for _, c := range cases { + var clock Clock + err := clock.UnmarshalText([]byte(c.value)) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("InvalidText(%v) == %v, want %v", c.value, err, c.want) + } + } +} diff --git a/clock/parse.go b/clock/parse.go new file mode 100644 index 00000000..56f9a2a8 --- /dev/null +++ b/clock/parse.go @@ -0,0 +1,173 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package clock + +import ( + "fmt" + "runtime" + "strconv" + "strings" +) + +// MustParse is as per Parse except that it panics if the string cannot be parsed. +// This is intended for setup code; don't use it for user inputs. +func MustParse(hms string) Clock { + t, err := Parse(hms) + if err != nil { + panic(err) + } + return t +} + +// Parse converts a string representation to a Clock. Acceptable representations +// are as per ISO-8601 - see https://en.wikipedia.org/wiki/ISO_8601#Times +// +// Also, conventional AM- and PM-based strings are parsed, such as "2am", "2:45pm". +// Remember that 12am is midnight and 12pm is noon. +func Parse(hms string) (clock Clock, err error) { + if strings.HasSuffix(hms, "am") || strings.HasSuffix(hms, "AM") { + return parseAmPm(hms, 0) + } else if strings.HasSuffix(hms, "pm") || strings.HasSuffix(hms, "PM") { + return parseAmPm(hms, 12) + } + return parseISO(hms) +} + +func parseISO(hms string) (clock Clock, err error) { + switch len(hms) { + case 2: // HH + return parseClockParts(hms, hms, "", "", "", 0, 0) + + case 4: // HHMM + return parseClockParts(hms, hms[:2], hms[2:], "", "", 0, 0) + + case 5: // HH:MM + if hms[2] != ':' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, hms[:2], hms[3:], "", "", 0, 0) + + case 6: // HHMMSS + return parseClockParts(hms, hms[:2], hms[2:4], hms[4:], "", 0, 0) + + case 8: // HH:MM:SS + if hms[2] != ':' || hms[5] != ':' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, hms[:2], hms[3:5], hms[6:], "", 0, 0) + + case 9, 10: // HH:MM:SS.0 + if hms[2] != ':' || hms[5] != ':' || hms[8] != '.' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, hms[:2], hms[3:5], hms[6:8], hms[9:]+"00", 0, 0) + + case 11: // HH:MM:SS.00 + if hms[2] != ':' || hms[5] != ':' || hms[8] != '.' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, hms[:2], hms[3:5], hms[6:8], hms[9:]+"0", 0, 0) + + case 12: // HH:MM:SS.000 + if hms[2] != ':' || hms[5] != ':' || hms[8] != '.' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, hms[:2], hms[3:5], hms[6:8], hms[9:], 0, 0) + } + return 0, parseError(hms, nil) +} + +func parseAmPm(hms string, offset int) (clock Clock, err error) { + n := len(hms) + + switch len(hms) { + case 3: // Ham + return parseClockParts(hms, "0"+hms[:1], "", "", "", 12, offset) + + case 4: // HHam + return parseClockParts(hms, hms[:2], "", "", "", 12, offset) + } + + colon := strings.IndexByte(hms, ':') + if colon < 0 { + return 0, parseError(hms, nil) + } + + h := hms[:colon] + rest := hms[colon+1 : n-2] + + switch len(rest) { + case 2: // MM + return parseClockParts(hms, h, rest, "", "", 12, offset) + + case 5: // MM:SS + if rest[2] != ':' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, h, rest[:2], rest[3:], "", 12, offset) + + case 6, 7: // MM:SS.0xm + if rest[2] != ':' || rest[5] != '.' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, h, rest[:2], rest[3:5], rest[6:]+"00", 12, offset) + + case 8: // MM:SS.00xm + if rest[2] != ':' || rest[5] != '.' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, h, rest[:2], rest[3:5], rest[6:]+"0", 12, offset) + + case 9: // MM:SS.000xm + if rest[2] != ':' || rest[5] != '.' { + return 0, parseError(hms, nil) + } + return parseClockParts(hms, h, rest[:2], rest[3:5], rest[6:], 12, offset) + } + return 0, parseError(hms, nil) +} + +func parseClockParts(hms, hh, mm, ss, mmms string, mod, offset int) (clock Clock, err error) { + h := 0 + m := 0 + s := 0 + ms := 0 + if hh != "" { + h, err = strconv.Atoi(hh) + if err != nil { + return 0, parseError(hms, err) + } + } + if mm != "" { + m, err = strconv.Atoi(mm) + if err != nil { + return 0, parseError(hms, err) + } + } + if ss != "" { + s, err = strconv.Atoi(ss) + if err != nil { + return 0, parseError(hms, err) + } + } + if mmms != "" { + ms, err = strconv.Atoi(mmms) + if err != nil { + return 0, parseError(hms, err) + } + } + if mod > 0 { + h = h % mod + } + return New(h+offset, m, s, ms), nil +} + +func parseError(hms string, err error) error { + _, _, line, _ := runtime.Caller(1) + if err != nil { + return fmt.Errorf("parse.go:%d: clock.Clock: cannot parse %s: %v", line, hms, err) + } + return fmt.Errorf("parse.go:%d: clock.Clock: cannot parse %s", line, hms) +} diff --git a/clock/sql.go b/clock/sql.go new file mode 100644 index 00000000..87903c2e --- /dev/null +++ b/clock/sql.go @@ -0,0 +1,41 @@ +package clock + +import ( + "database/sql/driver" + "fmt" + "time" +) + +// Scan parses some value. It implements sql.Scanner, +// https://golang.org/pkg/database/sql/#Scanner +func (c *Clock) Scan(value interface{}) (err error) { + if value == nil { + return nil + } + + return c.scanAny(value) +} + +func (c *Clock) scanAny(value interface{}) (err error) { + err = nil + switch value.(type) { + case int64: + *c = Clock(value.(int64)) + case []byte: + *c, err = Parse(string(value.([]byte))) + case string: + *c, err = Parse(value.(string)) + case time.Time: + *c = NewAt(value.(time.Time)) + default: + err = fmt.Errorf("%T %+v is not a meaningful clock", value, value) + } + return +} + +// Value converts the value to an int64. It implements driver.Valuer, +// https://golang.org/pkg/database/sql/driver/#Valuer +func (c Clock) Value() (driver.Value, error) { + + return int64(c), nil +} diff --git a/clock/sql_test.go b/clock/sql_test.go new file mode 100644 index 00000000..88a27812 --- /dev/null +++ b/clock/sql_test.go @@ -0,0 +1,73 @@ +package clock + +import ( + "database/sql/driver" + "testing" + "time" +) + +func TestClockScan(t *testing.T) { + now := time.Now() + + cases := []struct { + v interface{} + expected Clock + }{ + {int64(New(-1, -1, -1, -1)), New(-1, -1, -1, -1)}, + {int64(New(10, 60, 10, 0)), New(10, 60, 10, 0)}, + {int64(New(24, 10, 0, 10)), New(0, 10, 0, 10)}, + {"12:00:00.400", New(12, 0, 0, 400)}, + {"01:40:50.000pm", New(13, 40, 50, 0)}, + {"4:20:00.000pm", New(16, 20, 0, 0)}, + {[]byte("23:60:60.000"), New(0, 1, 0, 0)}, + {now, NewAt(now)}, + } + + for i, c := range cases { + var clock Clock + e := clock.Scan(c.v) + if e != nil { + t.Errorf("%d: Got %v for %d", i, e, c.expected) + } else if clock.Mod24() != c.expected.Mod24() { + t.Errorf("%d: Got %v, want %d", i, clock, c.expected) + } + + var d driver.Valuer = clock + + q, e := d.Value() + if e != nil { + t.Errorf("%d: Got %v for %d", i, e, c.expected) + } else if Clock(q.(int64)).Mod24() != c.expected.Mod24() { + t.Errorf("%d: Got %v, want %d", i, q, c.expected) + } + } +} + +func TestClockScanWithJunk(t *testing.T) { + cases := []struct { + v interface{} + expected string + }{ + {true, "bool true is not a meaningful clock"}, + {false, "bool false is not a meaningful clock"}, + } + + for i, c := range cases { + var clock Clock + e := clock.Scan(c.v) + if e.Error() != c.expected { + t.Errorf("%d: Got %q, want %q", i, e.Error(), c.expected) + } + } +} + +func TestClockScanWithNil(t *testing.T) { + var r *Clock + e := r.Scan(nil) + if e != nil { + t.Errorf("Got %v", e) + } + if r != nil { + t.Errorf("Got %v", r) + } +} diff --git a/coverage.sh b/coverage.sh new file mode 100755 index 00000000..2b8420f7 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,41 @@ +#!/bin/bash -e +# Developer tool to run the tests and obtain HTML coverage reports. + +DIR=$PWD +DOT="$(dirname $0)" +cd $DOT +TOP=$PWD + +# install Goveralls if absent +if ! type -p goveralls; then + echo go install github.com/mattn/goveralls + go install github.com/mattn/goveralls +fi + +mkdir -p reports +rm -f reports/*.html coverage?*.* + +for file in $(find . -type f -name \*_test.go | fgrep -v vendor/); do + dirname $file >> coverage$$.tmp +done + +sort coverage$$.tmp | uniq | tee coverage$$.dirs + +for pkg in $(cat coverage$$.dirs); do + name=$(echo $pkg | sed 's#^./##' | sed 's#/#-#g') + [ "$pkg" = "." ] && name=$(basename $PWD) + echo $pkg becomes $name + go test -v -coverprofile coverage$$.data $pkg + if [ -f coverage$$.data ]; then + go tool cover -html coverage$$.data -o reports/$name.html + unlink coverage$$.data + fi +done + +rm -f coverage$$.tmp coverage$$.dirs + +if [ -n "$(type -p chromium-browser)" ]; then + chromium-browser reports/*.html >/dev/null & +else + ls -lh reports/ +fi diff --git a/date.go b/date.go index 33a3cfd5..76946cb9 100644 --- a/date.go +++ b/date.go @@ -2,49 +2,24 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -// Package date provides functionality for working with dates. -// -// This package introduces a light-weight Date type that is storage-efficient -// and covenient for calendrical calculations and date parsing and formatting -// (including years outside the [0,9999] interval). -// -// Credits -// -// This package follows very closely the design of package time -// (http://golang.org/pkg/time/) in the standard library, many of the Date -// methods are implemented using the corresponding methods of the time.Time -// type, and much of the documentation is copied directly from that package. -// -// References -// -// https://golang.org/src/time/time.go -// -// https://en.wikipedia.org/wiki/Gregorian_calendar -// -// https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar -// -// https://en.wikipedia.org/wiki/Astronomical_year_numbering -// -// https://en.wikipedia.org/wiki/ISO_8601 -// -// https://tools.ietf.org/html/rfc822 -// -// https://tools.ietf.org/html/rfc850 -// -// https://tools.ietf.org/html/rfc1123 -// -// https://tools.ietf.org/html/rfc3339 -// package date import ( - "errors" - "fmt" "math" "time" + + "github.com/rickb777/date/gregorian" + "github.com/rickb777/date/period" ) -// A Date represents a date under the (proleptic) Gregorian calendar as +// PeriodOfDays describes a period of time measured in whole days. Negative values +// indicate days earlier than some mark. +type PeriodOfDays int32 + +// ZeroDays is the named zero value for PeriodOfDays. +const ZeroDays PeriodOfDays = 0 + +// A Date represents a date under the proleptic Gregorian calendar as // used by ISO 8601. This calendar uses astronomical year numbering, // so it includes a year 0 and represents earlier years as negative numbers // (i.e. year 0 is 1 BC; year -1 is 2 BC, and so on). @@ -55,22 +30,26 @@ import ( // // Programs using dates should typically store and pass them as values, // not pointers. That is, date variables and struct fields should be of -// type date.Date, not *date.Date. A Date value can be used by -// multiple goroutines simultaneously. +// type date.Date, not *date.Date unless the pointer indicates an optional +// value. A Date value can be used by multiple goroutines simultaneously. // // Date values can be compared using the Before, After, and Equal methods // as well as the == and != operators. +// // The Sub method subtracts two dates, returning the number of days between -// them. -// The Add method adds a Date and a number of days, producing a Date. +// them. The Add method adds a Date and a number of days, producing a Date. // -// The zero value of type Date is Thursday, January 1, 1970. -// As this date is unlikely to come up in practice, the IsZero method gives -// a simple way of detecting a date that has not been initialized explicitly. +// The zero value of type Date is Thursday, January 1, 1970 (called 'the +// epoch'), based on Unix convention. The IsZero method gives a simple way +// of detecting a date that has not been initialized explicitly, with the +// caveat that this is also a 'normal' date. // +// The first official date of the Gregorian calendar was Friday, October 15th +// 1582, quite unrelated to the epoch used here. The Date type does not +// distinguish between official Gregorian dates and earlier proleptic dates, +// which can also be represented when needed. type Date struct { - // day gives the number of days elapsed since date zero. - day int32 + day PeriodOfDays // day gives the number of days elapsed since date zero. } // New returns the Date value corresponding to the given year, month, and day. @@ -89,6 +68,18 @@ func NewAt(t time.Time) Date { return Date{encode(t)} } +// NewOfDays returns the Date value corresponding to the given period since the +// epoch (1st January 1970), which may be negative. +func NewOfDays(p PeriodOfDays) Date { + return Date{p} +} + +// Date returns the Date value corresponding to the given period since the +// epoch (1st January 1970), which may be negative. +func (p PeriodOfDays) Date() Date { + return Date{p} +} + // Today returns today's date according to the current local time. func Today() Date { t := time.Now() @@ -110,12 +101,12 @@ func TodayIn(loc *time.Location) Date { // Min returns the smallest representable date. func Min() Date { - return Date{math.MinInt32} + return Date{day: PeriodOfDays(math.MinInt32)} } // Max returns the largest representable date. func Max() Date { - return Date{math.MaxInt32} + return Date{day: PeriodOfDays(math.MaxInt32)} } // UTC returns a Time value corresponding to midnight on the given date, @@ -140,11 +131,19 @@ func (d Date) In(loc *time.Location) time.Time { } // Date returns the year, month, and day of d. +// The first day of the month is 1. func (d Date) Date() (year int, month time.Month, day int) { t := decode(d.day) return t.Date() } +// LastDayOfMonth returns the last day of the month specified by d. +// The first day of the month is 1. +func (d Date) LastDayOfMonth() int { + y, m, _ := d.Date() + return DaysIn(y, m) +} + // Day returns the day of the month specified by d. // The first day of the month is 1. func (d Date) Day() int { @@ -176,7 +175,7 @@ func (d Date) Weekday() time.Weekday { // Date zero, January 1, 1970, fell on a Thursday wdayZero := time.Thursday // Taking into account potential for overflow and negative offset - return time.Weekday((int32(wdayZero) + d.day%7 + 7) % 7) + return time.Weekday((int32(wdayZero) + int32(d.day)%7 + 7) % 7) } // ISOWeek returns the ISO 8601 year and week number in which d occurs. @@ -188,7 +187,9 @@ func (d Date) ISOWeek() (year, week int) { return t.ISOWeek() } -// IsZero reports whether t represents the zero date. +// IsZero reports whether d represents the zero (i.e. uninitialised) date. +// Because Date follows Unix conventions, it is based on 1970-01-01. So be +// careful with this: the corresponding 1970-01-01 date is not itself a 'zero'. func (d Date) IsZero() bool { return d.day == 0 } @@ -208,109 +209,76 @@ func (d Date) After(u Date) bool { return d.day > u.day } -// Add returns the date d plus the given number of days. -func (d Date) Add(days int) Date { - return Date{d.day + int32(days)} +// Min returns the earlier of two dates. +func (d Date) Min(u Date) Date { + if d.day > u.day { + return u + } + return d +} + +// Max returns the later of two dates. +func (d Date) Max(u Date) Date { + if d.day < u.day { + return u + } + return d +} + +// Add returns the date d plus the given number of days. The parameter may be negative. +func (d Date) Add(days PeriodOfDays) Date { + return Date{d.day + days} } // AddDate returns the date corresponding to adding the given number of years, // months, and days to d. For example, AddData(-1, 2, 3) applied to // January 1, 2011 returns March 4, 2010. +// +// AddDate normalizes its result in the same way that Date does, +// so, for example, adding one month to October 31 yields +// December 1, the normalized form for November 31. +// +// The addition of all fields is performed before normalisation of any; this can affect +// the result. For example, adding 0y 1m 3d to September 28 gives October 31 (not +// November 1). func (d Date) AddDate(years, months, days int) Date { - t := decode(d.day) - t = t.AddDate(years, months, days) + t := decode(d.day).AddDate(years, months, days) return Date{encode(t)} } -// Sub returns d-u as the number of days between the two dates. -func (d Date) Sub(u Date) (days int) { - return int(d.day - u.day) -} - -// MarshalBinary implements the encoding.BinaryMarshaler interface. -func (d Date) MarshalBinary() ([]byte, error) { - enc := []byte{ - byte(d.day >> 24), - byte(d.day >> 16), - byte(d.day >> 8), - byte(d.day), - } - return enc, nil -} - -// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. -func (d *Date) UnmarshalBinary(data []byte) error { - if len(data) == 0 { - return errors.New("Date.UnmarshalBinary: no data") - } - if len(data) != 4 { - return errors.New("Date.UnmarshalBinary: invalid length") - } - - d.day = int32(data[3]) | int32(data[2])<<8 | int32(data[1])<<16 | int32(data[0])<<24 - - return nil +// AddPeriod returns the date corresponding to adding the given period. If the +// period's fields are be negative, this results in an earlier date. +// +// Any time component is ignored. Therefore, be careful with periods containing +// more that 24 hours in the hours/minutes/seconds fields. These will not be +// normalised for you; if you want this behaviour, call delta.Normalise(false) +// on the input parameter. +// +// For example, PT24H adds nothing, whereas P1D adds one day as expected. To +// convert a period such as PT24H to its equivalent P1D, use +// delta.Normalise(false) as the input. +// +// See the description for AddDate. +func (d Date) AddPeriod(delta period.Period) Date { + return d.AddDate(delta.Years(), delta.Months(), delta.Days()) } -// GobEncode implements the gob.GobEncoder interface. -func (d Date) GobEncode() ([]byte, error) { - return d.MarshalBinary() +// Sub returns d-u as the number of days between the two dates. +func (d Date) Sub(u Date) (days PeriodOfDays) { + return d.day - u.day } -// GobDecode implements the gob.GobDecoder interface. -func (d *Date) GobDecode(data []byte) error { - return d.UnmarshalBinary(data) +// DaysSinceEpoch returns the number of days since the epoch (1st January 1970), which may be negative. +func (d Date) DaysSinceEpoch() (days PeriodOfDays) { + return d.day } -// MarshalJSON implements the json.Marshaler interface. -// The date is a quoted string in ISO 8601 extended format (e.g. "2006-01-02"). -// If the year of the date falls outside the [0,9999] range, this format -// produces an expanded year representation with possibly extra year digits -// beyond the prescribed four-digit minimum and with a + or - sign prefix -// (e.g. , "+12345-06-07", "-0987-06-05"). -func (d Date) MarshalJSON() ([]byte, error) { - return []byte(`"` + d.String() + `"`), nil +// IsLeap simply tests whether a given year is a leap year, using the proleptic Gregorian calendar algorithm. +func IsLeap(year int) bool { + return gregorian.IsLeap(year) } -// UnmarshalJSON implements the json.Unmarshaler interface. -// The date is expected to be a quoted string in ISO 8601 extended format -// (e.g. "2006-01-02", "+12345-06-07", "-0987-06-05"); -// the year must use at least 4 digits and if outside the [0,9999] range -// must be prefixed with a + or - sign. -func (d *Date) UnmarshalJSON(data []byte) (err error) { - value := string(data) - n := len(value) - if n < 2 || value[0] != '"' || value[n-1] != '"' { - return fmt.Errorf("Date.UnmarshalJSON: missing double quotes (%s)", value) - } - u, err := ParseISO(value[1 : n-1]) - if err != nil { - return err - } - d.day = u.day - return nil -} - -// MarshalText implements the encoding.TextMarshaler interface. -// The date is given in ISO 8601 extended format (e.g. "2006-01-02"). -// If the year of the date falls outside the [0,9999] range, this format -// produces an expanded year representation with possibly extra year digits -// beyond the prescribed four-digit minimum and with a + or - sign prefix -// (e.g. , "+12345-06-07", "-0987-06-05"). -func (d Date) MarshalText() ([]byte, error) { - return []byte(d.String()), nil -} - -// UnmarshalText implements the encoding.TextUnmarshaler interface. -// The date is expected to be in ISO 8601 extended format -// (e.g. "2006-01-02", "+12345-06-07", "-0987-06-05"); -// the year must use at least 4 digits and if outside the [0,9999] range -// must be prefixed with a + or - sign. -func (d *Date) UnmarshalText(data []byte) error { - u, err := ParseISO(string(data)) - if err != nil { - return err - } - d.day = u.day - return nil +// DaysIn gives the number of days in a given month, according to the proleptic Gregorian calendar. +func DaysIn(year int, month time.Month) int { + return gregorian.DaysIn(year, month) } diff --git a/date_test.go b/date_test.go index be7ba91f..085e3b22 100644 --- a/date_test.go +++ b/date_test.go @@ -2,19 +2,17 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package date_test +package date import ( - "bytes" - "encoding/gob" - "encoding/json" + "runtime/debug" "testing" "time" - "github.com/fxtlabs/date" + "github.com/rickb777/date/period" ) -func same(d date.Date, t time.Time) bool { +func same(d Date, t time.Time) bool { yd, wd := d.ISOWeek() yt, wt := t.ISOWeek() return d.Year() == t.Year() && @@ -25,7 +23,7 @@ func same(d date.Date, t time.Time) bool { yd == yt && wd == wt } -func TestNew(t *testing.T) { +func TestDate_New(t *testing.T) { cases := []string{ "0000-01-01T00:00:00+00:00", "0001-01-01T00:00:00+00:00", @@ -43,24 +41,67 @@ func TestNew(t *testing.T) { t.Errorf("New(%v) cannot parse input: %v", c, err) continue } - dOut := date.New(tIn.Year(), tIn.Month(), tIn.Day()) + dOut := New(tIn.Year(), tIn.Month(), tIn.Day()) if !same(dOut, tIn) { t.Errorf("New(%v) == %v, want date of %v", c, dOut, tIn) } - dOut = date.NewAt(tIn) + dOut = NewAt(tIn) if !same(dOut, tIn) { t.Errorf("NewAt(%v) == %v, want date of %v", c, dOut, tIn) } } } -func TestToday(t *testing.T) { - today := date.Today() +func Test_New_and_Add(t *testing.T) { + cases := []struct { + offset PeriodOfDays + expected Date + }{ + // For year Y, Julian date offset is + // D = [Y/100] - [Y/400] - 2 + {offset: -135140, expected: New(1600, time.January, 1)}, // 10 days Julian offset + {offset: -98615, expected: New(1700, time.January, 1)}, // 10 days Julian offset + {offset: -62091, expected: New(1800, time.January, 1)}, + {offset: -365, expected: New(1969, time.January, 1)}, + {offset: 0, expected: New(1970, time.January, 1)}, + {offset: 365, expected: New(1971, time.January, 1)}, + {offset: 36525, expected: New(2070, time.January, 1)}, + } + + zero := Date{} + + for i, c := range cases { + d2 := zero.Add(c.offset) + if !d2.Equal(c.expected) { + t.Errorf("%d: %d gives %s, wanted %s", i, c.offset, d2, c.expected) + } + } +} + +func TestDate_DaysSinceEpoch(t *testing.T) { + zero := Date{}.DaysSinceEpoch() + if zero != 0 { + t.Errorf("Non zero %v", zero) + } + today := Today() + days := today.DaysSinceEpoch() + copy1 := NewOfDays(days) + copy2 := days.Date() + if today != copy1 || days == 0 { + t.Errorf("Today == %v, want date of %v", today, copy1) + } + if today != copy2 || days == 0 { + t.Errorf("Today == %v, want date of %v", today, copy2) + } +} + +func TestDate_Today(t *testing.T) { + today := Today() now := time.Now() if !same(today, now) { t.Errorf("Today == %v, want date of %v", today, now) } - today = date.TodayUTC() + today = TodayUTC() now = time.Now().UTC() if !same(today, now) { t.Errorf("TodayUTC == %v, want date of %v", today, now) @@ -68,7 +109,7 @@ func TestToday(t *testing.T) { cases := []int{-10, -5, -3, 0, 1, 4, 8, 12} for _, c := range cases { location := time.FixedZone("zone", c*60*60) - today = date.TodayIn(location) + today = TodayIn(location) now = time.Now().In(location) if !same(today, now) { t.Errorf("TodayIn(%v) == %v, want date of %v", c, today, now) @@ -76,24 +117,22 @@ func TestToday(t *testing.T) { } } -func TestTime(t *testing.T) { +func TestDate_Time(t *testing.T) { cases := []struct { - year int - month time.Month - day int + d Date }{ - {-1234, time.February, 5}, - {0, time.April, 12}, - {1, time.January, 1}, - {1946, time.February, 4}, - {1970, time.January, 1}, - {1976, time.April, 1}, - {1999, time.December, 1}, - {1111111, time.June, 21}, + {New(-1234, time.February, 5)}, + {New(0, time.April, 12)}, + {New(1, time.January, 1)}, + {New(1946, time.February, 4)}, + {New(1970, time.January, 1)}, + {New(1976, time.April, 1)}, + {New(1999, time.December, 1)}, + {New(1111111, time.June, 21)}, } zones := []int{-12, -10, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 8, 12} for _, c := range cases { - d := date.New(c.year, c.month, c.day) + d := c.d tUTC := d.UTC() if !same(d, tUTC) { t.Errorf("TimeUTC(%v) == %v, want date part %v", d, tUTC, d) @@ -124,232 +163,185 @@ func TestTime(t *testing.T) { func TestPredicates(t *testing.T) { // The list of case dates must be sorted in ascending order cases := []struct { - year int - month time.Month - day int + d Date }{ - {-1234, time.February, 5}, - {0, time.April, 12}, - {1, time.January, 1}, - {1946, time.February, 4}, - {1970, time.January, 1}, - {1976, time.April, 1}, - {1999, time.December, 1}, - {1111111, time.June, 21}, + {New(-1234, time.February, 5)}, + {New(0, time.April, 12)}, + {New(1, time.January, 1)}, + {New(1946, time.February, 4)}, + {New(1970, time.January, 1)}, + {New(1976, time.April, 1)}, + {New(1999, time.December, 1)}, + {New(1111111, time.June, 21)}, } for i, ci := range cases { - di := date.New(ci.year, ci.month, ci.day) + di := ci.d for j, cj := range cases { - dj := date.New(cj.year, cj.month, cj.day) - p := di.Equal(dj) - q := i == j - if p != q { - t.Errorf("Equal(%v, %v) == %v, want %v", di, dj, p, q) - } - p = di.Before(dj) - q = i < j - if p != q { - t.Errorf("Before(%v, %v) == %v, want %v", di, dj, p, q) - } - p = di.After(dj) - q = i > j - if p != q { - t.Errorf("After(%v, %v) == %v, want %v", di, dj, p, q) - } - p = di == dj - q = i == j - if p != q { - t.Errorf("Equal(%v, %v) == %v, want %v", di, dj, p, q) - } - p = di != dj - q = i != j - if p != q { - t.Errorf("Equal(%v, %v) == %v, want %v", di, dj, p, q) - } + dj := cj.d + testPredicate(t, di, dj, di.Equal(dj), i == j, "Equal") + testPredicate(t, di, dj, di.Before(dj), i < j, "Before") + testPredicate(t, di, dj, di.After(dj), i > j, "After") + testPredicate(t, di, dj, di == dj, i == j, "==") + testPredicate(t, di, dj, di != dj, i != j, "!=") } } // Test IsZero - zero := date.Date{} + zero := Date{} if !zero.IsZero() { t.Errorf("IsZero(%v) == false, want true", zero) } - today := date.Today() + today := Today() if today.IsZero() { t.Errorf("IsZero(%v) == true, want false", today) } } +func testPredicate(t *testing.T, di, dj Date, p, q bool, m string) { + if p != q { + t.Errorf("%s(%v, %v) == %v, want %v\n%v", m, di, dj, p, q, debug.Stack()) + } +} + func TestArithmetic(t *testing.T) { - cases := []struct { - year int - month time.Month - day int - }{ - {-1234, time.February, 5}, - {0, time.April, 12}, - {1, time.January, 1}, - {1946, time.February, 4}, - {1970, time.January, 1}, - {1976, time.April, 1}, - {1999, time.December, 1}, - {1111111, time.June, 21}, + dates := []Date{ + New(-1234, time.February, 5), + New(0, time.April, 12), + New(1, time.January, 1), + New(1946, time.February, 4), + New(1970, time.January, 1), + New(1976, time.April, 1), + New(1999, time.December, 1), + New(1111111, time.June, 21), } - offsets := []int{-1000000, -9999, -555, -99, -22, -1, 0, 1, 22, 99, 555, 9999, 1000000} - for _, c := range cases { - d := date.New(c.year, c.month, c.day) + + offsets := []PeriodOfDays{-1000000, -9999, -555, -99, -22, -1, 0, 1, 22, 99, 555, 9999, 1000000} + + for _, d := range dates { + di := d for _, days := range offsets { - d2 := d.Add(days) - days2 := d2.Sub(d) + dj := di.Add(days) + days2 := dj.Sub(di) if days2 != days { - t.Errorf("AddSub(%v,%v) == %v, want %v", d, days, days2, days) + t.Errorf("AddSub(%v,%v) == %v, want %v", di, days, days2, days) } - d3 := d2.Add(-days) - if d3 != d { - t.Errorf("AddNeg(%v,%v) == %v, want %v", d, days, d3, d) + d3 := dj.Add(-days) + if d3 != di { + t.Errorf("AddNeg(%v,%v) == %v, want %v", di, days, d3, di) } - } - } -} - -func TestGobEncoding(t *testing.T) { - var b bytes.Buffer - encoder := gob.NewEncoder(&b) - decoder := gob.NewDecoder(&b) - cases := []date.Date{ - date.New(-11111, time.February, 3), - date.New(-1, time.December, 31), - date.New(0, time.January, 1), - date.New(1, time.January, 1), - date.New(1970, time.January, 1), - date.New(2012, time.June, 25), - date.New(12345, time.June, 7), - } - for _, c := range cases { - var d date.Date - err := encoder.Encode(&c) - if err != nil { - t.Errorf("Gob(%v) encode error %v", c, err) - } else { - err = decoder.Decode(&d) - if err != nil { - t.Errorf("Gob(%v) decode error %v", c, err) + eMin1 := min(di.day, dj.day) + aMin1 := di.Min(dj) + if aMin1.day != eMin1 { + t.Errorf("%v.Max(%v) is %s", di, dj, aMin1) + } + eMax1 := max(di.day, dj.day) + aMax1 := di.Max(dj) + if aMax1.day != eMax1 { + t.Errorf("%v.Max(%v) is %s", di, dj, aMax1) } } } } -func TestInvalidGob(t *testing.T) { +func TestDate_AddDate(t *testing.T) { cases := []struct { - bytes []byte - want string + d Date + years, months, days int + expected Date }{ - {[]byte{}, "Date.UnmarshalBinary: no data"}, - {[]byte{1, 2, 3}, "Date.UnmarshalBinary: invalid length"}, + {New(1970, time.January, 1), 1, 2, 3, New(1971, time.March, 4)}, + {New(1999, time.September, 28), 6, 4, 2, New(2006, time.January, 30)}, + {New(1999, time.September, 28), 0, 0, 3, New(1999, time.October, 1)}, + {New(1999, time.September, 28), 0, 1, 3, New(1999, time.October, 31)}, } for _, c := range cases { - var ignored date.Date - err := ignored.GobDecode(c.bytes) - if err == nil || err.Error() != c.want { - t.Errorf("InvalidGobDecode(%v) == %v, want %v", c.bytes, err, c.want) + di := c.d + dj := di.AddDate(c.years, c.months, c.days) + if dj != c.expected { + t.Errorf("%v AddDate(%v,%v,%v) == %v, want %v", di, c.years, c.months, c.days, dj, c.expected) } - err = ignored.UnmarshalBinary(c.bytes) - if err == nil || err.Error() != c.want { - t.Errorf("InvalidUnmarshalBinary(%v) == %v, want %v", c.bytes, err, c.want) + dk := dj.AddDate(-c.years, -c.months, -c.days) + if dk != di { + t.Errorf("%v AddDate(%v,%v,%v) == %v, want %v", dj, -c.years, -c.months, -c.days, dk, di) } } } -func TestJSONMarshalling(t *testing.T) { - var d date.Date +func TestDate_AddPeriod(t *testing.T) { cases := []struct { - value date.Date - want string + in Date + delta period.Period + expected Date }{ - {date.New(-11111, time.February, 3), `"-11111-02-03"`}, - {date.New(-1, time.December, 31), `"-0001-12-31"`}, - {date.New(0, time.January, 1), `"0000-01-01"`}, - {date.New(1, time.January, 1), `"0001-01-01"`}, - {date.New(1970, time.January, 1), `"1970-01-01"`}, - {date.New(2012, time.June, 25), `"2012-06-25"`}, - {date.New(12345, time.June, 7), `"+12345-06-07"`}, + {New(1970, time.January, 1), period.NewYMD(0, 0, 0), New(1970, time.January, 1)}, + {New(1971, time.January, 1), period.NewYMD(10, 0, 0), New(1981, time.January, 1)}, + {New(1972, time.January, 1), period.NewYMD(0, 10, 0), New(1972, time.November, 1)}, + {New(1972, time.January, 1), period.NewYMD(0, 24, 0), New(1974, time.January, 1)}, + {New(1973, time.January, 1), period.NewYMD(0, 0, 10), New(1973, time.January, 11)}, + {New(1973, time.January, 1), period.NewYMD(0, 0, 365), New(1974, time.January, 1)}, + {New(1974, time.January, 1), period.NewHMS(1, 2, 3), New(1974, time.January, 1)}, + // note: the period is not normalised so the HMS is ignored even though it's more than one day + {New(1975, time.January, 1), period.NewHMS(24, 2, 3), New(1975, time.January, 1)}, } - for _, c := range cases { - bytes, err := json.Marshal(c.value) - if err != nil { - t.Errorf("JSON(%v) marshal error %v", c, err) - } else if string(bytes) != c.want { - t.Errorf("JSON(%v) == %v, want %v", c.value, string(bytes), c.want) - } else { - err = json.Unmarshal(bytes, &d) - if err != nil { - t.Errorf("JSON(%v) unmarshal error %v", c.value, err) - } + for i, c := range cases { + out := c.in.AddPeriod(c.delta) + if out != c.expected { + t.Errorf("%d: %v.AddPeriod(%v) == %v, want %v", i, c.in, c.delta, out, c.expected) } } } -func TestInvalidJSON(t *testing.T) { - cases := []struct { - value string - want string - }{ - {`"not-a-date"`, `Date.ParseISO: cannot parse not-a-date`}, - {`2015-08-15"`, `Date.UnmarshalJSON: missing double quotes (2015-08-15")`}, - {`"2015-08-15`, `Date.UnmarshalJSON: missing double quotes ("2015-08-15)`}, - {`"215-08-15"`, `Date.ParseISO: cannot parse 215-08-15`}, +func min(a, b PeriodOfDays) PeriodOfDays { + if a < b { + return a } - for _, c := range cases { - var d date.Date - err := d.UnmarshalJSON([]byte(c.value)) - if err == nil || err.Error() != c.want { - t.Errorf("InvalidJSON(%v) == %v, want %v", c.value, err, c.want) - } + return b +} + +func max(a, b PeriodOfDays) PeriodOfDays { + if a > b { + return a } + return b } -func TestTextMarshalling(t *testing.T) { - var d date.Date +// See main testin in period_test.go +func TestIsLeap(t *testing.T) { cases := []struct { - value date.Date - want string + year int + expected bool }{ - {date.New(-11111, time.February, 3), "-11111-02-03"}, - {date.New(-1, time.December, 31), "-0001-12-31"}, - {date.New(0, time.January, 1), "0000-01-01"}, - {date.New(1, time.January, 1), "0001-01-01"}, - {date.New(1970, time.January, 1), "1970-01-01"}, - {date.New(2012, time.June, 25), "2012-06-25"}, - {date.New(12345, time.June, 7), "+12345-06-07"}, + {2000, true}, + {2001, false}, } for _, c := range cases { - bytes, err := c.value.MarshalText() - if err != nil { - t.Errorf("Text(%v) marshal error %v", c, err) - } else if string(bytes) != c.want { - t.Errorf("Text(%v) == %v, want %v", c.value, string(bytes), c.want) - } else { - err = d.UnmarshalText(bytes) - if err != nil { - t.Errorf("Text(%v) unmarshal error %v", c.value, err) - } + got := IsLeap(c.year) + if got != c.expected { + t.Errorf("TestIsLeap(%d) == %v, want %v", c.year, got, c.expected) } } } -func TestInvalidText(t *testing.T) { +func TestDaysIn(t *testing.T) { cases := []struct { - value string - want string + year int + month time.Month + expected int }{ - {`not-a-date`, `Date.ParseISO: cannot parse not-a-date`}, - {`215-08-15`, `Date.ParseISO: cannot parse 215-08-15`}, + {2000, time.January, 31}, + {2000, time.February, 29}, + {2001, time.February, 28}, + {2001, time.April, 30}, } for _, c := range cases { - var d date.Date - err := d.UnmarshalText([]byte(c.value)) - if err == nil || err.Error() != c.want { - t.Errorf("InvalidText(%v) == %v, want %v", c.value, err, c.want) + got1 := DaysIn(c.year, c.month) + if got1 != c.expected { + t.Errorf("DaysIn(%d, %d) == %v, want %v", c.year, c.month, got1, c.expected) + } + d := New(c.year, c.month, 1) + got2 := d.LastDayOfMonth() + if got2 != c.expected { + t.Errorf("DaysIn(%d) == %v, want %v", c.year, got2, c.expected) } } } diff --git a/datetool/main.go b/datetool/main.go new file mode 100644 index 00000000..19061441 --- /dev/null +++ b/datetool/main.go @@ -0,0 +1,99 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This tool prints equivalences between the string representation and the internal numerical +// representation for dates and clocks. +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/rickb777/date" + "github.com/rickb777/date/clock" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +func usage() { + fmt.Printf("Usage: %s [-t] number | date | time\n\n", os.Args[0]) + fmt.Printf(" -t: terse output\n") + fmt.Printf(" date: [+-]yyyy/mm/dd | yyyy.mm.dd | dd/mm/yyyy | dd.mm.yyyy\n") + fmt.Printf(" time: e.g. 11:15:20 | 2:45pm | 1:15:10.101\n") + os.Exit(0) +} + +var titled = false +var terse = false +var success = false +var printer = message.NewPrinter(language.English) + +func sprintf(num interface{}) string { + if terse { + return fmt.Sprintf("%d", num) + } else { + return printer.Sprintf("%d", num) + } +} + +func title() { + if !terse && !titled { + titled = true + fmt.Printf("%-15s %-15s %-15s %s\n", "input", "number", "clock", "date") + fmt.Printf("%-15s %-15s %-15s %s\n", "-----", "------", "-----", "----") + } +} + +func printArg(arg string) { + + i, err := strconv.ParseInt(arg, 10, 64) + if err == nil { + title() + d := date.NewOfDays(date.PeriodOfDays(i)) + c := clock.Clock(i) + fmt.Printf("%-15s %-15s %-15s %-12s %s\n", arg, sprintf(i), c, d, d.Weekday()) + success = true + return + } + + d, e1 := date.AutoParse(arg) + if e1 == nil { + title() + fmt.Printf("%-15s %-15s %15s %-12s %s\n", arg, sprintf(d.DaysSinceEpoch()), "", d, d.Weekday()) + success = true + } + + c, err := clock.Parse(arg) + if err == nil { + title() + fmt.Printf("%-15s %-15s %s\n", arg, sprintf(c), c) + success = true + } +} + +func main() { + argsWithoutProg := os.Args[1:] + if len(argsWithoutProg) == 0 { + usage() + } + + if len(argsWithoutProg) > 0 && argsWithoutProg[0] == "-t" { + terse = true + argsWithoutProg = argsWithoutProg[1:] + } + + for _, arg := range argsWithoutProg { + printArg(arg) + } + + if !success { + usage() + } + + if titled { + fmt.Printf("\n# dates are counted using days since Thursday 1st Jan 1970\n") + fmt.Printf("# clock operates via milliseconds since midnight\n") + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 00000000..a5ba05bd --- /dev/null +++ b/doc.go @@ -0,0 +1,48 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package date provides functionality for working with dates. +// It implements a light-weight Date type that is storage-efficient +// and convenient for calendrical calculations and date parsing and formatting +// (including years outside the [0,9999] interval). +// +// Subpackages provide: +// +// * `clock.Clock` which expresses a wall-clock style hours-minutes-seconds with millisecond precision. +// +// * `period.Period` which expresses a period corresponding to the ISO-8601 form (e.g. "PT30S"). +// +// * `timespan.DateRange` which expresses a period between two dates. +// +// * `timespan.TimeSpan` which expresses a duration of time between two instants. +// +// * `view.VDate` which wraps `Date` for use in templates etc. +// +// # Credits +// +// This package follows very closely the design of package time +// (http://golang.org/pkg/time/) in the standard library, many of the Date +// methods are implemented using the corresponding methods of the time.Time +// type, and much of the documentation is copied directly from that package. +// +// # References +// +// https://golang.org/src/time/time.go +// +// https://en.wikipedia.org/wiki/Gregorian_calendar +// +// https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar +// +// https://en.wikipedia.org/wiki/Astronomical_year_numbering +// +// https://en.wikipedia.org/wiki/ISO_8601 +// +// https://tools.ietf.org/html/rfc822 +// +// https://tools.ietf.org/html/rfc850 +// +// https://tools.ietf.org/html/rfc1123 +// +// https://tools.ietf.org/html/rfc3339 +package date diff --git a/example_test.go b/example_test.go index 5515271a..720d3457 100644 --- a/example_test.go +++ b/example_test.go @@ -2,29 +2,27 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package date_test +package date import ( "fmt" "time" - - "github.com/fxtlabs/date" ) func ExampleMax() { - d := date.Max() + d := Max() fmt.Println(d) // Output: +5881580-07-11 } func ExampleMin() { - d := date.Min() + d := Min() fmt.Println(d) // Output: -5877641-06-23 } func ExampleNew() { - d := date.New(9999, time.December, 31) + d := New(9999, time.December, 31) fmt.Printf("The world ends on %s\n", d) // Output: The world ends on 9999-12-31 } @@ -33,13 +31,13 @@ func ExampleParse() { // longForm shows by example how the reference date would be // represented in the desired layout. const longForm = "Mon, January 2, 2006" - d, _ := date.Parse(longForm, "Tue, February 3, 2013") + d, _ := Parse(longForm, "Tue, February 3, 2013") fmt.Println(d) // shortForm is another way the reference date would be represented // in the desired layout. const shortForm = "2006-Jan-02" - d, _ = date.Parse(shortForm, "2013-Feb-03") + d, _ = Parse(shortForm, "2013-Feb-03") fmt.Println(d) // Output: @@ -48,7 +46,7 @@ func ExampleParse() { } func ExampleParseISO() { - d, _ := date.ParseISO("+12345-06-07") + d, _ := ParseISO("+12345-06-07") year, month, day := d.Date() fmt.Println(year) fmt.Println(month) @@ -60,7 +58,7 @@ func ExampleParseISO() { } func ExampleDate_AddDate() { - d := date.New(1000, time.January, 1) + d := New(1000, time.January, 1) // Months and days do not need to be constrained to [1,12] and [1,365]. u := d.AddDate(0, 14, -1) fmt.Println(u) @@ -70,7 +68,7 @@ func ExampleDate_AddDate() { func ExampleDate_Format() { // layout shows by example how the reference time should be represented. const layout = "Jan 2, 2006" - d := date.New(2009, time.November, 10) + d := New(2009, time.November, 10) fmt.Println(d.Format(layout)) // Output: Nov 10, 2009 } @@ -79,7 +77,7 @@ func ExampleDate_FormatISO() { // According to legend, Rome was founded on April 21, 753 BC. // Note that with astronomical year numbering, 753 BC becomes -752 // because 1 BC is actually year 0. - d := date.New(-752, time.April, 21) + d := New(-752, time.April, 21) fmt.Println(d.FormatISO(5)) // Output: -00752-04-21 } diff --git a/format.go b/format.go index 7a4c75d0..ab611352 100644 --- a/format.go +++ b/format.go @@ -6,15 +6,16 @@ package date import ( "fmt" - "regexp" - "strconv" - "time" + "io" + "strings" ) // These are predefined layouts for use in Date.Format and Date.Parse. // The reference date used in the layouts is the same date used by the // time package in the standard library: -// Monday, Jan 2, 2006 +// +// Monday, Jan 2, 2006 +// // To define your own format, write down what the reference date would look // like formatted your way; see the values of the predefined layouts for // examples. The model is to demonstrate what the reference date looks like @@ -31,71 +32,28 @@ const ( RFC3339 = "2006-01-02" ) -// reISO8601 is the regular expression used to parse date strings in the -// ISO 8601 extended format, with or without an expanded year representation. -var reISO8601 = regexp.MustCompile(`^([-+]?\d{4,})-(\d{2})-(\d{2})$`) - -// ParseISO parses an ISO 8601 formatted string and returns the date value it represents. -// In addition to the common extended format (e.g. 2006-01-02), this function -// accepts date strings using the expanded year representation -// with possibly extra year digits beyond the prescribed four-digit minimum -// and with a + or - sign prefix (e.g. , "+12345-06-07", "-0987-06-05"). -// -// Note that ParseISO is a little looser than the ISO 8601 standard and will -// be happy to parse dates with a year longer than the four-digit minimum even -// if they are missing the + sign prefix. -// -// Function Date.Parse can be used to parse date strings in other formats, but it -// is currently not able to parse ISO 8601 formatted strings that use the -// expanded year format. -func ParseISO(value string) (Date, error) { - m := reISO8601.FindStringSubmatch(value) - if len(m) != 4 { - return Date{}, fmt.Errorf("Date.ParseISO: cannot parse %s", value) - } - // No need to check for errors since the regexp guarantees the matches - // are valid integers - year, _ := strconv.Atoi(m[1]) - month, _ := strconv.Atoi(m[2]) - day, _ := strconv.Atoi(m[3]) - - t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) - - return Date{encode(t)}, nil -} - -// Parse parses a formatted string and returns the Date value it represents. -// The layout defines the format by showing how the reference date, defined -// to be -// Monday, Jan 2, 2006 -// would be interpreted if it were the value; it serves as an example of the -// input format. The same interpretation will then be made to the input string. -// -// This function actually uses time.Parse to parse the input and can use any -// layout accepted by time.Parse, but returns only the date part of the -// parsed Time value. -// -// This function cannot currently parse ISO 8601 strings that use the expanded -// year format; you should use Date.ParseISO to parse those strings correctly. -func Parse(layout, value string) (Date, error) { - t, err := time.Parse(layout, value) - if err != nil { - return Date{0}, err - } - return Date{encode(t)}, nil -} - // String returns the time formatted in ISO 8601 extended format // (e.g. "2006-01-02"). If the year of the date falls outside the // [0,9999] range, this format produces an expanded year representation // with possibly extra year digits beyond the prescribed four-digit minimum // and with a + or - sign prefix (e.g. , "+12345-06-07", "-0987-06-05"). func (d Date) String() string { + buf := &strings.Builder{} + buf.Grow(12) + d.WriteTo(buf) + return buf.String() +} + +// WriteTo is as per String, albeit writing to an io.Writer. +func (d Date) WriteTo(w io.Writer) (n64 int64, err error) { + var n int year, month, day := d.Date() if 0 <= year && year < 10000 { - return fmt.Sprintf("%04d-%02d-%02d", year, month, day) + n, err = fmt.Fprintf(w, "%04d-%02d-%02d", year, month, day) + } else { + n, err = fmt.Fprintf(w, "%+05d-%02d-%02d", year, month, day) } - return fmt.Sprintf("%+05d-%02d-%02d", year, month, day) + return int64(n), err } // FormatISO returns a textual representation of the date value formatted @@ -120,7 +78,9 @@ func (d Date) FormatISO(yearDigits int) string { // Format returns a textual representation of the date value formatted according // to layout, which defines the format by showing how the reference date, // defined to be -// Mon, Jan 2, 2006 +// +// Mon, Jan 2, 2006 +// // would be displayed if it were the value; it serves as an example of the // desired output. // @@ -128,9 +88,64 @@ func (d Date) FormatISO(yearDigits int) string { // layout accepted by time.Format by extending its date to a time at // 00:00:00.000 UTC. // +// Additionally, it is able to insert the day-number suffix into the output string. +// This is done by including "nd" in the format string, which will become +// +// Mon, Jan 2nd, 2006 +// +// For example, New Year's Day might be rendered as "Fri, Jan 1st, 2016". To alter +// the suffix strings for a different locale, change DaySuffixes or use FormatWithSuffixes +// instead. +// // This function cannot currently format Date values according to the expanded // year variant of ISO 8601; you should use Date.FormatISO to that effect. func (d Date) Format(layout string) string { + return d.FormatWithSuffixes(layout, DaySuffixes) +} + +// FormatWithSuffixes is the same as Format, except the suffix strings can be specified +// explicitly, which allows multiple locales to be supported. The suffixes slice should +// contain 31 strings covering the days 1 (index 0) to 31 (index 30). +func (d Date) FormatWithSuffixes(layout string, suffixes []string) string { t := decode(d.day) - return t.Format(layout) + parts := strings.Split(layout, "nd") + switch len(parts) { + case 1: + return t.Format(layout) + + default: + // If the format contains "Monday", it has been split so repair it. + i := 1 + for i < len(parts) { + if i > 0 && strings.HasSuffix(parts[i-1], "Mo") && strings.HasPrefix(parts[i], "ay") { + parts[i-1] = parts[i-1] + "nd" + parts[i] + copy(parts[i:], parts[i+1:]) + parts = parts[:len(parts)-1] + } else { + i++ + } + } + a := make([]string, 0, 2*len(parts)-1) + for i, p := range parts { + if i > 0 { + a = append(a, suffixes[d.Day()-1]) + } + a = append(a, t.Format(p)) + } + return strings.Join(a, "") + } +} + +// DaySuffixes is the default array of strings used as suffixes when a format string +// contains "nd" (as in "second"). This can be altered at startup in order to change +// the default locale strings used for formatting dates. It supports every locale that +// uses the Gregorian calendar and has a suffix after the day-of-month number. +var DaySuffixes = []string{ + "st", "nd", "rd", "th", "th", // 1 - 5 + "th", "th", "th", "th", "th", // 6 - 10 + "th", "th", "th", "th", "th", // 11 - 15 + "th", "th", "th", "th", "th", // 16 - 20 + "st", "nd", "rd", "th", "th", // 21 - 25 + "th", "th", "th", "th", "th", // 26 - 30 + "st", // 31 } diff --git a/format_test.go b/format_test.go index 89edf96a..93568970 100644 --- a/format_test.go +++ b/format_test.go @@ -2,130 +2,33 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package date_test +package date import ( "testing" - "time" - - "github.com/fxtlabs/date" ) -func TestParseISO(t *testing.T) { +func TestDate_String(t *testing.T) { cases := []struct { value string - year int - month time.Month - day int }{ - {"1969-12-31", 1969, time.December, 31}, - {"+1970-01-01", 1970, time.January, 1}, - {"+01970-01-02", 1970, time.January, 2}, - {"2000-02-28", 2000, time.February, 28}, - {"+2000-02-29", 2000, time.February, 29}, - {"+02000-03-01", 2000, time.March, 1}, - {"+002004-02-28", 2004, time.February, 28}, - {"2004-02-29", 2004, time.February, 29}, - {"2004-03-01", 2004, time.March, 1}, - {"0000-01-01", 0, time.January, 1}, - {"+0001-02-03", 1, time.February, 3}, - {"+00019-03-04", 19, time.March, 4}, - {"0100-04-05", 100, time.April, 5}, - {"2000-05-06", 2000, time.May, 6}, - {"+30000-06-07", 30000, time.June, 7}, - {"+400000-07-08", 400000, time.July, 8}, - {"+5000000-08-09", 5000000, time.August, 9}, - {"-0001-09-11", -1, time.September, 11}, - {"-0019-10-12", -19, time.October, 12}, - {"-00100-11-13", -100, time.November, 13}, - {"-02000-12-14", -2000, time.December, 14}, - {"-30000-02-15", -30000, time.February, 15}, - {"-0400000-05-16", -400000, time.May, 16}, - {"-5000000-09-17", -5000000, time.September, 17}, + {"-0001-01-01"}, + {"0000-01-01"}, + {"1000-01-01"}, + {"1970-01-01"}, + {"2000-11-22"}, + {"+10000-01-01"}, } for _, c := range cases { - d, err := date.ParseISO(c.value) - if err != nil { - t.Errorf("ParseISO(%v) == %v", c.value, err) - } - year, month, day := d.Date() - if year != c.year || month != c.month || day != c.day { - t.Errorf("ParseISO(%v) == %v, want (%v, %v, %v)", c.value, d, c.year, c.month, c.day) - } - } - - badCases := []string{ - "1234-05", - "1234-5-6", - "1234-05-6", - "1234-5-06", - "12340506", - "1234/05/06", - "1234-0A-06", - "1234-05-0B", - "1234-05-06trailing", - "padding1234-05-06", - "1-02-03", - "10-11-12", - "100-02-03", - "+1-02-03", - "+10-11-12", - "+100-02-03", - "-123-05-06", - } - for _, c := range badCases { - d, err := date.ParseISO(c) - if err == nil { - t.Errorf("ParseISO(%v) == %v", c, d) - } - } -} - -func TestParse(t *testing.T) { - // Test ability to parse a few common date formats - cases := []struct { - layout string - value string - year int - month time.Month - day int - }{ - {date.ISO8601, "1969-12-31", 1969, time.December, 31}, - {date.ISO8601B, "19700101", 1970, time.January, 1}, - {date.RFC822, "29-Feb-00", 2000, time.February, 29}, - {date.RFC822W, "Mon, 01-Mar-04", 2004, time.March, 1}, - {date.RFC850, "Wednesday, 12-Aug-15", 2015, time.August, 12}, - {date.RFC1123, "05 Dec 1928", 1928, time.December, 5}, - {date.RFC1123W, "Mon, 05 Dec 1928", 1928, time.December, 5}, - {date.RFC3339, "2345-06-07", 2345, time.June, 7}, - } - for _, c := range cases { - d, err := date.Parse(c.layout, c.value) - if err != nil { - t.Errorf("Parse(%v) == %v", c.value, err) - } - year, month, day := d.Date() - if year != c.year || month != c.month || day != c.day { - t.Errorf("Parse(%v) == %v, want (%v, %v, %v)", c.value, d, c.year, c.month, c.day) - } - } - - // Test inability to parse ISO 8601 expanded year format - badCases := []string{ - "+1234-05-06", - "+12345-06-07", - "-1234-05-06", - "-12345-06-07", - } - for _, c := range badCases { - d, err := date.Parse(date.ISO8601, c) - if err == nil { - t.Errorf("Parse(%v) == %v", c, d) + d := MustParseISO(c.value) + value := d.String() + if value != c.value { + t.Errorf("String() == %v, want %v", value, c.value) } } } -func TestFormatISO(t *testing.T) { +func TestDate_FormatISO(t *testing.T) { cases := []struct { value string n int @@ -142,14 +45,42 @@ func TestFormatISO(t *testing.T) { {"+999999-12-31", 6}, } for _, c := range cases { - d, err := date.ParseISO(c.value) - if err != nil { - t.Errorf("FormatISO(%v) cannot parse input: %v", c.value, err) - continue - } + d := MustParseISO(c.value) value := d.FormatISO(c.n) if value != c.value { t.Errorf("FormatISO(%v) == %v, want %v", c, value, c.value) } } } + +func TestDate_Format(t *testing.T) { + cases := []struct { + value string + format string + expected string + }{ + {"1970-01-01", "2 Jan 2006", "1 Jan 1970"}, + {"1970-01-01", "Jan 02 2006", "Jan 01 1970"}, + {"1970-01-01", "Jan 2nd 2006", "Jan 1st 1970"}, + {"2016-01-01", "2nd Jan 2006", "1st Jan 2016"}, + {"2016-02-02", "Jan 2nd 2006", "Feb 2nd 2016"}, + {"2016-03-03", "Jan 2nd 2006", "Mar 3rd 2016"}, + {"2016-04-04", "2nd Jan 2006", "4th Apr 2016"}, + {"2016-05-20", "Jan 2nd 2006", "May 20th 2016"}, + {"2016-06-21", "Jan 2nd 2006", "Jun 21st 2016"}, + {"2016-07-22", "Jan 2nd 2006", "Jul 22nd 2016"}, + {"2016-08-23", "Jan 2nd 2006", "Aug 23rd 2016"}, + {"2016-09-30", "Jan 2nd 2006", "Sep 30th 2016"}, + {"2016-10-31", "Jan 2nd 2006", "Oct 31st 2016"}, + {"2016-01-07", "Monday January 2nd 2006", "Thursday January 7th 2016"}, + {"2016-01-07", "Monday 2nd Monday 2nd", "Thursday 7th Thursday 7th"}, + {"2016-11-01", "2nd 2nd 2nd", "1st 1st 1st"}, + } + for _, c := range cases { + d := MustParseISO(c.value) + actual := d.Format(c.format) + if actual != c.expected { + t.Errorf("Format(%v) == %v, want %v", c, actual, c.expected) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..2bc8244e --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/rickb777/date + +require ( + github.com/onsi/gomega v1.27.10 + github.com/rickb777/plural v1.4.1 + golang.org/x/text v0.13.0 +) + +require ( + github.com/google/go-cmp v0.5.9 // indirect + golang.org/x/net v0.15.0 // indirect + golang.org/x/tools v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +go 1.17 diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..b53fbaab --- /dev/null +++ b/go.sum @@ -0,0 +1,219 @@ +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= +github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= +github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= +github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= +github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= +github.com/onsi/ginkgo/v2 v2.9.0/go.mod h1:4xkjoL/tZv4SMWeww56BU5kAt19mVB47gTWxmrTcxyk= +github.com/onsi/ginkgo/v2 v2.9.1/go.mod h1:FEcmzVcCHl+4o9bQZVab+4dC9+j+91t2FHSzmGAPfuo= +github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= +github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= +github.com/onsi/ginkgo/v2 v2.9.7/go.mod h1:cxrmXWykAwTwhQsJOPfdIDiJ+l2RYq7U8hFU+M/1uw0= +github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= +github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= +github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= +github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= +github.com/onsi/gomega v1.26.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= +github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= +github.com/onsi/gomega v1.27.4/go.mod h1:riYq/GJKh8hhoM01HN6Vmuy93AarCXCBGpvFDK3q3fQ= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4= +github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rickb777/plural v1.4.1 h1:5MMLcbIaapLFmvDGRT5iPk8877hpTPt8Y9cdSKRw9sU= +github.com/rickb777/plural v1.4.1/go.mod h1:kdmXUpmKBJTS0FtG/TFumd//VBWsNTD7zOw7x4umxNw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gregorian/doc.go b/gregorian/doc.go new file mode 100644 index 00000000..d4c85412 --- /dev/null +++ b/gregorian/doc.go @@ -0,0 +1,16 @@ +// Copyright 2016 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package gregorian provides utility functions for the Gregorian calendar calculations. +// The Gregorian calendar was officially introduced on 15th October 1582 so, strictly speaking, +// it only applies after that date. Some countries did not switch to the Gregorian calendar +// for many years after (such as Great Britain in 1782). +// +// Extending the Gregorian calendar backwards to dates preceding its official introduction +// produces a proleptic calendar that should be used with some caution for historic dates +// because it can lead to confusion. +// +// See https://en.wikipedia.org/wiki/Gregorian_calendar +// https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar +package gregorian diff --git a/gregorian/util.go b/gregorian/util.go new file mode 100644 index 00000000..67df3ab4 --- /dev/null +++ b/gregorian/util.go @@ -0,0 +1,42 @@ +package gregorian + +import ( + "time" +) + +// IsLeap simply tests whether a given year is a leap year, using the Gregorian calendar algorithm. +func IsLeap(year int) bool { + return year%4 == 0 && (year%100 != 0 || year%400 == 0) +} + +// DaysInYear gives the number of days in a given year, according to the Gregorian calendar. +func DaysInYear(year int) int { + if IsLeap(year) { + return 366 + } + return 365 +} + +// DaysIn gives the number of days in a given month, according to the Gregorian calendar. +func DaysIn(year int, month time.Month) int { + if month == time.February && IsLeap(year) { + return 29 + } + return daysInMonth[month] +} + +var daysInMonth = []int{ + 0, + 31, // January + 28, + 31, // March + 30, + 31, // May + 30, + 31, // July + 31, + 30, // September + 31, + 30, // November + 31, +} diff --git a/gregorian/util_test.go b/gregorian/util_test.go new file mode 100644 index 00000000..680a118f --- /dev/null +++ b/gregorian/util_test.go @@ -0,0 +1,77 @@ +package gregorian + +import ( + "testing" + "time" +) + +func TestIsLeap(t *testing.T) { + cases := []struct { + year int + expected bool + }{ + {0, true}, // year zero is not defined under some conventions but is in ISO8601 + {2000, true}, + {2400, true}, + {2001, false}, + {2002, false}, + {2003, false}, + {2003, false}, + {2004, true}, + {2005, false}, + {1800, false}, + {1900, false}, + {2200, false}, + {2300, false}, + {2500, false}, + } + for _, c := range cases { + got := IsLeap(c.year) + if got != c.expected { + t.Errorf("TestIsLeap(%d) == %v, want %v", c.year, got, c.expected) + } + } +} + +func TestDaysInYear(t *testing.T) { + cases := []struct { + year int + expected int + }{ + {2000, 366}, + {2001, 365}, + } + for _, c := range cases { + got1 := DaysInYear(c.year) + if got1 != c.expected { + t.Errorf("DaysInYear(%d) == %v, want %v", c.year, got1, c.expected) + } + } +} + +func TestDaysIn(t *testing.T) { + cases := []struct { + year int + month time.Month + expected int + }{ + {2000, time.January, 31}, + {2000, time.February, 29}, + {2001, time.February, 28}, + {2001, time.April, 30}, + {2001, time.May, 31}, + {2001, time.June, 30}, + {2001, time.July, 31}, + {2001, time.August, 31}, + {2001, time.September, 30}, + {2001, time.October, 31}, + {2001, time.November, 30}, + {2001, time.December, 31}, + } + for _, c := range cases { + got1 := DaysIn(c.year, c.month) + if got1 != c.expected { + t.Errorf("DaysIn(%d, %d) == %v, want %v", c.year, c.month, got1, c.expected) + } + } +} diff --git a/marshal.go b/marshal.go new file mode 100644 index 00000000..15106937 --- /dev/null +++ b/marshal.go @@ -0,0 +1,120 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package date + +import ( + "bytes" + "errors" +) + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (d Date) MarshalBinary() ([]byte, error) { + enc := []byte{ + byte(d.day >> 24), + byte(d.day >> 16), + byte(d.day >> 8), + byte(d.day), + } + return enc, nil +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (d *Date) UnmarshalBinary(data []byte) error { + if len(data) == 0 { + return errors.New("Date.UnmarshalBinary: no data") + } + if len(data) != 4 { + return errors.New("Date.UnmarshalBinary: invalid length") + } + + d.day = PeriodOfDays(data[3]) | PeriodOfDays(data[2])<<8 | PeriodOfDays(data[1])<<16 | PeriodOfDays(data[0])<<24 + + return nil +} + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +func (ds DateString) MarshalBinary() ([]byte, error) { + return Date(ds).MarshalBinary() +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +func (ds *DateString) UnmarshalBinary(data []byte) error { + return (*Date)(ds).UnmarshalBinary(data) +} + +// MarshalJSON implements the json.Marshaler interface. +// The date is given in ISO 8601 extended format (e.g. "2006-01-02"). +// If the year of the date falls outside the [0,9999] range, this format +// produces an expanded year representation with possibly extra year digits +// beyond the prescribed four-digit minimum and with a + or - sign prefix +// (e.g. , "+12345-06-07", "-0987-06-05"). +// Note that the zero value is marshalled as a blank string, which allows +// "omitempty" to work. +func (d Date) MarshalJSON() ([]byte, error) { + buf := &bytes.Buffer{} + buf.Grow(14) + buf.WriteByte('"') + d.WriteTo(buf) + buf.WriteByte('"') + return buf.Bytes(), nil +} + +// MarshalText implements the encoding.TextMarshaler interface. +// The date is given in ISO 8601 extended format (e.g. "2006-01-02"). +// If the year of the date falls outside the [0,9999] range, this format +// produces an expanded year representation with possibly extra year digits +// beyond the prescribed four-digit minimum and with a + or - sign prefix +// (e.g. , "+12345-06-07", "-0987-06-05"). +func (d Date) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +// The date is expected to be in ISO 8601 extended format +// (e.g. "2006-01-02", "+12345-06-07", "-0987-06-05"); +// the year must use at least 4 digits and if outside the [0,9999] range +// must be prefixed with a + or - sign. +// Note that the a blank string is unmarshalled as the zero value. +func (d *Date) UnmarshalText(data []byte) (err error) { + if len(data) == 0 { + return nil + } + u, err := ParseISO(string(data)) + if err == nil { + d.day = u.day + } + return err +} + +// MarshalJSON implements the json.Marshaler interface. +// The date is given in ISO 8601 extended format (e.g. "2006-01-02"). +// If the year of the date falls outside the [0,9999] range, this format +// produces an expanded year representation with possibly extra year digits +// beyond the prescribed four-digit minimum and with a + or - sign prefix +// (e.g. , "+12345-06-07", "-0987-06-05"). +// Note that the zero value is marshalled as a blank string, which allows +// "omitempty" to work. +func (ds DateString) MarshalJSON() ([]byte, error) { + return Date(ds).MarshalJSON() +} + +// MarshalText implements the encoding.TextMarshaler interface. +// The date is given in ISO 8601 extended format (e.g. "2006-01-02"). +// If the year of the date falls outside the [0,9999] range, this format +// produces an expanded year representation with possibly extra year digits +// beyond the prescribed four-digit minimum and with a + or - sign prefix +// (e.g. , "+12345-06-07", "-0987-06-05"). +func (ds DateString) MarshalText() ([]byte, error) { + return Date(ds).MarshalText() +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +// The date is expected to be in ISO 8601 extended format +// (e.g. "2006-01-02", "+12345-06-07", "-0987-06-05"); +// the year must use at least 4 digits and if outside the [0,9999] range +// must be prefixed with a + or - sign. +func (ds *DateString) UnmarshalText(data []byte) (err error) { + return (*Date)(ds).UnmarshalText(data) +} diff --git a/marshal_test.go b/marshal_test.go new file mode 100644 index 00000000..1a02d94c --- /dev/null +++ b/marshal_test.go @@ -0,0 +1,222 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package date + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "testing" + "time" +) + +func TestDate_gob_Encode_round_tripe(t *testing.T) { + cases := []Date{ + New(-11111, time.February, 3), + New(-1, time.December, 31), + New(0, time.January, 1), + New(1, time.January, 1), + New(1970, time.January, 1), + New(2012, time.June, 25), + New(12345, time.June, 7), + } + for _, c := range cases { + var b bytes.Buffer + encoder := gob.NewEncoder(&b) + decoder := gob.NewDecoder(&b) + + var d Date + err := encoder.Encode(&c) + if err != nil { + t.Errorf("Gob(%v) encode error %v", c, err) + } else { + err = decoder.Decode(&d) + if err != nil { + t.Errorf("Gob(%v) decode error %v", c, err) + } else if d != c { + t.Errorf("Gob(%v) decode got %v", c, d) + } + } + + ds := c.DateString() + err = encoder.Encode(&ds) + if err != nil { + t.Errorf("Gob(%v) encode error %v", c, err) + } else { + err = decoder.Decode(&ds) + if err != nil { + t.Errorf("Gob(%v) decode error %v", c, err) + } else if ds != c.DateString() { + t.Errorf("Gob(%v) decode got %v", c, ds) + } + } + } +} + +func TestDate_MarshalJSON_round_trip(t *testing.T) { + cases := []struct { + value Date + want string + }{ + {New(-11111, time.February, 3), `"-11111-02-03"`}, + {New(-1, time.December, 31), `"-0001-12-31"`}, + {New(0, time.January, 1), `"0000-01-01"`}, + {New(1, time.January, 1), `"0001-01-01"`}, + {New(1970, time.January, 1), `"1970-01-01"`}, + {New(2012, time.June, 25), `"2012-06-25"`}, + {New(12345, time.June, 7), `"+12345-06-07"`}, + } + for _, c := range cases { + var d Date + bb1, err := json.Marshal(c.value) + if err != nil { + t.Errorf("JSON(%v) marshal error %v", c, err) + } else if string(bb1) != c.want { + t.Errorf("JSON(%v) == %v, want %v", c.value, string(bb1), c.want) + } else { + err = json.Unmarshal(bb1, &d) + if err != nil { + t.Errorf("JSON(%v) unmarshal error %v", c.value, err) + } else if d != c.value { + t.Errorf("JSON(%v) unmarshal got %v", c.value, d) + } + } + + // consistency + var ds DateString + bb2, err := json.Marshal(c.value.DateString()) + if err != nil { + t.Errorf("JSON(%v) marshal error %v", c, err) + } else if string(bb2) != c.want { + t.Errorf("JSON(%v) == %v, want %v", c.value.DateString(), string(bb2), c.want) + } else { + err = json.Unmarshal(bb2, &ds) + if err != nil { + t.Errorf("JSON(%v) unmarshal error %v", c.value.DateString(), err) + } else if ds != c.value.DateString() { + t.Errorf("JSON(%v) unmarshal got %v", c.value.DateString(), ds) + } + } + } +} + +func TestDate_MarshalText_round_trip(t *testing.T) { + cases := []struct { + value Date + want string + }{ + {New(-11111, time.February, 3), "-11111-02-03"}, + {New(-1, time.December, 31), "-0001-12-31"}, + {New(0, time.January, 1), "0000-01-01"}, + {New(1, time.January, 1), "0001-01-01"}, + {New(1970, time.January, 1), "1970-01-01"}, + {New(2012, time.June, 25), "2012-06-25"}, + {New(12345, time.June, 7), "+12345-06-07"}, + } + for _, c := range cases { + var d Date + bb1, err := c.value.MarshalText() + if err != nil { + t.Errorf("Text(%v) marshal error %v", c, err) + } else if string(bb1) != c.want { + t.Errorf("Text(%v) == %q, want %q", c.value, string(bb1), c.want) + } else { + err = d.UnmarshalText(bb1) + if err != nil { + t.Errorf("Text(%v) unmarshal error %v", c.value, err) + } else if d != c.value { + t.Errorf("Text(%v) unmarshal got %v", c.value, d) + } + } + + // consistency + var ds DateString + bb2, err := c.value.DateString().MarshalText() + if err != nil { + t.Errorf("Text(%v) marshal error %v", c, err) + } else if string(bb2) != c.want { + t.Errorf("Text(%v) == %v, want %q", c.value, string(bb2), c.want) + } else { + err = ds.UnmarshalText(bb2) + if err != nil { + t.Errorf("Text(%v) unmarshal error %v", c.value, err) + } else if ds != c.value.DateString() { + t.Errorf("Text(%v) unmarshal got %v", c.value, ds) + } + } + } +} + +func TestDate_MarshalBinary_round_trip(t *testing.T) { + cases := []struct { + value Date + }{ + {New(-11111, time.February, 3)}, + {New(-1, time.December, 31)}, + {New(0, time.January, 1)}, + {New(1, time.January, 1)}, + {New(1970, time.January, 1)}, + {New(2012, time.June, 25)}, + {New(12345, time.June, 7)}, + } + for _, c := range cases { + bb1, err := c.value.MarshalBinary() + if err != nil { + t.Errorf("Binary(%v) marshal error %v", c, err) + } else { + var d Date + err = d.UnmarshalBinary(bb1) + if err != nil { + t.Errorf("Binary(%v) unmarshal error %v", c.value, err) + } else if d != c.value { + t.Errorf("Binary(%v) unmarshal got %v", c.value, d) + } + } + + // consistency check + bb2, err := c.value.MarshalBinary() + if err != nil { + t.Errorf("Binary(%v) marshal error %v", c, err) + } else { + var ds DateString + err = ds.UnmarshalBinary(bb2) + if err != nil { + t.Errorf("Binary(%v) unmarshal error %v", c.value, err) + } else if ds != c.value.DateString() { + t.Errorf("Binary(%v) unmarshal got %v", c.value, ds) + } + } + } +} + +func TestDate_UnmarshalBinary_errors(t *testing.T) { + var d Date + err1 := d.UnmarshalBinary([]byte{}) + if err1 == nil { + t.Errorf("unmarshal no empty data error") + } + + err2 := d.UnmarshalBinary([]byte("12345")) + if err2 == nil { + t.Errorf("unmarshal no wrong length error") + } +} + +func TestDate_UnmarshalText_invalid_date_text(t *testing.T) { + cases := []struct { + value string + want string + }{ + {`not-a-date`, `Date.ParseISO: cannot parse "not-a-date": incorrect syntax`}, + {`215-08-15`, `Date.ParseISO: cannot parse "215-08-15": invalid year`}, + } + for _, c := range cases { + var d Date + err := d.UnmarshalText([]byte(c.value)) + if err == nil || err.Error() != c.want { + t.Errorf("InvalidText(%v) == %v, want %v", c.value, err, c.want) + } + } +} diff --git a/parse.go b/parse.go new file mode 100644 index 00000000..3f1b35a9 --- /dev/null +++ b/parse.go @@ -0,0 +1,202 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package date + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode" +) + +// MustAutoParse is as per AutoParse except that it panics if the string cannot be parsed. +// This is intended for setup code; don't use it for user inputs. +func MustAutoParse(value string) Date { + d, err := AutoParse(value) + if err != nil { + panic(err) + } + return d +} + +// AutoParse is like ParseISO, except that it automatically adapts to a variety of date formats +// provided that they can be detected unambiguously. Specifically, this includes the "European" +// and "British" date formats but not the common US format. Surrounding whitespace is ignored. +// The supported formats are: +// +// * all formats supported by ParseISO +// +// * yyyy/mm/dd | yyyy.mm.dd (or any similar pattern) +// +// * dd/mm/yyyy | dd.mm.yyyy (or any similar pattern) +// +// * surrounding whitespace is ignored +func AutoParse(value string) (Date, error) { + abs := strings.TrimSpace(value) + if len(abs) == 0 { + return Date{}, errors.New("Date.AutoParse: cannot parse a blank string") + } + + sign := "" + if abs[0] == '+' || abs[0] == '-' { + sign = abs[:1] + abs = abs[1:] + } + + if len(abs) >= 10 { + i1 := -1 + i2 := -1 + for i, r := range abs { + if unicode.IsPunct(r) { + if i1 < 0 { + i1 = i + } else { + i2 = i + } + } + } + if i1 >= 4 && i2 > i1 && abs[i1] == abs[i2] { + // just normalise the punctuation + a := []byte(abs) + a[i1] = '-' + a[i2] = '-' + abs = string(a) + } else if i1 >= 2 && i2 > i1 && abs[i1] == abs[i2] { + // harder case - need to swap the field order + dd := abs[0:i1] + mm := abs[i1+1 : i2] + yyyy := abs[i2+1:] + abs = fmt.Sprintf("%s-%s-%s", yyyy, mm, dd) + } + } + return ParseISO(sign + abs) +} + +// MustParseISO is as per ParseISO except that it panics if the string cannot be parsed. +// This is intended for setup code; don't use it for user inputs. +func MustParseISO(value string) Date { + d, err := ParseISO(value) + if err != nil { + panic(err) + } + return d +} + +// ParseISO parses an ISO 8601 formatted string and returns the date value it represents. +// In addition to the common formats (e.g. 2006-01-02 and 20060102), this function +// accepts date strings using the expanded year representation +// with possibly extra year digits beyond the prescribed four-digit minimum +// and with a + or - sign prefix (e.g. , "+12345-06-07", "-0987-06-05"). +// +// Note that ParseISO is a little looser than the ISO 8601 standard and will +// be happy to parse dates with a year longer in length than the four-digit minimum even +// if they are missing the + sign prefix. +// +// Function date.Parse can be used to parse date strings in other formats, but it +// is currently not able to parse ISO 8601 formatted strings that use the +// expanded year format. +// +// Background: https://en.wikipedia.org/wiki/ISO_8601#Dates +func ParseISO(value string) (Date, error) { + if len(value) < 8 { + return Date{}, fmt.Errorf("Date.ParseISO: cannot parse %q: incorrect length", value) + } + + abs := value + if value[0] == '+' || value[0] == '-' { + abs = value[1:] + } + + dash1 := strings.IndexByte(abs, '-') + fm1 := dash1 + 1 + fm2 := dash1 + 3 + fd1 := dash1 + 4 + fd2 := dash1 + 6 + + if dash1 < 0 { + // switch to YYYYMMDD format + dash1 = 4 + fm1 = 4 + fm2 = 6 + fd1 = 6 + fd2 = 8 + } else if abs[fm2] != '-' { + return Date{}, fmt.Errorf("Date.ParseISO: cannot parse %q: incorrect syntax", value) + } + //fmt.Printf("%s %d %d %d %d %d\n", value, dash1, fm1, fm2, fd1, fd2) + + if len(abs) != fd2 { + return Date{}, fmt.Errorf("Date.ParseISO: cannot parse %q: incorrect length", value) + } + + year, err := parseField(value, abs[:dash1], "year", 4, -1) + if err != nil { + return Date{}, err + } + + month, err := parseField(value, abs[fm1:fm2], "month", -1, 2) + if err != nil { + return Date{}, err + } + + day, err := parseField(value, abs[fd1:], "day", -1, 2) + if err != nil { + return Date{}, err + } + + if value[0] == '-' { + year = -year + } + + t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) + + return Date{encode(t)}, nil +} + +func parseField(value, field, name string, minLength, requiredLength int) (int, error) { + if (minLength > 0 && len(field) < minLength) || (requiredLength > 0 && len(field) != requiredLength) { + return 0, fmt.Errorf("Date.ParseISO: cannot parse %q: invalid %s", value, name) + } + number, err := strconv.Atoi(field) + if err != nil { + return 0, fmt.Errorf("Date.ParseISO: cannot parse %q: invalid %s", value, name) + } + return number, nil +} + +// MustParse is as per Parse except that it panics if the string cannot be parsed. +// This is intended for setup code; don't use it for user inputs. +func MustParse(layout, value string) Date { + d, err := Parse(layout, value) + if err != nil { + panic(err) + } + return d +} + +// Parse parses a formatted string of a known layout and returns the Date value it represents. +// The layout defines the format by showing how the reference date, defined +// to be +// +// Monday, Jan 2, 2006 +// +// would be interpreted if it were the value; it serves as an example of the +// input format. The same interpretation will then be made to the input string. +// +// This function actually uses time.Parse to parse the input and can use any +// layout accepted by time.Parse, but returns only the date part of the +// parsed Time value. +// +// This function cannot currently parse ISO 8601 strings that use the expanded +// year format; you should use date.ParseISO to parse those strings correctly. +func Parse(layout, value string) (Date, error) { + t, err := time.Parse(layout, value) + if err != nil { + return Date{}, err + } + return Date{encode(t)}, nil +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 00000000..40ad7edb --- /dev/null +++ b/parse_test.go @@ -0,0 +1,249 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package date + +import ( + "testing" + "time" +) + +func TestAutoParse(t *testing.T) { + cases := []struct { + value string + year int + month time.Month + day int + }{ + {"01-01-1970", 1970, time.January, 1}, + {"+1970-01-01", 1970, time.January, 1}, + {"+01970-01-02", 1970, time.January, 2}, + {" 31/12/1969 ", 1969, time.December, 31}, + {"1969/12/31", 1969, time.December, 31}, + {"1969.12.31", 1969, time.December, 31}, + {"1969-12-31", 1969, time.December, 31}, + {"2000-02-28", 2000, time.February, 28}, + {"+2000-02-29", 2000, time.February, 29}, + {"+02000-03-01", 2000, time.March, 1}, + {"+002004-02-28", 2004, time.February, 28}, + {"2004-02-29", 2004, time.February, 29}, + {"2004-03-01", 2004, time.March, 1}, + {"0000-01-01", 0, time.January, 1}, + {"+0001-02-03", 1, time.February, 3}, + {" +00019-03-04 ", 19, time.March, 4}, + {"0100-04-05", 100, time.April, 5}, + {"2000-05-06", 2000, time.May, 6}, + {"+5000000-08-09", 5000000, time.August, 9}, + {"-0001-09-11", -1, time.September, 11}, + {" -0019-10-12 ", -19, time.October, 12}, + {"-00100-11-13", -100, time.November, 13}, + {"-02000-12-14", -2000, time.December, 14}, + {"-30000-02-15", -30000, time.February, 15}, + {"-0400000-05-16", -400000, time.May, 16}, + {"-5000000-09-17", -5000000, time.September, 17}, + {"12340506", 1234, time.May, 6}, + {"+12340506", 1234, time.May, 6}, + {"-00191012", -19, time.October, 12}, + {" -00191012 ", -19, time.October, 12}, + } + for _, c := range cases { + d := MustAutoParse(c.value) + year, month, day := d.Date() + if year != c.year || month != c.month || day != c.day { + t.Errorf("ParseISO(%v) == %v, want (%v, %v, %v)", c.value, d, c.year, c.month, c.day) + } + } + + badCases := []string{ + "1234-05", + "1234-5-6", + "1234-05-6", + "1234-5-06", + "1234-0A-06", + "1234-05-0B", + "1234-05-06trailing", + "padding1234-05-06", + "1-02-03", + "10-11-12", + "100-02-03", + "+1-02-03", + "+10-11-12", + "+100-02-03", + "-123-05-06", + "--", + "", + " ", + } + for _, c := range badCases { + d, err := AutoParse(c) + if err == nil { + t.Errorf("ParseISO(%v) == %v", c, d) + } + } +} + +func TestParseISO(t *testing.T) { + cases := []struct { + value string + year int + month time.Month + day int + }{ + {"1969-12-31", 1969, time.December, 31}, + {"+1970-01-01", 1970, time.January, 1}, + {"+01970-01-02", 1970, time.January, 2}, + {"2000-02-28", 2000, time.February, 28}, + {"+2000-02-29", 2000, time.February, 29}, + {"+02000-03-01", 2000, time.March, 1}, + {"+002004-02-28", 2004, time.February, 28}, + {"2004-02-29", 2004, time.February, 29}, + {"2004-03-01", 2004, time.March, 1}, + {"0000-01-01", 0, time.January, 1}, + {"+0001-02-03", 1, time.February, 3}, + {"+00019-03-04", 19, time.March, 4}, + {"0100-04-05", 100, time.April, 5}, + {"2000-05-06", 2000, time.May, 6}, + {"+30000-06-07", 30000, time.June, 7}, + {"+400000-07-08", 400000, time.July, 8}, + {"+5000000-08-09", 5000000, time.August, 9}, + {"-0001-09-11", -1, time.September, 11}, + {"-0019-10-12", -19, time.October, 12}, + {"-00100-11-13", -100, time.November, 13}, + {"-02000-12-14", -2000, time.December, 14}, + {"-30000-02-15", -30000, time.February, 15}, + {"-0400000-05-16", -400000, time.May, 16}, + {"-5000000-09-17", -5000000, time.September, 17}, + {"12340506", 1234, time.May, 6}, + {"+12340506", 1234, time.May, 6}, + {"-00191012", -19, time.October, 12}, + } + for _, c := range cases { + d := MustParseISO(c.value) + year, month, day := d.Date() + if year != c.year || month != c.month || day != c.day { + t.Errorf("ParseISO(%v) == %v, want (%v, %v, %v)", c.value, d, c.year, c.month, c.day) + } + } + + badCases := []string{ + "1234-05", + "1234-5-6", + "1234-05-6", + "1234-5-06", + "1234/05/06", + "1234-0A-06", + "1234-05-0B", + "1234-05-06trailing", + "padding1234-05-06", + "1-02-03", + "10-11-12", + "100-02-03", + "+1-02-03", + "+10-11-12", + "+100-02-03", + "-123-05-06", + } + for _, c := range badCases { + d, err := ParseISO(c) + if err == nil { + t.Errorf("ParseISO(%v) == %v", c, d) + } + } +} + +func BenchmarkParseISO(b *testing.B) { + cases := []struct { + layout string + value string + year int + month time.Month + day int + }{ + {ISO8601, "1969-12-31", 1969, time.December, 31}, + {ISO8601, "2000-02-28", 2000, time.February, 28}, + {ISO8601, "2004-02-29", 2004, time.February, 29}, + {ISO8601, "2004-03-01", 2004, time.March, 1}, + {ISO8601, "0000-01-01", 0, time.January, 1}, + {ISO8601, "0001-02-03", 1, time.February, 3}, + {ISO8601, "0100-04-05", 100, time.April, 5}, + {ISO8601, "2000-05-06", 2000, time.May, 6}, + } + for n := 0; n < b.N; n++ { + c := cases[n%len(cases)] + _, err := ParseISO(c.value) + if err != nil { + b.Errorf("ParseISO(%v) == %v", c.value, err) + } + } +} + +func TestParse(t *testing.T) { + // Test ability to parse a few common date formats + cases := []struct { + layout string + value string + year int + month time.Month + day int + }{ + {ISO8601, "1969-12-31", 1969, time.December, 31}, + {ISO8601B, "19700101", 1970, time.January, 1}, + {RFC822, "29-Feb-00", 2000, time.February, 29}, + {RFC822W, "Mon, 01-Mar-04", 2004, time.March, 1}, + {RFC850, "Wednesday, 12-Aug-15", 2015, time.August, 12}, + {RFC1123, "05 Dec 1928", 1928, time.December, 5}, + {RFC1123W, "Mon, 05 Dec 1928", 1928, time.December, 5}, + {RFC3339, "2345-06-07", 2345, time.June, 7}, + {time.RFC3339Nano, "2020-04-01T12:11:10.101+09:00", 2020, time.April, 1}, + {"20060102", "20190619", 2019, time.June, 19}, + } + for _, c := range cases { + d := MustParse(c.layout, c.value) + year, month, day := d.Date() + if year != c.year || month != c.month || day != c.day { + t.Errorf("Parse(%v) == %v, want (%v, %v, %v)", c.value, d, c.year, c.month, c.day) + } + } + + // Test inability to parse ISO 8601 expanded year format + badCases := []string{ + "+1234-05-06", + "+12345-06-07", + "-1234-05-06", + "-12345-06-07", + } + for _, c := range badCases { + d, err := Parse(ISO8601, c) + if err == nil { + t.Errorf("Parse(%v) == %v", c, d) + } + } +} + +func BenchmarkParse(b *testing.B) { + // Test ability to parse a few common date formats + cases := []struct { + layout string + value string + year int + month time.Month + day int + }{ + {ISO8601, "1969-12-31", 1969, time.December, 31}, + {ISO8601, "2000-02-28", 2000, time.February, 28}, + {ISO8601, "2004-02-29", 2004, time.February, 29}, + {ISO8601, "2004-03-01", 2004, time.March, 1}, + {ISO8601, "0000-01-01", 0, time.January, 1}, + {ISO8601, "0001-02-03", 1, time.February, 3}, + {ISO8601, "0100-04-05", 100, time.April, 5}, + {ISO8601, "2000-05-06", 2000, time.May, 6}, + } + for n := 0; n < b.N; n++ { + c := cases[n%len(cases)] + _, err := Parse(c.layout, c.value) + if err != nil { + b.Errorf("Parse(%v) == %v", c.value, err) + } + } +} diff --git a/period/arithmetic.go b/period/arithmetic.go new file mode 100644 index 00000000..464a4130 --- /dev/null +++ b/period/arithmetic.go @@ -0,0 +1,111 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "time" +) + +// Add adds two periods together. Use this method along with Negate in order to subtract periods. +// +// The result is not normalised and may overflow arithmetically (to make this unlikely, use Normalise on +// the inputs before adding them). +func (period Period) Add(that Period) Period { + return Period{ + period.years + that.years, + period.months + that.months, + period.days + that.days, + period.hours + that.hours, + period.minutes + that.minutes, + period.seconds + that.seconds, + } +} + +//------------------------------------------------------------------------------------------------- + +// AddTo adds the period to a time, returning the result. +// A flag is also returned that is true when the conversion was precise and false otherwise. +// +// When the period specifies hours, minutes and seconds only, the result is precise. +// Also, when the period specifies whole years, months and days (i.e. without fractions), the +// result is precise. However, when years, months or days contains fractions, the result +// is only an approximation (it assumes that all days are 24 hours and every year is 365.2425 +// days, as per Gregorian calendar rules). +func (period Period) AddTo(t time.Time) (time.Time, bool) { + wholeYears := (period.years % 10) == 0 + wholeMonths := (period.months % 10) == 0 + wholeDays := (period.days % 10) == 0 + + if wholeYears && wholeMonths && wholeDays { + // in this case, time.AddDate(...).Add(...) provides an exact solution + stE3 := totalSecondsE3(period) + if period.years == 0 && period.months == 0 && period.days == 0 { + // AddDate (below) normalises its result, so we don't call it unless needed + return t.Add(stE3 * time.Millisecond), true + } + + t1 := t.AddDate(int(period.years/10), int(period.months/10), int(period.days/10)) + return t1.Add(stE3 * time.Millisecond), true + } + + d, precise := period.Duration() + return t.Add(d), precise +} + +//------------------------------------------------------------------------------------------------- + +// Scale a period by a multiplication factor. Obviously, this can both enlarge and shrink it, +// and change the sign if negative. The result is normalised, but integer overflows are silently +// ignored. +// +// Bear in mind that the internal representation is limited by fixed-point arithmetic with two +// decimal places; each field is only int16. +// +// Known issue: scaling by a large reduction factor (i.e. much less than one) doesn't work properly. +func (period Period) Scale(factor float32) Period { + result, _ := period.ScaleWithOverflowCheck(factor) + return result +} + +// ScaleWithOverflowCheck a period by a multiplication factor. Obviously, this can both enlarge and shrink it, +// and change the sign if negative. The result is normalised. An error is returned if integer overflow +// happened. +// +// Bear in mind that the internal representation is limited by fixed-point arithmetic with one +// decimal place; each field is only int16. +// +// Known issue: scaling by a large reduction factor (i.e. much less than one) doesn't work properly. +func (period Period) ScaleWithOverflowCheck(factor float32) (Period, error) { + ap, neg := period.absNeg() + + if -0.5 < factor && factor < 0.5 { + d, pr1 := ap.Duration() + mul := float64(d) * float64(factor) + p2, pr2 := NewOf(time.Duration(mul)) + return p2.Normalise(pr1 && pr2), nil + } + + y := int64(float32(ap.years) * factor) + m := int64(float32(ap.months) * factor) + d := int64(float32(ap.days) * factor) + hh := int64(float32(ap.hours) * factor) + mm := int64(float32(ap.minutes) * factor) + ss := int64(float32(ap.seconds) * factor) + + p64 := &period64{years: y, months: m, days: d, hours: hh, minutes: mm, seconds: ss, neg: neg} + return p64.normalise64(true).toPeriod() +} + +// RationalScale scales a period by a rational multiplication factor. Obviously, this can both enlarge and shrink it, +// and change the sign if negative. The result is normalised. An error is returned if integer overflow +// happened. +// +// If the divisor is zero, a panic will arise. +// +// Bear in mind that the internal representation is limited by fixed-point arithmetic with two +// decimal places; each field is only int16. +//func (period Period) RationalScale(multiplier, divisor int) (Period, error) { +// return period.rationalScale64(int64(multiplier), int64(divisor)) +//} diff --git a/period/arithmetic_test.go b/period/arithmetic_test.go new file mode 100644 index 00000000..cc9d5620 --- /dev/null +++ b/period/arithmetic_test.go @@ -0,0 +1,211 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "fmt" + "testing" + "time" + + . "github.com/onsi/gomega" +) + +func TestPeriodScale(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + one string + m float32 + expect string + }{ + {"P0D", 2, "P0D"}, + {"P1D", 2, "P2D"}, + {"P1D", 0, "P0D"}, + {"P1D", 365, "P365D"}, + {"P1M", 2, "P2M"}, + {"P1M", 12, "P1Y"}, + //TODO {"P1Y3M", 1.0/15, "P1M"}, + {"P1Y", 2, "P2Y"}, + {"PT1H", 2, "PT2H"}, + {"PT1M", 2, "PT2M"}, + {"PT1S", 2, "PT2S"}, + {"P1D", 0.5, "P0.5D"}, + {"P1M", 0.5, "P0.5M"}, + {"P1Y", 0.5, "P0.5Y"}, + {"PT1H", 0.5, "PT0.5H"}, + {"PT1H", 0.1, "PT6M"}, + //TODO {"PT1H", 0.01, "PT36S"}, + {"PT1M", 0.5, "PT0.5M"}, + {"PT1S", 0.5, "PT0.5S"}, + {"PT1H", 1.0 / 3600, "PT1S"}, + {"P1Y2M3DT4H5M6S", 2, "P2Y4M6DT8H10M12S"}, + {"P2Y4M6DT8H10M12S", -0.5, "-P1Y2M3DT4H5M6S"}, + {"-P2Y4M6DT8H10M12S", 0.5, "-P1Y2M3DT4H5M6S"}, + {"-P2Y4M6DT8H10M12S", -0.5, "P1Y2M3DT4H5M6S"}, + {"PT1M", 60, "PT1H"}, + {"PT1S", 60, "PT1M"}, + {"PT1S", 86400, "PT24H"}, + {"PT1S", 86400000, "P1000D"}, + {"P365.5D", 10, "P10Y2.5D"}, + //{"P365.5D", 0.1, "P36DT12H"}, + } + for i, c := range cases { + s := MustParse(c.one, false).Scale(c.m) + g.Expect(s).To(Equal(MustParse(c.expect, false)), info(i, c.expect)) + } +} + +func TestPeriodAdd(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + one, two string + expect string + }{ + {"P0D", "P0D", "P0D"}, + {"P1D", "P1D", "P2D"}, + {"P1M", "P1M", "P2M"}, + {"P1Y", "P1Y", "P2Y"}, + {"PT1H", "PT1H", "PT2H"}, + {"PT1M", "PT1M", "PT2M"}, + {"PT1S", "PT1S", "PT2S"}, + {"P1Y2M3DT4H5M6S", "P6Y5M4DT3H2M1S", "P7Y7M7DT7H7M7S"}, + {"P7Y7M7DT7H7M7S", "-P7Y7M7DT7H7M7S", "P0D"}, + } + for i, c := range cases { + s := MustParse(c.one, false).Add(MustParse(c.two, false)) + expectValid(t, s, info(i, c.expect)) + g.Expect(s).To(Equal(MustParse(c.expect, false)), info(i, c.expect)) + } +} + +func TestPeriodAddToTime(t *testing.T) { + g := NewGomegaWithT(t) + + const ms = 1000000 + const sec = 1000 * ms + const min = 60 * sec + const hr = 60 * min + + est, err := time.LoadLocation("America/New_York") + g.Expect(err).NotTo(HaveOccurred()) + + times := []time.Time{ + // A conveniently round number but with non-zero nanoseconds (14 July 2017 @ 2:40am UTC) + time.Unix(1500000000, 1).UTC(), + // This specific time fails for EST due behaviour of Time.AddDate + time.Date(2020, 11, 1, 1, 0, 0, 0, est), + } + + for _, t0 := range times { + cases := []struct { + value string + result time.Time + precise bool + }{ + // precise cases + {"P0D", t0, true}, + {"PT1S", t0.Add(sec), true}, + {"PT0.1S", t0.Add(100 * ms), true}, + {"-PT0.1S", t0.Add(-100 * ms), true}, + {"PT3276S", t0.Add(3276 * sec), true}, + {"PT1M", t0.Add(60 * sec), true}, + {"PT0.1M", t0.Add(6 * sec), true}, + {"PT3276M", t0.Add(3276 * min), true}, + {"PT1H", t0.Add(hr), true}, + {"PT0.1H", t0.Add(6 * min), true}, + {"PT3276H", t0.Add(3276 * hr), true}, + {"P1D", t0.AddDate(0, 0, 1), true}, + {"P3276D", t0.AddDate(0, 0, 3276), true}, + {"P1M", t0.AddDate(0, 1, 0), true}, + {"P3276M", t0.AddDate(0, 3276, 0), true}, + {"P1Y", t0.AddDate(1, 0, 0), true}, + {"-P1Y", t0.AddDate(-1, 0, 0), true}, + {"P3276Y", t0.AddDate(3276, 0, 0), true}, // near the upper limit of range + {"-P3276Y", t0.AddDate(-3276, 0, 0), true}, // near the lower limit of range + // approximate cases + {"P0.1D", t0.Add(144 * min), false}, + {"-P0.1D", t0.Add(-144 * min), false}, + {"P0.1M", t0.Add(oneMonthApprox / 10), false}, + {"P0.1Y", t0.Add(oneYearApprox / 10), false}, + } + for i, c := range cases { + p, err := ParseWithNormalise(c.value, false) + g.Expect(err).NotTo(HaveOccurred()) + + t1, prec := p.AddTo(t0) + g.Expect(t1).To(Equal(c.result), info(i, c.value, t0)) + g.Expect(prec).To(Equal(c.precise), info(i, c.value, t0)) + } + } +} + +func expectValid(t *testing.T, period Period, hint interface{}) Period { + t.Helper() + g := NewGomegaWithT(t) + info := fmt.Sprintf("%v: invalid: %#v", hint, period) + + // check all the signs are consistent + nPoz := pos(period.years) + pos(period.months) + pos(period.days) + pos(period.hours) + pos(period.minutes) + pos(period.seconds) + nNeg := neg(period.years) + neg(period.months) + neg(period.days) + neg(period.hours) + neg(period.minutes) + neg(period.seconds) + g.Expect(nPoz == 0 || nNeg == 0).To(BeTrue(), info+" inconsistent signs") + + // only one field must have a fraction + yearsFraction := fraction(period.years) + monthsFraction := fraction(period.months) + daysFraction := fraction(period.days) + hoursFraction := fraction(period.hours) + minutesFraction := fraction(period.minutes) + + if yearsFraction != 0 { + g.Expect(period.months).To(BeZero(), info+" year fraction exists") + g.Expect(period.days).To(BeZero(), info+" year fraction exists") + g.Expect(period.hours).To(BeZero(), info+" year fraction exists") + g.Expect(period.minutes).To(BeZero(), info+" year fraction exists") + g.Expect(period.seconds).To(BeZero(), info+" year fraction exists") + } + + if monthsFraction != 0 { + g.Expect(period.days).To(BeZero(), info+" month fraction exists") + g.Expect(period.hours).To(BeZero(), info+" month fraction exists") + g.Expect(period.minutes).To(BeZero(), info+" month fraction exists") + g.Expect(period.seconds).To(BeZero(), info+" month fraction exists") + } + + if daysFraction != 0 { + g.Expect(period.hours).To(BeZero(), info+" day fraction exists") + g.Expect(period.minutes).To(BeZero(), info+" day fraction exists") + g.Expect(period.seconds).To(BeZero(), info+" day fraction exists") + } + + if hoursFraction != 0 { + g.Expect(period.minutes).To(BeZero(), info+" hour fraction exists") + g.Expect(period.seconds).To(BeZero(), info+" hour fraction exists") + } + + if minutesFraction != 0 { + g.Expect(period.seconds).To(BeZero(), info+" minute fraction exists") + } + + return period +} + +func fraction(i int16) int { + return int(i) % 10 +} + +func pos(i int16) int { + if i > 0 { + return 1 + } + return 0 +} + +func neg(i int16) int { + if i < 0 { + return 1 + } + return 0 +} diff --git a/period/designator.go b/period/designator.go new file mode 100644 index 00000000..6d836ec4 --- /dev/null +++ b/period/designator.go @@ -0,0 +1,55 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +type ymdDesignator byte +type hmsDesignator byte + +const ( + Year ymdDesignator = 'Y' + Month ymdDesignator = 'M' + Week ymdDesignator = 'W' + Day ymdDesignator = 'D' + + Hour hmsDesignator = 'H' + Minute hmsDesignator = 'M' + Second hmsDesignator = 'S' +) + +func (d ymdDesignator) IsOneOf(xx ...ymdDesignator) bool { + for _, x := range xx { + if x == d { + return true + } + } + return false +} + +func (d ymdDesignator) IsNotOneOf(xx ...ymdDesignator) bool { + for _, x := range xx { + if x == d { + return false + } + } + return true +} + +func (d hmsDesignator) IsOneOf(xx ...hmsDesignator) bool { + for _, x := range xx { + if x == d { + return true + } + } + return false +} + +func (d hmsDesignator) IsNotOneOf(xx ...hmsDesignator) bool { + for _, x := range xx { + if x == d { + return false + } + } + return true +} diff --git a/period/doc.go b/period/doc.go new file mode 100644 index 00000000..5abe8447 --- /dev/null +++ b/period/doc.go @@ -0,0 +1,46 @@ +// Copyright 2016 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package period provides functionality for periods of time using ISO-8601 conventions. +// This deals with years, months, weeks/days, hours, minutes and seconds. +// +// *** Warning: this package is the subject of many issues, so a replacement is under +// development. Please see https://github.com/rickb777/period. +// +// Because of the vagaries of calendar systems, the meaning of year lengths, month lengths +// and even day lengths depends on context. So a period is not necessarily a fixed duration +// of time in terms of seconds. +// +// See https://en.wikipedia.org/wiki/ISO_8601#Durations +// +// Example representations: +// +// * "P2Y" is two years; +// +// * "P6M" is six months; +// +// * "P4D" is four days; +// +// * "P1W" is one week (seven days); +// +// * "PT3H" is three hours. +// +// * "PT20M" is twenty minutes. +// +// * "PT30S" is thirty seconds. +// +// These can be combined, for example: +// +// * "P3Y6M4W1D" is three years, 6 months, 4 weeks and one day. +// +// * "P2DT12H" is 2 days and 12 hours. +// +// Also, decimal fractions are supported to one decimal place. To comply with +// the standard, only the last non-zero component is allowed to have a fraction. +// For example +// +// * "P2.5Y" is 2.5 years. +// +// * "PT12M7.5S" is 12 minutes and 7.5 seconds. +package period diff --git a/period/flag.go b/period/flag.go new file mode 100644 index 00000000..9488efa7 --- /dev/null +++ b/period/flag.go @@ -0,0 +1,16 @@ +package period + +// Set enables use of Period by the flag API. +func (period *Period) Set(p string) error { + p2, err := Parse(p) + if err != nil { + return err + } + *period = p2 + return nil +} + +// Type is for compatibility with the spf13/pflag library. +func (period Period) Type() string { + return "period" +} diff --git a/period/format.go b/period/format.go new file mode 100644 index 00000000..3b095292 --- /dev/null +++ b/period/format.go @@ -0,0 +1,139 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "fmt" + "io" + "strings" + + "github.com/rickb777/plural" +) + +// Format converts the period to human-readable form using the default localisation. +// Multiples of 7 days are shown as weeks. +func (period Period) Format() string { + return period.FormatWithPeriodNames(PeriodYearNames, PeriodMonthNames, PeriodWeekNames, PeriodDayNames, PeriodHourNames, PeriodMinuteNames, PeriodSecondNames) +} + +// FormatWithoutWeeks converts the period to human-readable form using the default localisation. +// Multiples of 7 days are not shown as weeks. +func (period Period) FormatWithoutWeeks() string { + return period.FormatWithPeriodNames(PeriodYearNames, PeriodMonthNames, plural.Plurals{}, PeriodDayNames, PeriodHourNames, PeriodMinuteNames, PeriodSecondNames) +} + +// FormatWithPeriodNames converts the period to human-readable form in a localisable way. +func (period Period) FormatWithPeriodNames(yearNames, monthNames, weekNames, dayNames, hourNames, minNames, secNames plural.Plurals) string { + period = period.Abs() + + parts := make([]string, 0) + parts = appendNonBlank(parts, yearNames.FormatFloat(float10(period.years))) + parts = appendNonBlank(parts, monthNames.FormatFloat(float10(period.months))) + + if period.days > 0 || (period.IsZero()) { + if len(weekNames) > 0 { + weeks := period.days / 70 + mdays := period.days % 70 + //fmt.Printf("%v %#v - %d %d\n", period, period, weeks, mdays) + if weeks > 0 { + parts = appendNonBlank(parts, weekNames.FormatInt(int(weeks))) + } + if mdays > 0 || weeks == 0 { + parts = appendNonBlank(parts, dayNames.FormatFloat(float10(mdays))) + } + } else { + parts = appendNonBlank(parts, dayNames.FormatFloat(float10(period.days))) + } + } + parts = appendNonBlank(parts, hourNames.FormatFloat(float10(period.hours))) + parts = appendNonBlank(parts, minNames.FormatFloat(float10(period.minutes))) + parts = appendNonBlank(parts, secNames.FormatFloat(float10(period.seconds))) + + return strings.Join(parts, ", ") +} + +func appendNonBlank(parts []string, s string) []string { + if s == "" { + return parts + } + return append(parts, s) +} + +// PeriodDayNames provides the English default format names for the days part of the period. +// This is a sequence of plurals where the first match is used, otherwise the last one is used. +// The last one must include a "%v" placeholder for the number. +var PeriodDayNames = plural.FromZero("%v days", "%v day", "%v days") + +// PeriodWeekNames is as for PeriodDayNames but for weeks. +var PeriodWeekNames = plural.FromZero("", "%v week", "%v weeks") + +// PeriodMonthNames is as for PeriodDayNames but for months. +var PeriodMonthNames = plural.FromZero("", "%v month", "%v months") + +// PeriodYearNames is as for PeriodDayNames but for years. +var PeriodYearNames = plural.FromZero("", "%v year", "%v years") + +// PeriodHourNames is as for PeriodDayNames but for hours. +var PeriodHourNames = plural.FromZero("", "%v hour", "%v hours") + +// PeriodMinuteNames is as for PeriodDayNames but for minutes. +var PeriodMinuteNames = plural.FromZero("", "%v minute", "%v minutes") + +// PeriodSecondNames is as for PeriodDayNames but for seconds. +var PeriodSecondNames = plural.FromZero("", "%v second", "%v seconds") + +// String converts the period to ISO-8601 form. +func (period Period) String() string { + return period.toPeriod64("").String() +} + +func (p64 period64) String() string { + if p64 == (period64{}) { + return "P0D" + } + + buf := &strings.Builder{} + if p64.neg { + buf.WriteByte('-') + } + + buf.WriteByte('P') + + writeField64(buf, p64.years, byte(Year)) + writeField64(buf, p64.months, byte(Month)) + + if p64.days != 0 { + if p64.days%70 == 0 { + writeField64(buf, p64.days/7, byte(Week)) + } else { + writeField64(buf, p64.days, byte(Day)) + } + } + + if p64.hours != 0 || p64.minutes != 0 || p64.seconds != 0 { + buf.WriteByte('T') + } + + writeField64(buf, p64.hours, byte(Hour)) + writeField64(buf, p64.minutes, byte(Minute)) + writeField64(buf, p64.seconds, byte(Second)) + + return buf.String() +} + +func writeField64(w io.Writer, field int64, designator byte) { + if field != 0 { + if field%10 != 0 { + fmt.Fprintf(w, "%g", float32(field)/10) + } else { + fmt.Fprintf(w, "%d", field/10) + } + w.(io.ByteWriter).WriteByte(designator) + } +} + +func float10(v int16) float32 { + return float32(v) / 10 +} diff --git a/period/marshal.go b/period/marshal.go new file mode 100644 index 00000000..6e1f2f1c --- /dev/null +++ b/period/marshal.go @@ -0,0 +1,34 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +// MarshalBinary implements the encoding.BinaryMarshaler interface. +// This also provides support for gob encoding. +func (period Period) MarshalBinary() ([]byte, error) { + // binary method would take more space in many cases, so we simply use text + return period.MarshalText() +} + +// UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. +// This also provides support for gob decoding. +func (period *Period) UnmarshalBinary(data []byte) error { + return period.UnmarshalText(data) +} + +// MarshalText implements the encoding.TextMarshaler interface for Periods. +// This also provides support for JSON encoding. +func (period Period) MarshalText() ([]byte, error) { + return []byte(period.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for Periods. +// This also provides support for JSON decoding. +func (period *Period) UnmarshalText(data []byte) (err error) { + u, err := Parse(string(data), false) + if err == nil { + *period = u + } + return err +} diff --git a/period/marshal_test.go b/period/marshal_test.go new file mode 100644 index 00000000..191539a3 --- /dev/null +++ b/period/marshal_test.go @@ -0,0 +1,127 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "testing" + + . "github.com/onsi/gomega" +) + +func TestGobEncoding(t *testing.T) { + g := NewGomegaWithT(t) + + var b bytes.Buffer + encoder := gob.NewEncoder(&b) + decoder := gob.NewDecoder(&b) + cases := []string{ + "P0D", + "P1D", + "P1W", + "P1M", + "P1Y", + "PT1H", + "PT1M", + "PT1S", + "P2Y3M4W5D", + "-P2Y3M4W5D", + "P2Y3M4W5DT1H7M9S", + "-P2Y3M4W5DT1H7M9S", + "P48M", + } + for i, c := range cases { + period := MustParse(c, false) + var p Period + err := encoder.Encode(&period) + g.Expect(err).NotTo(HaveOccurred(), info(i, c)) + if err == nil { + err = decoder.Decode(&p) + g.Expect(err).NotTo(HaveOccurred(), info(i, c)) + g.Expect(p).To(Equal(period), info(i, c)) + } + } +} + +func TestPeriodJSONMarshalling(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value Period + want string + }{ + {New(-1111, -4, -3, -11, -59, -59), `"-P1111Y4M3DT11H59M59S"`}, + {New(-1, -10, -31, -5, -4, -20), `"-P1Y10M31DT5H4M20S"`}, + {New(0, 0, 0, 0, 0, 0), `"P0D"`}, + {New(0, 0, 0, 0, 0, 1), `"PT1S"`}, + {New(0, 0, 0, 0, 1, 0), `"PT1M"`}, + {New(0, 0, 0, 1, 0, 0), `"PT1H"`}, + {New(0, 0, 1, 0, 0, 0), `"P1D"`}, + {New(0, 1, 0, 0, 0, 0), `"P1M"`}, + {New(1, 0, 0, 0, 0, 0), `"P1Y"`}, + } + for i, c := range cases { + var p Period + bb, err := json.Marshal(c.value) + g.Expect(err).NotTo(HaveOccurred(), info(i, c)) + g.Expect(string(bb)).To(Equal(c.want), info(i, c)) + if string(bb) == c.want { + err = json.Unmarshal(bb, &p) + g.Expect(err).NotTo(HaveOccurred(), info(i, c)) + g.Expect(p).To(Equal(c.value), info(i, c)) + } + } +} + +func TestPeriodTextMarshalling(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value Period + want string + }{ + {New(-1111, -4, -3, -11, -59, -59), "-P1111Y4M3DT11H59M59S"}, + {New(-1, -9, -31, -5, -4, -20), "-P1Y9M31DT5H4M20S"}, + {New(0, 0, 0, 0, 0, 0), "P0D"}, + {New(0, 0, 0, 0, 0, 1), "PT1S"}, + {New(0, 0, 0, 0, 1, 0), "PT1M"}, + {New(0, 0, 0, 1, 0, 0), "PT1H"}, + {New(0, 0, 1, 0, 0, 0), "P1D"}, + {New(0, 1, 0, 0, 0, 0), "P1M"}, + {New(1, 0, 0, 0, 0, 0), "P1Y"}, + } + for i, c := range cases { + var p Period + bb, err := c.value.MarshalText() + g.Expect(err).NotTo(HaveOccurred(), info(i, c)) + g.Expect(string(bb)).To(Equal(c.want), info(i, c)) + if string(bb) == c.want { + err = p.UnmarshalText(bb) + g.Expect(err).NotTo(HaveOccurred(), info(i, c)) + g.Expect(p).To(Equal(c.value), info(i, c)) + } + } +} + +func TestInvalidPeriodText(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + want string + }{ + {``, `cannot parse a blank string as a period`}, + {`not-a-period`, `not-a-period: expected 'P' period mark at the start`}, + {`P000`, `P000: missing designator at the end`}, + } + for i, c := range cases { + var p Period + err := p.UnmarshalText([]byte(c.value)) + g.Expect(err).To(HaveOccurred(), info(i, c)) + g.Expect(err.Error()).To(Equal(c.want), info(i, c)) + } +} diff --git a/period/parse.go b/period/parse.go new file mode 100644 index 00000000..d88f1e7c --- /dev/null +++ b/period/parse.go @@ -0,0 +1,239 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "fmt" + "strconv" + "strings" +) + +// MustParse is as per Parse except that it panics if the string cannot be parsed. +// This is intended for setup code; don't use it for user inputs. +// By default, the value is normalised. +// Normalisation can be disabled using the optional flag. +func MustParse(value string, normalise ...bool) Period { + d, err := Parse(value, normalise...) + if err != nil { + panic(err) + } + return d +} + +// Parse parses strings that specify periods using ISO-8601 rules. +// +// In addition, a plus or minus sign can precede the period, e.g. "-P10D" +// +// By default, the value is normalised, e.g. multiple of 12 months become years +// so "P24M" is the same as "P2Y". However, this is done without loss of precision, +// so for example whole numbers of days do not contribute to the months tally +// because the number of days per month is variable. +// +// Normalisation can be disabled using the optional flag. +// +// The zero value can be represented in several ways: all of the following +// are equivalent: "P0Y", "P0M", "P0W", "P0D", "PT0H", PT0M", PT0S", and "P0". +// The canonical zero is "P0D". +func Parse(period string, normalise ...bool) (Period, error) { + return ParseWithNormalise(period, len(normalise) == 0 || normalise[0]) +} + +// ParseWithNormalise parses strings that specify periods using ISO-8601 rules +// with an option to specify whether to normalise parsed period components. +// +// This method is deprecated and should not be used. It may be removed in a +// future version. +func ParseWithNormalise(period string, normalise bool) (Period, error) { + if period == "" || period == "-" || period == "+" { + return Period{}, fmt.Errorf("cannot parse a blank string as a period") + } + + if period == "P0" { + return Period{}, nil + } + + p64, err := parse(period, normalise) + if err != nil { + return Period{}, err + } + return p64.toPeriod() +} + +func parse(period string, normalise bool) (*period64, error) { + neg := false + remaining := period + if remaining[0] == '-' { + neg = true + remaining = remaining[1:] + } else if remaining[0] == '+' { + remaining = remaining[1:] + } + + if remaining[0] != 'P' { + return nil, fmt.Errorf("%s: expected 'P' period mark at the start", period) + } + remaining = remaining[1:] + + var integer, fraction, weekValue int64 + result := &period64{input: period, neg: neg} + var years, months, weeks, days, hours, minutes, seconds itemState + var designator, previousFractionDesignator byte + var err error + nComponents := 0 + + years, months, weeks, days = Armed, Armed, Armed, Armed + + isHMS := false + for len(remaining) > 0 { + if remaining[0] == 'T' { + if isHMS { + return nil, fmt.Errorf("%s: 'T' designator cannot occur more than once", period) + } + isHMS = true + + years, months, weeks, days = Unready, Unready, Unready, Unready + hours, minutes, seconds = Armed, Armed, Armed + + remaining = remaining[1:] + + } else { + integer, fraction, designator, remaining, err = parseNextField(remaining, period) + if err != nil { + return nil, err + } + + if previousFractionDesignator != 0 && fraction != 0 { + return nil, fmt.Errorf("%s: '%c' & '%c' only the last field can have a fraction", period, previousFractionDesignator, designator) + } + + number := integer*10 + fraction + + switch designator { + case 'Y': + years, err = years.testAndSet(number, 'Y', result, &result.years) + case 'W': + weeks, err = weeks.testAndSet(number, 'W', result, &weekValue) + case 'D': + days, err = days.testAndSet(number, 'D', result, &result.days) + case 'H': + hours, err = hours.testAndSet(number, 'H', result, &result.hours) + case 'S': + seconds, err = seconds.testAndSet(number, 'S', result, &result.seconds) + case 'M': + if isHMS { + minutes, err = minutes.testAndSet(number, 'M', result, &result.minutes) + } else { + months, err = months.testAndSet(number, 'M', result, &result.months) + } + default: + return nil, fmt.Errorf("%s: expected a number not '%c'", period, designator) + } + nComponents++ + + if err != nil { + return nil, err + } + + if fraction != 0 { + previousFractionDesignator = designator + } + } + } + + if nComponents == 0 { + return nil, fmt.Errorf("%s: expected 'Y', 'M', 'W', 'D', 'H', 'M', or 'S' designator", period) + } + + result.days += weekValue * 7 + + if normalise { + result = result.normalise64(true) + } + + return result, nil +} + +//------------------------------------------------------------------------------------------------- + +type itemState int + +const ( + Unready itemState = iota + Armed + Set +) + +func (i itemState) testAndSet(number int64, designator byte, result *period64, value *int64) (itemState, error) { + switch i { + case Unready: + return i, fmt.Errorf("%s: '%c' designator cannot occur here", result.input, designator) + case Set: + return i, fmt.Errorf("%s: '%c' designator cannot occur more than once", result.input, designator) + } + + *value = number + return Set, nil +} + +//------------------------------------------------------------------------------------------------- + +func parseNextField(str, original string) (int64, int64, byte, string, error) { + i := scanDigits(str) + if i < 0 { + return 0, 0, 0, "", fmt.Errorf("%s: missing designator at the end", original) + } + + des := str[i] + integer, fraction, err := parseDecimalNumber(str[:i], original, des) + return integer, fraction, des, str[i+1:], err +} + +// Fixed-point one decimal place +func parseDecimalNumber(number, original string, des byte) (int64, int64, error) { + dec := strings.IndexByte(number, '.') + if dec < 0 { + dec = strings.IndexByte(number, ',') + } + + var integer, fraction int64 + var err error + if dec >= 0 { + integer, err = strconv.ParseInt(number[:dec], 10, 64) + if err == nil { + number = number[dec+1:] + switch len(number) { + case 0: // skip + case 1: + fraction, err = strconv.ParseInt(number, 10, 64) + default: + fraction, err = strconv.ParseInt(number[:1], 10, 64) + } + } + } else { + integer, err = strconv.ParseInt(number, 10, 64) + } + + if err != nil { + return 0, 0, fmt.Errorf("%s: expected a number but found '%c'", original, des) + } + + return integer, fraction, err +} + +// scanDigits finds the first non-digit byte after a given starting point. +// Note that it does not care about runes or UTF-8 encoding; it assumes that +// a period string is always valid ASCII as well as UTF-8. +func scanDigits(s string) int { + for i, c := range s { + if !isDigit(c) { + return i + } + } + return -1 +} + +func isDigit(c rune) bool { + return ('0' <= c && c <= '9') || c == '.' || c == ',' +} diff --git a/period/period.go b/period/period.go new file mode 100644 index 00000000..082466f2 --- /dev/null +++ b/period/period.go @@ -0,0 +1,617 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "fmt" + "time" +) + +const daysPerYearE4 = 3652425 // 365.2425 days by the Gregorian rule +const daysPerMonthE4 = 304369 // 30.4369 days per month +const daysPerMonthE6 = 30436875 // 30.436875 days per month + +const oneE4 = 10000 +const oneE5 = 100000 +const oneE6 = 1000000 +const oneE7 = 10000000 + +const hundredMs = 100 * time.Millisecond + +// reminder: int64 overflow is after 9,223,372,036,854,775,807 (math.MaxInt64) + +// Period holds a period of time and provides conversion to/from ISO-8601 representations. +// Therefore there are six fields: years, months, days, hours, minutes, and seconds. +// +// In the ISO representation, decimal fractions are supported, although only the last non-zero +// component is allowed to have a fraction according to the Standard. For example "P2.5Y" +// is 2.5 years. +// +// However, in this implementation, the precision is limited to one decimal place only, by +// means of integers with fixed point arithmetic. (This avoids using float32 in the struct, +// so there are no problems testing equality using ==.) +// +// The implementation limits the range of possible values to ± 2^16 / 10 in each field. +// Note in particular that the range of years is limited to approximately ± 3276. +// +// The concept of weeks exists in string representations of periods, but otherwise weeks +// are unimportant. The period contains a number of days from which the number of weeks can +// be calculated when needed. +// +// Note that although fractional weeks can be parsed, they will never be returned via String(). +// This is because the number of weeks is always inferred from the number of days. +type Period struct { + years, months, days, hours, minutes, seconds int16 +} + +// NewYMD creates a simple period without any fractional parts. The fields are initialised verbatim +// without any normalisation; e.g. 12 months will not become 1 year. Use the Normalise method if you +// need to. +// +// All the parameters must have the same sign (otherwise a panic occurs). +// Because this implementation uses int16 internally, the paramters must +// be within the range ± 2^16 / 10. +func NewYMD(years, months, days int) Period { + return New(years, months, days, 0, 0, 0) +} + +// NewHMS creates a simple period without any fractional parts. The fields are initialised verbatim +// without any normalisation; e.g. 120 seconds will not become 2 minutes. Use the Normalise method +// if you need to. +// +// All the parameters must have the same sign (otherwise a panic occurs). +// Because this implementation uses int16 internally, the paramters must +// be within the range ± 2^16 / 10. +func NewHMS(hours, minutes, seconds int) Period { + return New(0, 0, 0, hours, minutes, seconds) +} + +// New creates a simple period without any fractional parts. The fields are initialised verbatim +// without any normalisation; e.g. 120 seconds will not become 2 minutes. Use the Normalise method +// if you need to. +// +// All the parameters must have the same sign (otherwise a panic occurs). +func New(years, months, days, hours, minutes, seconds int) Period { + if (years >= 0 && months >= 0 && days >= 0 && hours >= 0 && minutes >= 0 && seconds >= 0) || + (years <= 0 && months <= 0 && days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0) { + return Period{ + int16(years) * 10, int16(months) * 10, int16(days) * 10, + int16(hours) * 10, int16(minutes) * 10, int16(seconds) * 10, + } + } + panic(fmt.Sprintf("Periods must have homogeneous signs; got P%dY%dM%dDT%dH%dM%dS", + years, months, days, hours, minutes, seconds)) +} + +// NewOf converts a time duration to a Period, and also indicates whether the conversion is precise. +// Any time duration that spans more than ± 3276 hours will be approximated by assuming that there +// are 24 hours per day, 365.2425 days per year (as per Gregorian calendar rules), and a month +// being 1/12 of that (approximately 30.4369 days). +// +// The result is not always fully normalised; for time differences less than 3276 hours (about 4.5 months), +// it will contain zero in the years, months and days fields but the number of days may be up to 3275; this +// reduces errors arising from the variable lengths of months. For larger time differences, greater than +// 3276 hours, the days, months and years fields are used as well. +func NewOf(duration time.Duration) (p Period, precise bool) { + var sign int16 = 1 + d := duration + if duration < 0 { + sign = -1 + d = -duration + } + + sign10 := sign * 10 + + totalHours := int64(d / time.Hour) + + // check for 16-bit overflow - occurs near the 4.5 month mark + if totalHours < 3277 { + // simple HMS case + minutes := d % time.Hour / time.Minute + seconds := d % time.Minute / hundredMs + return Period{0, 0, 0, sign10 * int16(totalHours), sign10 * int16(minutes), sign * int16(seconds)}, true + } + + totalDays := totalHours / 24 // ignoring daylight savings adjustments + + if totalDays < 3277 { + hours := totalHours - totalDays*24 + minutes := d % time.Hour / time.Minute + seconds := d % time.Minute / hundredMs + return Period{0, 0, sign10 * int16(totalDays), sign10 * int16(hours), sign10 * int16(minutes), sign * int16(seconds)}, false + } + + // TODO it is uncertain whether this is too imprecise and should be improved + years := (oneE4 * totalDays) / daysPerYearE4 + months := ((oneE4 * totalDays) / daysPerMonthE4) - (12 * years) + hours := totalHours - totalDays*24 + totalDays = ((totalDays * oneE4) - (daysPerMonthE4 * months) - (daysPerYearE4 * years)) / oneE4 + return Period{sign10 * int16(years), sign10 * int16(months), sign10 * int16(totalDays), sign10 * int16(hours), 0, 0}, false +} + +// Between converts the span between two times to a period. Based on the Gregorian conversion +// algorithms of `time.Time`, the resultant period is precise. +// +// To improve precision, result is not always fully normalised; for time differences less than 3276 hours +// (about 4.5 months), it will contain zero in the years, months and days fields but the number of hours +// may be up to 3275; this reduces errors arising from the variable lengths of months. For larger time +// differences (greater than 3276 hours) the days, months and years fields are used as well. +// +// Remember that the resultant period does not retain any knowledge of the calendar, so any subsequent +// computations applied to the period can only be precise if they concern either the date (year, month, +// day) part, or the clock (hour, minute, second) part, but not both. +func Between(t1, t2 time.Time) (p Period) { + sign := 1 + if t2.Before(t1) { + t1, t2, sign = t2, t1, -1 + } + + if t1.Location() != t2.Location() { + t2 = t2.In(t1.Location()) + } + + year, month, day, hour, min, sec, tenth := daysDiff(t1, t2) + + if sign < 0 { + p = New(-year, -month, -day, -hour, -min, -sec) + p.seconds -= int16(tenth) + } else { + p = New(year, month, day, hour, min, sec) + p.seconds += int16(tenth) + } + return +} + +func daysDiff(t1, t2 time.Time) (year, month, day, hour, min, sec, tenth int) { + duration := t2.Sub(t1) + + hh1, mm1, ss1 := t1.Clock() + hh2, mm2, ss2 := t2.Clock() + + day = int(duration / (24 * time.Hour)) + + hour = hh2 - hh1 + min = mm2 - mm1 + sec = ss2 - ss1 + tenth = (t2.Nanosecond() - t1.Nanosecond()) / 100000000 + + // Normalize negative values + if sec < 0 { + sec += 60 + min-- + } + + if min < 0 { + min += 60 + hour-- + } + + if hour < 0 { + hour += 24 + // no need to reduce day - it's calculated differently. + } + + // test 16bit storage limit (with 1 fixed decimal place) + if day > 3276 { + y1, m1, d1 := t1.Date() + y2, m2, d2 := t2.Date() + year = y2 - y1 + month = int(m2 - m1) + day = d2 - d1 + } + + return +} + +// IsZero returns true if applied to a zero-length period. +func (period Period) IsZero() bool { + return period == Period{} +} + +// IsPositive returns true if any field is greater than zero. By design, this also implies that +// all the other fields are greater than or equal to zero. +func (period Period) IsPositive() bool { + return period.years > 0 || period.months > 0 || period.days > 0 || + period.hours > 0 || period.minutes > 0 || period.seconds > 0 +} + +// IsNegative returns true if any field is negative. By design, this also implies that +// all the other fields are negative or zero. +func (period Period) IsNegative() bool { + return period.years < 0 || period.months < 0 || period.days < 0 || + period.hours < 0 || period.minutes < 0 || period.seconds < 0 +} + +// Sign returns +1 for positive periods and -1 for negative periods. If the period is zero, it returns zero. +func (period Period) Sign() int { + if period.IsZero() { + return 0 + } + if period.IsNegative() { + return -1 + } + return 1 +} + +// OnlyYMD returns a new Period with only the year, month and day fields. The hour, +// minute and second fields are zeroed. +func (period Period) OnlyYMD() Period { + return Period{period.years, period.months, period.days, 0, 0, 0} +} + +// OnlyHMS returns a new Period with only the hour, minute and second fields. The year, +// month and day fields are zeroed. +func (period Period) OnlyHMS() Period { + return Period{0, 0, 0, period.hours, period.minutes, period.seconds} +} + +// Abs converts a negative period to a positive one. +func (period Period) Abs() Period { + a, _ := period.absNeg() + return a +} + +func (period Period) absNeg() (Period, bool) { + if period.IsNegative() { + return period.Negate(), true + } + return period, false +} + +func (period Period) condNegate(neg bool) Period { + if neg { + return period.Negate() + } + return period +} + +// Negate changes the sign of the period. +func (period Period) Negate() Period { + return Period{-period.years, -period.months, -period.days, -period.hours, -period.minutes, -period.seconds} +} + +func absInt16(v int16) int16 { + if v < 0 { + return -v + } + return v +} + +// Years gets the whole number of years in the period. +// The result is the number of years and does not include any other field. +func (period Period) Years() int { + return int(period.YearsFloat()) +} + +// YearsFloat gets the number of years in the period, including a fraction if any is present. +// The result is the number of years and does not include any other field. +func (period Period) YearsFloat() float32 { + return float32(period.years) / 10 +} + +// Months gets the whole number of months in the period. +// The result is the number of months and does not include any other field. +// +// Note that after normalisation, whole multiple of 12 months are added to +// the number of years, so the number of months will be reduced correspondingly. +func (period Period) Months() int { + return int(period.MonthsFloat()) +} + +// MonthsFloat gets the number of months in the period. +// The result is the number of months and does not include any other field. +// +// Note that after normalisation, whole multiple of 12 months are added to +// the number of years, so the number of months will be reduced correspondingly. +func (period Period) MonthsFloat() float32 { + return float32(period.months) / 10 +} + +// Days gets the whole number of days in the period. This includes the implied +// number of weeks but does not include any other field. +func (period Period) Days() int { + return int(period.DaysFloat()) +} + +// DaysFloat gets the number of days in the period. This includes the implied +// number of weeks but does not include any other field. +func (period Period) DaysFloat() float32 { + return float32(period.days) / 10 +} + +// Weeks calculates the number of whole weeks from the number of days. If the result +// would contain a fraction, it is truncated. +// The result is the number of weeks and does not include any other field. +// +// Note that weeks are synthetic: they are internally represented using days. +// See ModuloDays(), which returns the number of days excluding whole weeks. +func (period Period) Weeks() int { + return int(period.days) / 70 +} + +// WeeksFloat calculates the number of weeks from the number of days. +// The result is the number of weeks and does not include any other field. +func (period Period) WeeksFloat() float32 { + return float32(period.days) / 70 +} + +// ModuloDays calculates the whole number of days remaining after the whole number of weeks +// has been excluded. +func (period Period) ModuloDays() int { + days := absInt16(period.days) % 70 + f := int(days / 10) + if period.days < 0 { + return -f + } + return f +} + +// Hours gets the whole number of hours in the period. +// The result is the number of hours and does not include any other field. +func (period Period) Hours() int { + return int(period.HoursFloat()) +} + +// HoursFloat gets the number of hours in the period. +// The result is the number of hours and does not include any other field. +func (period Period) HoursFloat() float32 { + return float32(period.hours) / 10 +} + +// Minutes gets the whole number of minutes in the period. +// The result is the number of minutes and does not include any other field. +// +// Note that after normalisation, whole multiple of 60 minutes are added to +// the number of hours, so the number of minutes will be reduced correspondingly. +func (period Period) Minutes() int { + return int(period.MinutesFloat()) +} + +// MinutesFloat gets the number of minutes in the period. +// The result is the number of minutes and does not include any other field. +// +// Note that after normalisation, whole multiple of 60 minutes are added to +// the number of hours, so the number of minutes will be reduced correspondingly. +func (period Period) MinutesFloat() float32 { + return float32(period.minutes) / 10 +} + +// Seconds gets the whole number of seconds in the period. +// The result is the number of seconds and does not include any other field. +// +// Note that after normalisation, whole multiple of 60 seconds are added to +// the number of minutes, so the number of seconds will be reduced correspondingly. +func (period Period) Seconds() int { + return int(period.SecondsFloat()) +} + +// SecondsFloat gets the number of seconds in the period. +// The result is the number of seconds and does not include any other field. +// +// Note that after normalisation, whole multiple of 60 seconds are added to +// the number of minutes, so the number of seconds will be reduced correspondingly. +func (period Period) SecondsFloat() float32 { + return float32(period.seconds) / 10 +} + +//------------------------------------------------------------------------------------------------- + +// DurationApprox converts a period to the equivalent duration in nanoseconds. +// When the period specifies hours, minutes and seconds only, the result is precise. +// however, when the period specifies years, months and days, it is impossible to be precise +// because the result may depend on knowing date and timezone information, so the duration +// is estimated on the basis of a year being 365.2425 days (as per Gregorian calendar rules) +// and a month being 1/12 of a that; days are all assumed to be 24 hours long. +func (period Period) DurationApprox() time.Duration { + d, _ := period.Duration() + return d +} + +// Duration converts a period to the equivalent duration in nanoseconds. +// A flag is also returned that is true when the conversion was precise and false otherwise. +// +// When the period specifies hours, minutes and seconds only, the result is precise. +// however, when the period specifies years, months and days, it is impossible to be precise +// because the result may depend on knowing date and timezone information, so the duration +// is estimated on the basis of a year being 365.2425 days as per Gregorian calendar rules) +// and a month being 1/12 of a that; days are all assumed to be 24 hours long. +func (period Period) Duration() (time.Duration, bool) { + // remember that the fields are all fixed-point 1E1 + tdE6 := time.Duration(totalDaysApproxE7(period) * 8640) + stE3 := totalSecondsE3(period) + return tdE6*time.Microsecond + stE3*time.Millisecond, tdE6 == 0 +} + +func totalSecondsE3(period Period) time.Duration { + // remember that the fields are all fixed-point 1E1 + // and these are divided by 1E1 + hhE3 := time.Duration(period.hours) * 360000 + mmE3 := time.Duration(period.minutes) * 6000 + ssE3 := time.Duration(period.seconds) * 100 + return hhE3 + mmE3 + ssE3 +} + +func totalDaysApproxE7(period Period) int64 { + // remember that the fields are all fixed-point 1E1 + ydE6 := int64(period.years) * (daysPerYearE4 * 100) + mdE6 := int64(period.months) * daysPerMonthE6 + ddE6 := int64(period.days) * oneE6 + return ydE6 + mdE6 + ddE6 +} + +// TotalDaysApprox gets the approximate total number of days in the period. The approximation assumes +// a year is 365.2425 days as per Gregorian calendar rules) and a month is 1/12 of that. Whole +// multiples of 24 hours are also included in the calculation. +func (period Period) TotalDaysApprox() int { + pn := period.Normalise(false) + tdE6 := totalDaysApproxE7(pn) + hE6 := (int64(pn.hours) * oneE6) / 24 + return int((tdE6 + hE6) / oneE7) +} + +// TotalMonthsApprox gets the approximate total number of months in the period. The days component +// is included by approximation, assuming a year is 365.2425 days (as per Gregorian calendar rules) +// and a month is 1/12 of that. Whole multiples of 24 hours are also included in the calculation. +func (period Period) TotalMonthsApprox() int { + pn := period.Normalise(false) + mE1 := int64(pn.years)*12 + int64(pn.months) + hE1 := int64(pn.hours) / 24 + dE1 := ((int64(pn.days) + hE1) * oneE6) / daysPerMonthE6 + return int((mE1 + dE1) / 10) +} + +// Normalise attempts to simplify the fields. It operates in either precise or imprecise mode. +// +// Because the number of hours per day is imprecise (due to daylight savings etc), and because +// the number of days per month is variable in the Gregorian calendar, there is a reluctance +// to transfer time to or from the days element, or to transfer days to or from the months +// element. To give control over this, there are two modes. +// +// In precise mode: +// Multiples of 60 seconds become minutes. +// Multiples of 60 minutes become hours. +// Multiples of 12 months become years. +// +// Additionally, in imprecise mode: +// Multiples of 24 hours become days. +// Multiples of approx. 30.4 days become months. +// +// Note that leap seconds are disregarded: every minute is assumed to have 60 seconds. +func (period Period) Normalise(precise bool) Period { + n, _ := period.toPeriod64("").normalise64(precise).toPeriod() + return n +} + +// Simplify applies some heuristic simplifications with the objective of reducing the number +// of non-zero fields and thus making the rendered form simpler. It should be applied to +// a normalised period, otherwise the results may be unpredictable. +// +// Note that months and days are never combined, due to the variability of month lengths. +// Days and hours are only combined when imprecise behaviour is selected; this is due to +// daylight savings transitions, during which there are more than or fewer than 24 hours +// per day. +// +// The following transformation rules are applied in order: +// +// * P1YnM becomes 12+n months for 0 < n <= 6 +// * P1DTnH becomes 24+n hours for 0 < n <= 6 (unless precise is true) +// * PT1HnM becomes 60+n minutes for 0 < n <= 10 +// * PT1MnS becomes 60+n seconds for 0 < n <= 10 +// +// At each step, if a fraction exists and would affect the calculation, the transformations +// stop. Also, when not precise, +// +// * for periods of at least ten years, month proper fractions are discarded +// * for periods of at least a year, day proper fractions are discarded +// * for periods of at least a month, hour proper fractions are discarded +// * for periods of at least a day, minute proper fractions are discarded +// * for periods of at least an hour, second proper fractions are discarded +// +// The thresholds can be set using the varargs th parameter. By default, the thresholds a, +// b, c, d are 6 months, 6 hours, 10 minutes, 10 seconds respectively as listed in the rules +// above. +// +// * No thresholds is equivalent to 6, 6, 10, 10. +// * A single threshold a is equivalent to a, a, a, a. +// * Two thresholds a, b are equivalent to a, a, b, b. +// * Three thresholds a, b, c are equivalent to a, b, c, c. +// * Four thresholds a, b, c, d are used as provided. +func (period Period) Simplify(precise bool, th ...int) Period { + switch len(th) { + case 0: + return period.doSimplify(precise, 60, 60, 100, 100) + case 1: + return period.doSimplify(precise, int16(th[0]*10), int16(th[0]*10), int16(th[0]*10), int16(th[0]*10)) + case 2: + return period.doSimplify(precise, int16(th[0]*10), int16(th[0]*10), int16(th[1]*10), int16(th[1]*10)) + case 3: + return period.doSimplify(precise, int16(th[0]*10), int16(th[1]*10), int16(th[2]*10), int16(th[2]*10)) + default: + return period.doSimplify(precise, int16(th[0]*10), int16(th[1]*10), int16(th[2]*10), int16(th[3]*10)) + } +} + +func (period Period) doSimplify(precise bool, a, b, c, d int16) Period { + if period.years%10 != 0 { + return period + } + + ap, neg := period.absNeg() + + // single year is dropped if there are some months + if ap.years == 10 && + 0 < ap.months && ap.months <= a && + ap.days == 0 { + ap.months += 120 + ap.years = 0 + } + + if ap.months%10 != 0 { + // month fraction is dropped for periods of at least ten years (1:120) + months := ap.months / 10 + if !precise && ap.years >= 100 && months == 0 { + ap.months = 0 + } + return ap.condNegate(neg) + } + + if ap.days%10 != 0 { + // day fraction is dropped for periods of at least a year (1:365) + days := ap.days / 10 + if !precise && (ap.years > 0 || ap.months >= 120) && days == 0 { + ap.days = 0 + } + return ap.condNegate(neg) + } + + if !precise && ap.days == 10 && + ap.years == 0 && + ap.months == 0 && + 0 < ap.hours && ap.hours <= b { + ap.hours += 240 + ap.days = 0 + } + + if ap.hours%10 != 0 { + // hour fraction is dropped for periods of at least a month (1:720) + hours := ap.hours / 10 + if !precise && (ap.years > 0 || ap.months > 0 || ap.days >= 300) && hours == 0 { + ap.hours = 0 + } + return ap.condNegate(neg) + } + + if ap.hours == 10 && + 0 < ap.minutes && ap.minutes <= c { + ap.minutes += 600 + ap.hours = 0 + } + + if ap.minutes%10 != 0 { + // minute fraction is dropped for periods of at least a day (1:1440) + minutes := ap.minutes / 10 + if !precise && (ap.years > 0 || ap.months > 0 || ap.days > 0 || ap.hours >= 240) && minutes == 0 { + ap.minutes = 0 + } + return ap.condNegate(neg) + } + + if ap.minutes == 10 && + ap.hours == 0 && + 0 < ap.seconds && ap.seconds <= d { + ap.seconds += 600 + ap.minutes = 0 + } + + if ap.seconds%10 != 0 { + // second fraction is dropped for periods of at least an hour (1:3600) + seconds := ap.seconds / 10 + if !precise && (ap.years > 0 || ap.months > 0 || ap.days > 0 || ap.hours > 0 || ap.minutes >= 600) && seconds == 0 { + ap.seconds = 0 + } + } + + return ap.condNegate(neg) +} diff --git a/period/period64.go b/period/period64.go new file mode 100644 index 00000000..f3e2f4b0 --- /dev/null +++ b/period/period64.go @@ -0,0 +1,142 @@ +package period + +import ( + "fmt" + "math" + "strings" +) + +// used for stages in arithmetic +type period64 struct { + // always positive values + years, months, days, hours, minutes, seconds int64 + // true if the period is negative + neg bool + input string +} + +func (period Period) toPeriod64(input string) *period64 { + if period.IsNegative() { + return &period64{ + years: int64(-period.years), months: int64(-period.months), days: int64(-period.days), + hours: int64(-period.hours), minutes: int64(-period.minutes), seconds: int64(-period.seconds), + neg: true, + input: input, + } + } + return &period64{ + years: int64(period.years), months: int64(period.months), days: int64(period.days), + hours: int64(period.hours), minutes: int64(period.minutes), seconds: int64(period.seconds), + input: input, + } +} + +func (p64 *period64) toPeriod() (Period, error) { + var f []string + if p64.years > math.MaxInt16 { + f = append(f, "years") + } + if p64.months > math.MaxInt16 { + f = append(f, "months") + } + if p64.days > math.MaxInt16 { + f = append(f, "days") + } + if p64.hours > math.MaxInt16 { + f = append(f, "hours") + } + if p64.minutes > math.MaxInt16 { + f = append(f, "minutes") + } + if p64.seconds > math.MaxInt16 { + f = append(f, "seconds") + } + + if len(f) > 0 { + if p64.input == "" { + p64.input = p64.String() + } + return Period{}, fmt.Errorf("%s: integer overflow occurred in %s", p64.input, strings.Join(f, ",")) + } + + if p64.neg { + return Period{ + int16(-p64.years), int16(-p64.months), int16(-p64.days), + int16(-p64.hours), int16(-p64.minutes), int16(-p64.seconds), + }, nil + } + + return Period{ + int16(p64.years), int16(p64.months), int16(p64.days), + int16(p64.hours), int16(p64.minutes), int16(p64.seconds), + }, nil +} + +func (p64 *period64) normalise64(precise bool) *period64 { + return p64.rippleUp(precise).moveFractionToRight() +} + +func (p64 *period64) rippleUp(precise bool) *period64 { + // remember that the fields are all fixed-point 1E1 + + p64.minutes += (p64.seconds / 600) * 10 + p64.seconds = p64.seconds % 600 + + p64.hours += (p64.minutes / 600) * 10 + p64.minutes = p64.minutes % 600 + + // 32670-(32670/60)-(32670/3600) = 32760 - 546 - 9.1 = 32204.9 + if !precise || p64.hours > 32204 { + p64.days += (p64.hours / 240) * 10 + p64.hours = p64.hours % 240 + } + + if !precise || p64.days > 32760 { + dE6 := p64.days * oneE5 + p64.months += (dE6 / daysPerMonthE6) * 10 + p64.days = (dE6 % daysPerMonthE6) / oneE5 + } + + p64.years += (p64.months / 120) * 10 + p64.months = p64.months % 120 + + return p64 +} + +// moveFractionToRight attempts to remove fractions in higher-order fields by moving their value to the +// next-lower-order field. For example, fractional years become months. +func (p64 *period64) moveFractionToRight() *period64 { + // remember that the fields are all fixed-point 1E1 + + y10 := p64.years % 10 + if y10 != 0 && (p64.months != 0 || p64.days != 0 || p64.hours != 0 || p64.minutes != 0 || p64.seconds != 0) { + p64.months += y10 * 12 + p64.years = (p64.years / 10) * 10 + } + + m10 := p64.months % 10 + if m10 != 0 && (p64.days != 0 || p64.hours != 0 || p64.minutes != 0 || p64.seconds != 0) { + p64.days += (m10 * daysPerMonthE6) / oneE6 + p64.months = (p64.months / 10) * 10 + } + + d10 := p64.days % 10 + if d10 != 0 && (p64.hours != 0 || p64.minutes != 0 || p64.seconds != 0) { + p64.hours += d10 * 24 + p64.days = (p64.days / 10) * 10 + } + + hh10 := p64.hours % 10 + if hh10 != 0 && (p64.minutes != 0 || p64.seconds != 0) { + p64.minutes += hh10 * 60 + p64.hours = (p64.hours / 10) * 10 + } + + mm10 := p64.minutes % 10 + if mm10 != 0 && p64.seconds != 0 { + p64.seconds += mm10 * 60 + p64.minutes = (p64.minutes / 10) * 10 + } + + return p64 +} diff --git a/period/period_test.go b/period/period_test.go new file mode 100644 index 00000000..6384e39b --- /dev/null +++ b/period/period_test.go @@ -0,0 +1,1121 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "fmt" + "strings" + "testing" + "time" + + . "github.com/onsi/gomega" +) + +var oneDay = 24 * time.Hour +var oneMonthApprox = 2629746 * time.Second // 30.436875 days +var oneYearApprox = 31556952 * time.Second // 365.2425 days + +func TestParseErrors(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + normalise bool + expected string + expvalue string + }{ + {"", false, "cannot parse a blank string as a period", ""}, + {`P000`, false, `: missing designator at the end`, "P000"}, + {"XY", false, ": expected 'P' period mark at the start", "XY"}, + {"PxY", false, ": expected a number but found 'x'", "PxY"}, + {"PxW", false, ": expected a number but found 'x'", "PxW"}, + {"PxD", false, ": expected a number but found 'x'", "PxD"}, + {"PTxH", false, ": expected a number but found 'x'", "PTxH"}, + {"PTxM", false, ": expected a number but found 'x'", "PTxM"}, + {"PTxS", false, ": expected a number but found 'x'", "PTxS"}, + {"P1HT1M", false, ": 'H' designator cannot occur here", "P1HT1M"}, + {"PT1Y", false, ": 'Y' designator cannot occur here", "PT1Y"}, + {"P1S", false, ": 'S' designator cannot occur here", "P1S"}, + {"P1D2D", false, ": 'D' designator cannot occur more than once", "P1D2D"}, + {"PT1HT1S", false, ": 'T' designator cannot occur more than once", "PT1HT1S"}, + {"P0.1YT0.1S", false, ": 'Y' & 'S' only the last field can have a fraction", "P0.1YT0.1S"}, + {"P0.1Y1M1DT1H1M0.1S", false, ": 'Y' & 'S' only the last field can have a fraction", "P0.1Y1M1DT1H1M0.1S"}, + {"P", false, ": expected 'Y', 'M', 'W', 'D', 'H', 'M', or 'S' designator", "P"}, + + // integer overflow + {"P32768Y", false, ": integer overflow occurred in years", "P32768Y"}, + {"P32768M", false, ": integer overflow occurred in months", "P32768M"}, + {"P32768W", false, ": integer overflow occurred in days", "P32768W"}, + {"P32768D", false, ": integer overflow occurred in days", "P32768D"}, + {"PT32768H", false, ": integer overflow occurred in hours", "PT32768H"}, + {"PT32768M", false, ": integer overflow occurred in minutes", "PT32768M"}, + {"PT32768S", false, ": integer overflow occurred in seconds", "PT32768S"}, + {"PT32768H32768M32768S", false, ": integer overflow occurred in hours,minutes,seconds", "PT32768H32768M32768S"}, + {"PT103412160000S", false, ": integer overflow occurred in seconds", "PT103412160000S"}, + } + for i, c := range cases { + _, ep := Parse(c.value, c.normalise) + g.Expect(ep).To(HaveOccurred(), info(i, c.value)) + g.Expect(ep.Error()).To(Equal(c.expvalue+c.expected), info(i, c.value)) + + _, en := Parse("-"+c.value, c.normalise) + g.Expect(en).To(HaveOccurred(), info(i, c.value)) + if c.expvalue != "" { + g.Expect(en.Error()).To(Equal("-"+c.expvalue+c.expected), info(i, c.value)) + } else { + g.Expect(en.Error()).To(Equal(c.expected), info(i, c.value)) + } + } +} + +//------------------------------------------------------------------------------------------------- + +func TestParsePeriodWithNormalise(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + reversed string + period Period + }{ + // all rollovers + {"PT1234.5S", "PT20M34.5S", Period{minutes: 200, seconds: 345}}, + {"PT1234.5M", "PT20H34.5M", Period{hours: 200, minutes: 345}}, + {"PT12345.6H", "P514DT9.6H", Period{days: 5140, hours: 96}}, + {"P3277D", "P8Y11M20.2D", Period{years: 80, months: 110, days: 202}}, + {"P1234.5M", "P102Y10.5M", Period{years: 1020, months: 105}}, + // largest possible number of seconds normalised only in hours, mins, sec + {"PT11592000S", "PT3220H", Period{hours: 32200}}, + {"-PT11592000S", "-PT3220H", Period{hours: -32200}}, + {"PT11595599S", "PT3220H59M59S", Period{hours: 32200, minutes: 590, seconds: 590}}, + // largest possible number of seconds normalised only in days, hours, mins, sec + {"PT283046400S", "P468W", Period{days: 32760}}, + {"-PT283046400S", "-P468W", Period{days: -32760}}, + {"PT43084443590S", "P1365Y3M2WT26H83M50S", Period{years: 13650, months: 30, days: 140, hours: 260, minutes: 830, seconds: 500}}, + {"PT103412159999S", "P3276Y11M29DT39H107M59S", Period{years: 32760, months: 110, days: 290, hours: 390, minutes: 1070, seconds: 590}}, + {"PT283132799S", "P468WT23H59M59S", Period{days: 32760, hours: 230, minutes: 590, seconds: 590}}, + // other examples are in TestNormalise + } + for i, c := range cases { + p, err := Parse(c.value) + s := info(i, c.value) + g.Expect(err).NotTo(HaveOccurred(), s) + expectValid(t, p, s) + g.Expect(p).To(Equal(c.period), s) + // reversal is expected not to be an identity + g.Expect(p.String()).To(Equal(c.reversed), s+" reversed") + } +} + +//------------------------------------------------------------------------------------------------- + +func TestParsePeriodWithoutNormalise(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + reversed string + period Period + }{ + // zero + {"P0D", "P0D", Period{}}, + // special zero cases: parse is not identity when reversed + {"P0", "P0D", Period{}}, + {"P0Y", "P0D", Period{}}, + {"P0M", "P0D", Period{}}, + {"P0W", "P0D", Period{}}, + {"PT0H", "P0D", Period{}}, + {"PT0M", "P0D", Period{}}, + {"PT0S", "P0D", Period{}}, + // ones + {"P1Y", "P1Y", Period{years: 10}}, + {"P1M", "P1M", Period{months: 10}}, + {"P1W", "P1W", Period{days: 70}}, + {"P1D", "P1D", Period{days: 10}}, + {"PT1H", "PT1H", Period{hours: 10}}, + {"PT1M", "PT1M", Period{minutes: 10}}, + {"PT1S", "PT1S", Period{seconds: 10}}, + // smallest + {"P0.1Y", "P0.1Y", Period{years: 1}}, + {"-P0.1Y", "-P0.1Y", Period{years: -1}}, + {"P0.1M", "P0.1M", Period{months: 1}}, + {"-P0.1M", "-P0.1M", Period{months: -1}}, + {"P0.1D", "P0.1D", Period{days: 1}}, + {"-P0.1D", "-P0.1D", Period{days: -1}}, + {"PT0.1H", "PT0.1H", Period{hours: 1}}, + {"-PT0.1H", "-PT0.1H", Period{hours: -1}}, + {"PT0.1M", "PT0.1M", Period{minutes: 1}}, + {"-PT0.1M", "-PT0.1M", Period{minutes: -1}}, + {"PT0.1S", "PT0.1S", Period{seconds: 1}}, + {"-PT0.1S", "-PT0.1S", Period{seconds: -1}}, + // week special case: also not identity when reversed + {"P0.1W", "P0.7D", Period{days: 7}}, + {"-P0.1W", "-P0.7D", Period{days: -7}}, + // largest + {"PT3276.7S", "PT3276.7S", Period{seconds: 32767}}, + {"PT3276.7M", "PT3276.7M", Period{minutes: 32767}}, + {"PT3276.7H", "PT3276.7H", Period{hours: 32767}}, + {"P3276.7D", "P3276.7D", Period{days: 32767}}, + {"P3276.7M", "P3276.7M", Period{months: 32767}}, + {"P3276.7Y", "P3276.7Y", Period{years: 32767}}, + + {"P3Y", "P3Y", Period{years: 30}}, + {"P6M", "P6M", Period{months: 60}}, + {"P5W", "P5W", Period{days: 350}}, + {"P4D", "P4D", Period{days: 40}}, + {"PT12H", "PT12H", Period{hours: 120}}, + {"PT30M", "PT30M", Period{minutes: 300}}, + {"PT25S", "PT25S", Period{seconds: 250}}, + {"PT30M67.6S", "PT30M67.6S", Period{minutes: 300, seconds: 676}}, + {"P2.Y", "P2Y", Period{years: 20}}, + {"P2.5Y", "P2.5Y", Period{years: 25}}, + {"P2.15Y", "P2.1Y", Period{years: 21}}, + {"P1Y2.M", "P1Y2M", Period{years: 10, months: 20}}, + {"P1Y2.5M", "P1Y2.5M", Period{years: 10, months: 25}}, + {"P1Y2.15M", "P1Y2.1M", Period{years: 10, months: 21}}, + // others + {"P3Y6M5W4DT12H40M5S", "P3Y6M39DT12H40M5S", Period{years: 30, months: 60, days: 390, hours: 120, minutes: 400, seconds: 50}}, + {"+P3Y6M5W4DT12H40M5S", "P3Y6M39DT12H40M5S", Period{years: 30, months: 60, days: 390, hours: 120, minutes: 400, seconds: 50}}, + {"-P3Y6M5W4DT12H40M5S", "-P3Y6M39DT12H40M5S", Period{years: -30, months: -60, days: -390, hours: -120, minutes: -400, seconds: -50}}, + {"P1Y14M35DT48H125M800S", "P1Y14M5WT48H125M800S", Period{years: 10, months: 140, days: 350, hours: 480, minutes: 1250, seconds: 8000}}, + } + for i, c := range cases { + p, err := Parse(c.value, false) + s := info(i, c.value) + g.Expect(err).NotTo(HaveOccurred(), s) + expectValid(t, p, s) + g.Expect(p).To(Equal(c.period), s) + // reversal is usually expected to be an identity + g.Expect(p.String()).To(Equal(c.reversed), s+" reversed") + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodString(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + period Period + }{ + // note: the negative cases are also covered (see below) + + {"P0D", Period{}}, + // ones + {"P1Y", Period{years: 10}}, + {"P1M", Period{months: 10}}, + {"P1W", Period{days: 70}}, + {"P1D", Period{days: 10}}, + {"PT1H", Period{hours: 10}}, + {"PT1M", Period{minutes: 10}}, + {"PT1S", Period{seconds: 10}}, + // smallest + {"P0.1Y", Period{years: 1}}, + {"P0.1M", Period{months: 1}}, + {"P0.7D", Period{days: 7}}, + {"P0.1D", Period{days: 1}}, + {"PT0.1H", Period{hours: 1}}, + {"PT0.1M", Period{minutes: 1}}, + {"PT0.1S", Period{seconds: 1}}, + + {"P3Y", Period{years: 30}}, + {"P6M", Period{months: 60}}, + {"P5W", Period{days: 350}}, + {"P4W", Period{days: 280}}, + {"P4D", Period{days: 40}}, + {"PT12H", Period{hours: 120}}, + {"PT30M", Period{minutes: 300}}, + {"PT5S", Period{seconds: 50}}, + {"P3Y6M39DT1H2M4.9S", Period{years: 30, months: 60, days: 390, hours: 10, minutes: 20, seconds: 49}}, + + {"P2.5Y", Period{years: 25}}, + {"P2.5M", Period{months: 25}}, + {"P2.5D", Period{days: 25}}, + {"PT2.5H", Period{hours: 25}}, + {"PT2.5M", Period{minutes: 25}}, + {"PT2.5S", Period{seconds: 25}}, + } + for i, c := range cases { + sp := c.period.String() + g.Expect(sp).To(Equal(c.value), info(i, c.value)) + + if !c.period.IsZero() { + sn := c.period.Negate().String() + g.Expect(sn).To(Equal("-"+c.value), info(i, c.value)) + } + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodIntComponents(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + y, m, w, d, dx, hh, mm, ss int + }{ + // note: the negative cases are also covered (see below) + + {value: "P0D"}, + {value: "P1Y", y: 1}, + {value: "P1W", w: 1, d: 7}, + {value: "P6M", m: 6}, + {value: "P12M", m: 12}, + {value: "P39D", w: 5, d: 39, dx: 4}, + {value: "P4D", d: 4, dx: 4}, + {value: "PT12H", hh: 12}, + {value: "PT60M", mm: 60}, + {value: "PT30M", mm: 30}, + {value: "PT5S", ss: 5}, + } + for i, c := range cases { + pp := MustParse(c.value, false) + g.Expect(pp.Years()).To(Equal(c.y), info(i, pp)) + g.Expect(pp.Months()).To(Equal(c.m), info(i, pp)) + g.Expect(pp.Weeks()).To(Equal(c.w), info(i, pp)) + g.Expect(pp.Days()).To(Equal(c.d), info(i, pp)) + g.Expect(pp.ModuloDays()).To(Equal(c.dx), info(i, pp)) + g.Expect(pp.Hours()).To(Equal(c.hh), info(i, pp)) + g.Expect(pp.Minutes()).To(Equal(c.mm), info(i, pp)) + g.Expect(pp.Seconds()).To(Equal(c.ss), info(i, pp)) + + pn := pp.Negate() + g.Expect(pn.Years()).To(Equal(-c.y), info(i, pn)) + g.Expect(pn.Months()).To(Equal(-c.m), info(i, pn)) + g.Expect(pn.Weeks()).To(Equal(-c.w), info(i, pn)) + g.Expect(pn.Days()).To(Equal(-c.d), info(i, pn)) + g.Expect(pn.ModuloDays()).To(Equal(-c.dx), info(i, pn)) + g.Expect(pn.Hours()).To(Equal(-c.hh), info(i, pn)) + g.Expect(pn.Minutes()).To(Equal(-c.mm), info(i, pn)) + g.Expect(pn.Seconds()).To(Equal(-c.ss), info(i, pn)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodFloatComponents(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + y, m, w, d, dx, hh, mm, ss float32 + }{ + // note: the negative cases are also covered (see below) + + {value: "P0"}, // zero case + + // YMD cases + {value: "P1Y", y: 1}, + {value: "P1.5Y", y: 1.5}, + {value: "P1.1Y", y: 1.1}, + {value: "P1M", m: 1}, + {value: "P1.5M", m: 1.5}, + {value: "P1.1M", m: 1.1}, + {value: "P6M", m: 6}, + {value: "P12M", m: 12}, + {value: "P7D", w: 1, d: 7}, + {value: "P7.7D", w: 1.1, d: 7.7}, + {value: "P7.1D", w: 7.1 / 7, d: 7.1}, + {value: "P1D", w: 1.0 / 7, d: 1}, + {value: "P1.1D", w: 1.1 / 7, d: 1.1}, + {value: "P1.1D", w: 1.1 / 7, d: 1.1}, + {value: "P39D", w: 5.571429, d: 39, dx: 4}, + {value: "P4D", w: 0.5714286, d: 4, dx: 4}, + + // HMS cases + {value: "PT1.1H", hh: 1.1}, + {value: "PT1H6M", hh: 1, mm: 6}, + {value: "PT12H", hh: 12}, + {value: "PT1.1M", mm: 1.1}, + {value: "PT1M6S", mm: 1, ss: 6}, + {value: "PT30M", mm: 30}, + {value: "PT1.1S", ss: 1.1}, + {value: "PT5S", ss: 5}, + } + for i, c := range cases { + pp := MustParse(c.value, false) + g.Expect(pp.YearsFloat()).To(Equal(c.y), info(i, pp)) + g.Expect(pp.MonthsFloat()).To(Equal(c.m), info(i, pp)) + g.Expect(pp.WeeksFloat()).To(Equal(c.w), info(i, pp)) + g.Expect(pp.DaysFloat()).To(Equal(c.d), info(i, pp)) + g.Expect(pp.HoursFloat()).To(Equal(c.hh), info(i, pp)) + g.Expect(pp.MinutesFloat()).To(Equal(c.mm), info(i, pp)) + g.Expect(pp.SecondsFloat()).To(Equal(c.ss), info(i, pp)) + + pn := pp.Negate() + g.Expect(pn.YearsFloat()).To(Equal(-c.y), info(i, pn)) + g.Expect(pn.MonthsFloat()).To(Equal(-c.m), info(i, pn)) + g.Expect(pn.WeeksFloat()).To(Equal(-c.w), info(i, pn)) + g.Expect(pn.DaysFloat()).To(Equal(-c.d), info(i, pn)) + g.Expect(pn.HoursFloat()).To(Equal(-c.hh), info(i, pn)) + g.Expect(pn.MinutesFloat()).To(Equal(-c.mm), info(i, pn)) + g.Expect(pn.SecondsFloat()).To(Equal(-c.ss), info(i, pn)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodToDuration(t *testing.T) { + cases := []struct { + value string + duration time.Duration + precise bool + }{ + // note: the negative cases are also covered (see below) + + {"P0D", time.Duration(0), true}, + {"PT1S", 1 * time.Second, true}, + {"PT0.1S", 100 * time.Millisecond, true}, + {"PT3276S", 3276 * time.Second, true}, + {"PT1M", 60 * time.Second, true}, + {"PT0.1M", 6 * time.Second, true}, + {"PT3276M", 3276 * time.Minute, true}, + {"PT1H", 3600 * time.Second, true}, + {"PT0.1H", 360 * time.Second, true}, + {"PT3220H", 3220 * time.Hour, true}, + // days, months and years conversions are never precise + {"P1D", 24 * time.Hour, false}, + {"P0.1D", 144 * time.Minute, false}, + {"P3276D", 3276 * 24 * time.Hour, false}, + {"P1M", oneMonthApprox, false}, + {"P0.1M", oneMonthApprox / 10, false}, + {"P3276M", 3276 * oneMonthApprox, false}, + {"P1Y", oneYearApprox, false}, + {"P3276Y", 3276 * oneYearApprox, false}, // near the upper limit of range + } + for i, c := range cases { + testPeriodToDuration(t, i, c.value, c.duration, c.precise) + testPeriodToDuration(t, i, "-"+c.value, -c.duration, c.precise) + } +} + +func testPeriodToDuration(t *testing.T, i int, value string, duration time.Duration, precise bool) { + t.Helper() + g := NewGomegaWithT(t) + hint := info(i, "%s %s %v", value, duration, precise) + pp := MustParse(value, false) + d1, prec := pp.Duration() + g.Expect(d1).To(Equal(duration), hint) + g.Expect(prec).To(Equal(precise), hint) + d2 := pp.DurationApprox() + if precise { + g.Expect(d2).To(Equal(duration), hint) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestSignPositiveNegative(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + positive bool + negative bool + sign int + }{ + {"P0D", false, false, 0}, + {"PT1S", true, false, 1}, + {"PT0.1S", true, false, 1}, + {"-PT1S", false, true, -1}, + {"-PT0.1S", false, true, -1}, + {"PT1M", true, false, 1}, + {"PT0.1M", true, false, 1}, + {"-PT1M", false, true, -1}, + {"-PT0.1M", false, true, -1}, + {"PT1H", true, false, 1}, + {"PT0.1H", true, false, 1}, + {"-PT1H", false, true, -1}, + {"-PT0.1H", false, true, -1}, + {"P1D", true, false, 1}, + {"P10.D", true, false, 1}, + {"-P1D", false, true, -1}, + {"-P0.1D", false, true, -1}, + {"P1M", true, false, 1}, + {"P0.1M", true, false, 1}, + {"-P1M", false, true, -1}, + {"-P0.1M", false, true, -1}, + {"P1Y", true, false, 1}, + {"P0.1Y", true, false, 1}, + {"-P1Y", false, true, -1}, + {"-P0.1Y", false, true, -1}, + } + for i, c := range cases { + p := MustParse(c.value, false) + g.Expect(p.IsPositive()).To(Equal(c.positive), info(i, c.value)) + g.Expect(p.IsNegative()).To(Equal(c.negative), info(i, c.value)) + g.Expect(p.Sign()).To(Equal(c.sign), info(i, c.value)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodApproxDays(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + approxDays int + }{ + // note: the negative cases are also covered (see below) + + {"P0D", 0}, + {"PT24H", 1}, + {"PT49H", 2}, + {"P1D", 1}, + {"P1M", 30}, + {"P1Y", 365}, + } + for i, c := range cases { + p := MustParse(c.value, false) + td1 := p.TotalDaysApprox() + g.Expect(td1).To(Equal(c.approxDays), info(i, c.value)) + + td2 := p.Negate().TotalDaysApprox() + g.Expect(td2).To(Equal(-c.approxDays), info(i, c.value)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodApproxMonths(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + value string + approxMonths int + }{ + // note: the negative cases are also covered (see below) + + {"P0D", 0}, + {"P1D", 0}, + {"P30D", 0}, + {"P31D", 1}, + {"P60D", 1}, + {"P62D", 2}, + {"P1M", 1}, + {"P12M", 12}, + {"P2M31D", 3}, + {"P1Y", 12}, + {"P2Y3M", 27}, + {"PT24H", 0}, + {"PT744H", 1}, + } + for i, c := range cases { + p := MustParse(c.value, false) + td1 := p.TotalMonthsApprox() + g.Expect(td1).To(Equal(c.approxMonths), info(i, c.value)) + + td2 := p.Negate().TotalMonthsApprox() + g.Expect(td2).To(Equal(-c.approxMonths), info(i, c.value)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestNewPeriod(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + period string + years, months, days, hours, minutes, seconds int + }{ + // note: the negative cases are also covered (see below) + + {period: "P0"}, // zero case + + {period: "PT1S", seconds: 1}, + {period: "PT1M", minutes: 1}, + {period: "PT1H", hours: 1}, + {period: "P1D", days: 1}, + {period: "P1M", months: 1}, + {period: "P1Y", years: 1}, + {period: "P100Y222M700D", years: 100, months: 222, days: 700}, + } + for i, c := range cases { + ep, _ := Parse(c.period, false) + pp := New(c.years, c.months, c.days, c.hours, c.minutes, c.seconds) + expectValid(t, pp, info(i, c.period)) + g.Expect(pp).To(Equal(ep), info(i, c.period)) + g.Expect(pp.Years()).To(Equal(c.years), info(i, c.period)) + g.Expect(pp.Months()).To(Equal(c.months), info(i, c.period)) + g.Expect(pp.Days()).To(Equal(c.days), info(i, c.period)) + + pn := New(-c.years, -c.months, -c.days, -c.hours, -c.minutes, -c.seconds) + en := ep.Negate() + expectValid(t, pn, info(i, en)) + g.Expect(pn).To(Equal(en), info(i, en)) + g.Expect(pn.Years()).To(Equal(-c.years), info(i, en)) + g.Expect(pn.Months()).To(Equal(-c.months), info(i, en)) + g.Expect(pn.Days()).To(Equal(-c.days), info(i, en)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestNewHMS(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + period Period + hours, minutes, seconds int + }{ + // note: the negative cases are also covered (see below) + + {}, // zero case + + {period: Period{seconds: 10}, seconds: 1}, + {period: Period{minutes: 10}, minutes: 1}, + {period: Period{hours: 10}, hours: 1}, + {period: Period{hours: 30, minutes: 40, seconds: 50}, hours: 3, minutes: 4, seconds: 5}, + {period: Period{hours: 32760, minutes: 32760, seconds: 32760}, hours: 3276, minutes: 3276, seconds: 3276}, + } + for i, c := range cases { + pp := NewHMS(c.hours, c.minutes, c.seconds) + expectValid(t, pp, info(i, c.period)) + g.Expect(pp).To(Equal(c.period), info(i, c.period)) + g.Expect(pp.Hours()).To(Equal(c.hours), info(i, c.period)) + g.Expect(pp.Minutes()).To(Equal(c.minutes), info(i, c.period)) + g.Expect(pp.Seconds()).To(Equal(c.seconds), info(i, c.period)) + + pn := NewHMS(-c.hours, -c.minutes, -c.seconds) + en := c.period.Negate() + expectValid(t, pn, info(i, en)) + g.Expect(pn).To(Equal(en), info(i, en)) + g.Expect(pn.Hours()).To(Equal(-c.hours), info(i, en)) + g.Expect(pn.Minutes()).To(Equal(-c.minutes), info(i, en)) + g.Expect(pn.Seconds()).To(Equal(-c.seconds), info(i, en)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestNewYMD(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + period Period + years, months, days int + }{ + // note: the negative cases are also covered (see below) + + {}, // zero case + + {period: Period{days: 10}, days: 1}, + {period: Period{months: 10}, months: 1}, + {period: Period{years: 10}, years: 1}, + {period: Period{years: 1000, months: 2220, days: 7000}, years: 100, months: 222, days: 700}, + {period: Period{years: 32760, months: 32760, days: 32760}, years: 3276, months: 3276, days: 3276}, + } + for i, c := range cases { + pp := NewYMD(c.years, c.months, c.days) + expectValid(t, pp, info(i, c.period)) + g.Expect(pp).To(Equal(c.period), info(i, c.period)) + g.Expect(pp.Years()).To(Equal(c.years), info(i, c.period)) + g.Expect(pp.Months()).To(Equal(c.months), info(i, c.period)) + g.Expect(pp.Days()).To(Equal(c.days), info(i, c.period)) + + pn := NewYMD(-c.years, -c.months, -c.days) + en := c.period.Negate() + expectValid(t, pn, info(i, en)) + g.Expect(pn).To(Equal(en), info(i, en)) + g.Expect(pn.Years()).To(Equal(-c.years), info(i, en)) + g.Expect(pn.Months()).To(Equal(-c.months), info(i, en)) + g.Expect(pn.Days()).To(Equal(-c.days), info(i, en)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestNewOf(t *testing.T) { + // note: the negative cases are also covered (see below) + + // HMS tests + testNewOf(t, 1, 100*time.Millisecond, Period{seconds: 1}, true) + testNewOf(t, 2, time.Second, Period{seconds: 10}, true) + testNewOf(t, 3, time.Minute, Period{minutes: 10}, true) + testNewOf(t, 4, time.Hour, Period{hours: 10}, true) + testNewOf(t, 5, time.Hour+time.Minute+time.Second, Period{hours: 10, minutes: 10, seconds: 10}, true) + testNewOf(t, 6, 24*time.Hour+time.Minute+time.Second, Period{hours: 240, minutes: 10, seconds: 10}, true) + testNewOf(t, 7, 3276*time.Hour+59*time.Minute+59*time.Second, Period{hours: 32760, minutes: 590, seconds: 590}, true) + testNewOf(t, 8, 30*time.Minute+67*time.Second+600*time.Millisecond, Period{minutes: 310, seconds: 76}, true) + + // YMD tests: must be over 3276 hours (approx 4.5 months), otherwise HMS will take care of it + // first rollover: >3276 hours + testNewOf(t, 10, 3277*time.Hour, Period{days: 1360, hours: 130}, false) + testNewOf(t, 11, 3288*time.Hour, Period{days: 1370}, false) + testNewOf(t, 12, 3289*time.Hour, Period{days: 1370, hours: 10}, false) + testNewOf(t, 13, 24*3276*time.Hour, Period{days: 32760}, false) + + // second rollover: >3276 days + testNewOf(t, 14, 24*3277*time.Hour, Period{years: 80, months: 110, days: 200}, false) + testNewOf(t, 15, 3277*oneDay, Period{years: 80, months: 110, days: 200}, false) + testNewOf(t, 16, 3277*oneDay+time.Hour+time.Minute+time.Second, Period{years: 80, months: 110, days: 200, hours: 10}, false) + testNewOf(t, 17, 36525*oneDay, Period{years: 1000}, false) +} + +func testNewOf(t *testing.T, i int, source time.Duration, expected Period, precise bool) { + t.Helper() + testNewOf1(t, i, source, expected, precise) + testNewOf1(t, i, -source, expected.Negate(), precise) +} + +func testNewOf1(t *testing.T, i int, source time.Duration, expected Period, precise bool) { + t.Helper() + g := NewGomegaWithT(t) + + n, p := NewOf(source) + rev, _ := expected.Duration() + info := fmt.Sprintf("%d: source %v expected %+v precise %v rev %v", i, source, expected, precise, rev) + expectValid(t, n, info) + g.Expect(n).To(Equal(expected), info) + g.Expect(p).To(Equal(precise), info) + if precise { + g.Expect(rev).To(Equal(source), info) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestBetween(t *testing.T) { + g := NewGomegaWithT(t) + now := time.Now() + + cases := []struct { + a, b time.Time + expected Period + }{ + // note: the negative cases are also covered (see below) + + {now, now, Period{}}, + + // simple positive date calculations + {utc(2015, 1, 1, 0, 0, 0, 0), utc(2015, 1, 1, 0, 0, 0, 100), Period{seconds: 1}}, + {utc(2015, 1, 1, 0, 0, 0, 0), utc(2015, 2, 2, 1, 1, 1, 1), Period{days: 320, hours: 10, minutes: 10, seconds: 10}}, + {utc(2015, 2, 1, 0, 0, 0, 0), utc(2015, 3, 2, 1, 1, 1, 1), Period{days: 290, hours: 10, minutes: 10, seconds: 10}}, + {utc(2015, 3, 1, 0, 0, 0, 0), utc(2015, 4, 2, 1, 1, 1, 1), Period{days: 320, hours: 10, minutes: 10, seconds: 10}}, + {utc(2015, 4, 1, 0, 0, 0, 0), utc(2015, 5, 2, 1, 1, 1, 1), Period{days: 310, hours: 10, minutes: 10, seconds: 10}}, + {utc(2015, 5, 1, 0, 0, 0, 0), utc(2015, 6, 2, 1, 1, 1, 1), Period{days: 320, hours: 10, minutes: 10, seconds: 10}}, + {utc(2015, 6, 1, 0, 0, 0, 0), utc(2015, 7, 2, 1, 1, 1, 1), Period{days: 310, hours: 10, minutes: 10, seconds: 10}}, + {utc(2015, 1, 1, 0, 0, 0, 0), utc(2015, 7, 2, 1, 1, 1, 1), Period{days: 1820, hours: 10, minutes: 10, seconds: 10}}, + + // less than one month + {utc(2016, 1, 2, 0, 0, 0, 0), utc(2016, 2, 1, 0, 0, 0, 0), Period{days: 300}}, + {utc(2015, 2, 2, 0, 0, 0, 0), utc(2015, 3, 1, 0, 0, 0, 0), Period{days: 270}}, // non-leap + {utc(2016, 2, 2, 0, 0, 0, 0), utc(2016, 3, 1, 0, 0, 0, 0), Period{days: 280}}, // leap year + {utc(2016, 3, 2, 0, 0, 0, 0), utc(2016, 4, 1, 0, 0, 0, 0), Period{days: 300}}, + {utc(2016, 4, 2, 0, 0, 0, 0), utc(2016, 5, 1, 0, 0, 0, 0), Period{days: 290}}, + {utc(2016, 5, 2, 0, 0, 0, 0), utc(2016, 6, 1, 0, 0, 0, 0), Period{days: 300}}, + {utc(2016, 6, 2, 0, 0, 0, 0), utc(2016, 7, 1, 0, 0, 0, 0), Period{days: 290}}, + + // BST drops an hour at the daylight-saving transition + {utc(2015, 1, 1, 0, 0, 0, 0), bst(2015, 7, 2, 1, 1, 1, 1), Period{days: 1820, minutes: 10, seconds: 10}}, + + // daytime only + {utc(2015, 1, 1, 2, 3, 4, 0), utc(2015, 1, 1, 2, 3, 4, 500), Period{seconds: 5}}, + {utc(2015, 1, 1, 2, 3, 4, 0), utc(2015, 1, 1, 4, 4, 7, 500), Period{hours: 20, minutes: 10, seconds: 35}}, + {utc(2015, 1, 1, 2, 3, 4, 500), utc(2015, 1, 1, 4, 4, 7, 0), Period{hours: 20, minutes: 10, seconds: 25}}, + + // different dates and times + {utc(2015, 2, 1, 1, 0, 0, 0), utc(2015, 5, 30, 5, 6, 7, 0), Period{days: 1180, hours: 40, minutes: 60, seconds: 70}}, + {utc(2015, 2, 1, 1, 0, 0, 0), bst(2015, 5, 30, 5, 6, 7, 0), Period{days: 1180, hours: 30, minutes: 60, seconds: 70}}, + + // earlier month in later year + {utc(2015, 12, 22, 0, 0, 0, 0), utc(2016, 1, 10, 5, 6, 7, 0), Period{days: 190, hours: 50, minutes: 60, seconds: 70}}, + {utc(2015, 2, 11, 5, 6, 7, 500), utc(2016, 1, 10, 0, 0, 0, 0), Period{days: 3320, hours: 180, minutes: 530, seconds: 525}}, + + // larger ranges + {utc(2009, 1, 1, 0, 0, 1, 0), utc(2016, 12, 31, 0, 0, 2, 0), Period{days: 29210, seconds: 10}}, + {utc(2009, 1, 1, 0, 0, 1, 0), utc(2017, 12, 21, 0, 0, 2, 0), Period{days: 32760, seconds: 10}}, + {utc(2009, 1, 1, 0, 0, 1, 0), utc(2017, 12, 22, 0, 0, 2, 0), Period{years: 80, months: 110, days: 210, seconds: 10}}, + {utc(2009, 1, 1, 10, 10, 10, 00), utc(2017, 12, 23, 5, 5, 5, 5), Period{years: 80, months: 110, days: 220, hours: 180, minutes: 540, seconds: 550}}, + {utc(1900, 1, 1, 0, 0, 1, 0), utc(2009, 12, 31, 0, 0, 2, 0), Period{years: 1090, months: 110, days: 300, seconds: 10}}, + + {japan(2021, 3, 1, 0, 0, 0, 0), japan(2021, 9, 7, 0, 0, 0, 0), Period{days: 1900}}, + {japan(2021, 3, 1, 0, 0, 0, 0), utc(2021, 9, 7, 0, 0, 0, 0), Period{days: 1900, hours: 90}}, + } + for i, c := range cases { + pp := Between(c.a, c.b) + g.Expect(pp).To(Equal(c.expected), info(i, c.expected)) + + pn := Between(c.b, c.a) + expectValid(t, pn, info(i, c.expected)) + en := c.expected.Negate() + g.Expect(pn).To(Equal(en), info(i, en)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestNormaliseUnchanged(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + source period64 + }{ + // note: the negative cases are also covered (see below) + + // zero case + {period64{}}, + + {period64{years: 1}}, + {period64{months: 1}}, + {period64{days: 1}}, + {period64{hours: 1}}, + {period64{minutes: 1}}, + {period64{seconds: 1}}, + + {period64{years: 10, months: 10, days: 10, hours: 10, minutes: 10, seconds: 11}}, + + {period64{days: 10, hours: 70}}, + {period64{days: 10, hours: 10, minutes: 10}}, + {period64{days: 10, hours: 10, seconds: 10}}, + {period64{months: 10, days: 10, hours: 10}}, + + {period64{minutes: 10, seconds: 10}}, + {period64{hours: 10, minutes: 10}}, + {period64{years: 10, months: 7}}, + + {period64{months: 11}}, + {period64{days: 11}}, + {period64{hours: 11}}, + {period64{minutes: 11}}, + {period64{seconds: 11}}, + + // don't carry days to months + // don't carry months to years + } + for i, c := range cases { + p, err := c.source.toPeriod() + g.Expect(err).NotTo(HaveOccurred()) + + testNormalise(t, i, c.source, p, true) + testNormalise(t, i, c.source, p, false) + c.source.neg = true + testNormalise(t, i, c.source, p.Negate(), true) + testNormalise(t, i, c.source, p.Negate(), false) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestNormaliseChanged(t *testing.T) { + cases := []struct { + source period64 + precise, approx string + }{ + // note: the negative cases are also covered (see below) + + // carry seconds to minutes + //{source: period64{seconds: 600}, precise: "PT1M"}, + //{source: period64{seconds: 700}, precise: "PT1M10S"}, + //{source: period64{seconds: 6990}, precise: "PT11M39S"}, + + // carry minutes to hours + //{source: period64{minutes: 700}, precise: "PT1H10M"}, + //{source: period64{minutes: 6990}, precise: "PT11H39M"}, + + // carry hours to days + //{source: period64{hours: 480}, precise: "PT48H", approx: "P2D"}, + //{source: period64{hours: 490}, precise: "PT49H", approx: "P2D T1H"}, + //{source: period64{hours: 32761}, precise: "PT3276.1H", approx: "P4M 14D T 16.9H"}, + //{source: period64{years: 10, months: 20, days: 30, hours: 32767}, precise: "P1Y 2M 3D T3276.7H", approx: "P1Y 6M 17D T17.5H"}, + //{source: period64{hours: 32768}, precise: "P136DT12.8H", approx: "P4M 14D T17.6H"}, + //{source: period64{years: 10, months: 20, days: 30, hours: 32768}, precise: "P1Y 2M 139D T12.8H", approx: "P1Y 6M 17D T17.6H"}, + + // carry days to months + //{source: period64{days: 310}, precise: "P31D", approx: "P1M 0.5D"}, + {source: period64{days: 32760}, precise: "P3276D", approx: "P8Y 11M 19.2D"}, + {source: period64{days: 32761}, precise: "P8Y 11M 19.3D"}, + + // carry months to years + {source: period64{months: 120}, precise: "P1Y"}, + {source: period64{months: 132}, precise: "P1Y 1.2M"}, + {source: period64{months: 250}, precise: "P2Y 1M"}, + + // full ripple up + {source: period64{months: 130, days: 310, hours: 240, minutes: 600, seconds: 611}, precise: "P1Y 1M 31D T25H 1M 1.1S", approx: "P1Y 2M 1D T13H 1M 1.1S"}, + } + for i, c := range cases { + if c.approx == "" { + c.approx = c.precise + } + pp := MustParse(nospace(c.precise), false) + pa := MustParse(nospace(c.approx), false) + testNormalise(t, i, c.source, pp, true) + testNormalise(t, i, c.source, pa, false) + c.source.neg = true + testNormalise(t, i, c.source, pp.Negate(), true) + testNormalise(t, i, c.source, pa.Negate(), false) + } +} + +func testNormalise(t *testing.T, i int, source period64, expected Period, precise bool) { + g := NewGomegaWithT(t) + t.Helper() + + sstr := source.String() + n, err := source.normalise64(precise).toPeriod() + info := fmt.Sprintf("%d: %s.Normalise(%v) expected %s to equal %s", i, sstr, precise, n, expected) + expectValid(t, n, info) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(n).To(Equal(expected), info) + + if !precise { + p1, _ := source.toPeriod() + d1, pr1 := p1.Duration() + d2, pr2 := expected.Duration() + g.Expect(pr1).To(Equal(pr2), info) + g.Expect(d1).To(Equal(d2), info) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodFormat(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + period string + expectW string // with weeks + expectD string // without weeks + }{ + // note: the negative cases are also covered (see below) + + {"P0D", "0 days", ""}, + + {"P1Y1M7D", "1 year, 1 month, 1 week", "1 year, 1 month, 7 days"}, + {"P1Y1M1W1D", "1 year, 1 month, 1 week, 1 day", "1 year, 1 month, 8 days"}, + {"PT1H1M1S", "1 hour, 1 minute, 1 second", ""}, + {"P1Y1M1W1DT1H1M1S", "1 year, 1 month, 1 week, 1 day, 1 hour, 1 minute, 1 second", ""}, + {"P3Y6M39DT2H7M9S", "3 years, 6 months, 5 weeks, 4 days, 2 hours, 7 minutes, 9 seconds", ""}, + {"P365D", "52 weeks, 1 day", ""}, + + {"P1Y", "1 year", ""}, + {"P3Y", "3 years", ""}, + {"P1.1Y", "1.1 years", ""}, + {"P2.5Y", "2.5 years", ""}, + + {"P1M", "1 month", ""}, + {"P6M", "6 months", ""}, + {"P1.1M", "1.1 months", ""}, + {"P2.5M", "2.5 months", ""}, + + {"P1W", "1 week", "7 days"}, + {"P1.1W", "1 week, 0.7 day", "7.7 days"}, + {"P7D", "1 week", "7 days"}, + {"P35D", "5 weeks", "35 days"}, + {"P1D", "1 day", "1 day"}, + {"P4D", "4 days", "4 days"}, + {"P1.1D", "1.1 days", ""}, + + {"PT1H", "1 hour", ""}, + {"PT1.1H", "1.1 hours", ""}, + + {"PT1M", "1 minute", ""}, + {"PT1.1M", "1.1 minutes", ""}, + + {"PT1S", "1 second", ""}, + {"PT1.1S", "1.1 seconds", ""}, + } + for i, c := range cases { + p := MustParse(c.period, false) + sp := p.Format() + g.Expect(sp).To(Equal(c.expectW), info(i, "%s -> %s", p, c.expectW)) + + en := p.Negate() + sn := en.Format() + g.Expect(sn).To(Equal(c.expectW), info(i, "%s -> %s", en, c.expectW)) + + if c.expectD != "" { + s := MustParse(c.period, false).FormatWithoutWeeks() + g.Expect(s).To(Equal(c.expectD), info(i, "%s -> %s", p, c.expectD)) + } + } +} + +//------------------------------------------------------------------------------------------------- + +func TestPeriodOnlyYMD(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + one string + expect string + }{ + {"P1Y2M3DT4H5M6S", "P1Y2M3D"}, + {"-P6Y5M4DT3H2M1S", "-P6Y5M4D"}, + } + for i, c := range cases { + s := MustParse(c.one, false).OnlyYMD() + g.Expect(s).To(Equal(MustParse(c.expect, false)), info(i, c.expect)) + } +} + +func TestPeriodOnlyHMS(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + one string + expect string + }{ + {"P1Y2M3DT4H5M6S", "PT4H5M6S"}, + {"-P6Y5M4DT3H2M1S", "-PT3H2M1S"}, + } + for i, c := range cases { + s := MustParse(c.one, false).OnlyHMS() + g.Expect(s).To(Equal(MustParse(c.expect, false)), info(i, c.expect)) + } +} + +//------------------------------------------------------------------------------------------------- + +func TestSimplify(t *testing.T) { + cases := []struct { + source, precise, approx string + }{ + // note: the negative cases are also covered (see below) + + // simplify 1 year to months (a = 9) + {source: "P1Y"}, + {source: "P1Y10M"}, + {source: "P1Y9M", precise: "P21M"}, + {source: "P1Y8.9M", precise: "P20.9M"}, + + // simplify 1 day to hours (approx only) (b = 6) + {source: "P1DT6H", precise: "P1DT6H", approx: "PT30H"}, + {source: "P1DT7H"}, + {source: "P1DT5.9H", precise: "P1DT5.9H", approx: "PT29.9H"}, + + // simplify 1 hour to minutes (c = 10) + {source: "PT1H"}, + {source: "PT1H21M"}, + {source: "PT1H10M", precise: "PT70M"}, + {source: "PT1H9.9M", precise: "PT69.9M"}, + + // simplify 1 minute to seconds (d = 30) + {source: "PT1M"}, // unchanged + {source: "PT1M31S"}, // ditto + {source: "PT1M30S", precise: "PT90S"}, + {source: "PT1M29.9S", precise: "PT89.9S"}, + + // fractional years don't simplify + {source: "P1.1Y"}, + + // retained proper fractions + {source: "P1Y0.1D"}, + {source: "P12M0.1D"}, + {source: "P1YT0.1H"}, + {source: "P1MT0.1H"}, + {source: "P1Y0.1M", precise: "P12.1M"}, + {source: "P1DT0.1H", precise: "P1DT0.1H", approx: "PT24.1H"}, + {source: "P1YT0.1M"}, + {source: "P1MT0.1M"}, + {source: "P1DT0.1M"}, + + // discard proper fractions - months + {source: "P10Y0.1M", precise: "P10Y0.1M", approx: "P10Y"}, + // discard proper fractions - days + {source: "P1Y0.1D", precise: "P1Y0.1D", approx: "P1Y"}, + {source: "P12M0.1D", precise: "P12M0.1D", approx: "P12M"}, + // discard proper fractions - hours + {source: "P1YT0.1H", precise: "P1YT0.1H", approx: "P1Y"}, + {source: "P1MT0.1H", precise: "P1MT0.1H", approx: "P1M"}, + {source: "P30DT0.1H", precise: "P30DT0.1H", approx: "P30D"}, + // discard proper fractions - minutes + {source: "P1YT0.1M", precise: "P1YT0.1M", approx: "P1Y"}, + {source: "P1MT0.1M", precise: "P1MT0.1M", approx: "P1M"}, + {source: "P1DT0.1M", precise: "P1DT0.1M", approx: "P1D"}, + {source: "PT24H0.1M", precise: "PT24H0.1M", approx: "PT24H"}, + // discard proper fractions - seconds + {source: "P1YT0.1S", precise: "P1YT0.1S", approx: "P1Y"}, + {source: "P1MT0.1S", precise: "P1MT0.1S", approx: "P1M"}, + {source: "P1DT0.1S", precise: "P1DT0.1S", approx: "P1D"}, + {source: "PT1H0.1S", precise: "PT1H0.1S", approx: "PT1H"}, + {source: "PT60M0.1S", precise: "PT60M0.1S", approx: "PT60M"}, + } + for i, c := range cases { + p := MustParse(nospace(c.source), false) + if c.precise == "" { + // unchanged cases + testSimplify(t, i, p, p, true) + testSimplify(t, i, p.Negate(), p.Negate(), true) + + } else if c.approx == "" { + // changed but precise/approx has same result + ep := MustParse(nospace(c.precise), false) + testSimplify(t, i, p, ep, true) + testSimplify(t, i, p.Negate(), ep.Negate(), true) + + } else { + // changed and precise/approx have different results + ep := MustParse(nospace(c.precise), false) + ea := MustParse(nospace(c.approx), false) + testSimplify(t, i, p, ep, true) + testSimplify(t, i, p.Negate(), ep.Negate(), true) + testSimplify(t, i, p, ea, false) + testSimplify(t, i, p.Negate(), ea.Negate(), false) + } + } + + g := NewGomegaWithT(t) + g.Expect(Period{days: 10, hours: 70}.Simplify(false, 6, 7, 30)).To(Equal(Period{hours: 310})) + g.Expect(Period{hours: 10, minutes: 300}.Simplify(true, 6, 30)).To(Equal(Period{minutes: 900})) + g.Expect(Period{years: 10, months: 110}.Simplify(true, 11)).To(Equal(Period{months: 230})) + g.Expect(Period{days: 10, hours: 60}.Simplify(false)).To(Equal(Period{hours: 300})) +} + +func testSimplify(t *testing.T, i int, source Period, expected Period, precise bool) { + g := NewGomegaWithT(t) + t.Helper() + + sstr := source.String() + n := source.Simplify(precise, 9, 6, 10, 30) + info := fmt.Sprintf("%d: %s.Simplify(%v) expected %s to equal %s", i, sstr, precise, n, expected) + expectValid(t, n, info) + g.Expect(n).To(Equal(expected), info) +} + +//------------------------------------------------------------------------------------------------- + +func utc(year int, month time.Month, day, hour, min, sec, msec int) time.Time { + return time.Date(year, month, day, hour, min, sec, msec*int(time.Millisecond), time.UTC) +} + +func bst(year int, month time.Month, day, hour, min, sec, msec int) time.Time { + return time.Date(year, month, day, hour, min, sec, msec*int(time.Millisecond), london) +} + +func japan(year int, month time.Month, day, hour, min, sec, msec int) time.Time { + return time.Date(year, month, day, hour, min, sec, msec*int(time.Millisecond), tokyo) +} + +var london *time.Location // UTC + 1 hour during summer +var tokyo *time.Location // UTC + 1 hour during summer + +func init() { + london, _ = time.LoadLocation("Europe/London") + tokyo, _ = time.LoadLocation("Asia/Tokyo") +} + +func info(i int, m ...interface{}) string { + if s, ok := m[0].(string); ok { + m[0] = i + return fmt.Sprintf("%d "+s, m...) + } + return fmt.Sprintf("%d %v", i, m[0]) +} + +func nospace(s string) string { + b := new(strings.Builder) + for _, r := range s { + if r != ' ' { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/period/sql.go b/period/sql.go new file mode 100644 index 00000000..31b54642 --- /dev/null +++ b/period/sql.go @@ -0,0 +1,36 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "database/sql/driver" + "fmt" +) + +// Scan parses some value, which can be either string or []byte. +// It implements sql.Scanner, https://golang.org/pkg/database/sql/#Scanner +func (period *Period) Scan(value interface{}) (err error) { + if value == nil { + return nil + } + + err = nil + switch v := value.(type) { + case []byte: + *period, err = Parse(string(v), false) + case string: + *period, err = Parse(v, false) + default: + err = fmt.Errorf("%T %+v is not a meaningful period", value, value) + } + + return err +} + +// Value converts the period to a string. It implements driver.Valuer, +// https://golang.org/pkg/database/sql/driver/#Valuer +func (period Period) Value() (driver.Value, error) { + return period.String(), nil +} diff --git a/period/sql_test.go b/period/sql_test.go new file mode 100644 index 00000000..b3462250 --- /dev/null +++ b/period/sql_test.go @@ -0,0 +1,57 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package period + +import ( + "database/sql/driver" + "testing" + + . "github.com/onsi/gomega" +) + +func TestPeriodScan(t *testing.T) { + g := NewGomegaWithT(t) + + cases := []struct { + v interface{} + expected Period + }{ + {[]byte("P1Y3M"), MustParse("P1Y3M", false)}, + {"P1Y3M", MustParse("P1Y3M", false)}, + + // normalise should be disabled so that the retrieved value exactly + // matches the stored value + {[]byte("P48M"), MustParse("P48M", false)}, + {"P48M", MustParse("P48M", false)}, + } + + for _, c := range cases { + r := new(Period) + e := r.Scan(c.v) + g.Expect(e).NotTo(HaveOccurred()) + g.Expect(*r).To(Equal(c.expected)) + + var d driver.Valuer = *r + + q, e := d.Value() + g.Expect(e).NotTo(HaveOccurred()) + g.Expect(q.(string)).To(Equal(c.expected.String())) + } +} + +func TestPeriodScan_nil_value(t *testing.T) { + g := NewGomegaWithT(t) + r := new(Period) + e := r.Scan(nil) + g.Expect(e).NotTo(HaveOccurred()) +} + +func TestPeriodScan_problem_type(t *testing.T) { + g := NewGomegaWithT(t) + r := new(Period) + e := r.Scan(1) + g.Expect(e).To(HaveOccurred()) + g.Expect(e.Error()).To(ContainSubstring("not a meaningful period")) +} diff --git a/rep.go b/rep.go index 37adce88..911eee51 100644 --- a/rep.go +++ b/rep.go @@ -10,7 +10,7 @@ const secondsPerDay = 60 * 60 * 24 // encode returns the number of days elapsed from date zero to the date // corresponding to the given Time value. -func encode(t time.Time) int32 { +func encode(t time.Time) PeriodOfDays { // Compute the number of seconds elapsed since January 1, 1970 00:00:00 // in the location specified by t and not necessarily UTC. // A Time value is represented internally as an offset from a UTC base @@ -22,14 +22,14 @@ func encode(t time.Time) int32 { // Unfortunately operator / rounds towards 0, so negative values // must be handled differently if secs >= 0 { - return int32(secs / secondsPerDay) + return PeriodOfDays(secs / secondsPerDay) } - return -int32((secondsPerDay - 1 - secs) / secondsPerDay) + return -PeriodOfDays((secondsPerDay - 1 - secs) / secondsPerDay) } // decode returns the Time value corresponding to 00:00:00 UTC of the date // represented by d, the number of days elapsed since date zero. -func decode(d int32) time.Time { +func decode(d PeriodOfDays) time.Time { secs := int64(d) * secondsPerDay return time.Unix(secs, 0).UTC() } diff --git a/rep_test.go b/rep_test.go index ee3de0ed..4ee31422 100644 --- a/rep_test.go +++ b/rep_test.go @@ -17,11 +17,11 @@ func TestEncode(t *testing.T) { tBase := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) for i, c := range cases { d := encode(tBase.AddDate(0, 0, c)) - if d != int32(c) { + if d != PeriodOfDays(c) { t.Errorf("Encode(%v) == %v, want %v", i, d, c) } d = encode(tBase.AddDate(0, 0, -c)) - if d != int32(-c) { + if d != PeriodOfDays(-c) { t.Errorf("Encode(%v) == %v, want %v", i, d, c) } } @@ -73,14 +73,14 @@ func TestEncodeDecode(t *testing.T) { func TestDecodeEncode(t *testing.T) { for i := 0; i < 1000; i++ { - c := rand.Int31() + c := PeriodOfDays(rand.Int31()) d := encode(decode(c)) if d != c { t.Errorf("DecodeEncode(%v) == %v, want %v", i, d, c) } } for i := 0; i < 1000; i++ { - c := -rand.Int31() + c := -PeriodOfDays(rand.Int31()) d := encode(decode(c)) if d != c { t.Errorf("DecodeEncode(%v) == %v, want %v", i, d, c) diff --git a/sql.go b/sql.go new file mode 100644 index 00000000..ec10c105 --- /dev/null +++ b/sql.go @@ -0,0 +1,108 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package date + +import ( + "database/sql/driver" + "fmt" + "strconv" + "time" +) + +// These methods allow Date and PeriodOfDays to be fields stored in an +// SQL database by implementing the database/sql/driver interfaces. +// The underlying column type can be an integer (period of days since the epoch), +// a string, or a DATE. + +// Scan parses some value. If the value holds an integer, it is treated as the +// period-of-days value that represents a Date. Otherwise, if it holds a string, +// the AutoParse function is used. +// +// This implements sql.Scanner https://golang.org/pkg/database/sql/#Scanner +func (d *Date) Scan(value interface{}) (err error) { + if value == nil { + return nil + } + + return d.scanAny(value) +} + +func (d *Date) scanAny(value interface{}) (err error) { + err = nil + switch v := value.(type) { + case int64: + *d = Date{PeriodOfDays(v)} + case []byte: + return d.scanString(string(v)) + case string: + return d.scanString(v) + case time.Time: + *d = NewAt(v) + default: + err = fmt.Errorf("%T %+v is not a meaningful date", value, value) + } + + return err +} + +func (d *Date) scanString(value string) (err error) { + n, err := strconv.ParseInt(value, 10, 64) + if err == nil { + *d = Date{PeriodOfDays(n)} + return nil + } + *d, err = AutoParse(value) + return err +} + +// Value converts the value to an int64. Note that if you need to store as a string, +// convert the Date to a DateString. +// +// This implements driver.Valuer https://golang.org/pkg/database/sql/driver/#Valuer +func (d Date) Value() (driver.Value, error) { + return int64(d.day), nil +} + +//------------------------------------------------------------------------------------------------- + +// DateString alters Date to make database storage use a string column, or +// a similar derived column such as SQL DATE. (Otherwise, Date is stored as +// an integer). +type DateString Date + +// Date provides a simple fluent type conversion to the underlying type. +func (ds DateString) Date() Date { + return Date(ds) +} + +// DateString provides a simple fluent type conversion from the underlying type. +func (d Date) DateString() DateString { + return DateString(d) +} + +// Scan parses some value. If the value holds an integer, it is treated as the +// period-of-days value that represents a Date. Otherwise, if it holds a string, +// the AutoParse function is used. +// +// This implements sql.Scanner https://golang.org/pkg/database/sql/#Scanner +func (ds *DateString) Scan(value interface{}) (err error) { + if value == nil { + return nil + } + return (*Date)(ds).Scan(value) +} + +// Value converts the value to a string. Note that if you only need to store as an int64, +// convert the DateString to a Date. +// +// This implements driver.Valuer https://golang.org/pkg/database/sql/driver/#Valuer +func (ds DateString) Value() (driver.Value, error) { + return ds.Date().String(), nil +} + +//------------------------------------------------------------------------------------------------- + +// Deprecated: DisableTextStorage is no longer used. +var DisableTextStorage = false diff --git a/sql_test.go b/sql_test.go new file mode 100644 index 00000000..44dc5f5e --- /dev/null +++ b/sql_test.go @@ -0,0 +1,133 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package date + +import ( + "database/sql/driver" + "testing" +) + +func TestDate_Scan(t *testing.T) { + cases := []struct { + v interface{} + expected PeriodOfDays + }{ + {int64(0), 0}, + {int64(1000), 1000}, + {int64(10000), 10000}, + {int64(0), 0}, + {int64(1000), 1000}, + {int64(10000), 10000}, + {"0", 0}, + {"1000", 1000}, + {"10000", 10000}, + {"2018-12-31", 17896}, + {"31/12/2018", 17896}, + {[]byte("10000"), 10000}, + {PeriodOfDays(10000).Date().Local(), 10000}, + } + + for i, c := range cases { + r := new(Date) + e := r.Scan(c.v) + if e != nil { + t.Errorf("%d: Got %v for %d", i, e, c.expected) + } + if r.DaysSinceEpoch() != c.expected { + t.Errorf("%d: Got %v, want %d", i, *r, c.expected) + } + + var d driver.Valuer = *r + + q, e := d.Value() + if e != nil { + t.Errorf("%d: Got %v for %d", i, e, c.expected) + } + if q.(int64) != int64(c.expected) { + t.Errorf("%d: Got %v, want %d", i, q, c.expected) + } + } +} + +func TestDateString_Scan(t *testing.T) { + cases := []struct { + v interface{} + expected string + }{ + {int64(0), "1970-01-01"}, + {int64(15000), "2011-01-26"}, + {"0", "1970-01-01"}, + {"15000", "2011-01-26"}, + {"2018-12-31", "2018-12-31"}, + {"31/12/2018", "2018-12-31"}, + //{[]byte("10000"), ""}, + //{PeriodOfDays(10000).Date().Local(), ""}, + } + + for i, c := range cases { + r := new(DateString) + e := r.Scan(c.v) + if e != nil { + t.Errorf("%d: Got %v for %s", i, e, c.expected) + } + if r.Date().String() != c.expected { + t.Errorf("%d: Got %v, want %s", i, r.Date(), c.expected) + } + + var d driver.Valuer = *r + + q, e := d.Value() + if e != nil { + t.Errorf("%d: Got %v for %s", i, e, c.expected) + } + if q.(string) != c.expected { + t.Errorf("%d: Got %v, want %s", i, q, c.expected) + } + } +} + +func TestDate_Scan_with_junk(t *testing.T) { + cases := []struct { + v interface{} + expected string + }{ + {true, "bool true is not a meaningful date"}, + {true, "bool true is not a meaningful date"}, + } + + for i, c := range cases { + r := new(Date) + e := r.Scan(c.v) + if e.Error() != c.expected { + t.Errorf("%d: Got %q, want %q", i, e.Error(), c.expected) + } + } +} + +func TestDateString_Scan_with_junk(t *testing.T) { + cases := []struct { + v interface{} + expected string + }{ + {true, "bool true is not a meaningful date"}, + {true, "bool true is not a meaningful date"}, + } + + for i, c := range cases { + r := new(DateString) + e := r.Scan(c.v) + if e.Error() != c.expected { + t.Errorf("%d: Got %q, want %q", i, e.Error(), c.expected) + } + } +} + +func TestDate_Scan_with_nil(t *testing.T) { + var r *Date + e := r.Scan(nil) + if e != nil { + t.Errorf("Got %v", e) + } +} diff --git a/timespan/daterange.go b/timespan/daterange.go new file mode 100644 index 00000000..dae51aee --- /dev/null +++ b/timespan/daterange.go @@ -0,0 +1,308 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package timespan + +import ( + "fmt" + "time" + + "github.com/rickb777/date" + "github.com/rickb777/date/period" +) + +const minusOneNano time.Duration = -1 + +// DateRange carries a date and a number of days and describes a range between two dates. +type DateRange struct { + mark date.Date + days date.PeriodOfDays +} + +// NewDateRangeOf assembles a new date range from a start time and a duration, discarding +// the precise time-of-day information. The start time includes a location, which is not +// necessarily UTC. The duration can be negative. +func NewDateRangeOf(start time.Time, duration time.Duration) DateRange { + sd := date.NewAt(start) + ed := date.NewAt(start.Add(duration)) + return DateRange{sd, date.PeriodOfDays(ed.Sub(sd))} +} + +// NewDateRange assembles a new date range from two dates. These are half-open, so +// if start and end are the same, the range spans zero (not one) day. Similarly, if they +// are on subsequent days, the range is one date (not two). +// The result is normalised. +func NewDateRange(start, end date.Date) DateRange { + if end.Before(start) { + return DateRange{end, date.PeriodOfDays(start.Sub(end))} + } + return DateRange{start, date.PeriodOfDays(end.Sub(start))} +} + +// NewYearOf constructs the range encompassing the whole year specified. +func NewYearOf(year int) DateRange { + start := date.New(year, time.January, 1) + end := date.New(year+1, time.January, 1) + return DateRange{start, date.PeriodOfDays(end.Sub(start))} +} + +// NewMonthOf constructs the range encompassing the whole month specified for a given year. +// It handles leap years correctly. +func NewMonthOf(year int, month time.Month) DateRange { + start := date.New(year, month, 1) + endT := time.Date(year, month+1, 1, 0, 0, 0, 0, time.UTC) + end := date.NewAt(endT) + return DateRange{start, date.PeriodOfDays(end.Sub(start))} +} + +// EmptyRange constructs an empty range. This is often a useful basis for +// further operations but note that the end date is undefined. +func EmptyRange(day date.Date) DateRange { + return DateRange{day, 0} +} + +// OneDayRange constructs a range of exactly one day. This is often a useful basis for +// further operations. Note that the last date is the same as the start date. +func OneDayRange(day date.Date) DateRange { + return DateRange{day, 1} +} + +// DayRange constructs a range of n days. +// +// Note that n can be negative. In this case, the specified day will be the end day, +// which is outside of the half-open range; the last day will be the day before the +// day specified. +func DayRange(day date.Date, n date.PeriodOfDays) DateRange { + if n < 0 { + return DateRange{day.Add(n), -n} + } + return DateRange{day, n} +} + +// Days returns the period represented by this range. This will never be negative. +func (dateRange DateRange) Days() date.PeriodOfDays { + if dateRange.days < 0 { + return -dateRange.days + } + return dateRange.days +} + +// IsZero returns true if this has a zero start date and the the range is empty. +// Usually this is because the range was created via the zero value. +func (dateRange DateRange) IsZero() bool { + return dateRange.days == 0 && dateRange.mark.IsZero() +} + +// IsEmpty returns true if this has a starting date but the range is empty (zero days). +func (dateRange DateRange) IsEmpty() bool { + return dateRange.days == 0 +} + +// Start returns the earliest date represented by this range. +func (dateRange DateRange) Start() date.Date { + if dateRange.days < 0 { + return dateRange.mark.Add(date.PeriodOfDays(1 + dateRange.days)) + } + return dateRange.mark +} + +// Last returns the last date (inclusive) represented by this range. Be careful because +// if the range is empty (i.e. has zero days), then the last is undefined so an empty date +// is returned. Therefore it is often more useful to use End() instead of Last(). +// See also IsEmpty(). +func (dateRange DateRange) Last() date.Date { + if dateRange.days < 0 { + return dateRange.mark // because mark is at the end + } else if dateRange.days == 0 { + return date.Date{} + } + return dateRange.mark.Add(dateRange.days - 1) +} + +// End returns the date following the last date of the range. End can be considered to +// be the exclusive end, i.e. the final value of a half-open range. +// +// If the range is empty (i.e. has zero days), then the start date is returned, this being +// also the (half-open) end value in that case. This is more useful than the undefined result +// returned by Last() for empty ranges. +func (dateRange DateRange) End() date.Date { + if dateRange.days < 0 { + return dateRange.mark.Add(1) // because mark is at the end + } + return dateRange.mark.Add(dateRange.days) +} + +// Normalise ensures that the number of days is zero or positive. +// The normalised date range is returned; +// in this value, the mark date is the same as the start date. +func (dateRange DateRange) Normalise() DateRange { + if dateRange.days < 0 { + return DateRange{dateRange.mark.Add(dateRange.days), -dateRange.days} + } + return dateRange +} + +// ShiftBy moves the date range by moving both the start and end dates similarly. +// A negative parameter is allowed. +func (dateRange DateRange) ShiftBy(days date.PeriodOfDays) DateRange { + if days == 0 { + return dateRange + } + newMark := dateRange.mark.Add(days) + return DateRange{newMark, dateRange.days} +} + +// ExtendBy extends (or reduces) the date range by moving the end date. +// A negative parameter is allowed and this may cause the range to become inverted +// (i.e. the mark date becomes the end date instead of the start date). +func (dateRange DateRange) ExtendBy(days date.PeriodOfDays) DateRange { + if days == 0 { + return dateRange + } + return DateRange{dateRange.mark, dateRange.days + days}.Normalise() +} + +// ShiftByPeriod moves the date range by moving both the start and end dates similarly. +// A negative parameter is allowed. +// +// Any time component is ignored. Therefore, be careful with periods containing +// more that 24 hours in the hours/minutes/seconds fields. These will not be +// normalised for you; if you want this behaviour, call delta.Normalise(false) +// on the input parameter. +// +// For example, PT24H adds nothing, whereas P1D adds one day as expected. To +// convert a period such as PT24H to its equivalent P1D, use +// delta.Normalise(false) as the input. +func (dateRange DateRange) ShiftByPeriod(delta period.Period) DateRange { + if delta.IsZero() { + return dateRange + } + newMark := dateRange.mark.AddPeriod(delta) + //fmt.Printf("mark + %v : %v -> %v", delta, dateRange.mark, newMark) + return DateRange{newMark, dateRange.days} +} + +// ExtendByPeriod extends (or reduces) the date range by moving the end date. +// A negative parameter is allowed and this may cause the range to become inverted +// (i.e. the mark date becomes the end date instead of the start date). +func (dateRange DateRange) ExtendByPeriod(delta period.Period) DateRange { + if delta.IsZero() { + return dateRange + } + newEnd := dateRange.End().AddPeriod(delta) + //fmt.Printf("%v, end + %v : %v -> %v", dateRange.mark, delta, dateRange.End(), newEnd) + return NewDateRange(dateRange.Start(), newEnd) +} + +// String describes the date range in human-readable form. +func (dateRange DateRange) String() string { + norm := dateRange.Normalise() + switch norm.days { + case 0: + return fmt.Sprintf("0 days at %s", norm.mark) + case 1: + return fmt.Sprintf("1 day on %s", norm.mark) + default: + return fmt.Sprintf("%d days from %s to %s", norm.days, norm.Start(), norm.Last()) + } +} + +// Contains tests whether the date range contains a specified date. +// Empty date ranges (i.e. zero days) never contain anything. +func (dateRange DateRange) Contains(d date.Date) bool { + if dateRange.days == 0 { + return false + } + return !(d.Before(dateRange.Start()) || d.After(dateRange.Last())) +} + +// StartUTC assumes that the start date is a UTC date and gets the start time of that date, as UTC. +// It returns midnight on the first day of the range. +func (dateRange DateRange) StartUTC() time.Time { + return dateRange.Start().UTC() +} + +// EndUTC assumes that the end date is a UTC date and returns the time a nanosecond after the end time +// in a specified location. Along with StartUTC, this gives a 'half-open' range where the start +// is inclusive and the end is exclusive. +func (dateRange DateRange) EndUTC() time.Time { + return dateRange.End().UTC() +} + +// ContainsTime tests whether a given local time is within the date range. The time range is +// from midnight on the start day to one nanosecond before midnight on the day after the end date. +// Empty date ranges (i.e. zero days) never contain anything. +// +// If a calculation needs to be 'half-open' (i.e. the end date is exclusive), simply use the +// expression 'dateRange.ExtendBy(-1).ContainsTime(t)' +func (dateRange DateRange) ContainsTime(t time.Time) bool { + if dateRange.days == 0 { + return false + } + utc := t.In(time.UTC) + return !(utc.Before(dateRange.StartUTC()) || dateRange.EndUTC().Add(minusOneNano).Before(utc)) +} + +// Merge combines two date ranges by calculating a date range that just encompasses them both. +// There are two special cases. +// +// Firstly, if one range is entirely contained within the other range, the larger of the two is +// returned. Otherwise, the result is from the start of the earlier one to the end of the later +// one, even if the two ranges don't overlap. +// +// Secondly, if either range is the zero value (see IsZero), it is excluded from the merge and +// the other range is returned unchanged. +func (dateRange DateRange) Merge(otherRange DateRange) DateRange { + if otherRange.IsZero() { + return dateRange + } + if dateRange.IsZero() { + return otherRange + } + minStart := dateRange.Start().Min(otherRange.Start()) + maxEnd := dateRange.End().Max(otherRange.End()) + return NewDateRange(minStart, maxEnd) +} + +// Duration computes the duration (in nanoseconds) from midnight at the start of the date +// range up to and including the very last nanosecond before midnight on the end day. +// The calculation is for UTC, which does not have daylight saving and every day has 24 hours. +// +// If the range is greater than approximately 290 years, the result will hard-limit to the +// minimum or maximum possible duration (see time.Sub(t)). +func (dateRange DateRange) Duration() time.Duration { + return dateRange.End().UTC().Sub(dateRange.Start().UTC()) +} + +// DurationIn computes the duration (in nanoseconds) from midnight at the start of the date +// range up to and including the very last nanosecond before midnight on the end day. +// The calculation is for the specified location, which may have daylight saving, so not every day +// necessarily has 24 hours. If the date range spans the day the clocks are changed, this is +// taken into account. +// +// If the range is greater than approximately 290 years, the result will hard-limit to the +// minimum or maximum possible duration (see time.Sub(t)). +func (dateRange DateRange) DurationIn(loc *time.Location) time.Duration { + return dateRange.EndTimeIn(loc).Sub(dateRange.StartTimeIn(loc)) +} + +// StartTimeIn returns the start time in a specified location. +func (dateRange DateRange) StartTimeIn(loc *time.Location) time.Time { + return dateRange.Start().In(loc) +} + +// EndTimeIn returns the nanosecond after the end time in a specified location. Along with +// StartTimeIn, this gives a 'half-open' range where the start is inclusive and the end is +// exclusive. +func (dateRange DateRange) EndTimeIn(loc *time.Location) time.Time { + return dateRange.End().In(loc) +} + +// TimeSpanIn obtains the time span corresponding to the date range in a specified location. +// The result is normalised. +func (dateRange DateRange) TimeSpanIn(loc *time.Location) TimeSpan { + s := dateRange.StartTimeIn(loc) + d := dateRange.DurationIn(loc) + return TimeSpan{s, d} +} diff --git a/timespan/daterange_test.go b/timespan/daterange_test.go new file mode 100644 index 00000000..a7211db1 --- /dev/null +++ b/timespan/daterange_test.go @@ -0,0 +1,347 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package timespan + +import ( + "fmt" + "strings" + "testing" + "time" + + . "github.com/rickb777/date" + "github.com/rickb777/date/period" +) + +var d0320 = New(2015, time.March, 20) +var d0321 = New(2015, time.March, 21) +var d0325 = New(2015, time.March, 25) +var d0326 = New(2015, time.March, 26) +var d0327 = New(2015, time.March, 27) +var d0328 = New(2015, time.March, 28) +var d0329 = New(2015, time.March, 29) // n.b. clocks go forward (UK) +var d0330 = New(2015, time.March, 30) +var d0331 = New(2015, time.March, 31) +var d0401 = New(2015, time.April, 1) +var d0402 = New(2015, time.April, 2) +var d0403 = New(2015, time.April, 3) +var d0404 = New(2015, time.April, 4) +var d0407 = New(2015, time.April, 7) +var d0408 = New(2015, time.April, 8) +var d0409 = New(2015, time.April, 9) +var d0410 = New(2015, time.April, 10) +var d0501 = New(2015, time.May, 1) +var d1025 = New(2015, time.October, 25) + +var london *time.Location = mustLoadLocation("Europe/London") + +func mustLoadLocation(name string) *time.Location { + loc, err := time.LoadLocation("Europe/London") + if err != nil { + panic(err) + } + return loc +} + +func TestNewDateRangeOf(t *testing.T) { + dr := NewDateRangeOf(t0327, 7*24*time.Hour) + isEq(t, 0, dr.mark, d0327) + isEq(t, 0, dr.Days(), PeriodOfDays(7)) + isEq(t, 0, dr.IsEmpty(), false) + isEq(t, 0, dr.Start(), d0327) + isEq(t, 0, dr.Last(), d0402) + isEq(t, 0, dr.End(), d0403) + + dr2 := NewDateRangeOf(t0327, -7*24*time.Hour) + isEq(t, 0, dr2.mark, d0327) + isEq(t, 0, dr2.Days(), PeriodOfDays(7)) + isEq(t, 0, dr2.IsEmpty(), false) + isEq(t, 0, dr2.Start(), d0321) + isEq(t, 0, dr2.Last(), d0327) + isEq(t, 0, dr2.End(), d0328) +} + +func TestNewDateRangeWithNormalise(t *testing.T) { + r1 := NewDateRange(d0327, d0402) + isEq(t, 0, r1.Start(), d0327) + isEq(t, 0, r1.Last(), d0401) + isEq(t, 0, r1.End(), d0402) + + r2 := NewDateRange(d0402, d0327) + isEq(t, 0, r2.Start(), d0327) + isEq(t, 0, r2.Last(), d0401) + isEq(t, 0, r2.End(), d0402) +} + +func TestEmptyRange(t *testing.T) { + drN0 := DateRange{d0327, -1} + isEq(t, 0, drN0.Days(), PeriodOfDays(1)) + isEq(t, 0, drN0.IsZero(), false) + isEq(t, 0, drN0.IsEmpty(), false) + isEq(t, 0, drN0.Start(), d0327) + isEq(t, 0, drN0.Last(), d0327) + isEq(t, 0, drN0.String(), "1 day on 2015-03-26") + + dr0 := DateRange{} + isEq(t, 0, dr0.Days(), PeriodOfDays(0)) + isEq(t, 0, dr0.IsZero(), true) + isEq(t, 0, dr0.IsEmpty(), true) + isEq(t, 0, dr0.String(), "0 days at 1970-01-01") + + dr1 := EmptyRange(Date{}) + isEq(t, 0, dr1.IsZero(), true) + isEq(t, 0, dr1.IsEmpty(), true) + isEq(t, 0, dr1.Days(), PeriodOfDays(0)) + + dr2 := EmptyRange(d0327) + isEq(t, 0, dr2.IsZero(), false) + isEq(t, 0, dr2.IsEmpty(), true) + isEq(t, 0, dr2.Start(), d0327) + isEq(t, 0, dr2.Last().IsZero(), true) + isEq(t, 0, dr2.End(), d0327) + isEq(t, 0, dr2.Days(), PeriodOfDays(0)) + isEq(t, 0, dr2.String(), "0 days at 2015-03-27") +} + +func TestOneDayRange(t *testing.T) { + dr1 := OneDayRange(Date{}) + isEq(t, 0, dr1.IsZero(), false) + isEq(t, 0, dr1.IsEmpty(), false) + isEq(t, 0, dr1.Days(), PeriodOfDays(1)) + + dr2 := OneDayRange(d0327) + isEq(t, 0, dr2.Start(), d0327) + isEq(t, 0, dr2.Last(), d0327) + isEq(t, 0, dr2.End(), d0328) + isEq(t, 0, dr2.Days(), PeriodOfDays(1)) + isEq(t, 0, dr2.String(), "1 day on 2015-03-27") +} + +func TestDayRange(t *testing.T) { + dr1 := DayRange(Date{}, 0) + isEq(t, 0, dr1.IsZero(), true) + isEq(t, 0, dr1.IsEmpty(), true) + isEq(t, 0, dr1.Days(), PeriodOfDays(0)) + + dr2 := DayRange(d0327, 2) + isEq(t, 0, dr2.Start(), d0327) + isEq(t, 0, dr2.Last(), d0328) + isEq(t, 0, dr2.End(), d0329) + isEq(t, 0, dr2.Days(), PeriodOfDays(2)) + isEq(t, 0, dr2.String(), "2 days from 2015-03-27 to 2015-03-28") + + dr3 := DayRange(d0327, -2) + isEq(t, 0, dr3.Start(), d0325) + isEq(t, 0, dr3.Last(), d0326) + isEq(t, 0, dr3.End(), d0327) + isEq(t, 0, dr3.Days(), PeriodOfDays(2)) + isEq(t, 0, dr3.String(), "2 days from 2015-03-25 to 2015-03-26") +} + +func TestNewYearOf(t *testing.T) { + dr := NewYearOf(2015) + isEq(t, 0, dr.Days(), PeriodOfDays(365)) + isEq(t, 0, dr.Start(), New(2015, time.January, 1)) + isEq(t, 0, dr.Last(), New(2015, time.December, 31)) + isEq(t, 0, dr.End(), New(2016, time.January, 1)) +} + +func TestNewMonthOf(t *testing.T) { + dr := NewMonthOf(2015, time.February) + isEq(t, 0, dr.Days(), PeriodOfDays(28)) + isEq(t, 0, dr.Start(), New(2015, time.February, 1)) + isEq(t, 0, dr.Last(), New(2015, time.February, 28)) + isEq(t, 0, dr.End(), New(2015, time.March, 1)) +} + +func TestShiftAndExtend(t *testing.T) { + cases := []struct { + dr DateRange + n PeriodOfDays + start Date + end Date + s string + }{ + {DayRange(d0327, 6).ShiftBy(0), 6, d0327, d0402, "6 days from 2015-03-27 to 2015-04-01"}, + {DayRange(d0327, 6).ShiftBy(7), 6, d0403, d0409, "6 days from 2015-04-03 to 2015-04-08"}, + {DayRange(d0327, 6).ShiftBy(-1), 6, d0326, d0401, "6 days from 2015-03-26 to 2015-03-31"}, + {DayRange(d0327, 6).ShiftBy(-7), 6, d0320, d0326, "6 days from 2015-03-20 to 2015-03-25"}, + {NewDateRange(d0327, d0402).ShiftBy(-7), 6, d0320, d0326, "6 days from 2015-03-20 to 2015-03-25"}, + + {EmptyRange(d0327).ExtendBy(0), 0, d0327, d0327, "0 days at 2015-03-27"}, + {EmptyRange(d0327).ExtendBy(6), 6, d0327, d0402, "6 days from 2015-03-27 to 2015-04-01"}, + {DayRange(d0327, 6).ExtendBy(0), 6, d0327, d0402, "6 days from 2015-03-27 to 2015-04-01"}, + {DayRange(d0327, 6).ExtendBy(7), 13, d0327, d0409, "13 days from 2015-03-27 to 2015-04-08"}, + {DayRange(d0327, 6).ExtendBy(-6), 0, d0327, d0327, "0 days at 2015-03-27"}, + {DayRange(d0327, 6).ExtendBy(-8), 2, d0325, d0327, "2 days from 2015-03-25 to 2015-03-26"}, + + {DayRange(d0327, 6).ShiftByPeriod(period.NewYMD(0, 0, 0)), 6, d0327, d0402, "6 days from 2015-03-27 to 2015-04-01"}, + {DayRange(d0327, 6).ShiftByPeriod(period.NewYMD(0, 0, 7)), 6, d0403, d0409, "6 days from 2015-04-03 to 2015-04-08"}, + {DayRange(d0327, 6).ShiftByPeriod(period.NewYMD(0, 0, -7)), 6, d0320, d0326, "6 days from 2015-03-20 to 2015-03-25"}, + + {DayRange(d0327, 6).ExtendByPeriod(period.NewYMD(0, 0, 0)), 6, d0327, d0402, "6 days from 2015-03-27 to 2015-04-01"}, + {DayRange(d0327, 6).ExtendByPeriod(period.NewYMD(0, 0, 7)), 13, d0327, d0409, "13 days from 2015-03-27 to 2015-04-08"}, + {DayRange(d0327, 6).ExtendByPeriod(period.NewYMD(0, 0, -5)), 1, d0327, d0328, "1 day on 2015-03-27"}, + {DayRange(d0327, 6).ExtendByPeriod(period.NewYMD(0, 0, -6)), 0, d0327, d0327, "0 days at 2015-03-27"}, + {DayRange(d0327, 6).ExtendByPeriod(period.NewYMD(0, 0, -7)), 1, d0326, d0327, "1 day on 2015-03-26"}, + } + + for i, c := range cases { + isEq(t, i, c.dr.Days(), c.n) + isEq(t, i, c.dr.Start(), c.start) + isEq(t, i, c.dr.End(), c.end) + isEq(t, i, c.dr.String(), c.s) + } +} + +func TestContains0(t *testing.T) { + old := time.Local + time.Local = time.FixedZone("Test", 7200) + dr := EmptyRange(d0326) + isEq(t, 0, dr.Contains(d0320), false, dr, d0320) + time.Local = old +} + +func TestContains1(t *testing.T) { + old := time.Local + time.Local = time.FixedZone("Test", 7200) + dr := DayRange(d0326, 2) + isEq(t, 0, dr.Contains(d0320), false, dr, d0320) + isEq(t, 0, dr.Contains(d0325), false, dr, d0325) + isEq(t, 0, dr.Contains(d0326), true, dr, d0326) + isEq(t, 0, dr.Contains(d0327), true, dr, d0327) + isEq(t, 0, dr.Contains(d0328), false, dr, d0328) + isEq(t, 0, dr.Contains(d0401), false, dr, d0401) + isEq(t, 0, dr.Contains(d0410), false, dr, d0410) + isEq(t, 0, dr.Contains(d0501), false, dr, d0501) + time.Local = old +} + +func TestContains2(t *testing.T) { + old := time.Local + time.Local = time.FixedZone("Test", 7200) + dr := OneDayRange(d0326) + isEq(t, 0, dr.Contains(d0325), false, dr, d0325) + isEq(t, 0, dr.Contains(d0326), true, dr, d0326) + isEq(t, 0, dr.Contains(d0327), false, dr, d0327) + time.Local = old +} + +func TestContainsTime0(t *testing.T) { + old := time.Local + time.Local = time.FixedZone("Test", 7200) + t0328e := time.Date(2015, 3, 28, 23, 59, 59, 999999999, time.UTC) + t0329 := time.Date(2015, 3, 29, 0, 0, 0, 0, time.UTC) + + dr := EmptyRange(d0327) + isEq(t, 0, dr.StartUTC(), t0327, dr, t0327) + isEq(t, 0, dr.EndUTC(), t0327, dr, t0327) + isEq(t, 0, dr.ContainsTime(t0327), false, dr, t0327) + isEq(t, 0, dr.ContainsTime(t0328), false, dr, t0328) + isEq(t, 0, dr.ContainsTime(t0328e), false, dr, t0328e) + isEq(t, 0, dr.ContainsTime(t0329), false, dr, t0329) + time.Local = old +} + +func TestContainsTimeUTC(t *testing.T) { + old := time.Local + time.Local = time.FixedZone("Test", 7200) + t0328e := time.Date(2015, 3, 28, 23, 59, 59, 999999999, time.UTC) + t0329 := time.Date(2015, 3, 29, 0, 0, 0, 0, time.UTC) + + dr := DayRange(d0327, 2) + isEq(t, 0, dr.StartUTC(), t0327, dr, t0327) + isEq(t, 0, dr.EndUTC(), t0329, dr, t0329) + isEq(t, 0, dr.ContainsTime(t0327), true, dr, t0327) + isEq(t, 0, dr.ContainsTime(t0328), true, dr, t0328) + isEq(t, 0, dr.ContainsTime(t0328e), true, dr, t0328e) + isEq(t, 0, dr.ContainsTime(t0329), false, dr, t0329) + time.Local = old +} + +func TestMerge1(t *testing.T) { + dr1 := DayRange(d0327, 2) + dr2 := DayRange(d0327, 8) + m1 := dr1.Merge(dr2) + m2 := dr2.Merge(dr1) + isEq(t, 0, m1.Start(), d0327) + isEq(t, 0, m1.End(), d0404) + isEq(t, 0, m1, m2) +} + +func TestMerge2(t *testing.T) { + dr1 := DayRange(d0328, 2) + dr2 := DayRange(d0327, 8) + m1 := dr1.Merge(dr2) + m2 := dr2.Merge(dr1) + isEq(t, 0, m1.Start(), d0327) + isEq(t, 0, m1.End(), d0404) + isEq(t, 0, m1, m2) +} + +func TestMergeOverlapping(t *testing.T) { + dr1 := OneDayRange(d0320).ExtendBy(12) + dr2 := OneDayRange(d0401).ExtendBy(6) + m1 := dr1.Merge(dr2) + m2 := dr2.Merge(dr1) + isEq(t, 0, m1.Start(), d0320) + isEq(t, 0, m1.End(), d0408) + isEq(t, 0, m1, m2) +} + +func TestMergeNonOverlapping(t *testing.T) { + dr1 := OneDayRange(d0320).ExtendBy(2) + dr2 := OneDayRange(d0401).ExtendBy(6) + m1 := dr1.Merge(dr2) + m2 := dr2.Merge(dr1) + isEq(t, 0, m1.Start(), d0320) + isEq(t, 0, m1.End(), d0408) + isEq(t, 0, m1, m2) +} + +func TestMergeEmpties(t *testing.T) { + dr1 := EmptyRange(d0320) + dr2 := EmptyRange(d0408) // curiously, this is *not* included because it has no size. + m1 := dr1.Merge(dr2) + m2 := dr2.Merge(dr1) + isEq(t, 0, m1.Start(), d0320) + isEq(t, 0, m1.End(), d0408) + isEq(t, 0, m1, m2) +} + +func TestMergeZeroes(t *testing.T) { + dr0 := DateRange{} + dr1 := OneDayRange(d0401).ExtendBy(6) + m1 := dr1.Merge(dr0) + m2 := dr0.Merge(dr1) + m3 := dr0.Merge(dr0) + isEq(t, 0, m1.Start(), d0401) + isEq(t, 0, m1.End(), d0408) + isEq(t, 0, m1, m2) + isEq(t, 0, m3.IsZero(), true) + isEq(t, 0, m3, dr0) +} + +func TestDurationNormalUTC(t *testing.T) { + dr := OneDayRange(d0329) + isEq(t, 0, dr.Duration(), time.Hour*24) +} + +func TestDurationInZoneWithDaylightSaving(t *testing.T) { + isEq(t, 0, OneDayRange(d0328).DurationIn(london), time.Hour*24) + isEq(t, 0, OneDayRange(d0329).DurationIn(london), time.Hour*23) + isEq(t, 0, OneDayRange(d1025).DurationIn(london), time.Hour*25) + isEq(t, 0, NewDateRange(d0328, d0331).DurationIn(london), time.Hour*71) +} + +func isEq(t *testing.T, i int, a, b interface{}, msg ...interface{}) { + t.Helper() + if a != b { + sa := make([]string, len(msg)) + for i, m := range msg { + sa[i] = fmt.Sprintf(", %v", m) + } + t.Errorf("%d: %+v is not equal to %+v%s", i, a, b, strings.Join(sa, "")) + } +} diff --git a/timespan/doc.go b/timespan/doc.go new file mode 100644 index 00000000..93737cff --- /dev/null +++ b/timespan/doc.go @@ -0,0 +1,8 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package timespan provides spans of time (TimeSpan), and ranges of dates (DateRange). +// Both are half-open intervals for which the start is included and the end is excluded. +// This allows for empty spans and also facilitates aggregating spans together. +package timespan diff --git a/timespan/timespan.go b/timespan/timespan.go new file mode 100644 index 00000000..80fcce4b --- /dev/null +++ b/timespan/timespan.go @@ -0,0 +1,291 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package timespan + +import ( + "fmt" + "strings" + "time" + + "github.com/rickb777/date" + "github.com/rickb777/date/period" +) + +// TimestampFormat is a simple format for date & time, "2006-01-02 15:04:05". +const TimestampFormat = "2006-01-02 15:04:05" + +//const ISOFormat = "2006-01-02T15:04:05" + +// TimeSpan holds a span of time between two instants with a 1 nanosecond resolution. +// It is implemented using a time.Duration, therefore is limited to a maximum span of 290 years. +type TimeSpan struct { + mark time.Time + duration time.Duration +} + +// ZeroTimeSpan creates a new zero-duration time span at a specified time. +func ZeroTimeSpan(start time.Time) TimeSpan { + return TimeSpan{start, 0} +} + +// TimeSpanOf creates a new time span at a specified time and duration. +func TimeSpanOf(start time.Time, d time.Duration) TimeSpan { + return TimeSpan{start, d} +} + +// NewTimeSpan creates a new time span from two times. The start and end can be in either +// order; the result will be normalised. The inputs are half-open: the start is included and +// the end is excluded. +func NewTimeSpan(t1, t2 time.Time) TimeSpan { + if t2.Before(t1) { + return TimeSpan{t2, t1.Sub(t2)} + } + return TimeSpan{t1, t2.Sub(t1)} +} + +// Start gets the end time of the time span. +func (ts TimeSpan) Start() time.Time { + if ts.duration < 0 { + return ts.mark.Add(ts.duration) + } + return ts.mark +} + +// End gets the end time of the time span. Strictly, this is one nanosecond after the +// range of time included in the time span; this implements the half-open model. +func (ts TimeSpan) End() time.Time { + if ts.duration < 0 { + return ts.mark + } + return ts.mark.Add(ts.duration) +} + +// Duration gets the duration of the time span. +func (ts TimeSpan) Duration() time.Duration { + return ts.duration +} + +// IsEmpty returns true if this is an empty time span (zero duration). +func (ts TimeSpan) IsEmpty() bool { + return ts.duration == 0 +} + +// Normalise ensures that the mark time is at the start time and the duration is positive. +// The normalised time span is returned. +func (ts TimeSpan) Normalise() TimeSpan { + if ts.duration < 0 { + return TimeSpan{ts.mark.Add(ts.duration), -ts.duration} + } + return ts +} + +// ShiftBy moves the time span by moving both the start and end times similarly. +// A negative parameter is allowed. +func (ts TimeSpan) ShiftBy(d time.Duration) TimeSpan { + return TimeSpan{ts.mark.Add(d), ts.duration} +} + +// ExtendBy lengthens the time span by a specified amount. The parameter may be negative, +// in which case it is possible that the end of the time span will appear to be before the +// start. However, the result is normalised so that the resulting start is the lesser value. +func (ts TimeSpan) ExtendBy(d time.Duration) TimeSpan { + return TimeSpan{ts.mark, ts.duration + d}.Normalise() +} + +// ExtendWithoutWrapping lengthens the time span by a specified amount. The parameter may be +// negative, but if its magnitude is large than the time span's duration, it will be truncated +// so that the result has zero duration in that case. The start time is never altered. +func (ts TimeSpan) ExtendWithoutWrapping(d time.Duration) TimeSpan { + tsn := ts.Normalise() + if d < 0 && -d > tsn.duration { + return TimeSpan{tsn.mark, 0} + } + return TimeSpan{tsn.mark, tsn.duration + d} +} + +// String produces a human-readable description of a time span. +func (ts TimeSpan) String() string { + return fmt.Sprintf("%s from %s to %s", ts.duration, ts.mark.Format(TimestampFormat), ts.End().Format(TimestampFormat)) +} + +// In returns a TimeSpan adjusted from its current location to a new location. Because +// location is considered to be a presentational attribute, the actual time itself is not +// altered by this function. This matches the behaviour of time.Time.In(loc). +func (ts TimeSpan) In(loc *time.Location) TimeSpan { + t := ts.mark.In(loc) + return TimeSpan{t, ts.duration} +} + +// DateRangeIn obtains the date range corresponding to the time span in a specified location. +// The result is normalised. +func (ts TimeSpan) DateRangeIn(loc *time.Location) DateRange { + no := ts.Normalise() + startDate := date.NewAt(no.mark.In(loc)) + endDate := date.NewAt(no.End().In(loc)) + return NewDateRange(startDate, endDate) +} + +// Contains tests whether a given moment of time is enclosed within the time span. The +// start time is inclusive; the end time is exclusive. +// If t has a different locality to the time-span, it is adjusted accordingly. +func (ts TimeSpan) Contains(t time.Time) bool { + tl := t.In(ts.mark.Location()) + return ts.mark.Equal(tl) || ts.mark.Before(tl) && ts.End().After(tl) +} + +// Merge combines two time spans by calculating a time span that just encompasses them both. +// As a special case, if one span is entirely contained within the other span, the larger of +// the two is returned. Otherwise, the result is the start of the earlier one to the end of the +// later one, even if the two spans don't overlap. +func (ts TimeSpan) Merge(other TimeSpan) TimeSpan { + if ts.mark.After(other.mark) { + // swap the ranges to simplify the logic + return other.Merge(ts) + + } else if ts.End().After(other.End()) { + // other is a proper subrange of ts + return ts + + } else { + return NewTimeSpan(ts.mark, other.End()) + } +} + +// RFC5545DateTimeLayout is the format string used by iCalendar (RFC5545). Note +// that "Z" is to be appended when the time is UTC. +const RFC5545DateTimeLayout = "20060102T150405" + +// RFC5545DateTimeZulu is the UTC format string used by iCalendar (RFC5545). Note +// that this cannot be used for parsing with time.Parse. +const RFC5545DateTimeZulu = RFC5545DateTimeLayout + "Z" + +func layoutHasTimezone(layout string) bool { + return strings.IndexByte(layout, 'Z') >= 0 || strings.Contains(layout, "-07") +} + +// Equal reports whether ts and us represent the same time start and duration. +// Two times can be equal even if they are in different locations. +// For example, 6:00 +0200 CEST and 4:00 UTC are Equal. +func (ts TimeSpan) Equal(us TimeSpan) bool { + return ts.Duration() == us.Duration() && ts.Start().Equal(us.Start()) +} + +// Format returns a textual representation of the time value formatted according to layout. +// It produces a string containing the start and end time. Or, if useDuration is true, +// it returns a string containing the start time and the duration. +// +// The layout string is as specified for time.Format. If it doesn't have a timezone element +// ("07" or "Z") and the times in the timespan are UTC, the "Z" zulu indicator is added. +// This is as required by iCalendar (RFC5545). +// +// Also, if the layout is blank, it defaults to RFC5545DateTimeLayout. +// +// The separator between the two parts of the result would be "/" for RFC5545, but can be +// anything. +func (ts TimeSpan) Format(layout, separator string, useDuration bool) string { + if layout == "" { + layout = RFC5545DateTimeLayout + } + + // if the time is UTC and the format doesn't contain zulu field ("Z") or timezone field ("07") + if ts.mark.Location().String() == "UTC" && !layoutHasTimezone(layout) { + layout = RFC5545DateTimeZulu + } + + s := ts.Start() + e := ts.End() + + if useDuration { + p := period.Between(s, e) + return fmt.Sprintf("%s%s%s", s.Format(layout), separator, p) + } + + return fmt.Sprintf("%s%s%s", s.Format(layout), separator, e.Format(layout)) +} + +// FormatRFC5545 formats the timespan as a string containing the start time and end time, or the +// start time and duration, if useDuration is true. The two parts are separated by slash. +// The time(s) is expressed as UTC zulu. +// This is as required by iCalendar (RFC5545). +func (ts TimeSpan) FormatRFC5545(useDuration bool) string { + return ts.Format(RFC5545DateTimeZulu, "/", useDuration) +} + +// MarshalText formats the timespan as a string using, using RFC5545 layout. +// This implements the encoding.TextMarshaler interface. +func (ts TimeSpan) MarshalText() (text []byte, err error) { + s := ts.Format(RFC5545DateTimeZulu, "/", true) + return []byte(s), nil +} + +// ParseRFC5545InLocation parses a string as a timespan. The string must contain either of +// +// time "/" time +// time "/" period +// +// If the input time(s) ends in "Z", the location is UTC (as per RFC5545). Otherwise, the +// specified location will be used for the resulting times; this behaves the same as +// time.ParseInLocation. +func ParseRFC5545InLocation(text string, loc *time.Location) (TimeSpan, error) { + slash := strings.IndexByte(text, '/') + if slash < 0 { + return TimeSpan{}, fmt.Errorf("cannot parse %q because there is no separator '/'", text) + } + + start := text[:slash] + rest := text[slash+1:] + + st, err := parseTimeInLocation(start, loc) + if err != nil { + return TimeSpan{}, fmt.Errorf("cannot parse start time in %q: %s", text, err.Error()) + } + + //fmt.Printf("got %20s %s\n", st.Location(), st.Format(RFC5545DateTimeLayout)) + + if rest == "" { + return TimeSpan{}, fmt.Errorf("cannot parse %q because there is end time or duration", text) + } + + if rest[0] == 'P' { + pe, e2 := period.Parse(rest) + if e2 != nil { + return TimeSpan{}, fmt.Errorf("cannot parse period in %q: %s", text, e2.Error()) + } + + du, precise := pe.Duration() + if precise { + return TimeSpan{st, du}, nil + } + + et := st.AddDate(pe.Years(), pe.Months(), pe.Days()) + return NewTimeSpan(st, et), nil + } + + et, err := parseTimeInLocation(rest, loc) + return NewTimeSpan(st, et), err +} + +func parseTimeInLocation(text string, loc *time.Location) (time.Time, error) { + if strings.HasSuffix(text, "Z") { + text = text[:len(text)-1] + return time.ParseInLocation(RFC5545DateTimeLayout, text, time.UTC) + } + return time.ParseInLocation(RFC5545DateTimeLayout, text, loc) +} + +// UnmarshalText parses a string as a timespan. It expects RFC5545 layout. +// +// If the receiver timespan is non-nil and has a time with a location, +// this location is used for parsing. Otherwise time.Local is used. +// +// This implements the encoding.TextUnmarshaler interface. +func (ts *TimeSpan) UnmarshalText(text []byte) (err error) { + loc := time.Local + if ts != nil { + loc = ts.mark.Location() + } + *ts, err = ParseRFC5545InLocation(string(text), loc) + return +} diff --git a/timespan/timespan_test.go b/timespan/timespan_test.go new file mode 100644 index 00000000..a40056c9 --- /dev/null +++ b/timespan/timespan_test.go @@ -0,0 +1,373 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package timespan + +import ( + "testing" + "time" + + "github.com/rickb777/date" +) + +const zero time.Duration = 0 + +var t0327 = time.Date(2015, 3, 27, 0, 0, 0, 0, time.UTC) +var t0328 = time.Date(2015, 3, 28, 0, 0, 0, 0, time.UTC) +var t0329 = time.Date(2015, 3, 29, 0, 0, 0, 0, time.UTC) // n.b. clocks go forward (UK) +var t0330 = time.Date(2015, 3, 30, 0, 0, 0, 0, time.UTC) + +func TestZeroTimeSpan(t *testing.T) { + ts := ZeroTimeSpan(t0327) + isEq(t, 0, ts.mark, t0327) + isEq(t, 0, ts.Duration(), zero) + isEq(t, 0, ts.End(), t0327) +} + +func TestNewTimeSpan(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0327) + isEq(t, 0, ts1.mark, t0327) + isEq(t, 0, ts1.Duration(), zero) + isEq(t, 0, ts1.IsEmpty(), true) + isEq(t, 0, ts1.End(), t0327) + + ts2 := NewTimeSpan(t0327, t0328) + isEq(t, 0, ts2.mark, t0327) + isEq(t, 0, ts2.Duration(), time.Hour*24) + isEq(t, 0, ts2.IsEmpty(), false) + isEq(t, 0, ts2.End(), t0328) + + ts3 := NewTimeSpan(t0329, t0327) + isEq(t, 0, ts3.mark, t0327) + isEq(t, 0, ts3.Duration(), time.Hour*48) + isEq(t, 0, ts3.IsEmpty(), false) + isEq(t, 0, ts3.End(), t0329) +} + +func TestTSEnd(t *testing.T) { + ts1 := TimeSpan{t0328, time.Hour * 24} + isEq(t, 0, ts1.Start(), t0328) + isEq(t, 0, ts1.End(), t0329) + + // not normalised, deliberately + ts2 := TimeSpan{t0328, -time.Hour * 24} + isEq(t, 0, ts2.Start(), t0327) + isEq(t, 0, ts2.End(), t0328) +} + +func TestTSShiftBy(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0328).ShiftBy(time.Hour * 24) + isEq(t, 0, ts1.mark, t0328) + isEq(t, 0, ts1.Duration(), time.Hour*24) + isEq(t, 0, ts1.End(), t0329) + + ts2 := NewTimeSpan(t0328, t0329).ShiftBy(-time.Hour * 24) + isEq(t, 0, ts2.mark, t0327) + isEq(t, 0, ts2.Duration(), time.Hour*24) + isEq(t, 0, ts2.End(), t0328) +} + +func TestTSExtendBy(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0328).ExtendBy(time.Hour * 24) + isEq(t, 0, ts1.mark, t0327) + isEq(t, 0, ts1.Duration(), time.Hour*48) + isEq(t, 0, ts1.End(), t0329) + + ts2 := NewTimeSpan(t0328, t0329).ExtendBy(-time.Hour * 48) + isEq(t, 0, ts2.mark, t0327) + isEq(t, 0, ts2.Duration(), time.Hour*24) + isEq(t, 0, ts2.End(), t0328) +} + +func TestTSExtendWithoutWrapping(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0328).ExtendWithoutWrapping(time.Hour * 24) + isEq(t, 0, ts1.mark, t0327) + isEq(t, 0, ts1.Duration(), time.Hour*48) + isEq(t, 0, ts1.End(), t0329) + + ts2 := NewTimeSpan(t0328, t0329).ExtendWithoutWrapping(-time.Hour * 48) + isEq(t, 0, ts2.mark, t0328) + isEq(t, 0, ts2.Duration(), zero) + isEq(t, 0, ts2.End(), t0328) +} + +func TestTSString(t *testing.T) { + s := NewTimeSpan(t0327, t0328).String() + isEq(t, 0, s, "24h0m0s from 2015-03-27 00:00:00 to 2015-03-28 00:00:00") +} + +func TestTSEqual(t *testing.T) { + // use Berlin, which is UTC+1/+2 + berlin, _ := time.LoadLocation("Europe/Berlin") + t0 := time.Date(2015, 2, 20, 10, 13, 25, 0, time.UTC) + t1 := t0.Add(time.Hour) + z0 := ZeroTimeSpan(t0) + ts1 := z0.ExtendBy(time.Hour) + + cases := []struct { + a, b TimeSpan + }{ + {z0, NewTimeSpan(t0, t0)}, + {z0, z0.In(berlin)}, + {ts1, ts1}, + {ts1, NewTimeSpan(t0, t1)}, + {ts1, ts1.In(berlin)}, + {ts1, ZeroTimeSpan(t1).ExtendBy(-time.Hour)}, + } + + for i, c := range cases { + if !c.a.Equal(c.b) { + t.Errorf("%d: %v is not equal to %v", i, c.a, c.b) + } + } +} + +func TestTSNotEqual(t *testing.T) { + t0 := time.Date(2015, 2, 20, 10, 13, 25, 0, time.UTC) + t1 := t0.Add(time.Hour) + + cases := []struct { + a, b TimeSpan + }{ + {ZeroTimeSpan(t0), TimeSpanOf(t0, time.Hour)}, + {ZeroTimeSpan(t0), ZeroTimeSpan(t1)}, + } + + for i, c := range cases { + if c.a.Equal(c.b) { + t.Errorf("%d: %v is not equal to %v", i, c.a, c.b) + } + } +} + +func TestTSFormat(t *testing.T) { + // use Berlin, which is UTC-1 + berlin, _ := time.LoadLocation("Europe/Berlin") + t0 := time.Date(2015, 3, 27, 10, 13, 14, 0, time.UTC) + + cases := []struct { + start time.Time + duration time.Duration + useDuration bool + layout, separator, exp string + }{ + {t0, time.Hour, true, "", " for ", "20150327T101314Z for PT1H"}, + {t0, time.Hour, true, "", "/", "20150327T101314Z/PT1H"}, + {t0.In(berlin), time.Minute, true, "", "/", "20150327T111314/PT1M"}, + {t0.In(berlin), time.Hour, true, "2006-01-02T15:04:05", "/", "2015-03-27T11:13:14/PT1H"}, + {t0.In(berlin), time.Hour, true, "2006-01-02T15:04:05-07", "/", "2015-03-27T11:13:14+01/PT1H"}, + {t0, time.Hour, true, "2006-01-02T15:04:05-07", "/", "2015-03-27T10:13:14+00/PT1H"}, + {t0, time.Hour, true, "2006-01-02T15:04:05Z07", "/", "2015-03-27T10:13:14Z/PT1H"}, + + {t0, time.Hour, false, "", " to ", "20150327T101314Z to 20150327T111314Z"}, + {t0, time.Hour, false, "", "/", "20150327T101314Z/20150327T111314Z"}, + {t0.In(berlin), time.Minute, false, "", "/", "20150327T111314/20150327T111414"}, + {t0.In(berlin), time.Hour, false, "2006-01-02T15:04:05", "/", "2015-03-27T11:13:14/2015-03-27T12:13:14"}, + {t0.In(berlin), time.Hour, false, "2006-01-02T15:04:05-07", "/", "2015-03-27T11:13:14+01/2015-03-27T12:13:14+01"}, + {t0, time.Hour, false, "2006-01-02T15:04:05-07", "/", "2015-03-27T10:13:14+00/2015-03-27T11:13:14+00"}, + {t0, time.Hour, false, "2006-01-02T15:04:05Z07", "/", "2015-03-27T10:13:14Z/2015-03-27T11:13:14Z"}, + } + + for _, c := range cases { + ts := TimeSpan{c.start, c.duration} + isEq(t, 0, ts.Format(c.layout, c.separator, c.useDuration), c.exp) + } +} + +func TestTSMarshalText(t *testing.T) { + // use Berlin, which is UTC+1 or +2 in summer + berlin, _ := time.LoadLocation("Europe/Berlin") + t0 := time.Date(2015, 2, 14, 10, 13, 14, 0, time.UTC) + t1 := time.Date(2015, 6, 27, 10, 13, 15, 0, time.UTC) + + cases := []struct { + start time.Time + duration time.Duration + exp string + }{ + {t0, time.Hour, "20150214T101314Z/PT1H"}, + {t1, 2 * time.Hour, "20150627T101315Z/PT2H"}, + {t0.In(berlin), time.Minute, "20150214T111314Z/PT1M"}, // UTC+1 + {t1.In(berlin), time.Second, "20150627T121315Z/PT1S"}, // UTC+2 + } + + for i, c := range cases { + ts := TimeSpan{c.start, c.duration} + + s := ts.FormatRFC5545(true) + isEq(t, i, s, c.exp) + + b, err := ts.MarshalText() + isEq(t, i, err, nil) + isEq(t, i, string(b), c.exp) + } +} + +func TestTSParseInLocation(t *testing.T) { + // use Berlin, which is UTC-1 + berlin, _ := time.LoadLocation("Europe/Berlin") + t0120 := time.Date(2015, 1, 20, 10, 13, 14, 0, time.UTC) + // just before start of daylight savings + t0325 := time.Date(2015, 3, 25, 10, 13, 14, 0, time.UTC) + + cases := []struct { + start time.Time + duration time.Duration + text string + }{ + {t0325, time.Hour, "20150325T101314Z/PT1H"}, + {t0325, 2 * time.Second, "20150325T101314Z/PT2S"}, + {t0120.In(berlin), time.Minute, "20150120T111314/PT1M"}, + {t0325, 336 * time.Hour, "20150325T101314Z/P2W"}, + {t0120.In(berlin), 72 * time.Hour, "20150120T111314/P3D"}, + // This case has the daylight-savings clock shift + {t0325.In(berlin), 167 * time.Hour, "20150325T111314/P1W"}, + } + + for i, c := range cases { + ts1, err := ParseRFC5545InLocation(c.text, c.start.Location()) + if err != nil { + t.Errorf("%d: %s %v %v", i, c.text, ts1.String(), err) + } + + if !ts1.Start().Equal(c.start) { + t.Errorf("%d: %s", i, ts1) + } + + if ts1.Duration() != c.duration { + t.Errorf("%d: %s", i, ts1) + } + + ts2 := TimeSpan{}.In(c.start.Location()) + err = ts2.UnmarshalText([]byte(c.text)) + if err != nil { + t.Errorf("%d: %s: %v %v", i, c.text, ts2.String(), err) + } + + if !ts1.Equal(ts2) { + t.Errorf("%d: %s: %v is not equal to %v", i, c.text, ts1, ts2) + } + } +} + +func TestTSParseInLocationErrors(t *testing.T) { + cases := []struct { + text string + }{ + {"20150327T101314Z PT1H"}, + {"2015XX27T101314/PT1H"}, + {"20150127T101314/2016XX27T101314"}, + {"20150127T101314/P1Z"}, + {"20150327T101314Z/"}, + {"/PT1H"}, + } + + for _, c := range cases { + ts, err := ParseRFC5545InLocation(c.text, time.UTC) + if err == nil { + t.Errorf(ts.String()) + } + } +} + +func TestTSContains(t *testing.T) { + ts := NewTimeSpan(t0327, t0329) + isEq(t, 0, ts.Contains(t0327.Add(minusOneNano)), false) + isEq(t, 0, ts.Contains(t0327), true) + isEq(t, 0, ts.Contains(t0328), true) + isEq(t, 0, ts.Contains(t0329.Add(minusOneNano)), true) + isEq(t, 0, ts.Contains(t0329), false) +} + +func TestTSIn(t *testing.T) { + ts := ZeroTimeSpan(t0327).In(time.FixedZone("Test", 7200)) + isEq(t, 0, ts.mark.Equal(t0327), true) + isEq(t, 0, ts.Duration(), zero) + isEq(t, 0, ts.End().Equal(t0327), true) +} + +func TestTSMerge1(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0328) + ts2 := NewTimeSpan(t0327, t0330) + m1 := ts1.Merge(ts2) + m2 := ts2.Merge(ts1) + isEq(t, 0, m1.mark, t0327) + isEq(t, 0, m1.End(), t0330) + isEq(t, 0, m1, m2) +} + +func TestTSMerge2(t *testing.T) { + ts1 := NewTimeSpan(t0328, t0329) + ts2 := NewTimeSpan(t0327, t0330) + m1 := ts1.Merge(ts2) + m2 := ts2.Merge(ts1) + isEq(t, 0, m1.mark, t0327) + isEq(t, 0, m1.End(), t0330) + isEq(t, 0, m1, m2) +} + +func TestTSMerge3(t *testing.T) { + ts1 := NewTimeSpan(t0329, t0330) + ts2 := NewTimeSpan(t0327, t0330) + m1 := ts1.Merge(ts2) + m2 := ts2.Merge(ts1) + isEq(t, 0, m1.mark, t0327) + isEq(t, 0, m1.End(), t0330) + isEq(t, 0, m1, m2) +} + +func TestTSMergeOverlapping(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0329) + ts2 := NewTimeSpan(t0328, t0330) + m1 := ts1.Merge(ts2) + m2 := ts2.Merge(ts1) + isEq(t, 0, m1.mark, t0327) + isEq(t, 0, m1.End(), t0330) + isEq(t, 0, m1, m2) +} + +func TestTSMergeNonOverlapping(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0328) + ts2 := NewTimeSpan(t0329, t0330) + m1 := ts1.Merge(ts2) + m2 := ts2.Merge(ts1) + isEq(t, 0, m1.mark, t0327) + isEq(t, 0, m1.End(), t0330) + isEq(t, 0, m1, m2) +} + +func TestConversion1(t *testing.T) { + ts1 := ZeroTimeSpan(t0327) + dr := ts1.DateRangeIn(time.UTC) + ts2 := dr.TimeSpanIn(time.UTC) + isEq(t, 0, dr.Start(), d0327) + isEq(t, 0, dr.IsEmpty(), true) + isEq(t, 0, ts1.Start(), ts1.End()) + isEq(t, 0, ts1.Duration(), zero) + isEq(t, 0, dr.Days(), date.PeriodOfDays(0)) + isEq(t, 0, ts2.Duration(), zero) + isEq(t, 0, ts1, ts2) +} + +func TestConversion2(t *testing.T) { + ts1 := NewTimeSpan(t0327, t0328) + dr := ts1.DateRangeIn(time.UTC) + ts2 := dr.TimeSpanIn(time.UTC) + isEq(t, 0, dr.Start(), d0327) + isEq(t, 0, dr.End(), d0328) + isEq(t, 0, ts1, ts2) + isEq(t, 0, ts1.Duration(), time.Hour*24) +} + +func TestConversion3(t *testing.T) { + dr1 := NewDateRange(d0327, d0330) // weekend of clocks changing + ts1 := dr1.TimeSpanIn(london) + dr2 := ts1.DateRangeIn(london) + ts2 := dr2.TimeSpanIn(london) + isEq(t, 0, dr1.Start(), d0327) + isEq(t, 0, dr1.End(), d0330) + isEq(t, 0, dr1, dr2) + isEq(t, 0, ts1, ts2) + isEq(t, 0, ts1.Duration(), time.Hour*71) +} diff --git a/view/vdate.go b/view/vdate.go new file mode 100644 index 00000000..96ff23d4 --- /dev/null +++ b/view/vdate.go @@ -0,0 +1,189 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package view provides a simple API for formatting dates as strings in a manner that is easy to use in view-models, +// especially when using Go templates. +package view + +import ( + "github.com/rickb777/date" +) + +const ( + // DMYFormat is a typical British representation. + DMYFormat = "02/01/2006" + // MDYFormat is a typical American representation. + MDYFormat = "01/02/2006" + // ISOFormat is ISO-8601 YYYY-MM-DD. + ISOFormat = "2006-02-01" + // DefaultFormat is used by Format() unless a different format is set. + DefaultFormat = DMYFormat +) + +// A VDate holds a Date and provides easy ways to render it, e.g. in Go templates. +type VDate struct { + d date.Date + f string +} + +// NewVDate wraps a Date. +func NewVDate(d date.Date) VDate { + return VDate{d, DefaultFormat} +} + +// Date returns the underlying date. +func (v VDate) Date() date.Date { + return v.d +} + +// IsYesterday returns true if the date is yesterday's date. +func (v VDate) IsYesterday() bool { + return v.d.DaysSinceEpoch()+1 == date.Today().DaysSinceEpoch() +} + +// IsToday returns true if the date is today's date. +func (v VDate) IsToday() bool { + return v.d.DaysSinceEpoch() == date.Today().DaysSinceEpoch() +} + +// IsTomorrow returns true if the date is tomorrow's date. +func (v VDate) IsTomorrow() bool { + return v.d.DaysSinceEpoch()-1 == date.Today().DaysSinceEpoch() +} + +// IsOdd returns true if the date is an odd number. This is useful for +// zebra striping etc. +func (v VDate) IsOdd() bool { + return v.d.DaysSinceEpoch()%2 == 0 +} + +// String formats the date in basic ISO8601 format YYYY-MM-DD. +func (v VDate) String() string { + if v.d.IsZero() { + return "" + } + return v.d.String() +} + +// WithFormat creates a new instance containing the specified format string. +func (v VDate) WithFormat(f string) VDate { + return VDate{v.d, f} +} + +// Format formats the date using the specified format string, or "02/01/2006" by default. +// Use WithFormat to set this up. +func (v VDate) Format() string { + return v.d.Format(v.f) +} + +// Mon returns the day name as three letters. +func (v VDate) Mon() string { + return v.d.Format("Mon") +} + +// Monday returns the full day name. +func (v VDate) Monday() string { + return v.d.Format("Monday") +} + +// Day2 returns the day number without a leading zero. +func (v VDate) Day2() string { + return v.d.Format("2") +} + +// Day02 returns the day number with a leading zero if necessary. +func (v VDate) Day02() string { + return v.d.Format("02") +} + +// Day2nd returns the day number without a leading zero but with the appropriate +// "st", "nd", "rd", "th" suffix. +func (v VDate) Day2nd() string { + return v.d.Format("2nd") +} + +// Month1 returns the month number without a leading zero. +func (v VDate) Month1() string { + return v.d.Format("1") +} + +// Month01 returns the month number with a leading zero if necessary. +func (v VDate) Month01() string { + return v.d.Format("01") +} + +// Jan returns the month name abbreviated to three letters. +func (v VDate) Jan() string { + return v.d.Format("Jan") +} + +// January returns the full month name. +func (v VDate) January() string { + return v.d.Format("January") +} + +// Year returns the four-digit year. +func (v VDate) Year() string { + return v.d.Format("2006") +} + +// Next returns a fluent generator for later dates. +func (v VDate) Next() VDateDelta { + return VDateDelta{v.d, v.f, 1} +} + +// Previous returns a fluent generator for earlier dates. +func (v VDate) Previous() VDateDelta { + return VDateDelta{v.d, v.f, -1} +} + +//------------------------------------------------------------------------------------------------- +// Only lossy transcoding is supported here because the intention is that data exchange should be +// via the main Date type; VDate is only intended for output through view layers. + +// MarshalText implements the encoding.TextMarshaler interface. +func (v VDate) MarshalText() ([]byte, error) { + return v.d.MarshalText() +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +// Note that the format value gets lost. +func (v *VDate) UnmarshalText(data []byte) (err error) { + u := &date.Date{} + err = u.UnmarshalText(data) + if err == nil { + v.d = *u + v.f = DefaultFormat + } + return err +} + +//------------------------------------------------------------------------------------------------- + +// VDateDelta is a VDate with the ability to add or subtract days, weeks, months or years. +type VDateDelta struct { + d date.Date + f string + sign date.PeriodOfDays +} + +// Day adds or subtracts one day. +func (dd VDateDelta) Day() VDate { + return VDate{dd.d.Add(dd.sign), dd.f} +} + +// Week adds or subtracts one week. +func (dd VDateDelta) Week() VDate { + return VDate{dd.d.Add(dd.sign * 7), dd.f} +} + +// Month adds or subtracts one month. +func (dd VDateDelta) Month() VDate { + return VDate{dd.d.AddDate(0, int(dd.sign), 0), dd.f} +} + +// Year adds or subtracts one year. +func (dd VDateDelta) Year() VDate { + return VDate{dd.d.AddDate(int(dd.sign), 0, 0), dd.f} +} diff --git a/view/vdate_test.go b/view/vdate_test.go new file mode 100644 index 00000000..2eca7489 --- /dev/null +++ b/view/vdate_test.go @@ -0,0 +1,184 @@ +// Copyright 2015 Rick Beton. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package view + +import ( + "encoding/json" + "testing" + "time" + + "github.com/rickb777/date" +) + +func TestBasicFormatting(t *testing.T) { + d := NewVDate(date.New(2016, 2, 7)) + is(t, d.String(), "2016-02-07") + is(t, d.Format(), "07/02/2016") + is(t, d.WithFormat(MDYFormat).Format(), "02/07/2016") + is(t, d.Mon(), "Sun") + is(t, d.Monday(), "Sunday") + is(t, d.Day2(), "7") + is(t, d.Day02(), "07") + is(t, d.Day2nd(), "7th") + is(t, d.Month1(), "2") + is(t, d.Month01(), "02") + is(t, d.Jan(), "Feb") + is(t, d.January(), "February") + is(t, d.Year(), "2016") +} + +func TestZeroFormatting(t *testing.T) { + d := NewVDate(date.Date{}) + is(t, d.String(), "") + is(t, d.Format(), "01/01/1970") + is(t, d.WithFormat(ISOFormat).Format(), "1970-01-01") + is(t, d.Mon(), "Thu") + is(t, d.Monday(), "Thursday") + is(t, d.Day2(), "1") + is(t, d.Day02(), "01") + is(t, d.Day2nd(), "1st") + is(t, d.Month1(), "1") + is(t, d.Month01(), "01") + is(t, d.Jan(), "Jan") + is(t, d.January(), "January") + is(t, d.Year(), "1970") +} + +func TestDate(t *testing.T) { + d := date.New(2016, 2, 7) + vd := NewVDate(d) + if vd.Date() != d { + t.Errorf("%v != %v", vd.Date(), d) + } +} + +func TestIsToday(t *testing.T) { + today := date.Today() + + cases := []struct { + value VDate + expectYesterday bool + expectToday bool + expectTomorrow bool + }{ + {NewVDate(date.New(2012, time.June, 25)), false, false, false}, + {NewVDate(today.Add(-2)), false, false, false}, + {NewVDate(today.Add(-1)), true, false, false}, + {NewVDate(today.Add(0)), false, true, false}, + {NewVDate(today.Add(1)), false, false, true}, + {NewVDate(today.Add(2)), false, false, false}, + } + for _, c := range cases { + if c.value.IsYesterday() != c.expectYesterday { + t.Errorf("%s should be 'yesterday': %v", c.value, c.expectYesterday) + } + if c.value.IsToday() != c.expectToday { + t.Errorf("%s should be 'today': %v", c.value, c.expectToday) + } + if c.value.IsTomorrow() != c.expectTomorrow { + t.Errorf("%s should be 'tomorrow': %v", c.value, c.expectTomorrow) + } + } + +} + +func TestIsOdd(t *testing.T) { + d25 := date.New(2012, time.June, 25) + + cases := []struct { + value VDate + expectOdd bool + }{ + {NewVDate(d25), true}, + {NewVDate(d25.Add(-2)), true}, + {NewVDate(d25.Add(-1)), false}, + {NewVDate(d25.Add(0)), true}, + {NewVDate(d25.Add(1)), false}, + {NewVDate(d25.Add(2)), true}, + } + for _, c := range cases { + if c.value.IsOdd() != c.expectOdd { + t.Errorf("%s should be odd: %v", c.value, c.expectOdd) + } + } + +} + +func TestNext(t *testing.T) { + d := NewVDate(date.New(2016, 2, 7)) + is(t, d.Next().Day().String(), "2016-02-08") + is(t, d.Next().Week().String(), "2016-02-14") + is(t, d.Next().Month().String(), "2016-03-07") + is(t, d.Next().Year().String(), "2017-02-07") +} + +func TestPrevious(t *testing.T) { + d := NewVDate(date.New(2016, 2, 7)) + is(t, d.Previous().Day().String(), "2016-02-06") + is(t, d.Previous().Week().String(), "2016-01-31") + is(t, d.Previous().Month().String(), "2016-01-07") + is(t, d.Previous().Year().String(), "2015-02-07") +} + +func is(t *testing.T, s1, s2 string) { + t.Helper() + if s1 != s2 { + t.Errorf("%s != %s", s1, s2) + } +} + +func TestJSONMarshalling(t *testing.T) { + cases := []struct { + value VDate + want string + }{ + {NewVDate(date.New(-1, time.December, 31)), `"-0001-12-31"`}, + {NewVDate(date.New(2012, time.June, 25)), `"2012-06-25"`}, + {NewVDate(date.New(12345, time.June, 7)), `"+12345-06-07"`}, + } + for _, c := range cases { + var d VDate + bytes, err := json.Marshal(c.value) + if err != nil { + t.Errorf("JSON(%v) marshal error %v", c, err) + } else if string(bytes) != c.want { + t.Errorf("JSON(%v) == %v, want %v", c.value, string(bytes), c.want) + } else { + err = json.Unmarshal(bytes, &d) + if err != nil { + t.Errorf("JSON(%v) unmarshal error %v", c.value, err) + } else if d != c.value { + t.Errorf("JSON(%#v) unmarshal got %#v", c.value, d) + } + } + } +} + +func TestTextMarshalling(t *testing.T) { + cases := []struct { + value VDate + want string + }{ + {NewVDate(date.New(-1, time.December, 31)), "-0001-12-31"}, + {NewVDate(date.New(2012, time.June, 25)), "2012-06-25"}, + {NewVDate(date.New(12345, time.June, 7)), "+12345-06-07"}, + } + for _, c := range cases { + var d VDate + bytes, err := c.value.MarshalText() + if err != nil { + t.Errorf("Text(%v) marshal error %v", c, err) + } else if string(bytes) != c.want { + t.Errorf("Text(%v) == %v, want %v", c.value, string(bytes), c.want) + } else { + err = d.UnmarshalText(bytes) + if err != nil { + t.Errorf("Text(%v) unmarshal error %v", c.value, err) + } else if d != c.value { + t.Errorf("Text(%#v) unmarshal got %#v", c.value, d) + } + } + } +}