From af4032fdf1a62f8ce6d31560474c69618d1fac3b Mon Sep 17 00:00:00 2001 From: Aran Wilkinson Date: Thu, 25 Jul 2024 17:46:20 +0100 Subject: [PATCH] feat: add JamTime, TimeSlot and Epoch implementation --- go.mod | 9 ++ go.sum | 13 +++ internal/jamtime/epoch.go | 69 +++++++++++++++ internal/jamtime/jamtime.go | 113 ++++++++++++++++++++++++ internal/jamtime/jamtime_test.go | 145 +++++++++++++++++++++++++++++++ internal/jamtime/timeslot.go | 66 ++++++++++++++ internal/time/constants.go | 9 -- internal/time/epoch.go | 3 - internal/time/timeslot.go | 3 - 9 files changed, 415 insertions(+), 15 deletions(-) create mode 100644 go.sum create mode 100644 internal/jamtime/epoch.go create mode 100644 internal/jamtime/jamtime.go create mode 100644 internal/jamtime/jamtime_test.go create mode 100644 internal/jamtime/timeslot.go delete mode 100644 internal/time/constants.go delete mode 100644 internal/time/epoch.go delete mode 100644 internal/time/timeslot.go diff --git a/go.mod b/go.mod index 6c5067c0..1c47de98 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,12 @@ module github.com/eigerco/strawberry go 1.22.5 + +require github.com/go-quicktest/qt v1.101.0 + +require ( + github.com/google/go-cmp v0.5.9 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..9af19d68 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= diff --git a/internal/jamtime/epoch.go b/internal/jamtime/epoch.go new file mode 100644 index 00000000..9172ee5e --- /dev/null +++ b/internal/jamtime/epoch.go @@ -0,0 +1,69 @@ +package jamtime + +import ( + "errors" + "time" +) + +const ( + TimeslotsPerEpoch = 600 + EpochDuration = TimeslotsPerEpoch * TimeslotDuration // 1 hour +) + +// Epoch represents a JAM Epoch +type Epoch uint32 + +// ToEpoch converts a JamTime to its corresponding Epoch +func (jt JamTime) ToEpoch() Epoch { + return Epoch(jt.Seconds / uint64(EpochDuration.Seconds())) +} + +// FromEpoch creates a JamTime from an Epoch (start of the epoch) +func FromEpoch(e Epoch) JamTime { + return JamTime{Seconds: uint64(e) * uint64(EpochDuration.Seconds())} +} + +// ToEpoch converts a Timeslot to its corresponding Epoch +func (ts Timeslot) ToEpoch() Epoch { + return Epoch(ts / TimeslotsPerEpoch) +} + +// CurrentEpoch returns the current epoch +func CurrentEpoch() Epoch { + return Now().ToEpoch() +} + +// EpochStart returns the JamTime at the start of the epoch +func (e Epoch) EpochStart() JamTime { + return FromEpoch(e) +} + +// EpochEnd returns the JamTime at the end of the epoch +func (e Epoch) EpochEnd() JamTime { + return FromEpoch(e + 1).Add(-time.Nanosecond) +} + +// NextEpoch returns the next epoch +func (e Epoch) NextEpoch() Epoch { + return e + 1 +} + +// PreviousEpoch returns the previous epoch +func (e Epoch) PreviousEpoch() Epoch { + return e - 1 +} + +// ValidateEpoch checks if a given Epoch is within the valid range +func ValidateEpoch(e Epoch) error { + jamTime := FromEpoch(e) + return ValidateJamTime(jamTime.ToTime()) +} + +// EpochAndTimeslotToJamTime converts an Epoch and a timeslot within that epoch to JamTime +func EpochAndTimeslotToJamTime(e Epoch, timeslotInEpoch uint32) (JamTime, error) { + if timeslotInEpoch >= TimeslotsPerEpoch { + return JamTime{}, errors.New("timeslot number exceeds epoch length") + } + epochStart := FromEpoch(e) + return JamTime{Seconds: epochStart.Seconds + uint64(timeslotInEpoch)*uint64(TimeslotDuration.Seconds())}, nil +} diff --git a/internal/jamtime/jamtime.go b/internal/jamtime/jamtime.go new file mode 100644 index 00000000..cd5e4286 --- /dev/null +++ b/internal/jamtime/jamtime.go @@ -0,0 +1,113 @@ +package jamtime + +import ( + "errors" + "fmt" + "time" +) + +var now = time.Now + +// JamEpoch represents the start of the JAM Common Era +var JamEpoch = time.Date(2024, time.January, 1, 12, 0, 0, 0, time.UTC) + +// JamTime represents a time in the JAM Common Era +type JamTime struct { + Seconds uint64 +} + +// Now returns the current time as a JamTime +func Now() JamTime { + return FromTime(now()) +} + +// FromTime converts a standard time.Time to JamTime +func FromTime(t time.Time) JamTime { + duration := t.Sub(JamEpoch) + seconds := uint64(duration.Seconds()) + return JamTime{Seconds: seconds} +} + +// ToTime converts a JamTime to a standard time.Time +func (jt JamTime) ToTime() time.Time { + duration := time.Duration(jt.Seconds) * time.Second + return JamEpoch.Add(duration) +} + +// FromSeconds creates a JamTime from the number of seconds since the JAM Epoch +func FromSeconds(seconds uint64) JamTime { + return JamTime{Seconds: seconds} +} + +// Before reports whether the time instant jt is before u +func (jt JamTime) Before(u JamTime) bool { + return jt.Seconds < u.Seconds +} + +// After reports whether the time instant jt is after u +func (jt JamTime) After(u JamTime) bool { + return jt.Seconds > u.Seconds +} + +// Equal reports whether jt and u represent the same time instant +func (jt JamTime) Equal(u JamTime) bool { + return jt.Seconds == u.Seconds +} + +// Add returns the time jt+d +func (jt JamTime) Add(d time.Duration) JamTime { + return JamTime{Seconds: jt.Seconds + uint64(d.Seconds())} +} + +// Sub returns the duration jt-u +func (jt JamTime) Sub(u JamTime) time.Duration { + return time.Duration(int64(jt.Seconds-u.Seconds)) * time.Second +} + +// IsInFutureTimeSlot checks if a given JamTime is in a future timeslot +func (jt JamTime) IsInFutureTimeSlot() bool { + return jt.ToTimeslot() > CurrentTimeslot() +} + +// ToTimeslot converts a JamTime to its corresponding Timeslot +func (jt JamTime) ToTimeslot() Timeslot { + return Timeslot(jt.Seconds / uint64(TimeslotDuration.Seconds())) +} + +// MarshalJSON implements the json.Marshaler interface +func (jt JamTime) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, []byte(jt.ToTime().Format(time.RFC3339)))), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface +func (jt *JamTime) UnmarshalJSON(data []byte) error { + t, err := time.Parse(`"`+time.RFC3339+`"`, string(data)) + if err != nil { + return err + } + *jt = FromTime(t) + return nil +} + +// JamTimeToEpochAndTimeslot converts a JamTime to its Epoch and timeslot within that epoch +func (jt JamTime) ToEpochAndTimeslot() (Epoch, uint32) { + epoch := jt.ToEpoch() + timeslotInEpoch := uint32((jt.Seconds / uint64(TimeslotDuration.Seconds())) % TimeslotsPerEpoch) + return epoch, timeslotInEpoch +} + +// IsInSameEpoch checks if two JamTimes are in the same epoch +func (jt JamTime) IsInSameEpoch(other JamTime) bool { + return jt.ToEpoch() == other.ToEpoch() +} + +// ValidateJamTime checks if a given time.Time is within the valid range for JamTime +func ValidateJamTime(t time.Time) error { + if t.Before(JamEpoch) { + return errors.New("time is before JAM Epoch") + } + if t.After(time.Date(2840, time.August, 15, 23, 59, 59, 999999999, time.UTC)) { + return errors.New("time is after maximum representable JAM time") + } + return nil +} diff --git a/internal/jamtime/jamtime_test.go b/internal/jamtime/jamtime_test.go new file mode 100644 index 00000000..9b24b3f0 --- /dev/null +++ b/internal/jamtime/jamtime_test.go @@ -0,0 +1,145 @@ +package jamtime + +import ( + "encoding/json" + "testing" + "time" + + "github.com/go-quicktest/qt" +) + +func TestJamTimeConversion(t *testing.T) { + t.Run("successfully convert to to and from JamTime", func(t *testing.T) { + standardTime := time.Date(2025, time.March, 15, 12, 0, 0, 0, time.UTC) + jamTime := FromTime(standardTime) + convertedTime := jamTime.ToTime() + + qt.Assert(t, qt.IsTrue(standardTime.Equal(convertedTime))) + + secondsInYear := uint64(31_536_000) + jamTime = FromSeconds(secondsInYear) + expectedTime := JamEpoch.Add(time.Duration(secondsInYear) * time.Second) + + qt.Assert(t, qt.IsTrue(jamTime.ToTime().Equal(expectedTime))) + }) +} + +func TestJamTimeComparison(t *testing.T) { + t.Run("non equal", func(t *testing.T) { + t1 := FromSeconds(1000) + t2 := FromSeconds(2000) + + qt.Assert(t, qt.IsTrue(t1.Before(t2))) + qt.Assert(t, qt.IsTrue(t2.After(t1))) + qt.Assert(t, qt.IsFalse(t1.Equal(t2))) + }) + + t.Run("equal", func(t *testing.T) { + t1 := FromSeconds(1000) + t2 := FromSeconds(1000) + + qt.Assert(t, qt.IsTrue(t1.Equal(t2))) + qt.Assert(t, qt.IsTrue(t2.Equal(t1))) + }) +} + +func TestJamTimeArithmetic(t *testing.T) { + t.Run("adding jamtime", func(t *testing.T) { + t1 := FromSeconds(1000) + duration := 500 * time.Second + + t2 := t1.Add(duration) + qt.Assert(t, qt.Not(qt.Equals(t2, t1))) + qt.Assert(t, qt.Equals(time.Duration(t2.Seconds)*time.Second, time.Duration(1500)*time.Second)) + }) + + t.Run("subbing jamtime", func(t *testing.T) { + t1 := FromSeconds(1000) + t2 := FromSeconds(500) + + duration := t1.Sub(t2) + qt.Assert(t, qt.Equals(duration, time.Duration(500)*time.Second)) + }) +} + +func TestJamTimeJSON(t *testing.T) { + t.Run("successfully marshal to json", func(t *testing.T) { + jamTime := FromSeconds(1000) + jsonData, err := json.Marshal(jamTime) + if err != nil { + t.Fatalf("JSON marshaling failed: %v", err) + } + + qt.Assert(t, qt.DeepEquals(jsonData, []uint8(`"2024-01-01T12:16:40Z"`))) + }) + + t.Run("successfully unmarshal jamtime", func(t *testing.T) { + jsonData := []byte(`"2024-01-01T12:00:00Z"`) + + var unmarshaledTime JamTime + err := json.Unmarshal(jsonData, &unmarshaledTime) + if err != nil { + t.Fatalf("JSON unmarshaling failed: %v", err) + } + + qt.Assert(t, qt.IsTrue(unmarshaledTime.ToTime().Equal(JamEpoch))) + }) + + t.Run("successfully unmarshal jamtime in future", func(t *testing.T) { + jsonData := []byte(`"2024-01-01T12:00:01Z"`) + + var unmarshaledTime JamTime + err := json.Unmarshal(jsonData, &unmarshaledTime) + if err != nil { + t.Fatalf("JSON unmarshaling failed: %v", err) + } + + want := JamEpoch.Add(1 * time.Second) + + qt.Assert(t, qt.IsTrue(unmarshaledTime.ToTime().Equal(want))) + }) +} + +func TestJamTimeFromToTimeslotConversion(t *testing.T) { + t.Run("convert jamtime to timeslot", func(t *testing.T) { + jamTime := FromSeconds(3600) // 10 minutes after JAM Epoch + timeslot := jamTime.ToTimeslot() + + qt.Assert(t, qt.Equals(uint32(timeslot), 600)) + }) + + t.Run("convert timeslot to jamtime", func(t *testing.T) { + slot := Timeslot(100) + + jamTime := FromTimeslot(slot) + + qt.Assert(t, qt.Equals(jamTime.Seconds, 600)) + }) +} + +func TestJamTimeIsInFutureTimeSlot(t *testing.T) { + currentTime := Now() + pastTime := currentTime.Add(-5 * time.Minute) + futureTime := currentTime.Add(10 * time.Minute) + + qt.Assert(t, qt.IsFalse(currentTime.IsInFutureTimeSlot())) + qt.Assert(t, qt.IsFalse(pastTime.IsInFutureTimeSlot())) + qt.Assert(t, qt.IsTrue(futureTime.IsInFutureTimeSlot())) +} + +func TestEpochConversion(t *testing.T) { + t.Run("jamtime to epoch", func(t *testing.T) { + jamTime := FromSeconds(3600) // 1 hour after JAM Epoch + epoch := jamTime.ToEpoch() + + qt.Assert(t, qt.Equals(epoch, 1)) + }) + + t.Run("epoch to jamtime", func(t *testing.T) { + e := Epoch(1) + + convertedJamTime := FromEpoch(e) + + qt.Assert(t, qt.Equals(convertedJamTime.Seconds, 3600)) + }) +} diff --git a/internal/jamtime/timeslot.go b/internal/jamtime/timeslot.go new file mode 100644 index 00000000..ee11f03c --- /dev/null +++ b/internal/jamtime/timeslot.go @@ -0,0 +1,66 @@ +package jamtime + +import "time" + +const ( + TimeslotDuration = 6 * time.Second +) + +// Timeslot represents a 6-second window in JAM time +type Timeslot uint32 + +// FromTimeslot creates a JamTime from a Timeslot (start of the timeslot) +func FromTimeslot(ts Timeslot) JamTime { + return JamTime{Seconds: uint64(ts) * uint64(TimeslotDuration.Seconds())} +} + +// CurrentTimeslot returns the current timeslot +func CurrentTimeslot() Timeslot { + return Now().ToTimeslot() +} + +// IsInFutureTimeslot checks if a given Timeslot is in the future +func (ts Timeslot) IsInFuture() bool { + return ts > CurrentTimeslot() +} + +// TimeslotStart returns the JamTime at the start of the timeslot +func (ts Timeslot) TimeslotStart() JamTime { + return FromTimeslot(ts) +} + +// TimeslotEnd returns the JamTime at the end of the timeslot +func (ts Timeslot) TimeslotEnd() JamTime { + return FromTimeslot(ts + 1).Add(-time.Nanosecond) +} + +// NextTimeslot returns the next timeslot +func (ts Timeslot) NextTimeslot() Timeslot { + return ts + 1 +} + +// PreviousTimeslot returns the previous timeslot +func (ts Timeslot) PreviousTimeslot() Timeslot { + return ts - 1 +} + +// TimeslotInEpoch returns the timeslot number within the epoch (0-599) +func (ts Timeslot) TimeslotInEpoch() uint32 { + return uint32(ts % TimeslotsPerEpoch) +} + +// IsFirstTimeslotInEpoch checks if the timeslot is the first in its epoch +func (ts Timeslot) IsFirstTimeslotInEpoch() bool { + return ts.TimeslotInEpoch() == 0 +} + +// IsLastTimeslotInEpoch checks if the timeslot is the last in its epoch +func (ts Timeslot) IsLastTimeslotInEpoch() bool { + return ts.TimeslotInEpoch() == TimeslotsPerEpoch-1 +} + +// ValidateTimeslot checks if a given Timeslot is within the valid range +func ValidateTimeslot(ts Timeslot) error { + jamTime := FromTimeslot(ts) + return ValidateJamTime(jamTime.ToTime()) +} diff --git a/internal/time/constants.go b/internal/time/constants.go deleted file mode 100644 index 186c1e87..00000000 --- a/internal/time/constants.go +++ /dev/null @@ -1,9 +0,0 @@ -package time - -import "time" - -const ( - TimeslotsPerEpoch = 600 - TimeslotDuration = 6 * time.Second - EpochDuration = TimeslotsPerEpoch * TimeslotDuration -) diff --git a/internal/time/epoch.go b/internal/time/epoch.go deleted file mode 100644 index 9dc60b07..00000000 --- a/internal/time/epoch.go +++ /dev/null @@ -1,3 +0,0 @@ -package time - -type Epoch uint32 diff --git a/internal/time/timeslot.go b/internal/time/timeslot.go deleted file mode 100644 index 2b3b77e8..00000000 --- a/internal/time/timeslot.go +++ /dev/null @@ -1,3 +0,0 @@ -package time - -type Timeslot uint32