diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f3e8252 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/dontlaugh/lilrange + +go 1.12 + +require github.com/stretchr/testify v1.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4347755 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/lilrange.go b/lilrange.go new file mode 100644 index 0000000..98d9fcd --- /dev/null +++ b/lilrange.go @@ -0,0 +1,134 @@ +package lilrange + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type Range struct { + Start time.Time + End time.Time + Duration time.Duration + repr string +} + +func (t Range) Within(instant time.Time) bool { + return instant.After(t.Start) && instant.Before(t.End) +} + +func (r Range) Next() Range { + return Range{ + r.Start.Add(24 * time.Hour), + r.End.Add(24 * time.Hour), + r.Duration, + r.repr, + } +} + +func Parse(s string) (*Range, error) { + var tr Range + splitted := strings.Split(s, "-") + if len(splitted) != 2 { + return nil, fmt.Errorf("invalid time range value: %s", s) + } + startHr, startMin, err := extractAndValidate(splitted[0]) + if err != nil { + return nil, err + } + endHr, endMin, err := extractAndValidate(splitted[1]) + if err != nil { + return nil, err + } + // 0. determine duration + dur, _ := CalculateDurationMinutes(startHr, startMin, endHr, endMin) + // 1. determine if "today's start" has happened + now := time.Now() + nowYear, nowMonth, nowDate := now.Date() + todaysStart := time.Date(nowYear, nowMonth, nowDate, startHr, startMin, 0, 0, time.UTC) + endTime := todaysStart.Add(time.Minute * time.Duration(dur)) + + if now.After(endTime) { + // today's range has ended. Our default behavior is to return the next + // range, e.g. tomorrow's + tr.Start = todaysStart.Add(time.Hour * 24) + tr.End = endTime.Add(time.Hour * 24) + } else { + // Today's range has not ended. We are either a) inside this range, or + // b) it is in the future and has not started. Either way, assign the + // computed times and let the caller decide what to do. + tr.Start = todaysStart + tr.End = endTime + } + tr.Duration = time.Minute * time.Duration(dur) + tr.repr = s + return &tr, nil +} + +func CalculateDurationMinutes(startHr, startMin, endHr, endMin int) (int, bool) { + if !between(startHr, 0, 23) || !between(endHr, 0, 23) || !between(startMin, 0, 59) || !between(endMin, 0, 59) { + panic("invalid values for hours and/or minutes") + } + var crossesMidnight bool + if startHr > endHr { + crossesMidnight = true + } + var duration int + if crossesMidnight { + minUntilMidnight := ((24 - startHr) * 60) - startMin + minAfterMidnight := (endHr * 60) + endMin + duration = minUntilMidnight + minAfterMidnight + } else { + duration = ((endHr - startHr) * 60) - startMin + endMin + } + return duration, crossesMidnight +} + +func validRune(r rune) bool { + valid := []rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'} + for _, x := range valid { + if x == r { + return true + } + } + return false +} + +func extractAndValidate(s string) (int, int, error) { + runes := []rune(s) + if len(runes) != 4 { + return -1, -1, fmt.Errorf("invalid time component: %s", s) + } + for pos, char := range s { + // TODO make this a comparison to range of ASCII bytes rather than another loop + if !validRune(char) { + return -1, -1, fmt.Errorf("invalid char at position %v: %c [%v]", + pos, char, char) + + } + } + hr, min := string(runes[0:2]), string(runes[2:4]) + // We've asserted that our chars are ASCII 0-9, so Atoi should never fail. + hrInt, err := strconv.Atoi(hr) + if err != nil { + panic("how is this possible") + } + minInt, err := strconv.Atoi(min) + if err != nil { + panic("universe is broken, sorry") + } + if !between(hrInt, 0, 23) { + return -1, -1, errors.New("hour value must be between 0 and 23") + } + if !between(minInt, 0, 59) { + return -1, -1, errors.New("minute value must be between 0 and 59") + } + return hrInt, minInt, nil +} + +// x is between this and that +func between(x, this, that int) bool { + return x >= this && x <= that +} diff --git a/lilrange_test.go b/lilrange_test.go new file mode 100644 index 0000000..79d1486 --- /dev/null +++ b/lilrange_test.go @@ -0,0 +1,113 @@ +package lilrange + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + type testCase struct { + input string + expected *Range + } + var invalidInputs []testCase = []testCase{ + // invalid inputs, so we expect nil + {"0300", nil}, + {"0300-000", nil}, + {"300-000", nil}, + {"-000", nil}, + {"", nil}, + {"-", nil}, + {"-0300", nil}, + {"-0", nil}, + {"0030-0", nil}, + } + + for _, c := range invalidInputs { + actual, _ := Parse(c.input) + assert.EqualValues(t, c.expected, actual) + } + + type durationTest struct { + input string + expected time.Duration + } + var durationTests []durationTest = []durationTest{ + // valid inputs, and we assert on the durations we compute + {"0100-0200", 1 * time.Hour}, + {"0000-0002", 2 * time.Minute}, + {"2359-0000", 1 * time.Minute}, + {"2359-0001", 2 * time.Minute}, + {"2259-0001", 62 * time.Minute}, + } + + for _, dt := range durationTests { + actual, err := Parse(dt.input) + if err != nil { + t.Errorf("duration test fail: %v", err) + } + assert.EqualValues(t, dt.expected, actual.Duration) + } +} + +func TestExtractAndValidate(t *testing.T) { + type testCase struct { + input string + expected []int + } + var cases = []testCase{ + // invalid cases + {"0", []int{-1, -1}}, + {"223", []int{-1, -1}}, + {"2460", []int{-1, -1}}, + {"2460", []int{-1, -1}}, + {"0260", []int{-1, -1}}, + {"9901", []int{-1, -1}}, + {"12.1", []int{-1, -1}}, + {"12:1", []int{-1, -1}}, + {"1:21", []int{-1, -1}}, + {"1:21", []int{-1, -1}}, + {"1/21", []int{-1, -1}}, + // valid cases + {"2359", []int{23, 59}}, + {"0000", []int{0, 0}}, + {"0001", []int{0, 1}}, + {"1111", []int{11, 11}}, + {"0223", []int{2, 23}}, + } + + for _, c := range cases { + hr, min, _ := extractAndValidate(c.input) + assert.EqualValues(t, c.expected, []int{hr, min}) + } +} + +func TestCalculateDurationMinutes(t *testing.T) { + + type testCase struct { + // inputs: startHr, startMin, endHr, endMin + inputs []int + expected int + } + var cases = []testCase{ + {[]int{0, 0, 1, 0}, 60}, + {[]int{1, 0, 2, 0}, 60}, + {[]int{23, 0, 1, 0}, 120}, + {[]int{23, 15, 1, 0}, 105}, + {[]int{23, 15, 1, 10}, 115}, + } + + for _, c := range cases { + dur, _ := CalculateDurationMinutes(c.inputs[0], c.inputs[1], + c.inputs[2], c.inputs[3]) + assert.EqualValues(t, c.expected, dur) + } + // Assert that invalid inputs panic. + assert.Panics(t, func() { CalculateDurationMinutes(-1, 0, 0, 0) }) + assert.Panics(t, func() { CalculateDurationMinutes(25, 0, 0, 0) }) + assert.Panics(t, func() { CalculateDurationMinutes(0, 0, 25, 0) }) + assert.Panics(t, func() { CalculateDurationMinutes(0, 69, 0, 0) }) + assert.Panics(t, func() { CalculateDurationMinutes(0, 0, 0, 69) }) +}