detached bf74b02

test(daemon): use portsmocks MockClock/MockTimer for poller test

Replace the hand-rolled pollerFakeClock/pollerFakeTimer with a
mockery-generated MockClock/MockTimer harness (portsmocks), following
the existing coordinatorMockClock pattern in
render_coordinator_test.go. CLAUDE.md prefers generated mocks over
hand-written fakes, and this repo already has one for exactly this
shape.
commit
bf74b023c61771c48b77db47ec3581ecc9cad930
author
brice · · 2026-07-20 08:36 UTC
parent
83b5fe2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +66 -60
M internal/usecase/daemon/bar_refresh_test.go +66 -60
@@ -12,7 +12,9 @@ import (
1212
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1516 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/stretchr/testify/mock"
1618 "github.com/stretchr/testify/require"
1719 )
1820
@@ -434,53 +436,49 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
434436 require.Equal(t, "bottom-b", stateB.bottomRight)
435437 }
436438
437-// pollerFakeTimer is a manually-fired timer for barScriptPoller tests. Its
438-// channel is buffered so the test goroutine can push a tick before the
439-// poller reaches its select without blocking.
440-type pollerFakeTimer struct {
441- ch chan time.Time
439+// pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock
440+// and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following
441+// the coordinatorMockClock pattern in render_coordinator_test.go: the mocks
442+// supply the ports.Clock/ports.Timer implementations, while a small
443+// hand-owned struct retains the real channel and requested duration for each
444+// timer so the test goroutine can fire ticks and assert on re-arming.
445+type pollerMockClock struct {
446+ clock *portsmocks.MockClock
447+ timers chan *pollerMockTimer
442448 }
443449
444-func (t *pollerFakeTimer) C() <-chan time.Time { return t.ch }
445-func (t *pollerFakeTimer) Reset(time.Duration) bool { return true }
446-func (t *pollerFakeTimer) Stop() bool { return true }
447-
448-// pollerFakeClock records every timer barScriptPoller creates, along with the
449-// duration requested, so a test can assert on re-arming behavior without real
450-// sleeps or waiting out domain.MinBarInterval. NewTimer runs on the poller
451-// goroutine while the test goroutine reads via count/at, so all access is
452-// mutex-guarded; nothing here calls testify from the poller goroutine.
453-type pollerFakeClock struct {
454- mu sync.Mutex
455- timers []*pollerFakeTimer
456- durs []time.Duration
457-}
458-
459-func (c *pollerFakeClock) Now() time.Time { return time.Unix(0, 0) }
460-
461-func (c *pollerFakeClock) NewTimer(d time.Duration) ports.Timer {
462- c.mu.Lock()
463- defer c.mu.Unlock()
464- t := &pollerFakeTimer{ch: make(chan time.Time, 1)}
465- c.timers = append(c.timers, t)
466- c.durs = append(c.durs, d)
467- return t
468-}
469-
470-func (c *pollerFakeClock) count() int {
471- c.mu.Lock()
472- defer c.mu.Unlock()
473- return len(c.timers)
450+type pollerMockTimer struct {
451+ mock *portsmocks.MockTimer
452+ ch chan time.Time
453+ duration time.Duration
474454 }
475455
476-func (c *pollerFakeClock) at(i int) (*pollerFakeTimer, time.Duration) {
477- c.mu.Lock()
478- defer c.mu.Unlock()
479- return c.timers[i], c.durs[i]
456+func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock {
457+ t.Helper()
458+ clk := &pollerMockClock{
459+ clock: portsmocks.NewMockClock(t),
460+ timers: make(chan *pollerMockTimer, capacity),
461+ }
462+ clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
463+ clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
464+ timer := &pollerMockTimer{
465+ mock: portsmocks.NewMockTimer(t),
466+ duration: d,
467+ ch: make(chan time.Time, 1),
468+ }
469+ // Stop is called 0, 1, or 2 times per timer depending on whether it is
470+ // replaced mid-loop or torn down by the poller's deferred Stop on
471+ // exit, so its expectation is unconstrained.
472+ timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
473+ timer.mock.EXPECT().Stop().Maybe().Return(true)
474+ clk.timers <- timer
475+ return timer.mock
476+ }).Maybe()
477+ return clk
480478 }
481479
482480 // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
483-// directly (not through Serve) with a fake clock, covering the three
481+// directly (not through Serve) with a mock clock, covering the three
484482 // behaviors the Task 5 rewrite touched but left untested:
485483 //
486484 // 1. after a tick fires, the loop arms a NEW timer instead of blocking
@@ -489,9 +487,9 @@ func (c *pollerFakeClock) at(i int) (*pollerFakeTimer, time.Duration) {
489487 // rather than the one the poller started with, and
490488 // 3. cancelling the context makes the goroutine return instead of leaking.
491489 func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
492- clk := &pollerFakeClock{}
490+ clk := newPollerMockClock(t, 8)
493491 d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
494- d.clock = clk
492+ d.clock = clk.clock
495493 d.barScripts.reload = make(chan struct{}, 1)
496494
497495 ctx, cancel := context.WithCancel(context.Background())
@@ -502,16 +500,23 @@ func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
502500 }()
503501
504502 // Stage 1: the poller arms its first timer at the configured interval.
505- require.Eventually(t, func() bool { return clk.count() >= 1 }, time.Second, time.Millisecond)
506- _, dur0 := clk.at(0)
507- require.Equal(t, 2*time.Second, dur0)
503+ var timer0 *pollerMockTimer
504+ select {
505+ case timer0 = <-clk.timers:
506+ case <-time.After(time.Second):
507+ t.Fatal("timed out waiting for the poller's initial timer")
508+ }
509+ require.Equal(t, 2*time.Second, timer0.duration)
508510
509511 // Stage 2: firing that timer processes the tick and re-arms a new one.
510- timer0, _ := clk.at(0)
511512 timer0.ch <- time.Unix(0, 0)
512- require.Eventually(t, func() bool { return clk.count() >= 2 }, time.Second, time.Millisecond)
513- _, dur1 := clk.at(1)
514- require.Equal(t, 2*time.Second, dur1, "tick re-arm should keep the same interval when config is unchanged")
513+ var timer1 *pollerMockTimer
514+ select {
515+ case timer1 = <-clk.timers:
516+ case <-time.After(time.Second):
517+ t.Fatal("timed out waiting for the poller to re-arm after a tick")
518+ }
519+ require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged")
515520
516521 // Stage 3: changing the interval and signaling reload re-arms at the NEW
517522 // interval instead of waiting out the timer armed under the old one.
@@ -519,20 +524,21 @@ func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
519524 d.barScripts.cfg.interval = 7 * time.Second
520525 d.barScripts.mu.Unlock()
521526 d.barScripts.reload <- struct{}{}
522- require.Eventually(t, func() bool { return clk.count() >= 3 }, time.Second, time.Millisecond)
523- _, dur2 := clk.at(2)
524- require.Equal(t, 7*time.Second, dur2, "reload should re-arm at the newly configured interval")
527+ var timer2 *pollerMockTimer
528+ select {
529+ case timer2 = <-clk.timers:
530+ case <-time.After(time.Second):
531+ t.Fatal("timed out waiting for the poller to re-arm after reload")
532+ }
533+ require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval")
525534
526535 // Stage 4: cancelling the context exits the goroutine instead of leaking it.
527536 cancel()
528- require.Eventually(t, func() bool {
529- select {
530- case <-done:
531- return true
532- default:
533- return false
534- }
535- }, time.Second, time.Millisecond, "barScriptPoller should exit promptly after context cancellation")
537+ select {
538+ case <-done:
539+ case <-time.After(time.Second):
540+ t.Fatal("barScriptPoller did not exit after context cancellation")
541+ }
536542 }
537543
538544 func TestApplyConfigSignalsPollerReload(t *testing.T) {
@@ -12,7 +12,9 @@ import (
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
17 )19 )
18 20
@@ -434,53 +436,49 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
434 require.Equal(t, "bottom-b", stateB.bottomRight)436 require.Equal(t, "bottom-b", stateB.bottomRight)
435 }437 }
436 438
437-// pollerFakeTimer is a manually-fired timer for barScriptPoller tests. Its439+// pollerMockClock and pollerMockTimer wrap the generated portsmocks.MockClock
438-// channel is buffered so the test goroutine can push a tick before the440+// and portsmocks.MockTimer (see internal/ports/mocks/clock_mock.go), following
439-// poller reaches its select without blocking.441+// the coordinatorMockClock pattern in render_coordinator_test.go: the mocks
440-type pollerFakeTimer struct {442+// supply the ports.Clock/ports.Timer implementations, while a small
441- ch chan time.Time443+// hand-owned struct retains the real channel and requested duration for each
444+// timer so the test goroutine can fire ticks and assert on re-arming.
445+type pollerMockClock struct {
446+ clock *portsmocks.MockClock
447+ timers chan *pollerMockTimer
442 }448 }
443 449
444-func (t *pollerFakeTimer) C() <-chan time.Time { return t.ch }450+type pollerMockTimer struct {
445-func (t *pollerFakeTimer) Reset(time.Duration) bool { return true }451+ mock *portsmocks.MockTimer
446-func (t *pollerFakeTimer) Stop() bool { return true }452+ ch chan time.Time
447-453+ duration time.Duration
448-// pollerFakeClock records every timer barScriptPoller creates, along with the
449-// duration requested, so a test can assert on re-arming behavior without real
450-// sleeps or waiting out domain.MinBarInterval. NewTimer runs on the poller
451-// goroutine while the test goroutine reads via count/at, so all access is
452-// mutex-guarded; nothing here calls testify from the poller goroutine.
453-type pollerFakeClock struct {
454- mu sync.Mutex
455- timers []*pollerFakeTimer
456- durs []time.Duration
457-}
458-
459-func (c *pollerFakeClock) Now() time.Time { return time.Unix(0, 0) }
460-
461-func (c *pollerFakeClock) NewTimer(d time.Duration) ports.Timer {
462- c.mu.Lock()
463- defer c.mu.Unlock()
464- t := &pollerFakeTimer{ch: make(chan time.Time, 1)}
465- c.timers = append(c.timers, t)
466- c.durs = append(c.durs, d)
467- return t
468-}
469-
470-func (c *pollerFakeClock) count() int {
471- c.mu.Lock()
472- defer c.mu.Unlock()
473- return len(c.timers)
474 }454 }
475 455
476-func (c *pollerFakeClock) at(i int) (*pollerFakeTimer, time.Duration) {456+func newPollerMockClock(t *testing.T, capacity int) *pollerMockClock {
477- c.mu.Lock()457+ t.Helper()
478- defer c.mu.Unlock()458+ clk := &pollerMockClock{
479- return c.timers[i], c.durs[i]459+ clock: portsmocks.NewMockClock(t),
460+ timers: make(chan *pollerMockTimer, capacity),
461+ }
462+ clk.clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
463+ clk.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
464+ timer := &pollerMockTimer{
465+ mock: portsmocks.NewMockTimer(t),
466+ duration: d,
467+ ch: make(chan time.Time, 1),
468+ }
469+ // Stop is called 0, 1, or 2 times per timer depending on whether it is
470+ // replaced mid-loop or torn down by the poller's deferred Stop on
471+ // exit, so its expectation is unconstrained.
472+ timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
473+ timer.mock.EXPECT().Stop().Maybe().Return(true)
474+ clk.timers <- timer
475+ return timer.mock
476+ }).Maybe()
477+ return clk
480 }478 }
481 479
482 // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller480 // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller
483-// directly (not through Serve) with a fake clock, covering the three481+// directly (not through Serve) with a mock clock, covering the three
484 // behaviors the Task 5 rewrite touched but left untested:482 // behaviors the Task 5 rewrite touched but left untested:
485 //483 //
486 // 1. after a tick fires, the loop arms a NEW timer instead of blocking484 // 1. after a tick fires, the loop arms a NEW timer instead of blocking
@@ -489,9 +487,9 @@ func (c *pollerFakeClock) at(i int) (*pollerFakeTimer, time.Duration) {
489 // rather than the one the poller started with, and487 // rather than the one the poller started with, and
490 // 3. cancelling the context makes the goroutine return instead of leaking.488 // 3. cancelling the context makes the goroutine return instead of leaking.
491 func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {489 func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
492- clk := &pollerFakeClock{}490+ clk := newPollerMockClock(t, 8)
493 d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)491 d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second)
494- d.clock = clk492+ d.clock = clk.clock
495 d.barScripts.reload = make(chan struct{}, 1)493 d.barScripts.reload = make(chan struct{}, 1)
496 494
497 ctx, cancel := context.WithCancel(context.Background())495 ctx, cancel := context.WithCancel(context.Background())
@@ -502,16 +500,23 @@ func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
502 }()500 }()
503 501
504 // Stage 1: the poller arms its first timer at the configured interval.502 // Stage 1: the poller arms its first timer at the configured interval.
505- require.Eventually(t, func() bool { return clk.count() >= 1 }, time.Second, time.Millisecond)503+ var timer0 *pollerMockTimer
506- _, dur0 := clk.at(0)504+ select {
507- require.Equal(t, 2*time.Second, dur0)505+ case timer0 = <-clk.timers:
506+ case <-time.After(time.Second):
507+ t.Fatal("timed out waiting for the poller's initial timer")
508+ }
509+ require.Equal(t, 2*time.Second, timer0.duration)
508 510
509 // Stage 2: firing that timer processes the tick and re-arms a new one.511 // Stage 2: firing that timer processes the tick and re-arms a new one.
510- timer0, _ := clk.at(0)
511 timer0.ch <- time.Unix(0, 0)512 timer0.ch <- time.Unix(0, 0)
512- require.Eventually(t, func() bool { return clk.count() >= 2 }, time.Second, time.Millisecond)513+ var timer1 *pollerMockTimer
513- _, dur1 := clk.at(1)514+ select {
514- require.Equal(t, 2*time.Second, dur1, "tick re-arm should keep the same interval when config is unchanged")515+ case timer1 = <-clk.timers:
516+ case <-time.After(time.Second):
517+ t.Fatal("timed out waiting for the poller to re-arm after a tick")
518+ }
519+ require.Equal(t, 2*time.Second, timer1.duration, "tick re-arm should keep the same interval when config is unchanged")
515 520
516 // Stage 3: changing the interval and signaling reload re-arms at the NEW521 // Stage 3: changing the interval and signaling reload re-arms at the NEW
517 // interval instead of waiting out the timer armed under the old one.522 // interval instead of waiting out the timer armed under the old one.
@@ -519,20 +524,21 @@ func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) {
519 d.barScripts.cfg.interval = 7 * time.Second524 d.barScripts.cfg.interval = 7 * time.Second
520 d.barScripts.mu.Unlock()525 d.barScripts.mu.Unlock()
521 d.barScripts.reload <- struct{}{}526 d.barScripts.reload <- struct{}{}
522- require.Eventually(t, func() bool { return clk.count() >= 3 }, time.Second, time.Millisecond)527+ var timer2 *pollerMockTimer
523- _, dur2 := clk.at(2)528+ select {
524- require.Equal(t, 7*time.Second, dur2, "reload should re-arm at the newly configured interval")529+ case timer2 = <-clk.timers:
530+ case <-time.After(time.Second):
531+ t.Fatal("timed out waiting for the poller to re-arm after reload")
532+ }
533+ require.Equal(t, 7*time.Second, timer2.duration, "reload should re-arm at the newly configured interval")
525 534
526 // Stage 4: cancelling the context exits the goroutine instead of leaking it.535 // Stage 4: cancelling the context exits the goroutine instead of leaking it.
527 cancel()536 cancel()
528- require.Eventually(t, func() bool {537+ select {
529- select {538+ case <-done:
530- case <-done:539+ case <-time.After(time.Second):
531- return true540+ t.Fatal("barScriptPoller did not exit after context cancellation")
532- default:541+ }
533- return false
534- }
535- }, time.Second, time.Millisecond, "barScriptPoller should exit promptly after context cancellation")
536 }542 }
537 543
538 func TestApplyConfigSignalsPollerReload(t *testing.T) {544 func TestApplyConfigSignalsPollerReload(t *testing.T) {

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 12

Tags 11

↑↓ moveenter openesc close