commit ac9cdf956447b8f0d3138324bf03404102d39734 Author: Arpad Ryszka Date: Wed Mar 4 21:11:49 2026 +0100 initial implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebf0f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cover diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..66616ab --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +sources = $(shell find . -name "*.go") + +default: build + +build: $(sources) + go build + +fmt: $(sources) + go fmt + +check: $(sources) + go test -count 1 + +.cover: $(sources) + go test -count 1 -coverprofile .cover + +cover: .cover + go tool cover -func .cover + +showcover: .cover + go tool cover -html .cover + +bench: $(sources) + go test -bench Benchmark -run ^$ + +clean: + go clean + rm .cover diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26ff161 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.squareroundforest.org/arpio/times + +go 1.25.0 diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..038471e --- /dev/null +++ b/lib.go @@ -0,0 +1,43 @@ +package times + +import "time" + +type Clock interface { + Now() time.Time + After(time.Duration) <-chan time.Time +} + +type TestClock struct{ + clock test +} + +func Sys() Clock { + return sys{} +} + +func Test() TestClock { + var zero time.Time + return TestFrom(zero) +} + +func TestFrom(t time.Time) TestClock { + return TestClock{clock: makeTestClock(t)} +} + +func (c TestClock) Now() time.Time { + return c.clock.now() +} + +func (c TestClock) After(d time.Duration) <-chan time.Time { + return c.clock.after(d) +} + +// negative pass not allowed +func (c TestClock) Pass(d time.Duration) { + c.clock.pass(d) +} + +// backwards jump not allowed +func (c TestClock) Jump(t time.Time) { + c.clock.jump(t) +} diff --git a/lib_test.go b/lib_test.go new file mode 100644 index 0000000..f59a144 --- /dev/null +++ b/lib_test.go @@ -0,0 +1,232 @@ +package times_test + +import ( + "testing" + "code.squareroundforest.org/arpio/times" + "time" + "sort" +) + +func TestSys(t *testing.T) { + t.Run("now", func(t *testing.T) { + c := times.Sys() + t0 := time.Now() + tt := c.Now() + t1 := time.Now() + if tt.Before(t0) || tt.After(t1) { + t.Fatal(tt) + } + }) + + t.Run("after", func(t *testing.T) { + c := times.Sys() + t0 := time.Now() + ct := c.After(time.Millisecond) + tt := <-ct + if tt.Before(t0.Add(time.Millisecond)) { + t.Fatal(tt) + } + }) +} + +func TestTest(t *testing.T) { + t.Run("now default", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + t1 := c.Now() + t2 := c.Now() + if t0.After(t1) || t1.After(t2) { + t.Fatal(t0, t1, t2) + } + }) + + t.Run("now custom initial", func(t *testing.T) { + now := time.Now() + c := times.TestFrom(now) + t0 := c.Now() + t1 := c.Now() + t2 := c.Now() + if t0.Before(now) || t0.After(t1) || t1.After(t2) { + t.Fatal(now, t0, t1, t2) + } + }) + + t.Run("now after pass", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + c.Pass(time.Millisecond) + t1 := c.Now() + if t1.Before(t0.Add(time.Millisecond)) { + t.Fatal(t0, t1) + } + }) + + t.Run("now after zero pass", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + c.Pass(0) + t1 := c.Now() + if t1.Before(t0) { + t.Fatal(t0, t1) + } + }) + + t.Run("now after negative pass", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + c.Pass(-1000) + t1 := c.Now() + if t1.Before(t0) { + t.Fatal(t0, t1) + } + }) + + t.Run("now after jump", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + c.Jump(t0.Add(time.Millisecond)) + t1 := c.Now() + if t1.Before(t0.Add(time.Millisecond)) { + t.Fatal(t0, t1) + } + }) + + t.Run("now after jump present", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + c.Jump(t0) + t1 := c.Now() + if t1.Before(t0) { + t.Fatal(t0, t1) + } + }) + + t.Run("now after jump to past", func(t *testing.T) { + c := times.Test() + t0 := c.Now() + c.Jump(t0.Add(-1000)) + t1 := c.Now() + if t1.Before(t0) { + t.Fatal(t0, t1) + } + }) + + t.Run("after 0 default", func(t *testing.T) { + c := times.Test() + a := c.After(0) + t0 := c.Now() + tt := <-a + t1 := c.Now() + if t0.After(tt) || t1.Before(tt) { + t.Fatal(t0, tt, t1) + } + }) + + t.Run("after 0 custom initial", func(t *testing.T) { + c := times.TestFrom(time.Now()) + a := c.After(0) + t0 := c.Now() + tt := <-a + t1 := c.Now() + if t0.After(tt) || t1.Before(tt) { + t.Fatal(t0, tt, t1) + } + }) + + t.Run("after negative default", func(t *testing.T) { + c := times.Test() + a := c.After(-1000) + t0 := c.Now() + tt := <-a + t1 := c.Now() + if t0.After(tt) || t1.Before(tt) { + t.Fatal(t0, tt, t1) + } + }) + + t.Run("after negative custom initial", func(t *testing.T) { + c := times.TestFrom(time.Now()) + a := c.After(-1000) + t0 := c.Now() + tt := <-a + t1 := c.Now() + if t0.After(tt) || t1.Before(tt) { + t.Fatal(t0, tt, t1) + } + }) + + t.Run("after on pass", func(t *testing.T) { + c := times.Test() + a := c.After(2 * time.Millisecond) + select { + case <-a: + t.Fatal() + default: + } + + c.Pass(time.Millisecond) + select { + case <-a: + t.Fatal() + default: + } + + c.Pass(time.Millisecond) + t0 := c.Now() + tt := <-a + t1 := c.Now() + if t0.After(tt) || t1.Before(tt) { + t.Fatal(t0, tt, t1) + } + }) + + t.Run("after on jump", func(t *testing.T) { + start := time.Now() + c := times.TestFrom(start) + a := c.After(2 * time.Millisecond) + select { + case <-a: + t.Fatal() + default: + } + + c.Jump(start.Add(time.Millisecond)) + select { + case <-a: + t.Fatal() + default: + } + + c.Jump(start.Add(2 * time.Millisecond)) + t0 := c.Now() + tt := <-a + t1 := c.Now() + if t0.After(tt) || t1.Before(tt) { + t.Fatal(t0, tt, t1) + } + }) + + t.Run("multiple afters set in varying order and only part trigger", func(t *testing.T) { + initOrder := []time.Duration{2, 1, 2, 3, 2, 3} + c := times.Test() + + var a []<-chan time.Time + for _, d := range initOrder { + a = append(a, c.After(d * time.Millisecond)) + } + + sort.Slice(a, func(i, j int) bool { return initOrder[i] < initOrder[j] }) + sort.Slice(initOrder, func(i, j int) bool { return initOrder[i] < initOrder[j] }) + + var last time.Duration + for len(initOrder) > 0 { + if initOrder[0] > last { + c.Pass((initOrder[0] - last) * time.Millisecond) + last = initOrder[0] + } + + <-a[0] + a, initOrder = a[1:], initOrder[1:] + } + }) +} diff --git a/license b/license new file mode 100644 index 0000000..00e6290 --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Arpad Ryszka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/sys.go b/sys.go new file mode 100644 index 0000000..ee1dc1f --- /dev/null +++ b/sys.go @@ -0,0 +1,13 @@ +package times + +import "time" + +type sys struct {} + +func (sys) Now() time.Time { + return time.Now() +} + +func (sys) After(d time.Duration) <-chan time.Time { + return time.After(d) +} diff --git a/test.go b/test.go new file mode 100644 index 0000000..52489fa --- /dev/null +++ b/test.go @@ -0,0 +1,102 @@ +package times + +import ( + "time" + "sort" +) + +type clockState struct { + current time.Time + chans map[time.Time][]chan<- time.Time + chantlist []time.Time +} + +type test struct { + state chan clockState +} + +func makeTestClock(initial time.Time) test { + s := make(chan clockState, 1) + s <- clockState{ + current: initial, + chans: make(map[time.Time][]chan<- time.Time), + } + + return test{state: s} +} + +func triggerChans(s clockState) clockState { + for len(s.chantlist) > 0 && !s.chantlist[0].After(s.current) { + var ct time.Time + ct, s.chantlist = s.chantlist[0], s.chantlist[1:] + for _, c := range s.chans[ct] { + c <- s.current + } + + delete(s.chans, ct) + } + + return s +} + +func (t test) now() time.Time { + s := <-t.state + defer func() { + t.state <- s + }() + + return s.current +} + +func (t test) after(d time.Duration) <-chan time.Time { + s := <-t.state + defer func() { + t.state <- s + }() + + c := make(chan time.Time, 1) + ct := s.current.Add(d) + if !ct.After(s.current) { + c <- s.current + return c + } + + _, ctset := s.chans[ct] + s.chans[ct] = append(s.chans[ct], c) + if !ctset { + s.chantlist = append(s.chantlist, ct) + sort.Slice(s.chantlist, func(i, j int) bool { + return s.chantlist[i].Before(s.chantlist[j]) + }) + } + + return c +} + +func (t test) pass(d time.Duration) { + s := <-t.state + defer func() { + t.state <- s + }() + + if d < 0 { + d = 0 + } + + s.current = s.current.Add(d) + s = triggerChans(s) +} + +func (t test) jump(to time.Time) { + s := <-t.state + defer func() { + t.state <- s + }() + + if to.Before(s.current) { + return + } + + s.current = to + s = triggerChans(s) +}