test(daemon): cover bar poller re-arm and interval change
barScriptPoller had zero test coverage and was only reachable via Serve, so a rewrite that broke re-arming (e.g. blocking forever after the first tick) would silently stop status-bar refreshes in production while the suite stayed green. Drive the poller directly with a fake clock that records timer durations, and assert: a tick re-arms a new timer at the same interval, a reload re-arms at the newly configured interval, and context cancellation exits the goroutine.
- commit
- 83b5fe2ef8a8dfe0de09fcd7d2ad8e9b2a904866
- author
- brice · · 2026-07-20 08:29 UTC
- parent
- 747b112
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +101 -0
M internal/usecase/daemon/bar_refresh_test.go +101 -0
| @@ -434,6 +434,107 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 434 | 434 | require.Equal(t, "bottom-b", stateB.bottomRight) | |
| 435 | 435 | } | |
| 436 | 436 | ||
| 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 | |
| 442 | + | } | |
| 443 | + | ||
| 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) | |
| 474 | + | } | |
| 475 | + | ||
| 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] | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller | |
| 483 | + | // directly (not through Serve) with a fake clock, covering the three | |
| 484 | + | // behaviors the Task 5 rewrite touched but left untested: | |
| 485 | + | // | |
| 486 | + | // 1. after a tick fires, the loop arms a NEW timer instead of blocking | |
| 487 | + | // forever (the regression that would silently stop bar refreshes), | |
| 488 | + | // 2. a reload signal re-arms the timer at the newly configured interval | |
| 489 | + | // rather than the one the poller started with, and | |
| 490 | + | // 3. cancelling the context makes the goroutine return instead of leaking. | |
| 491 | + | func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) { | |
| 492 | + | clk := &pollerFakeClock{} | |
| 493 | + | d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second) | |
| 494 | + | d.clock = clk | |
| 495 | + | d.barScripts.reload = make(chan struct{}, 1) | |
| 496 | + | ||
| 497 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 498 | + | done := make(chan struct{}) | |
| 499 | + | go func() { | |
| 500 | + | d.barScriptPoller(ctx) | |
| 501 | + | close(done) | |
| 502 | + | }() | |
| 503 | + | ||
| 504 | + | // 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) | |
| 508 | + | ||
| 509 | + | // 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 | + | 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") | |
| 515 | + | ||
| 516 | + | // 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. | |
| 518 | + | d.barScripts.mu.Lock() | |
| 519 | + | d.barScripts.cfg.interval = 7 * time.Second | |
| 520 | + | d.barScripts.mu.Unlock() | |
| 521 | + | 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") | |
| 525 | + | ||
| 526 | + | // Stage 4: cancelling the context exits the goroutine instead of leaking it. | |
| 527 | + | 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") | |
| 536 | + | } | |
| 537 | + | ||
| 437 | 538 | func TestApplyConfigSignalsPollerReload(t *testing.T) { | |
| 438 | 539 | r := &fakeBarRunner{} | |
| 439 | 540 | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| @@ -434,6 +434,107 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||||
| 434 | require.Equal(t, "bottom-b", stateB.bottomRight) | 434 | require.Equal(t, "bottom-b", stateB.bottomRight) | ||
| 435 | } | 435 | } | ||
| 436 | 436 | ||||
| 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 | |||
| 442 | + | } | |||
| 443 | + | ||||
| 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) | |||
| 474 | + | } | |||
| 475 | + | ||||
| 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] | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | // TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel drives barScriptPoller | |||
| 483 | + | // directly (not through Serve) with a fake clock, covering the three | |||
| 484 | + | // behaviors the Task 5 rewrite touched but left untested: | |||
| 485 | + | // | |||
| 486 | + | // 1. after a tick fires, the loop arms a NEW timer instead of blocking | |||
| 487 | + | // forever (the regression that would silently stop bar refreshes), | |||
| 488 | + | // 2. a reload signal re-arms the timer at the newly configured interval | |||
| 489 | + | // rather than the one the poller started with, and | |||
| 490 | + | // 3. cancelling the context makes the goroutine return instead of leaking. | |||
| 491 | + | func TestBarScriptPollerRearmsTicksReloadsAndExitsOnCancel(t *testing.T) { | |||
| 492 | + | clk := &pollerFakeClock{} | |||
| 493 | + | d := newBarRefreshTestDaemon(&fakeBarRunner{}, 2*time.Second) | |||
| 494 | + | d.clock = clk | |||
| 495 | + | d.barScripts.reload = make(chan struct{}, 1) | |||
| 496 | + | ||||
| 497 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 498 | + | done := make(chan struct{}) | |||
| 499 | + | go func() { | |||
| 500 | + | d.barScriptPoller(ctx) | |||
| 501 | + | close(done) | |||
| 502 | + | }() | |||
| 503 | + | ||||
| 504 | + | // 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) | |||
| 508 | + | ||||
| 509 | + | // 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 | + | 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") | |||
| 515 | + | ||||
| 516 | + | // 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. | |||
| 518 | + | d.barScripts.mu.Lock() | |||
| 519 | + | d.barScripts.cfg.interval = 7 * time.Second | |||
| 520 | + | d.barScripts.mu.Unlock() | |||
| 521 | + | 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") | |||
| 525 | + | ||||
| 526 | + | // Stage 4: cancelling the context exits the goroutine instead of leaking it. | |||
| 527 | + | 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") | |||
| 536 | + | } | |||
| 537 | + | ||||
| 437 | func TestApplyConfigSignalsPollerReload(t *testing.T) { | 538 | func TestApplyConfigSignalsPollerReload(t *testing.T) { | ||
| 438 | r := &fakeBarRunner{} | 539 | r := &fakeBarRunner{} | ||
| 439 | d := newBarRefreshTestDaemon(r, 60*time.Second) | 540 | d := newBarRefreshTestDaemon(r, 60*time.Second) | ||