15 Commits

Author SHA1 Message Date
f3bf5c7d6b Merge pull request #3 from taigrr/cd/modernize-and-cleanup
chore: bump Go 1.18→1.26, fix typos, clean up tests
2026-02-22 23:57:45 -05:00
8b431b5d2f chore: bump Go 1.18→1.26, fix typos, clean up tests
- Update go.mod to Go 1.26
- Fix help text typos ('menut' → 'menu', 'most-significant' → 'most-specific')
- Fix error comment typo ('ll' → 'all')
- Remove unnecessary fmt.Sprintf in test names
- Remove unused fmt imports from test files
- Add compile-time interface check for TimeWindow
2026-02-22 21:01:23 +00:00
433d28a87b add sponsor button 2022-10-22 20:47:21 -07:00
2e68bb06af Use a struct instead of string slices 2022-10-14 22:04:08 -07:00
f989049717 Add gap test 2022-10-14 20:50:12 -07:00
Ethan Holz
6adb6be6b4 test: Added more comprehensive tests 2022-10-14 16:33:43 -05:00
Ethan Holz
dd79d7a54b feat: Added help and option to specify date. Changed so that calendar displays even if MSP does not. 2022-10-14 16:33:42 -05:00
9317c4b137 Merge pull request #1 from ethanholz/generate-timeline
Added functionality to generate timeline and preliminary support for help function
2022-10-14 13:11:04 -07:00
Ethan Holz
a2c1864a77 fix: Removed logging from main.go 2022-10-14 14:19:43 -05:00
Ethan Holz
8612f90d46 feat: Completed functionality for generating a period timeline 2022-10-14 14:17:50 -05:00
Ethan Holz
9698d90308 feat: Initial changes to handline generating a timeline 2022-10-13 17:02:55 -05:00
88ce350a61 updates to changeover and tests 2022-04-03 22:33:50 -07:00
0f88864200 add changes to changeover code based on unit tests 2022-04-03 19:10:55 -07:00
71bb0676c0 changeover features in progress 2022-04-03 18:39:46 -07:00
01897a6ae8 update readme to match changes 2022-03-07 17:20:57 -08:00
12 changed files with 1042 additions and 74 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: taigrr # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -8,7 +8,14 @@ An MSP, or Most Specific Period is defined as follows:
- Given two valid periods, each exactly 30 days long, but offset by a week, the second (newer) period is given precedence.
- Given two valid periods with exact same start and end time, the period with the lowest-ranking lexicographic identifier is returned (i.e. period B has precedence over period A, as B comes after A lexicographically.) This is because the default behavior is to choose the newer period, and named periods are often in lexicographical order (increasing numbers, letters, etc.)
This library operates off Period structs, which contain the following:
- StartTime (time.Time)
- EndTime (time.Time)
- Identifier (string)
This library operates off Period interfaces, which contain the following:
```
type Period interface {
GetStartTime() time.Time // Inclusive start time
GetEndTime() time.Time // Exclusive end time ("expiration time")
GetIdentifier() string
}
```
An example program is available to observe the operation and usage of this library.

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/taigrr/most-specific-period
go 1.17
go 1.26

33
main.go
View File

@@ -20,9 +20,11 @@ type Period struct {
func (p Period) GetEndTime() time.Time {
return p.EndTime
}
func (p Period) GetStartTime() time.Time {
return p.StartTime
}
func (p Period) GetIdentifier() string {
return p.Identifier
}
@@ -33,13 +35,35 @@ func init() {
flag.PrintDefaults()
}
}
func warnMessage() {
fmt.Print("Please type your date formats as follows, hit return between each field (RFC 3339), and hit Control+D to signal you are complete: \nIdentifier: id\nStartTime: 2019-10-12T07:20:50.52Z\nEndTime: 2019-10-12T07:20:50.52Z\n")
}
func helpMessage() {
fmt.Print("\nmost-specific-period [-h][-d]\n\nGenerates a timeline of periods and will provide a most specific period if available.\n\n-h\tShows this help menu\n-d\tProvide an RFC 3339 time to provide an alternate point for calculating MSP.")
}
func main() {
var start time.Time
help := flag.Bool("h", false, "displays help command")
userDate := flag.String("d", "", "use a custom date to calculate MSP")
flag.Parse()
if *help {
helpMessage()
os.Exit(0)
}
if userDate != nil && *userDate != "" {
t, err := time.Parse(time.RFC3339, *userDate)
if err != nil {
fmt.Println("Please enter the date using the YYYY-MM-DDT00:00:00.00Z")
os.Exit(1)
}
start = t
} else {
start = time.Now()
}
terminal := false
fi, _ := os.Stdin.Stat()
if (fi.Mode() & os.ModeCharDevice) == 0 {
@@ -98,9 +122,14 @@ func main() {
count++
}
m, err := msp.MostSpecificPeriod(time.Now(), periods...)
vals := msp.GenerateTimeline(periods...)
fmt.Print("\nTimeline of changeovers:\n")
for _, val := range vals {
fmt.Println(val)
}
m, err := msp.MostSpecificPeriod(start, periods...)
if err != nil {
fmt.Printf("Error: %v\n", err)
fmt.Printf("No significant period found\n")
os.Exit(1)
}
if terminal {

60
msp/changeover.go Normal file
View File

@@ -0,0 +1,60 @@
package msp
import (
"sort"
"time"
)
func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
timeStamps := []time.Time{}
for _, x := range periods {
timeStamps = append(timeStamps, x.GetEndTime())
timeStamps = append(timeStamps, x.GetStartTime())
}
if len(timeStamps) == 0 {
return
}
sort.Slice(timeStamps, func(i, j int) bool {
return timeStamps[i].Before(timeStamps[j])
})
// timeStamps is sorted, so this will always result in an unused time
// struct, as it's before the first
previousTs := timeStamps[0].Add(-10 * time.Nanosecond)
for _, ts := range timeStamps {
if ts.Equal(previousTs) {
continue
}
previousTs = ts
before := ts.Add(-1 * time.Nanosecond)
after := ts.Add(1 * time.Nanosecond)
from, _ := MostSpecificPeriod(before, periods...)
to, _ := MostSpecificPeriod(after, periods...)
if from == to {
continue
}
changeovers = append(changeovers, ts)
}
return
}
func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error) {
changeOvers := GetChangeOvers(periods...)
for _, ts := range changeOvers {
if ts.After(t) {
return ts, nil
}
}
return time.Time{}, ErrNoNextChangeover
}
func FlattenPeriods(periods ...Period) (ids []string) {
changeovers := GetChangeOvers(periods...)
for _, c := range changeovers {
id, err := MostSpecificPeriod(c, periods...)
if err != nil {
continue
}
ids = append(ids, id)
}
return
}

480
msp/changeover_test.go Normal file
View File

@@ -0,0 +1,480 @@
package msp
import (
"testing"
"time"
)
func slicesEqual[K comparable](x []K, y []K) bool {
if len(x) != len(y) {
return false
}
for i, w := range x {
if w != y[i] {
return false
}
}
return true
}
func TestGetChangeOvers(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now()
testCases := []struct {
ts time.Time
testID string
result []time.Time
periods []Period
}{
{
testID: "No choices",
ts: now,
result: []time.Time{},
periods: []Period{},
},
{
testID: "Two Choices, shorter is second",
ts: now,
result: []time.Time{now.Add(-5 * time.Minute), now.Add(-2 * time.Minute), now.Add(time.Minute)},
periods: []Period{
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one is a year, other a minute",
ts: now,
result: []time.Time{now.Add(-1 * time.Hour * 24 * 365), now.Add(-5 * time.Minute), now.Add(time.Minute)},
periods: []Period{
TimeWindow{
StartTime: now.Add(-1 * time.Hour * 24 * 365),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, shorter is first",
ts: now,
result: []time.Time{now.Add(-5 * time.Minute), now.Add(-2 * time.Minute), now.Add(time.Minute)},
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one in the past",
ts: now,
result: []time.Time{now.Add(-2 * time.Minute), now.Add(-time.Minute), now.Add(time.Minute)},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one invalid",
ts: now,
result: []time.Time{now.Add(-2 * time.Minute), now.Add(time.Minute)},
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, Identical periods",
ts: now,
result: []time.Time{now.Add(-time.Minute), now.Add(time.Minute)},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "One choice",
ts: now,
result: []time.Time{now.Add(-time.Minute), now.Add(time.Minute)},
periods: []Period{TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
}},
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
changeovers := GetChangeOvers(tc.periods...)
if !slicesEqual(changeovers, tc.result) {
t.Errorf("Expected %v but got %v", tc.result, changeovers)
}
})
}
}
func TestFlattenPeriods(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now()
testCases := []struct {
ts time.Time
testID string
result []string
err error
periods []Period
}{
{
testID: "No choices",
ts: now,
result: []string{},
err: ErrNoValidPeriods,
periods: []Period{},
},
{
testID: "Two Choices, shorter is second",
ts: now,
result: []string{"A", "B"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one is a year, other a minute",
ts: now,
result: []string{"A", "B"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-1 * time.Hour * 24 * 365),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, shorter is first",
ts: now,
result: []string{"B", "A"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one in the past",
ts: now,
result: []string{"B", "A"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one invalid",
ts: now,
result: []string{"B"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, Identical periods",
ts: now,
result: []string{"B"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Triple Nested Periods",
ts: now,
result: []string{"A", "B", "C", "B", "A"},
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-15 * time.Minute),
EndTime: now.Add(15 * time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(5 * time.Minute),
Identifier: "C",
},
TimeWindow{
StartTime: now.Add(-10 * time.Minute),
EndTime: now.Add(10 * time.Minute),
Identifier: "B",
},
},
},
{
testID: "One choice",
ts: now,
result: []string{"A"},
err: nil,
periods: []Period{TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
}},
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
changeovers := FlattenPeriods(tc.periods...)
if !slicesEqual(changeovers, tc.result) {
t.Errorf("Expected %v but got %v", tc.result, changeovers)
}
})
}
}
func TestGetNextChangeOver(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now()
testCases := []struct {
ts time.Time
testID string
result time.Time
err error
periods []Period
}{
{
testID: "No choices",
ts: now,
result: time.Time{},
err: ErrNoNextChangeover,
periods: []Period{},
},
{
testID: "Two Choices, shorter is second",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one is a year, other a minute",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-1 * time.Hour * 24 * 365),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, shorter is first",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one in the past",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one invalid",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, Identical periods",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "One choice",
ts: now,
result: now.Add(time.Minute),
err: nil,
periods: []Period{TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
}},
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
ts, err := GetNextChangeOver(now, tc.periods...)
if tc.err != err {
t.Errorf("Error %v does not match expected %v", tc.err, err)
}
if ts != tc.result {
t.Errorf("Got %v but expected %v", ts, tc.result)
}
})
}
}

View File

@@ -5,8 +5,10 @@ import (
)
var (
// ErrEndAfterStart occurs when a period given has an end time after its start time
// ErrEndAfterStart occurs when a period's start time is after its end time
ErrEndAfterStart = errors.New("error: start time is after end time")
// ErrNoValidPeriods occurs when an empty set of periods is passed or when ll periods are invalid
// ErrNoValidPeriods occurs when an empty set of periods is passed or when all periods are invalid
ErrNoValidPeriods = errors.New("error: no valid periods available")
// ErrNoNextChangeover occurs when GetNextChangeover is called but there are no changeovers after t
ErrNoNextChangeover = errors.New("error: no valid changeovers available")
)

View File

@@ -61,7 +61,9 @@ func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error)
func ValidTimePeriods(ts time.Time, periods ...Period) []Period {
var valid []Period
for _, p := range periods {
if p.GetStartTime().Before(ts) && p.GetEndTime().After(ts) {
start := p.GetStartTime()
end := p.GetEndTime()
if (start.Before(ts) || start.Equal(ts)) && (end.After(ts)) {
valid = append(valid, p)
}
}

View File

@@ -1,28 +1,11 @@
package msp
import (
"fmt"
"testing"
"time"
)
type TimeWindow struct {
StartTime time.Time
EndTime time.Time
Identifier string
}
func (p TimeWindow) GetIdentifier() string {
return p.Identifier
}
func (p TimeWindow) GetEndTime() time.Time {
return p.EndTime
}
func (p TimeWindow) GetStartTime() time.Time {
return p.StartTime
}
//(periods ...Period) (id string, err error) {
// (periods ...Period) (id string, err error) {
func TestMostSpecificPeriod(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now()
@@ -32,81 +15,137 @@ func TestMostSpecificPeriod(t *testing.T) {
result string
err error
periods []Period
}{{testID: "No choices",
}{
{
testID: "No choices",
ts: now,
result: "",
err: ErrNoValidPeriods,
periods: []Period{}},
{testID: "Two Choices, shorter is second",
periods: []Period{},
},
{
testID: "Two Choices, shorter is second",
ts: now,
result: "B",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(-5 * time.Minute),
periods: []Period{
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A"},
TimeWindow{StartTime: now.Add(-2 * time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B"}}},
{testID: "Two Choices, one is a year, other a minute",
Identifier: "B",
},
},
},
{
testID: "Two Choices, one is a year, other a minute",
ts: now,
result: "B",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(-1 * time.Hour * 24 * 365),
periods: []Period{
TimeWindow{
StartTime: now.Add(-1 * time.Hour * 24 * 365),
EndTime: now.Add(time.Minute),
Identifier: "A"},
TimeWindow{StartTime: now.Add(-5 * time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B"}}},
Identifier: "B",
},
},
},
{testID: "Two Choices, shorter is first",
{
testID: "Two Choices, shorter is first",
ts: now,
result: "A",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(-2 * time.Minute),
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A"},
TimeWindow{StartTime: now.Add(-5 * time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B"}}},
{testID: "Two Choices, one in the past",
Identifier: "B",
},
},
},
{
testID: "Two Choices, one in the past",
ts: now,
result: "A",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(-time.Minute),
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A"},
TimeWindow{StartTime: now.Add(-2 * time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "B"}}},
{testID: "Two Choices, one invalid",
Identifier: "B",
},
},
},
{
testID: "Two Choices, one invalid",
ts: now,
result: "B",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(time.Minute),
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A"},
TimeWindow{StartTime: now.Add(-2 * time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B"}}},
{testID: "Two Choices, Identical periods",
Identifier: "B",
},
},
},
{
testID: "Two Choices, Identical periods",
ts: now,
result: "B",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(-time.Minute),
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A"},
TimeWindow{StartTime: now.Add(-time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B"}}},
{testID: "One choice",
Identifier: "B",
},
},
},
{
testID: "One choice",
ts: now,
result: "A",
err: nil,
periods: []Period{TimeWindow{StartTime: now.Add(-time.Minute),
periods: []Period{TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A"}}}}
Identifier: "A",
}},
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s", tc.testID), func(t *testing.T) {
t.Run(tc.testID, func(t *testing.T) {
id, err := MostSpecificPeriod(tc.ts, tc.periods...)
if id != tc.result {
t.Errorf("ID '%s' does not match expected '%s'", id, tc.result)
@@ -114,7 +153,6 @@ func TestMostSpecificPeriod(t *testing.T) {
if err != tc.err {
t.Errorf("Error '%v' does not match expected '%v'", err, tc.err)
}
})
}
}

57
msp/timeline.go Normal file
View File

@@ -0,0 +1,57 @@
package msp
import (
"fmt"
"time"
)
type TimeWindow struct {
StartTime time.Time
EndTime time.Time
Identifier string
}
func (p TimeWindow) GetIdentifier() string {
return p.Identifier
}
func (p TimeWindow) GetEndTime() time.Time {
return p.EndTime
}
func (p TimeWindow) GetStartTime() time.Time {
return p.StartTime
}
func (t TimeWindow) String() string {
return fmt.Sprintf("%s\t%s\t%s",
t.GetIdentifier(),
t.GetStartTime(),
t.GetEndTime())
}
// Outputs a formatted timeline of periods
func GenerateTimeline(periods ...Period) (out []Period) {
if len(periods) == 0 {
return out
}
periodsByID := make(map[string]Period)
ids := FlattenPeriods(periods...)
for _, val := range periods {
id := val.GetIdentifier()
periodsByID[id] = val
}
start := periodsByID[ids[0]].GetStartTime()
for _, val := range ids {
next, err := GetNextChangeOver(start, periods...)
if err == nil {
if next.Equal(periodsByID[val].GetStartTime()) {
start = periodsByID[val].GetStartTime()
next = periodsByID[val].GetEndTime()
}
out = append(out, TimeWindow{StartTime: start, EndTime: next, Identifier: val})
start = next
}
}
return out
}

278
msp/timeline_test.go Normal file
View File

@@ -0,0 +1,278 @@
package msp
import (
"fmt"
"testing"
"time"
)
// (periods ...Period) (id string, err error) {
func TestGenerateTime(t *testing.T) {
now := time.Now()
testCases := []struct {
ts time.Time
testID string
result []string
periods []Period
}{
{
testID: "No choices",
ts: now,
result: []string{},
periods: []Period{},
},
{
testID: "Two Choices, shorter is second",
ts: now,
result: []string{
fmt.Sprintf("A\t%s\t%s\n", now.Add(-5*time.Minute), now.Add(-2*time.Minute)),
fmt.Sprintf("B\t%s\t%s\n", now.Add(-2*time.Minute), now.Add(time.Minute)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one is a year, other a minute",
ts: now,
result: []string{
fmt.Sprintf("A\t%s\t%s\n", now.Add(-1*time.Hour*24*365), now.Add(-5*time.Minute)),
fmt.Sprintf("B\t%s\t%s\n", now.Add(-5*time.Minute), now.Add(time.Minute)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-1 * time.Hour * 24 * 365),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, shorter is first",
ts: now,
result: []string{
fmt.Sprintf("B\t%s\t%s\n", now.Add(-5*time.Minute), now.Add(-2*time.Minute)),
fmt.Sprintf("A\t%s\t%s\n", now.Add(-2*time.Minute), now.Add(time.Minute)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one in the past",
ts: now,
result: []string{
fmt.Sprintf("B\t%s\t%s\n", now.Add(-2*time.Minute), now.Add(-time.Minute)),
fmt.Sprintf("A\t%s\t%s\n", now.Add(-time.Minute), now.Add(time.Minute)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, one invalid",
ts: now,
result: []string{
fmt.Sprintf("B\t%s\t%s\n", now.Add(-2*time.Minute), now.Add(time.Minute)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "Two Choices, Identical periods",
ts: now,
result: []string{
fmt.Sprintf("B\t%s\t%s\n", now.Add(-time.Minute), now.Add(time.Minute)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "B",
},
},
},
{
testID: "One choice",
ts: now,
result: []string{
fmt.Sprintf("A\t%s\t%s\n", now.Add(-time.Minute), now.Add(time.Minute)),
},
periods: []Period{TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
}},
},
{
testID: "not in current point in time",
ts: now,
result: []string{
fmt.Sprintf("A\t%s\t%s\n", now.Add(-time.Hour*24*30), now.Add(-time.Hour*24*29)),
fmt.Sprintf("B\t%s\t%s\n", now.Add(time.Hour*24*90), now.Add(time.Hour*24*120)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 30),
EndTime: now.Add(-time.Hour * 24 * 29),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(time.Hour * 24 * 90),
EndTime: now.Add(time.Hour * 24 * 120),
Identifier: "B",
},
},
},
{
testID: "three overlapping periods",
ts: now,
result: []string{
fmt.Sprintf("C\t%s\t%s\n", now.Add(-time.Hour*24*31), now.Add(-time.Hour*24*30)),
fmt.Sprintf("A\t%s\t%s\n", now.Add(-time.Hour*24*30), now.Add(-time.Hour*24*29)),
fmt.Sprintf("C\t%s\t%s\n", now.Add(-time.Hour*24*29), now.Add(time.Hour*24*90)),
fmt.Sprintf("B\t%s\t%s\n", now.Add(time.Hour*24*90), now.Add(time.Hour*24*120)),
fmt.Sprintf("C\t%s\t%s\n", now.Add(time.Hour*24*120), now.Add(time.Hour*24*140)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 30),
EndTime: now.Add(-time.Hour * 24 * 29),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(time.Hour * 24 * 90),
EndTime: now.Add(time.Hour * 24 * 120),
Identifier: "B",
},
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 31),
EndTime: now.Add(time.Hour * 24 * 140),
Identifier: "C",
},
},
},
{
testID: "multiple overlapping periods",
ts: now,
result: []string{
fmt.Sprintf("D\t%s\t%s\n", now.Add(-time.Hour*24*150), now.Add(-time.Hour*24*65)),
fmt.Sprintf("E\t%s\t%s\n", now.Add(-time.Hour*24*65), now.Add(-time.Hour*24*31)),
fmt.Sprintf("C\t%s\t%s\n", now.Add(-time.Hour*24*31), now.Add(-time.Hour*24*30)),
fmt.Sprintf("A\t%s\t%s\n", now.Add(-time.Hour*24*30), now.Add(-time.Hour*24*29)),
fmt.Sprintf("C\t%s\t%s\n", now.Add(-time.Hour*24*29), now.Add(time.Hour*24*90)),
fmt.Sprintf("B\t%s\t%s\n", now.Add(time.Hour*24*90), now.Add(time.Hour*24*120)),
fmt.Sprintf("C\t%s\t%s\n", now.Add(time.Hour*24*120), now.Add(time.Hour*24*140)),
fmt.Sprintf("E\t%s\t%s\n", now.Add(time.Hour*24*140), now.Add(time.Hour*24*175)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 30),
EndTime: now.Add(-time.Hour * 24 * 29),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(time.Hour * 24 * 90),
EndTime: now.Add(time.Hour * 24 * 120),
Identifier: "B",
},
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 31),
EndTime: now.Add(time.Hour * 24 * 140),
Identifier: "C",
},
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 150),
EndTime: now.Add(time.Hour * 24 * 175),
Identifier: "D",
},
TimeWindow{
StartTime: now.Add(-time.Hour * 24 * 65),
EndTime: now.Add(time.Hour * 24 * 175),
Identifier: "E",
},
},
},
{
testID: "periods with a gap in the middle",
ts: now,
result: []string{
fmt.Sprintf("A\t%s\t%s\n", now.Add(-time.Minute*10), now.Add(-time.Minute*5)),
fmt.Sprintf("B\t%s\t%s\n", now.Add(time.Minute*5), now.Add(time.Minute*10)),
},
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute * 10),
EndTime: now.Add(-time.Minute * 5),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(time.Minute * 5),
EndTime: now.Add(time.Minute * 10),
Identifier: "B",
},
},
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
timeline := GenerateTimeline(tc.periods...)
if len(timeline) != len(tc.result) {
t.Fatalf("Time line had %d results, expected %d", len(timeline), len(tc.result))
}
for idx, period := range timeline {
if period.(TimeWindow).String()+"\n" != tc.result[idx] {
t.Errorf("Expected:\t%s\nHad:\t%s", period, tc.result[idx])
}
}
})
}
}

View File

@@ -2,6 +2,9 @@ package msp
import "time"
// Compile-time interface check.
var _ Period = TimeWindow{}
type Period interface {
GetStartTime() time.Time
GetEndTime() time.Time