detached 27a2aaf

test(core): review fixes for the animations-on variants

Restore the default animations setting of the sized-workspace tests, make the on
variants fail when no spring ever ran, extend the last-output unplug test with a
workspace switch spring and a replug, and share one mock clock between the swipe
and landing rigs.
commit
27a2aaf96fc0cbe23e19e44cfb51f31aade45c84
author
brice · · 2026-10-04 20:40 UTC
parent
431292a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +149 -89
M internal/core/animations_interrupt_internal_test.go +69 -6
@@ -124,19 +124,82 @@ func TestOutputUnplugMidRectMotion(t *testing.T) {
124124 }
125125 }
126126
127-// The last output going away keeps its screen as a placeholder: its rect
128-// motions must not outlive it (no frame would ever settle them but the
129-// fallback timer).
127+// The last output going away keeps its screen as a placeholder: none of its
128+// springs (rect motions, a workspace switch) may outlive the output, or the
129+// fallback timer would wake core for a screen nothing draws. When an output
130+// comes back, a new move animates and lands from a fresh Seq.
130131 func TestLastOutputUnplugMidRectMotion(t *testing.T) {
131132 c, ic := indicatorCore(t)
132133 sc := c.cur()
133134 movingRects(t, c, ic, sc)
135+ // A workspace switch spring runs too.
136+ m := sc.mon
137+ m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now)
138+ if !m.switchMotion.on || !sc.springing() {
139+ t.Fatal("no workspace switch to interrupt")
140+ }
134141 c.removeScreen("A")
142+ if sc.mon.springing() || len(sc.rects) != 0 || sc.rectsWS != nil || sc.mon.switchMotion.on || sc.mon.switchOff != 0 {
143+ t.Fatalf("the placeholder keeps springs: monitor %t, rects %d, switch %t (%v)", sc.mon.springing(), len(sc.rects), sc.mon.switchMotion.on, sc.mon.switchOff)
144+ }
135145 if err := c.publish(context.Background()); err != nil {
136146 t.Fatal(err)
137147 }
138- <-c.ch.Scenes
139- if len(sc.rects) != 0 || c.animating() {
140- t.Fatalf("the placeholder keeps motions: rects %d, animating %t", len(sc.rects), c.animating())
148+ gone := (<-c.ch.Scenes)[0]
149+ if c.animating() || c.frameC != nil {
150+ t.Fatalf("the placeholder keeps asking for frames: animating %t, timer %t", c.animating(), c.frameC != nil)
151+ }
152+
153+ // The output comes back: its windows are where the move left them.
154+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
155+ settled := indicatorScene(t, c)
156+ if settled.Seq <= gone.Seq {
157+ t.Fatalf("scene after the replug reuses Seq %d (placeholder %d)", settled.Seq, gone.Seq)
158+ }
159+ x := func(s ports.Scene, id WindowID) int {
160+ for _, w := range s.Windows {
161+ if w.ID == id {
162+ return w.Rect.X
163+ }
164+ }
165+ t.Fatalf("window %d not in the scene", id)
166+ return 0
167+ }
168+ if x(settled, 2) != 0 || x(settled, 1) != 150 {
169+ t.Fatalf("after the replug 1 at %d and 2 at %d, want the settled 150 and 0", x(settled, 1), x(settled, 2))
170+ }
171+ if c.animating() || len(c.cur().rects) != 0 {
172+ t.Fatal("a motion runs after the replug")
173+ }
174+
175+ // A new move animates from the settled rects and lands.
176+ before := c.snapshot()
177+ c.applyAction(ActionMoveColumnRight)
178+ c.transition(before, ic.now)
179+ start := indicatorScene(t, c)
180+ if x(start, 1) != 150 || x(start, 2) != 0 {
181+ t.Fatalf("right after the move 1 at %d and 2 at %d, want the old 150 and 0", x(start, 1), x(start, 2))
182+ }
183+ ic.now = ic.now.Add(16 * time.Millisecond)
184+ if err := c.step(context.Background(), nil); err != nil {
185+ t.Fatal(err)
186+ }
187+ mid := (<-c.ch.Scenes)[0]
188+ if got := x(mid, 1); got >= 150 || got <= 0 {
189+ t.Fatalf("window 1 at %d on the first frame, want between 0 and 150", got)
190+ }
191+ if mid.Seq == start.Seq {
192+ t.Fatalf("an animating frame reuses Seq %d", mid.Seq)
193+ }
194+ ic.now = ic.now.Add(5 * time.Second)
195+ if err := c.step(context.Background(), nil); err != nil {
196+ t.Fatal(err)
197+ }
198+ end := (<-c.ch.Scenes)[0]
199+ if x(end, 1) != 0 || x(end, 2) != 150 || end.Seq == mid.Seq {
200+ t.Fatalf("landed 1 at %d and 2 at %d (Seq %d after %d), want 0 and 150 and a fresh Seq", x(end, 1), x(end, 2), end.Seq, mid.Seq)
201+ }
202+ if c.animating() || c.frameC != nil {
203+ t.Fatal("still animating after the landing")
141204 }
142205 }
@@ -124,19 +124,82 @@ func TestOutputUnplugMidRectMotion(t *testing.T) {
124 }124 }
125 }125 }
126 126
127-// The last output going away keeps its screen as a placeholder: its rect127+// The last output going away keeps its screen as a placeholder: none of its
128-// motions must not outlive it (no frame would ever settle them but the128+// springs (rect motions, a workspace switch) may outlive the output, or the
129-// fallback timer).129+// fallback timer would wake core for a screen nothing draws. When an output
130+// comes back, a new move animates and lands from a fresh Seq.
130 func TestLastOutputUnplugMidRectMotion(t *testing.T) {131 func TestLastOutputUnplugMidRectMotion(t *testing.T) {
131 c, ic := indicatorCore(t)132 c, ic := indicatorCore(t)
132 sc := c.cur()133 sc := c.cur()
133 movingRects(t, c, ic, sc)134 movingRects(t, c, ic, sc)
135+ // A workspace switch spring runs too.
136+ m := sc.mon
137+ m.switchOff, m.switchMotion = 0.5, c.spring(workspaceSpring(0.5, 0), ic.now)
138+ if !m.switchMotion.on || !sc.springing() {
139+ t.Fatal("no workspace switch to interrupt")
140+ }
134 c.removeScreen("A")141 c.removeScreen("A")
142+ if sc.mon.springing() || len(sc.rects) != 0 || sc.rectsWS != nil || sc.mon.switchMotion.on || sc.mon.switchOff != 0 {
143+ t.Fatalf("the placeholder keeps springs: monitor %t, rects %d, switch %t (%v)", sc.mon.springing(), len(sc.rects), sc.mon.switchMotion.on, sc.mon.switchOff)
144+ }
135 if err := c.publish(context.Background()); err != nil {145 if err := c.publish(context.Background()); err != nil {
136 t.Fatal(err)146 t.Fatal(err)
137 }147 }
138- <-c.ch.Scenes148+ gone := (<-c.ch.Scenes)[0]
139- if len(sc.rects) != 0 || c.animating() {149+ if c.animating() || c.frameC != nil {
140- t.Fatalf("the placeholder keeps motions: rects %d, animating %t", len(sc.rects), c.animating())150+ t.Fatalf("the placeholder keeps asking for frames: animating %t, timer %t", c.animating(), c.frameC != nil)
151+ }
152+
153+ // The output comes back: its windows are where the move left them.
154+ c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
155+ settled := indicatorScene(t, c)
156+ if settled.Seq <= gone.Seq {
157+ t.Fatalf("scene after the replug reuses Seq %d (placeholder %d)", settled.Seq, gone.Seq)
158+ }
159+ x := func(s ports.Scene, id WindowID) int {
160+ for _, w := range s.Windows {
161+ if w.ID == id {
162+ return w.Rect.X
163+ }
164+ }
165+ t.Fatalf("window %d not in the scene", id)
166+ return 0
167+ }
168+ if x(settled, 2) != 0 || x(settled, 1) != 150 {
169+ t.Fatalf("after the replug 1 at %d and 2 at %d, want the settled 150 and 0", x(settled, 1), x(settled, 2))
170+ }
171+ if c.animating() || len(c.cur().rects) != 0 {
172+ t.Fatal("a motion runs after the replug")
173+ }
174+
175+ // A new move animates from the settled rects and lands.
176+ before := c.snapshot()
177+ c.applyAction(ActionMoveColumnRight)
178+ c.transition(before, ic.now)
179+ start := indicatorScene(t, c)
180+ if x(start, 1) != 150 || x(start, 2) != 0 {
181+ t.Fatalf("right after the move 1 at %d and 2 at %d, want the old 150 and 0", x(start, 1), x(start, 2))
182+ }
183+ ic.now = ic.now.Add(16 * time.Millisecond)
184+ if err := c.step(context.Background(), nil); err != nil {
185+ t.Fatal(err)
186+ }
187+ mid := (<-c.ch.Scenes)[0]
188+ if got := x(mid, 1); got >= 150 || got <= 0 {
189+ t.Fatalf("window 1 at %d on the first frame, want between 0 and 150", got)
190+ }
191+ if mid.Seq == start.Seq {
192+ t.Fatalf("an animating frame reuses Seq %d", mid.Seq)
193+ }
194+ ic.now = ic.now.Add(5 * time.Second)
195+ if err := c.step(context.Background(), nil); err != nil {
196+ t.Fatal(err)
197+ }
198+ end := (<-c.ch.Scenes)[0]
199+ if x(end, 1) != 0 || x(end, 2) != 150 || end.Seq == mid.Seq {
200+ t.Fatalf("landed 1 at %d and 2 at %d (Seq %d after %d), want 0 and 150 and a fresh Seq", x(end, 1), x(end, 2), end.Seq, mid.Seq)
201+ }
202+ if c.animating() || c.frameC != nil {
203+ t.Fatal("still animating after the landing")
141 }204 }
142 }205 }
M internal/core/border_invariants_test.go +1 -1
@@ -54,7 +54,7 @@ func TestBorderSceneInvariantsAnimated(t *testing.T) {
5454 moved++
5555 }
5656 // Settled means no spring runs: a further flip has nothing to do.
57- if _, again := r.flipAll(t, 16*time.Millisecond, left.Name, right.Name); again {
57+ if _, again := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, left.Name, right.Name); again {
5858 t.Fatal("a flip after the settle still published: a spring runs")
5959 }
6060 return settled
@@ -54,7 +54,7 @@ func TestBorderSceneInvariantsAnimated(t *testing.T) {
54 moved++54 moved++
55 }55 }
56 // Settled means no spring runs: a further flip has nothing to do.56 // Settled means no spring runs: a further flip has nothing to do.
57- if _, again := r.flipAll(t, 16*time.Millisecond, left.Name, right.Name); again {57+ if _, again := r.clk.flip(t, r.frames, r.scenes, 16*time.Millisecond, left.Name, right.Name); again {
58 t.Fatal("a flip after the settle still published: a spring runs")58 t.Fatal("a flip after the settle still published: a spring runs")
59 }59 }
60 return settled60 return settled
M internal/core/drag_test.go +10 -3
@@ -27,6 +27,8 @@ type dragRig struct {
2727 frames chan ports.OutputFrame
2828 clock *stepClock
2929 outputs []string
30+ // lands counts the settles that found a spring running.
31+ lands int
3032 }
3133
3234 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and
@@ -73,6 +75,12 @@ func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...p
7375 if animated {
7476 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
7577 ch.Frames, ch.Clock = r.frames, r.clock.clock
78+ // An on variant that never saw a spring would be the off variant again.
79+ t.Cleanup(func() {
80+ if r.lands == 0 {
81+ t.Error("no spring ever ran: the animations-on variant checked nothing")
82+ }
83+ })
7684 }
7785 c, err := core.New(cfg, ch)
7886 if err != nil {
@@ -115,9 +123,8 @@ const barrier ports.WindowID = 1 << 30
115123 func (r *dragRig) settle(t *testing.T) []ports.Scene {
116124 t.Helper()
117125 if r.frames != nil {
118- r.clock.advance(5 * time.Second)
119- for _, o := range r.outputs {
120- send(r.frames, ports.OutputFrame{Output: o})
126+ if _, ok := r.clock.settle(t, r.frames, r.scenes, r.outputs...); ok {
127+ r.lands++
121128 }
122129 }
123130 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
@@ -27,6 +27,8 @@ type dragRig struct {
27 frames chan ports.OutputFrame27 frames chan ports.OutputFrame
28 clock *stepClock28 clock *stepClock
29 outputs []string29 outputs []string
30+ // lands counts the settles that found a spring running.
31+ lands int
30 }32 }
31 33
32 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and34 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and
@@ -73,6 +75,12 @@ func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...p
73 if animated {75 if animated {
74 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)76 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
75 ch.Frames, ch.Clock = r.frames, r.clock.clock77 ch.Frames, ch.Clock = r.frames, r.clock.clock
78+ // An on variant that never saw a spring would be the off variant again.
79+ t.Cleanup(func() {
80+ if r.lands == 0 {
81+ t.Error("no spring ever ran: the animations-on variant checked nothing")
82+ }
83+ })
76 }84 }
77 c, err := core.New(cfg, ch)85 c, err := core.New(cfg, ch)
78 if err != nil {86 if err != nil {
@@ -115,9 +123,8 @@ const barrier ports.WindowID = 1 << 30
115 func (r *dragRig) settle(t *testing.T) []ports.Scene {123 func (r *dragRig) settle(t *testing.T) []ports.Scene {
116 t.Helper()124 t.Helper()
117 if r.frames != nil {125 if r.frames != nil {
118- r.clock.advance(5 * time.Second)126+ if _, ok := r.clock.settle(t, r.frames, r.scenes, r.outputs...); ok {
119- for _, o := range r.outputs {127+ r.lands++
120- send(r.frames, ports.OutputFrame{Output: o})
121 }128 }
122 }129 }
123 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))130 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
M internal/core/step_clock_test.go +41 -9
@@ -44,13 +44,21 @@ func (s *stepClock) advance(d time.Duration) {
4444 s.mu.Unlock()
4545 }
4646
47-// settle moves the clock past every spring, flips each output and returns
48-// the set core published for them (ok false: no spring ran, so nothing
49-// moved). The last flip names no output: core takes it only after it
50-// handled the previous ones, so the scene channel then holds their result.
51-func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {
47+// flip moves the clock by d and sends a page flip for each output, then
48+// returns the scene set core published for them (ok false: none, so no
49+// spring ran). Core publishes before it takes its next event, so a flip that
50+// names no output is a barrier: once its send returns, every earlier event
51+// is handled and the scene channel holds the result. One is sent first too,
52+// and a scene left over from earlier events is dropped, so a scene found
53+// afterwards is the flips' own.
54+func (s *stepClock) flip(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, d time.Duration, outputs ...string) ([]ports.Scene, bool) {
5255 t.Helper()
53- s.advance(5 * time.Second)
56+ send(frames, ports.OutputFrame{Output: "sync"})
57+ select {
58+ case <-scenes:
59+ default:
60+ }
61+ s.advance(d)
5462 for _, o := range outputs {
5563 send(frames, ports.OutputFrame{Output: o})
5664 }
@@ -63,6 +71,12 @@ func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes c
6371 }
6472 }
6573
74+// settle is flip past every spring.
75+func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {
76+ t.Helper()
77+ return s.flip(t, frames, scenes, 5*time.Second, outputs...)
78+}
79+
6680 // landRig is a multiRig whose settle lands every running spring: with
6781 // animations on it runs on a clock the test moves (swipeRig) and sends the
6882 // page flips; with animations off it is the plain rig and land is the
@@ -71,6 +85,8 @@ func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes c
7185 type landRig struct {
7286 *multiRig
7387 land func([]ports.Scene) []ports.Scene
88+ // lands counts the settles that found a spring running.
89+ lands *int
7490 }
7591
7692 // startLanding runs core on the given outputs (gaps and terminal policy as
@@ -84,7 +100,7 @@ func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ..
84100 }
85101 c.Animations.On = false
86102 }, outs...)
87- return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }}
103+ return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }, lands: new(int)}
88104 }
89105 defaults := altCmdDefaults()
90106 r := startSwipeOn(t, func(c *ports.Config) {
@@ -94,10 +110,26 @@ func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ..
94110 }
95111 c.Animations.On = true
96112 }, outs...)
97- return &landRig{multiRig: r.multiRig, land: func(set []ports.Scene) []ports.Scene { return r.landed(t, set) }}
113+ lands := new(int)
114+ // An on variant that never saw a spring would be the off variant again.
115+ t.Cleanup(func() {
116+ if *lands == 0 {
117+ t.Error("no spring ever ran: the animations-on variant checked nothing")
118+ }
119+ })
120+ return &landRig{multiRig: r.multiRig, lands: lands, land: func(set []ports.Scene) []ports.Scene {
121+ landed, ok := r.settleAll(t, r.outs...)
122+ if !ok {
123+ return set
124+ }
125+ *lands++
126+ return landed
127+ }}
98128 }
99129
100-// both runs body with animations off, then on.
130+// both runs body with animations off, then on. A rig of the on run fails the
131+// test when it never settled a running spring (startLanding,
132+// startAnimatedDragRig).
101133 func both(t *testing.T, body func(t *testing.T, animated bool)) {
102134 t.Helper()
103135 t.Run("off", func(t *testing.T) { body(t, false) })
@@ -44,13 +44,21 @@ func (s *stepClock) advance(d time.Duration) {
44 s.mu.Unlock()44 s.mu.Unlock()
45 }45 }
46 46
47-// settle moves the clock past every spring, flips each output and returns47+// flip moves the clock by d and sends a page flip for each output, then
48-// the set core published for them (ok false: no spring ran, so nothing48+// returns the scene set core published for them (ok false: none, so no
49-// moved). The last flip names no output: core takes it only after it49+// spring ran). Core publishes before it takes its next event, so a flip that
50-// handled the previous ones, so the scene channel then holds their result.50+// names no output is a barrier: once its send returns, every earlier event
51-func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {51+// is handled and the scene channel holds the result. One is sent first too,
52+// and a scene left over from earlier events is dropped, so a scene found
53+// afterwards is the flips' own.
54+func (s *stepClock) flip(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, d time.Duration, outputs ...string) ([]ports.Scene, bool) {
52 t.Helper()55 t.Helper()
53- s.advance(5 * time.Second)56+ send(frames, ports.OutputFrame{Output: "sync"})
57+ select {
58+ case <-scenes:
59+ default:
60+ }
61+ s.advance(d)
54 for _, o := range outputs {62 for _, o := range outputs {
55 send(frames, ports.OutputFrame{Output: o})63 send(frames, ports.OutputFrame{Output: o})
56 }64 }
@@ -63,6 +71,12 @@ func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes c
63 }71 }
64 }72 }
65 73
74+// settle is flip past every spring.
75+func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {
76+ t.Helper()
77+ return s.flip(t, frames, scenes, 5*time.Second, outputs...)
78+}
79+
66 // landRig is a multiRig whose settle lands every running spring: with80 // landRig is a multiRig whose settle lands every running spring: with
67 // animations on it runs on a clock the test moves (swipeRig) and sends the81 // animations on it runs on a clock the test moves (swipeRig) and sends the
68 // page flips; with animations off it is the plain rig and land is the82 // page flips; with animations off it is the plain rig and land is the
@@ -71,6 +85,8 @@ func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes c
71 type landRig struct {85 type landRig struct {
72 *multiRig86 *multiRig
73 land func([]ports.Scene) []ports.Scene87 land func([]ports.Scene) []ports.Scene
88+ // lands counts the settles that found a spring running.
89+ lands *int
74 }90 }
75 91
76 // startLanding runs core on the given outputs (gaps and terminal policy as92 // startLanding runs core on the given outputs (gaps and terminal policy as
@@ -84,7 +100,7 @@ func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ..
84 }100 }
85 c.Animations.On = false101 c.Animations.On = false
86 }, outs...)102 }, outs...)
87- return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }}103+ return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }, lands: new(int)}
88 }104 }
89 defaults := altCmdDefaults()105 defaults := altCmdDefaults()
90 r := startSwipeOn(t, func(c *ports.Config) {106 r := startSwipeOn(t, func(c *ports.Config) {
@@ -94,10 +110,26 @@ func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ..
94 }110 }
95 c.Animations.On = true111 c.Animations.On = true
96 }, outs...)112 }, outs...)
97- return &landRig{multiRig: r.multiRig, land: func(set []ports.Scene) []ports.Scene { return r.landed(t, set) }}113+ lands := new(int)
114+ // An on variant that never saw a spring would be the off variant again.
115+ t.Cleanup(func() {
116+ if *lands == 0 {
117+ t.Error("no spring ever ran: the animations-on variant checked nothing")
118+ }
119+ })
120+ return &landRig{multiRig: r.multiRig, lands: lands, land: func(set []ports.Scene) []ports.Scene {
121+ landed, ok := r.settleAll(t, r.outs...)
122+ if !ok {
123+ return set
124+ }
125+ *lands++
126+ return landed
127+ }}
98 }128 }
99 129
100-// both runs body with animations off, then on.130+// both runs body with animations off, then on. A rig of the on run fails the
131+// test when it never settled a running spring (startLanding,
132+// startAnimatedDragRig).
101 func both(t *testing.T, body func(t *testing.T, animated bool)) {133 func both(t *testing.T, body func(t *testing.T, animated bool)) {
102 t.Helper()134 t.Helper()
103 t.Run("off", func(t *testing.T) { body(t, false) })135 t.Run("off", func(t *testing.T) { body(t, false) })
M internal/core/swipe_test.go +7 -56
@@ -2,14 +2,11 @@ package core_test
22
33 import (
44 "context"
5- "sync"
65 "testing"
76 "time"
87
98 "github.com/bnema/neferwl/internal/core"
10- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
119 "github.com/bnema/neferwl/internal/ports"
12- "github.com/stretchr/testify/mock"
1310 )
1411
1512 // swipeRig runs core on one 800x600 output with no gaps or border, a
@@ -17,8 +14,7 @@ import (
1714 type swipeRig struct {
1815 *multiRig
1916 frames chan ports.OutputFrame
20- mu sync.Mutex
21- now time.Time
17+ clk *stepClock
2218 // at is the device time of the next swipe event.
2319 at time.Duration
2420 // outs are the plugged outputs, for landed.
@@ -42,26 +38,13 @@ func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
4238 if edit != nil {
4339 edit(&cfg)
4440 }
45- r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)}
41+ r := &swipeRig{frames: make(chan ports.OutputFrame), clk: newStepClock(t)}
4642 r.multiRig = &multiRig{
4743 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),
4844 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
4945 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
5046 }
51- clock := portsmocks.NewMockClock(t)
52- clock.EXPECT().Now().RunAndReturn(func() time.Time {
53- r.mu.Lock()
54- defer r.mu.Unlock()
55- return r.now
56- }).Maybe()
57- // The fallback timer never fires: frames come from the test. One timer
58- // built before core runs: a mock created by core while the test ends
59- // would race its own cleanup check.
60- timer := portsmocks.NewMockTimer(t)
61- timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
62- timer.EXPECT().Stop().Return(true).Maybe()
63- clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
64- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: clock, Frames: r.frames})
47+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: r.clk.clock, Frames: r.frames})
6548 if err != nil {
6649 t.Fatal(err)
6750 }
@@ -76,21 +59,8 @@ func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
7659 return r
7760 }
7861
79-// landed is the scene set once every spring an action started on any output
80-// landed: the set itself when nothing animates (animations off, or an
81-// action that moves nothing).
82-func (r *swipeRig) landed(t *testing.T, set []ports.Scene) []ports.Scene {
83- t.Helper()
84- if landed, ok := r.settleAll(t, r.outs...); ok {
85- return landed
86- }
87- return set
88-}
89-
9062 func (r *swipeRig) advance(d time.Duration) {
91- r.mu.Lock()
92- r.now = r.now.Add(d)
93- r.mu.Unlock()
63+ r.clk.advance(d)
9464 }
9565
9666 // begin starts a swipe; nothing moves until it picks an axis.
@@ -127,30 +97,11 @@ func (r *swipeRig) settle(t *testing.T) ports.Scene {
12797 return r.frame(t, 5*time.Second)
12898 }
12999
130-// flipAll moves the clock by d, sends a page flip for each output and
131-// returns the latest scene set they published (ok false: none, nothing
132-// animated). The last flip names no output: core takes it only after it
133-// handled the previous ones, so the scene channel then holds their result.
134-func (r *swipeRig) flipAll(t *testing.T, d time.Duration, outputs ...string) (set []ports.Scene, ok bool) {
135- t.Helper()
136- r.advance(d)
137- for _, o := range outputs {
138- send(r.frames, ports.OutputFrame{Output: o})
139- }
140- send(r.frames, ports.OutputFrame{Output: "sync"})
141- select {
142- case set = <-r.scenes:
143- return set, true
144- default:
145- return nil, false
146- }
147-}
148-
149-// settleAll moves the clock past every spring on the outputs and returns
150-// the scene set that results (ok false: nothing was animating).
100+// settleAll moves the clock past every spring, flips the outputs and
101+// returns the scene set that results (ok false: nothing was animating).
151102 func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {
152103 t.Helper()
153- return r.flipAll(t, 5*time.Second, outputs...)
104+ return r.clk.settle(t, r.frames, r.scenes, outputs...)
154105 }
155106
156107 // flick sends a quick swipe of n updates of (dx, dy) and lifts the
@@ -2,14 +2,11 @@ package core_test
2 2
3 import (3 import (
4 "context"4 "context"
5- "sync"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
9 "github.com/bnema/neferwl/internal/core"8 "github.com/bnema/neferwl/internal/core"
10- portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
12- "github.com/stretchr/testify/mock"
13 )10 )
14 11
15 // swipeRig runs core on one 800x600 output with no gaps or border, a12 // swipeRig runs core on one 800x600 output with no gaps or border, a
@@ -17,8 +14,7 @@ import (
17 type swipeRig struct {14 type swipeRig struct {
18 *multiRig15 *multiRig
19 frames chan ports.OutputFrame16 frames chan ports.OutputFrame
20- mu sync.Mutex17+ clk *stepClock
21- now time.Time
22 // at is the device time of the next swipe event.18 // at is the device time of the next swipe event.
23 at time.Duration19 at time.Duration
24 // outs are the plugged outputs, for landed.20 // outs are the plugged outputs, for landed.
@@ -42,26 +38,13 @@ func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
42 if edit != nil {38 if edit != nil {
43 edit(&cfg)39 edit(&cfg)
44 }40 }
45- r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)}41+ r := &swipeRig{frames: make(chan ports.OutputFrame), clk: newStepClock(t)}
46 r.multiRig = &multiRig{42 r.multiRig = &multiRig{
47 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),43 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64),
48 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),44 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
49 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,45 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
50 }46 }
51- clock := portsmocks.NewMockClock(t)47+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: r.clk.clock, Frames: r.frames})
52- clock.EXPECT().Now().RunAndReturn(func() time.Time {
53- r.mu.Lock()
54- defer r.mu.Unlock()
55- return r.now
56- }).Maybe()
57- // The fallback timer never fires: frames come from the test. One timer
58- // built before core runs: a mock created by core while the test ends
59- // would race its own cleanup check.
60- timer := portsmocks.NewMockTimer(t)
61- timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
62- timer.EXPECT().Stop().Return(true).Maybe()
63- clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
64- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: clock, Frames: r.frames})
65 if err != nil {48 if err != nil {
66 t.Fatal(err)49 t.Fatal(err)
67 }50 }
@@ -76,21 +59,8 @@ func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
76 return r59 return r
77 }60 }
78 61
79-// landed is the scene set once every spring an action started on any output
80-// landed: the set itself when nothing animates (animations off, or an
81-// action that moves nothing).
82-func (r *swipeRig) landed(t *testing.T, set []ports.Scene) []ports.Scene {
83- t.Helper()
84- if landed, ok := r.settleAll(t, r.outs...); ok {
85- return landed
86- }
87- return set
88-}
89-
90 func (r *swipeRig) advance(d time.Duration) {62 func (r *swipeRig) advance(d time.Duration) {
91- r.mu.Lock()63+ r.clk.advance(d)
92- r.now = r.now.Add(d)
93- r.mu.Unlock()
94 }64 }
95 65
96 // begin starts a swipe; nothing moves until it picks an axis.66 // begin starts a swipe; nothing moves until it picks an axis.
@@ -127,30 +97,11 @@ func (r *swipeRig) settle(t *testing.T) ports.Scene {
127 return r.frame(t, 5*time.Second)97 return r.frame(t, 5*time.Second)
128 }98 }
129 99
130-// flipAll moves the clock by d, sends a page flip for each output and100+// settleAll moves the clock past every spring, flips the outputs and
131-// returns the latest scene set they published (ok false: none, nothing101+// returns the scene set that results (ok false: nothing was animating).
132-// animated). The last flip names no output: core takes it only after it
133-// handled the previous ones, so the scene channel then holds their result.
134-func (r *swipeRig) flipAll(t *testing.T, d time.Duration, outputs ...string) (set []ports.Scene, ok bool) {
135- t.Helper()
136- r.advance(d)
137- for _, o := range outputs {
138- send(r.frames, ports.OutputFrame{Output: o})
139- }
140- send(r.frames, ports.OutputFrame{Output: "sync"})
141- select {
142- case set = <-r.scenes:
143- return set, true
144- default:
145- return nil, false
146- }
147-}
148-
149-// settleAll moves the clock past every spring on the outputs and returns
150-// the scene set that results (ok false: nothing was animating).
151 func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {102 func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {
152 t.Helper()103 t.Helper()
153- return r.flipAll(t, 5*time.Second, outputs...)104+ return r.clk.settle(t, r.frames, r.scenes, outputs...)
154 }105 }
155 106
156 // flick sends a quick swipe of n updates of (dx, dy) and lifts the107 // flick sends a quick swipe of n updates of (dx, dy) and lifts the
M internal/core/workspace_size_test.go +21 -14
@@ -8,19 +8,10 @@ import (
88
99 var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}
1010
11-// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
12-// bound to Alt+w, and shows it.
13-func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
14- t.Helper()
15- r, s := sizedLanding(t, false, overflow, edit)
16- return r.multiRig, s
17-}
18-
19-// sizedLanding is sizedRig with animations on or off; the scene it returns
20-// and every one of its keyLanded and mapLanded are settled.
21-func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
22- t.Helper()
23- r := startLanding(t, animated, func(c *ports.Config) {
11+// sizedConfig is the config of the sized-workspace rigs: gaps off, two
12+// columns, the named workspace "focus" (1920x1080) bound to Alt+w.
13+func sizedConfig(overflow string, edit func(*ports.Config)) func(*ports.Config) {
14+ return func(c *ports.Config) {
2415 c.Layout.Gaps = 0
2516 c.Layout.MaxColumns = 2
2617 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
@@ -28,7 +19,23 @@ func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports
2819 if edit != nil {
2920 edit(c)
3021 }
31- }, ultrawide)
22+ }
23+}
24+
25+// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
26+// bound to Alt+w, and shows it. The animations setting is the default's
27+// unless edit changes it.
28+func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
29+ t.Helper()
30+ r := startMulti(t, sizedConfig(overflow, edit), ultrawide)
31+ return r, r.key(t, "w", ports.ModAlt)[0]
32+}
33+
34+// sizedLanding is sizedRig with animations forced on or off; the scene it
35+// returns and every keyLanded and mapLanded of the rig are settled.
36+func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
37+ t.Helper()
38+ r := startLanding(t, animated, sizedConfig(overflow, edit), ultrawide)
3239 return r, r.keyLanded(t, "w", ports.ModAlt)[0]
3340 }
3441
@@ -8,19 +8,10 @@ import (
8 8
9 var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}9 var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial: "3", Width: 3440, Height: 1440}
10 10
11-// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)11+// sizedConfig is the config of the sized-workspace rigs: gaps off, two
12-// bound to Alt+w, and shows it.12+// columns, the named workspace "focus" (1920x1080) bound to Alt+w.
13-func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {13+func sizedConfig(overflow string, edit func(*ports.Config)) func(*ports.Config) {
14- t.Helper()14+ return func(c *ports.Config) {
15- r, s := sizedLanding(t, false, overflow, edit)
16- return r.multiRig, s
17-}
18-
19-// sizedLanding is sizedRig with animations on or off; the scene it returns
20-// and every one of its keyLanded and mapLanded are settled.
21-func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
22- t.Helper()
23- r := startLanding(t, animated, func(c *ports.Config) {
24 c.Layout.Gaps = 015 c.Layout.Gaps = 0
25 c.Layout.MaxColumns = 216 c.Layout.MaxColumns = 2
26 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}17 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
@@ -28,7 +19,23 @@ func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports
28 if edit != nil {19 if edit != nil {
29 edit(c)20 edit(c)
30 }21 }
31- }, ultrawide)22+ }
23+}
24+
25+// sizedRig runs an ultrawide with the named workspace "focus" (1920x1080)
26+// bound to Alt+w, and shows it. The animations setting is the default's
27+// unless edit changes it.
28+func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
29+ t.Helper()
30+ r := startMulti(t, sizedConfig(overflow, edit), ultrawide)
31+ return r, r.key(t, "w", ports.ModAlt)[0]
32+}
33+
34+// sizedLanding is sizedRig with animations forced on or off; the scene it
35+// returns and every keyLanded and mapLanded of the rig are settled.
36+func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
37+ t.Helper()
38+ r := startLanding(t, animated, sizedConfig(overflow, edit), ultrawide)
32 return r, r.keyLanded(t, "w", ports.ModAlt)[0]39 return r, r.keyLanded(t, "w", ports.ModAlt)[0]
33 }40 }
34 41

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close