1package daemon
2
3import (
4 "context"
5 "io"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 renderer "github.com/bnema/vev-vt"
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/keys"
18 "github.com/bnema/vev/internal/usecase/layout"
19 "github.com/bnema/vev/internal/usecase/ui"
20)
21
22func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
23 d, sess, _, _, releases := newManualTabSession(t, 2)
24 defer releases[0]()
25 defer releases[1]()
26 selectTestAttachmentTab(sess, 0)
27
28 d.noteAttention(sess, sess.tabs[1])
29
30 sess.mu.Lock()
31 defer sess.mu.Unlock()
32 require.True(t, sess.tabs[1].attention)
33 require.False(t, sess.tabs[1].attentionAt.IsZero())
34 require.False(t, sess.tabs[0].attention)
35}
36
37func TestNoteAttentionFlagsVisibleTabUntilPainted(t *testing.T) {
38 d, sess, _, _, releases := newManualTabSession(t, 1)
39 defer releases[0]()
40 selectTestAttachmentTab(sess, 0)
41
42 d.noteAttention(sess, sess.tabs[0])
43
44 sess.mu.Lock()
45 defer sess.mu.Unlock()
46 require.True(t, sess.tabs[0].attention)
47 require.False(t, sess.tabs[0].attentionAt.IsZero())
48}
49
50func TestPaintDoesNotAckActiveAttentionOnBlankPulseFrame(t *testing.T) {
51 d, sess, ac, sends, releases := newManualTabSession(t, 1)
52 defer releases[0]()
53 selectTestAttachmentTab(sess, 0)
54 d.setAttentionFrame(0)
55 d.noteAttention(sess, sess.tabs[0])
56
57 d.paint(sess, ac, true, nil)
58 data := mustOutputData(t, sends)
59 require.NotContains(t, string(data), string(ui.AttentionGlyph))
60
61 sess.mu.Lock()
62 require.True(t, sess.tabs[0].attention)
63 sess.mu.Unlock()
64
65 d.setAttentionFrame(1)
66 d.paint(sess, ac, true, nil)
67 data = mustOutputData(t, sends)
68 require.Contains(t, string(data), string(ui.AttentionGlyph))
69
70 sess.mu.Lock()
71 require.False(t, sess.tabs[0].attention)
72 sess.mu.Unlock()
73}
74
75func TestPTYReaderActiveVisibleAgentNotificationRendersBellBeforeAck(t *testing.T) {
76 tests := []struct {
77 name string
78 data []byte
79 }{
80 {name: "bell", data: []byte("\a")},
81 {name: "osc notify", data: []byte("\x1b]777;notify;Claude;needs input\x1b\\")},
82 {name: "osc progress complete", data: []byte("\x1b]9;4;1;50\a\x1b]9;4;0;100\a")},
83 }
84
85 for _, tt := range tests {
86 t.Run(tt.name, func(t *testing.T) {
87 pty := newScriptPTY([][]byte{tt.data})
88 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
89 selectTestAttachmentTab(sess, 0)
90 d.setAttentionFrame(1)
91
92 d.sessWg.Add(1)
93 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
94
95 require.Eventually(t, func() bool {
96 sess.mu.Lock()
97 defer sess.mu.Unlock()
98 return sess.tabs[0].attention && !sess.tabs[0].attentionAt.IsZero()
99 }, 2*time.Second, 5*time.Millisecond)
100
101 d.paint(sess, ac, true, nil)
102 data := mustOutputData(t, sends)
103 require.Contains(t, string(data), string(ui.AttentionGlyph))
104
105 sess.mu.Lock()
106 require.False(t, sess.tabs[0].attention)
107 require.True(t, sess.tabs[0].attentionAt.IsZero())
108 sess.mu.Unlock()
109
110 cleared := mustOutputData(t, sends)
111 require.NotContains(t, string(cleared), string(ui.AttentionGlyph))
112
113 _ = pty.Close()
114 d.sessWg.Wait()
115 })
116 }
117}
118
119func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
120 d, sess, ac, _, releases := newManualTabSession(t, 1)
121 defer releases[0]()
122 selectTestAttachmentTab(sess, 0)
123 require.True(t, sess.detachIfCurrent(ac))
124
125 d.noteAttention(sess, sess.tabs[0])
126
127 sess.mu.Lock()
128 defer sess.mu.Unlock()
129 require.True(t, sess.tabs[0].attention)
130 require.False(t, sess.tabs[0].attentionAt.IsZero())
131}
132
133func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
134 p1, releasePTY1 := newBlockingPTY(t)
135 p2 := newScriptPTY([][]byte{[]byte("\a")})
136 d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
137 defer releasePTY1()
138 selectTestAttachmentTab(sess, 0)
139
140 d.sessWg.Add(1)
141 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
142
143 require.Eventually(t, func() bool {
144 sess.mu.Lock()
145 defer sess.mu.Unlock()
146 return sess.tabs[1].attention && !sess.tabs[1].attentionAt.IsZero()
147 }, 2*time.Second, 5*time.Millisecond)
148
149 _ = p2.Close()
150 d.sessWg.Wait()
151}
152
153// TestNoteAttentionDoesNotBlockOnWedgedOtherClient is the regression test for
154// the reader-blocks-on-a-slow-client bug: noteAttention runs on the PTY
155// reader goroutine (see ptyReader in render.go), so it must never repaint
156// synchronously — paint ends in Transport.Send, which has no deadline, and a
157// stalled client's socket would otherwise stall the reader of an unrelated
158// session. A second session's client whose Send never returns must not stop
159// noteAttention from returning promptly.
160func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
161 d, sess, _, _, releases := newManualTabSession(t, 2)
162 defer releases[0]()
163 defer releases[1]()
164 selectTestAttachmentTab(sess, 0)
165
166 trW := portsmocks.NewMockTransport(t)
167 block := make(chan struct{})
168 t.Cleanup(func() { close(block) })
169 trW.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
170 <-block
171 return nil
172 }).Maybe()
173 trW.EXPECT().Close().Return(nil).Maybe()
174 acW := &attachedClient{tr: trW, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
175 acW.initOverlays()
176 sctxW, cancelW := context.WithCancel(d.serveCtx)
177 t.Cleanup(cancelW)
178 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
179 sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", attachments: map[*attachedClient]struct{}{acW: {}}}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}}
180 acW.setSession(sessW)
181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182 d.sessions[sessW.id] = sessW
183
184 done := make(chan struct{})
185 go func() {
186 d.noteAttention(sess, sess.tabs[1])
187 close(done)
188 }()
189
190 select {
191 case <-done:
192 case <-time.After(2 * time.Second):
193 t.Fatal("noteAttention blocked on a wedged other-session client's Send")
194 }
195
196 sess.mu.Lock()
197 defer sess.mu.Unlock()
198 require.True(t, sess.tabs[1].attention)
199}
200
201type scriptPTY struct {
202 mu sync.Mutex
203 reads [][]byte
204 closed chan struct{}
205 once sync.Once
206}
207
208func newScriptPTY(reads [][]byte) *scriptPTY {
209 return &scriptPTY{reads: reads, closed: make(chan struct{})}
210}
211
212func (p *scriptPTY) Read(b []byte) (int, error) {
213 p.mu.Lock()
214 if len(p.reads) > 0 {
215 next := p.reads[0]
216 p.reads = p.reads[1:]
217 p.mu.Unlock()
218 return copy(b, next), nil
219 }
220 p.mu.Unlock()
221 <-p.closed
222 return 0, io.EOF
223}
224
225func (p *scriptPTY) Write(b []byte) (int, error) { return len(b), nil }
226func (p *scriptPTY) Close() error {
227 p.once.Do(func() { close(p.closed) })
228 return nil
229}
230func (p *scriptPTY) Resize(domain.Size) error { return nil }
231func (p *scriptPTY) Pid() int { return 4242 }
232func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
233
234func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
235 d, sess, ac, sends, releases := newManualTabSession(t, 2)
236 defer releases[0]()
237 defer releases[1]()
238 selectTestAttachmentTab(sess, 0)
239 sess.mu.Lock()
240 sess.tabs[0].attention = true
241 sess.tabs[0].attentionAt = time.Unix(1, 0)
242 sess.tabs[1].attention = true
243 sess.tabs[1].attentionAt = time.Unix(2, 0)
244 sess.mu.Unlock()
245
246 d.paint(sess, ac, true, nil)
247 _ = mustOutputData(t, sends)
248 // The deferred repaint (from ackAttention) targets all attached clients,
249 // including this one, but the bars it would draw are already exactly what
250 // the full paint above just sent: no second frame should follow.
251 select {
252 case f := <-sends:
253 t.Fatalf("unexpected extra frame after ack: %v", f.Type)
254 case <-time.After(50 * time.Millisecond):
255 }
256
257 sess.mu.Lock()
258 defer sess.mu.Unlock()
259 require.False(t, sess.tabs[0].attention)
260 require.True(t, sess.tabs[0].attentionAt.IsZero())
261 require.True(t, sess.tabs[1].attention)
262 require.False(t, sess.tabs[1].attentionAt.IsZero())
263}
264
265func TestPaintPreservesBackgroundAttention(t *testing.T) {
266 d, sess, ac, sends, releases := newManualTabSession(t, 2)
267 defer releases[0]()
268 defer releases[1]()
269 selectTestAttachmentTab(sess, 0)
270 sess.mu.Lock()
271 sess.tabs[1].attention = true
272 sess.tabs[1].attentionAt = time.Unix(2, 0)
273 sess.mu.Unlock()
274 d.setAttentionFrame(1)
275
276 d.paint(sess, ac, true, nil)
277 data := mustOutputData(t, sends)
278
279 require.Contains(t, string(data), "")
280 sess.mu.Lock()
281 defer sess.mu.Unlock()
282 require.True(t, sess.tabs[1].attention)
283}
284
285func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
286 d, sess, ac, sends, releases := newManualTabSession(t, 2)
287 defer releases[0]()
288 defer releases[1]()
289 selectTestAttachmentTab(sess, 0)
290 sess.mu.Lock()
291 sess.tabs[1].attention = true
292 sess.tabs[1].attentionAt = time.Unix(2, 0)
293 sess.mu.Unlock()
294
295 require.True(t, selectTestAttachmentTab(sess, 1))
296 d.paint(sess, ac, true, nil)
297 data := mustOutputData(t, sends)
298 // As above: the deferred all-clients repaint has nothing new to say about
299 // this client's bars, so no second frame follows.
300 select {
301 case f := <-sends:
302 t.Fatalf("unexpected extra frame after ack: %v", f.Type)
303 case <-time.After(50 * time.Millisecond):
304 }
305
306 require.NotContains(t, string(data), "")
307 sess.mu.Lock()
308 defer sess.mu.Unlock()
309 require.False(t, sess.tabs[1].attention)
310 require.True(t, sess.tabs[1].attentionAt.IsZero())
311}
312
313func TestCloseRingingTabClearsClientBar(t *testing.T) {
314 d, sess, ac, sends, releases := newManualTabSession(t, 2)
315 defer releases[0]()
316 defer releases[1]()
317 selectTestAttachmentTab(sess, 0)
318 sess.mu.Lock()
319 ringing := sess.tabs[1]
320 ringing.attention = true
321 ringing.attentionAt = time.Unix(2, 0)
322 sess.mu.Unlock()
323 d.setAttentionFrame(1)
324
325 d.paint(sess, ac, true, nil)
326 before := mustOutputData(t, sends)
327 require.Contains(t, string(before), "")
328
329 require.NoError(t, d.closeTab(sess, ringing, true))
330 after := mustOutputData(t, sends)
331 require.NotContains(t, string(after), "")
332}
333
334// TestAnimationRepaintConfinedToBarRows is the regression test for the pulse
335// animator: repaintAttachedClients must diff against the warm barCache
336// (reset=false), so a repaint with nothing new to say sends no frame at all,
337// and a repaint following only a pulse-frame advance touches the bar rows
338// without re-emitting screen content.
339func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
340 d, sess, ac, sends, releases := newManualTabSession(t, 2)
341 defer releases[0]()
342 defer releases[1]()
343 selectTestAttachmentTab(sess, 0)
344 sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
345 sess.mu.Lock()
346 sess.tabs[1].attention = true
347 sess.tabs[1].attentionAt = time.Unix(1, 0)
348 sess.mu.Unlock()
349
350 d.paint(sess, ac, true, nil)
351 full := mustOutputData(t, sends)
352 require.Contains(t, string(full), "MIDSCREENMARKER")
353
354 // Nothing changed: a repaint against the warm cache must send nothing.
355 d.repaintAttachedClients(sess)
356 select {
357 case f := <-sends:
358 t.Fatalf("unexpected frame on unchanged repaint: %v", f.Type)
359 case <-time.After(50 * time.Millisecond):
360 }
361
362 // Advance the pulse frame (only the bells' rendered style changes) and
363 // repaint: the bytes sent must not repaint screen content.
364 d.advanceAttentionFrame()
365 d.repaintAttachedClients(sess)
366 data := mustOutputData(t, sends)
367 require.NotContains(t, string(data), "MIDSCREENMARKER")
368}
369
370// TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant
371// through the production composition pipeline: with a warm cache and no pane
372// damage, changing only bars.attentionFrame damages only the chrome rows.
373func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374 d, sess, _, _, releases := newManualTabSession(t, 2)
375 defer releases[0]()
376 defer releases[1]()
377 selectTestAttachmentTab(sess, 0)
378 sess.mu.Lock()
379 sess.tabs[1].attention = true
380 sess.tabs[1].attentionAt = time.Unix(1, 0)
381 sess.mu.Unlock()
382
383 tb := sess.tabs[0]
384 pane := tb.focusedPane()
385 pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))
386 bars := d.barStateFor(sess, "")
387 area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height}
388 placement := layout.Placement{ID: pane.id, Content: area}
389 state := capturedRenderState{
390 reset: true,
391 layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true},
392 panes: []capturedPaneRenderState{{
393 id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true,
394 damage: []renderer.Damage{renderer.FullRedraw()},
395 }},
396 bars: bars,
397 }
398 out := composeFrame(state, composeCacheInput{})
399 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
400 require.Len(t, state.panes, 1)
401 require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
402 firstContent := rowText(out.frame.Row(1))
403
404 // The pane was included in the warmed cache; it has no new damage when the
405 // attention pulse advances, so the subsequent compose must retain it.
406 bars.attentionFrame++
407 state.reset, state.bars = false, bars
408 state.panes[0].damage = nil
409 out = composeFrame(state, out.cache)
410 damage := out.damage
411 require.Len(t, state.panes, 1, "pane state must remain present when it has no damage")
412 require.Empty(t, state.panes[0].damage)
413 require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame")
414 require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
415 require.NotEmpty(t, damage)
416 lastRow := pane.screen.Frame.Height + 1
417 for _, dmg := range damage {
418 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
419 }
420}
421
422func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
423 d, sess, ac, sends, releases := newManualTabSession(t, 3)
424 sess.mu.Lock()
425 sess.registerAttachmentLocked(ac)
426 sess.mu.Unlock()
427 d.ptys = newBlockingOpenFactory(t, d)
428 defer releases[0]()
429 defer releases[1]()
430 defer releases[2]()
431 selectTestAttachmentTab(sess, 0)
432 sess.mu.Lock()
433 sess.tabs[1].attention = true
434 sess.tabs[1].attentionAt = time.Unix(20, 0)
435 sess.tabs[2].attention = true
436 sess.tabs[2].attentionAt = time.Unix(10, 0)
437 sess.mu.Unlock()
438
439 d.handleInput(sess, ac, []byte("\x1ba"))
440
441 require.Equal(t, 2, testAttachmentTabIndex(sess))
442 requireFloatingInitialized(t, testAttachmentTab(sess))
443 _ = mustOutputData(t, sends)
444}
445
446func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
447 p1, release1 := newBlockingPTY(t)
448 p2, release2 := newBlockingPTY(t)
449 p3, release3 := newBlockingPTY(t)
450 defer release1()
451 defer release2()
452 defer release3()
453 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
454 sctx2, cancel2 := context.WithCancel(d.serveCtx)
455 defer cancel2()
456 tab2a := newTestTabWithContext(p2, sctx2, cancel2)
457 tab2b := newTestTabWithContext(p3, sctx2, cancel2)
458 tab2a.attention = true
459 tab2a.attentionAt = time.Unix(9, 0)
460 tab2b.attention = true
461 tab2b.attentionAt = time.Unix(5, 0)
462 sess2 := &session{sessionCore: sessionCore{id: "other",
463 name: "other"}, ctx: sctx2,
464 cancel: cancel2,
465 tabs: []*tab{tab2a, tab2b},
466 }
467 d.sessions[sess2.id] = sess2
468
469 require.NoError(t, d.jumpAttention(sess1, ac))
470
471 require.Same(t, sess2, ac.currentSession())
472 require.Contains(t, sess2.snapshotAttachments(), ac)
473 require.Empty(t, sess1.snapshotAttachments())
474 require.Equal(t, 1, testAttachmentTabIndex(sess2))
475 _ = mustOutputData(t, sends)
476}
477
478func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
479 d, sess, ac, sends, releases := newManualTabSession(t, 2)
480 defer releases[0]()
481 defer releases[1]()
482 selectTestAttachmentTab(sess, 0)
483
484 require.NoError(t, d.jumpAttention(sess, ac), "no target to jump to is not a failure")
485
486 require.Same(t, sess, ac.currentSession())
487 require.Equal(t, 0, testAttachmentTabIndex(sess))
488 select {
489 case f := <-sends:
490 t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
491 case <-time.After(20 * time.Millisecond):
492 }
493}
494
495// TestJumpAttentionSwitchFailureReportsNotice drives jumpAttention's
496// cross-session path once a target has already been chosen, then makes the
497// commit itself fail (the origin session's client no longer matches ac —
498// simulating a client that detached between target selection and commit).
499// Unlike the no-target case, a target existed and the switch to it failed:
500// that is a genuine error, not a benign no-op, and must reach the user.
501func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
502 p1, release1 := newBlockingPTY(t)
503 p2, release2 := newBlockingPTY(t)
504 defer release1()
505 defer release2()
506 d, sess1, ac, _ := newManualSessionWithPTYs(t, p1)
507 sctx2, cancel2 := context.WithCancel(d.serveCtx)
508 defer cancel2()
509 tab2 := newTestTabWithContext(p2, sctx2, cancel2)
510 tab2.attention = true
511 tab2.attentionAt = time.Unix(9, 0)
512 sess2 := &session{sessionCore: sessionCore{id: "other", name: "other"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}
513 d.sessions[sess2.id] = sess2
514
515 sess1.mu.Lock()
516 clearAttachmentsForTestLocked(sess1)
517 sess1.mu.Unlock()
518
519 err := d.jumpAttention(sess1, ac)
520
521 require.Error(t, err)
522 require.Same(t, sess1, ac.currentSession(), "a failed switch must leave the client on its origin session")
523
524 d.reportError(sess1, err)
525 history := d.notices.history()
526 require.Len(t, history, 1)
527 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
528}
529
530func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
531 d, sess, _, sends, releases := newManualTabSession(t, 2)
532 defer releases[0]()
533 defer releases[1]()
534 clk := newManualAttentionClock()
535 d.clock = clk
536 selectTestAttachmentTab(sess, 0)
537
538 ctx, cancel := context.WithCancel(context.Background())
539 done := make(chan struct{})
540 go func() {
541 d.attentionAnimator(ctx)
542 close(done)
543 }()
544
545 select {
546 case <-clk.timers:
547 t.Fatal("parked animator requested timer with no attention")
548 case <-time.After(20 * time.Millisecond):
549 }
550
551 sess.mu.Lock()
552 sess.tabs[1].attention = true
553 sess.tabs[1].attentionAt = time.Unix(1, 0)
554 sess.mu.Unlock()
555 d.pokeAttentionTicker()
556
557 timer := clk.nextTimer(t)
558 timer.fire()
559 _ = mustOutputData(t, sends)
560 require.Equal(t, 1, d.attentionFrame())
561
562 sess.mu.Lock()
563 sess.tabs[1].attention = false
564 sess.tabs[1].attentionAt = time.Time{}
565 sess.mu.Unlock()
566 d.pokeAttentionTicker()
567
568 require.Eventually(t, func() bool { return d.attentionFrame() == 0 }, time.Second, time.Millisecond)
569 _ = mustOutputData(t, sends)
570 select {
571 case <-clk.timers:
572 t.Fatal("animator requested new timer after attention cleared")
573 case <-time.After(20 * time.Millisecond):
574 }
575
576 cancel()
577 require.Eventually(t, func() bool {
578 select {
579 case <-done:
580 return true
581 default:
582 return false
583 }
584 }, time.Second, time.Millisecond)
585}
586
587func TestPokeAttentionTickerIsNonBlockingCapOne(t *testing.T) {
588 d := New(nil, stubClock{}, nil)
589 require.Equal(t, 1, cap(d.animWake))
590 d.pokeAttentionTicker()
591 d.pokeAttentionTicker()
592 require.Len(t, d.animWake, 1)
593}
594
595type manualAttentionClock struct {
596 timers chan *manualAttentionTimer
597}
598
599func newManualAttentionClock() *manualAttentionClock {
600 return &manualAttentionClock{timers: make(chan *manualAttentionTimer, 8)}
601}
602
603func (c *manualAttentionClock) Now() time.Time { return time.Unix(100, 0) }
604func (c *manualAttentionClock) NewTimer(time.Duration) ports.Timer {
605 t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
606 c.timers <- t
607 return t
608}
609func (c *manualAttentionClock) nextTimer(t *testing.T) *manualAttentionTimer {
610 t.Helper()
611 select {
612 case timer := <-c.timers:
613 return timer
614 case <-time.After(time.Second):
615 t.Fatal("timed out waiting for attention timer")
616 return nil
617 }
618}
619
620type manualAttentionTimer struct {
621 ch chan time.Time
622}
623
624func (t *manualAttentionTimer) C() <-chan time.Time { return t.ch }
625func (t *manualAttentionTimer) Reset(time.Duration) bool { return true }
626func (t *manualAttentionTimer) Stop() bool { return true }
627func (t *manualAttentionTimer) fire() { t.ch <- time.Now() }