1package client
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/domain/terminalcap"
16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol/catalogue"
18 "github.com/bnema/vev/internal/usecase/ui"
19)
20
21// Client-owned picker controller tests (Plan 001 P5.2b): rendering, local
22// search/cursor behaviour across live updates, and the input-ownership proof
23// that picker input never reaches a session.
24
25// pickerChunkReader yields pre-pushed chunks to the supervisor's single input
26// lifetime. push blocks until the lifetime reads the chunk, so a test can
27// order a read deterministically without a sleep.
28type pickerChunkReader struct {
29 chunks chan []byte
30}
31
32func newPickerChunkReader() *pickerChunkReader {
33 return &pickerChunkReader{chunks: make(chan []byte)}
34}
35
36func (r *pickerChunkReader) push(data []byte) {
37 r.chunks <- append([]byte(nil), data...)
38}
39
40func (r *pickerChunkReader) close() { close(r.chunks) }
41
42func (r *pickerChunkReader) Read(buffer []byte) (int, error) {
43 chunk, ok := <-r.chunks
44 if !ok {
45 return 0, io.EOF
46 }
47 return copy(buffer, chunk), nil
48}
49
50// pickerInputSpy forwards each read to the real consumer and signals that it
51// was delivered, so a test can wait exactly for consumption.
52type pickerInputSpy struct {
53 inner pickerInputConsumer
54 done chan struct{}
55}
56
57func newPickerInputSpy(inner pickerInputConsumer) *pickerInputSpy {
58 return &pickerInputSpy{inner: inner, done: make(chan struct{}, 1)}
59}
60
61func (s *pickerInputSpy) ConsumeTerminalRead(data []byte) bool {
62 consumed := s.inner.ConsumeTerminalRead(data)
63 select {
64 case s.done <- struct{}{}:
65 default:
66 }
67 return consumed
68}
69
70func (s *pickerInputSpy) await(t *testing.T) {
71 t.Helper()
72 select {
73 case <-s.done:
74 case <-time.After(5 * time.Second):
75 t.Fatal("terminal input was not consumed")
76 }
77}
78
79// pickerRecordingTerminal records every byte written to the terminal output,
80// which is where a leaked picker key would surface if it reached a session.
81type pickerRecordingTerminal struct {
82 in io.Reader
83 out bytes.Buffer
84 mu sync.Mutex
85 enters int
86}
87
88func (t *pickerRecordingTerminal) EnableKittyKeyboard(int) error { return nil }
89
90func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) {
91 t.mu.Lock()
92 t.enters++
93 t.mu.Unlock()
94 return func() error { return nil }, nil
95}
96
97func (t *pickerRecordingTerminal) Geometry() (domain.Geometry, error) {
98 return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
99}
100
101func (t *pickerRecordingTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
102
103func (t *pickerRecordingTerminal) In() io.Reader { return t.in }
104
105func (t *pickerRecordingTerminal) Out() io.Writer { return &t.out }
106
107func (t *pickerRecordingTerminal) Flush() error { return nil }
108
109func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, now time.Time, sessions ...string) ports.BrokerSnapshot {
110 catalogSessions := make([]catalogue.RemoteCatalogSession, 0, len(sessions))
111 for i, name := range sessions {
112 catalogSessions = append(catalogSessions, pickerTestSession(name, byte(i+1), catalogue_Up))
113 }
114 return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{
115 pickerTestLocalObservation(now, catalogSessions...),
116 }}
117}
118
119func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
120 t.Helper()
121 clock := newSupervisorTestClock()
122 return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock
123}
124
125func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
126 t.Helper()
127 line, ok := pickerLineByLabel(controller.Catalogue().Lines(), label)
128 require.True(t, ok, "row %q must be projected", label)
129 return line.Key
130}
131
132func mustCursorKey(t *testing.T, controller *pickerController) string {
133 t.Helper()
134 key, ok := controller.CursorKey()
135 require.True(t, ok, "the picker must have a selected row")
136 return key
137}
138
139func (p *pickerController) modelState(t *testing.T) pickerState {
140 t.Helper()
141 state := p.state()
142 require.True(t, state.hasModel, "the picker must have a model")
143 return state
144}
145
146func (p *pickerController) modelQuery(t *testing.T) string {
147 t.Helper()
148 return p.modelState(t).query
149}
150
151func (p *pickerController) modelSearchActive(t *testing.T) bool {
152 t.Helper()
153 return p.modelState(t).searchActive
154}
155
156func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) {
157 controller, clock := pickerTestController(t)
158 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
159 firstRender := controller.Render(domain.Size{Cols: 80, Rows: 24})
160 require.NotEmpty(t, firstRender)
161 alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
162 require.Equal(t, alphaKey, mustCursorKey(t, controller))
163
164 // A newer publication is folded synchronously: the very next render already
165 // reflects it, with no debounce or intervening frame.
166 controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "beta"))
167 require.Equal(t, pickerRowKeyByLabel(t, controller, "beta"), mustCursorKey(t, controller))
168 require.NotEmpty(t, controller.Render(domain.Size{Cols: 80, Rows: 24}))
169}
170
171func TestPickerControllerSearchSurvivesHostUpdate(t *testing.T) {
172 controller, clock := pickerTestController(t)
173 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
174
175 spy := newPickerInputSpy(controller)
176 reader := newPickerChunkReader()
177 t.Cleanup(reader.close)
178 _ = startTerminalInputLifetime(reader, spy)
179
180 reader.push([]byte("/alp"))
181 spy.await(t)
182 require.Equal(t, "alp", controller.modelQuery(t))
183 require.True(t, controller.modelSearchActive(t))
184
185 // A host update while the query is typed keeps the editor and the matched
186 // cursor; it never resets the local search.
187 controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma"))
188 require.Equal(t, "alp", controller.modelQuery(t))
189 require.True(t, controller.modelSearchActive(t))
190 require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), mustCursorKey(t, controller))
191}
192
193// TestPickerControllerInputNeverReachesPTY feeds every input shape a picker can
194// receive and proves the only effects are local: each batch is consumed, the
195// terminal receives no bytes, and the decisions are presentation facts, never a
196// session write.
197func TestPickerControllerInputNeverReachesPTY(t *testing.T) {
198 controller, clock := pickerTestController(t)
199 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
200
201 alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
202 betaKey := pickerRowKeyByLabel(t, controller, "beta")
203
204 spy := newPickerInputSpy(controller)
205 reader := newPickerChunkReader()
206 t.Cleanup(reader.close)
207 terminal := &pickerRecordingTerminal{in: reader}
208 _ = startTerminalInputLifetime(terminal.In(), spy)
209
210 chunks := [][]byte{
211 []byte("\x1b[B"), // Down -> beta
212 []byte("k"), // up -> alpha
213 []byte("j"), // down -> beta
214 []byte("\x1b[A"), // Up -> alpha
215 []byte("\x1b[<0;10;5M"), // mouse report: consumed and dropped
216 []byte("\x1b[200~rm -rf /\x1b[201~"), // bracketed paste: content dropped
217 []byte("\x00\x01\x7f"), // control bytes: consumed and dropped
218 []byte("\x1b"), // lone ESC withheld for disambiguation
219 }
220 for _, chunk := range chunks {
221 reader.push(chunk)
222 spy.await(t)
223 }
224 require.Equal(t, alphaKey, mustCursorKey(t, controller), "arrow keys moved the local cursor")
225 require.False(t, controller.modelSearchActive(t), "mouse, paste, and control bytes never opened search")
226 require.Equal(t, betaKey, pickerRowKeyByLabel(t, controller, "beta"))
227
228 // A withheld lone ESC resolves through the bounded window as one Escape.
229 controller.FlushPending()
230 op, _ := controller.TakeOp()
231 require.True(t, op.close, "Escape retires the picker locally")
232
233 require.Empty(t, terminal.out.Bytes(), "the picker wrote nothing to the terminal")
234}
235
236func TestPickerControllerInputRouting(t *testing.T) {
237 tests := []struct {
238 name string
239 chunk []byte
240 flush bool
241 wantCursor string
242 wantNoSelection bool
243 wantSearch bool
244 wantQuery string
245 wantOp pickerOp
246 }{
247 {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
248 {name: "up reaches the section header", chunk: []byte("\x1b[A"), wantNoSelection: true},
249 {name: "left climbs to the section header", chunk: []byte("\x1b[D"), wantNoSelection: true},
250 {name: "ss3 left climbs to the section header", chunk: []byte("\x1bOD"), wantNoSelection: true},
251 {name: "right off a section keeps the row", chunk: []byte("\x1b[C"), wantCursor: "alpha"},
252 {name: "left right fold and unfold", chunk: []byte("\x1b[D\x1b[D\x1bOC\x1b[B"), wantCursor: "alpha"},
253 {name: "h climbs to the section header", chunk: []byte("h"), wantNoSelection: true},
254 {name: "fold and unfold keep the list", chunk: []byte("hhl\x1b[B"), wantCursor: "alpha"},
255 {name: "enter on a section header does not commit", chunk: []byte("h\r\r\x1b[B"), wantCursor: "alpha"},
256 {name: "h and l are literal in search", chunk: []byte("/hl"), wantNoSelection: true, wantSearch: true, wantQuery: "hl"},
257 {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
258 {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
259 {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
260 {name: "kill", chunk: []byte("x"), wantCursor: "alpha", wantOp: pickerOp{kill: true}},
261 {name: "escape exits search first", chunk: []byte("/al\x1b"), flush: true, wantCursor: "alpha"},
262 {name: "escape closes", chunk: []byte("\x1b"), flush: true, wantCursor: "alpha", wantOp: pickerOp{close: true}},
263 {name: "f1 is consumed", chunk: []byte("\x1b[11~"), wantCursor: "alpha"},
264 }
265
266 for _, tt := range tests {
267 t.Run(tt.name, func(t *testing.T) {
268 controller, clock := pickerTestController(t)
269 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
270 alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
271 spy := newPickerInputSpy(controller)
272 reader := newPickerChunkReader()
273 t.Cleanup(reader.close)
274 _ = startTerminalInputLifetime(reader, spy)
275
276 reader.push(tt.chunk)
277 spy.await(t)
278 if tt.flush {
279 controller.FlushPending()
280 }
281 if tt.wantOp != (pickerOp{}) {
282 op, _ := controller.TakeOp()
283 require.Equal(t, tt.wantOp, op)
284 }
285 require.Equal(t, tt.wantSearch, controller.modelSearchActive(t))
286 if tt.wantQuery != "" {
287 require.Equal(t, tt.wantQuery, controller.modelQuery(t))
288 }
289 if tt.wantNoSelection {
290 _, ok := controller.CursorKey()
291 require.False(t, ok, "the cursor row is not a selection")
292 return
293 }
294 want := alphaKey
295 if tt.wantCursor == "beta" {
296 want = pickerRowKeyByLabel(t, controller, "beta")
297 }
298 require.Equal(t, want, mustCursorKey(t, controller))
299 })
300 }
301}
302
303// TestPickerControllerNotOwningInputLeavesReadUnconsumed pins the ownership
304// boundary: once the picker releases input, a read is not consumed, so a later
305// attach slice keeps its ordinary pipeline.
306func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
307 controller, clock := pickerTestController(t)
308 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
309 controller.SetOwnsInput(false)
310 require.False(t, controller.ConsumeTerminalRead([]byte("j")))
311 controller.SetOwnsInput(true)
312 require.True(t, controller.ConsumeTerminalRead([]byte("j")))
313}
314
315func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) {
316 controller, clock := pickerTestController(t)
317 checking := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
318 checking.Checking = true
319 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
320 require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the rows")
321
322 unreachable := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
323 unreachable.Availability = domain.RemoteAvailabilityUnreachable
324 unreachable.FailureEpisode = 1
325 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}})
326 require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure")
327}
328
329// TestPickerControllerResolveInspectableRowStillResolvesExplicitly: a stale row
330// the picker displays for inspection still resolves to one exact request,
331// because freshness is presentation information and only the destination may
332// refuse the exact identity. The informational row stays dimmed and badged.
333func TestPickerControllerResolveInspectableRowStillResolvesExplicitly(t *testing.T) {
334 controller, clock := pickerTestController(t)
335 stale := pickerTestLocalObservation(clock.Now().Add(-2*pickerTestFreshness), pickerTestSession("alpha", 1, catalogue_Up))
336 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}})
337
338 // The stale session row stays a cursor destination and admits an explicit
339 // attempt; only the host status row itself is never committable.
340 key, ok := controller.CursorKey()
341 require.True(t, ok, "a stale session row stays a cursor destination")
342 require.NotEmpty(t, key)
343 require.False(t, controller.modelSearchActive(t))
344
345 request, err := controller.Resolve(pickerTestBase())
346 require.NoError(t, err)
347 require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
348 require.Equal(t, "alpha", request.Target.SessionName)
349}
350
351// TestPickerControllerResolveUsesLatestSnapshot proves resolution revalidates
352// against the snapshot in force at resolve time, not the one displayed.
353func TestPickerControllerResolveUsesLatestSnapshot(t *testing.T) {
354 controller, clock := pickerTestController(t)
355 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
356 key := mustCursorKey(t, controller)
357
358 // The host disappears before the selection resolves.
359 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2})
360
361 _, err := controller.Resolve(pickerTestBase())
362 require.Error(t, err)
363 require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueNoSelection), "the vanished row leaves no selection")
364 _, err = controller.Catalogue().Resolve(key, pickerTestBase())
365 require.Error(t, err)
366 require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone))
367 require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "the refusal is surfaced as a bounded notice")
368}
369
370func TestPickerControllerResolveReturnsExactRequest(t *testing.T) {
371 controller, clock := pickerTestController(t)
372 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
373
374 request, err := controller.Resolve(pickerTestBase())
375 require.NoError(t, err)
376 require.NoError(t, request.Validate())
377 require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
378 require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
379 require.Equal(t, "alpha", request.Target.SessionName)
380}
381
382// TestPickerControllerConcurrentUpdatesAndInput exercises the mutex boundaries
383// under -race: publications, input consumption, rendering, and resolution run
384// concurrently and must not race or panic.
385func TestPickerControllerConcurrentUpdatesAndInput(t *testing.T) {
386 const iterations = 200
387 controller, clock := pickerTestController(t)
388 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
389
390 var wait sync.WaitGroup
391 wait.Add(3)
392
393 go func() {
394 defer wait.Done()
395 for revision := ports.BrokerRevision(2); revision < 2+iterations; revision++ {
396 controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta"))
397 }
398 }()
399
400 go func() {
401 defer wait.Done()
402 for range iterations {
403 controller.ConsumeTerminalRead([]byte("\x1b[B\x1b[Aj"))
404 controller.FlushPending()
405 controller.TakeOp()
406 }
407 }()
408
409 go func() {
410 defer wait.Done()
411 for range iterations {
412 _ = controller.Render(domain.Size{Cols: 80, Rows: 24})
413 _ = controller.RenderNotice(domain.Size{Cols: 80, Rows: 24})
414 _, _ = controller.Resolve(pickerTestBase())
415 _, _ = controller.CursorKey()
416 }
417 }()
418
419 wait.Wait()
420}
421
422// TestSupervisorPickerFoldsPublicationsAndInput proves the supervisor wiring:
423// a broker publication reaches the picker through the established connection's
424// subscription, and terminal input is consumed by the picker from the same
425// single input lifetime the supervisor uses for EOF, never written to the
426// terminal.
427func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
428 ctx, cancel := context.WithCancel(context.Background())
429 defer cancel()
430
431 clock := newSupervisorTestClock()
432 reader := newPickerChunkReader()
433 t.Cleanup(reader.close)
434 terminal := &pickerRecordingTerminal{in: reader}
435 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
436 service := newSupervisorTestService(ports.BrokerConnectionID{1})
437 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
438 return service, nil
439 })
440 controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
441 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
442
443 runErr := make(chan error, 1)
444 go func() { runErr <- sup.Run(ctx) }()
445
446 require.Eventually(t, func() bool {
447 return controller.Catalogue().Epoch() == 3
448 }, time.Second, time.Millisecond, "the supervisor folds the committed publication into the picker")
449 require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines()))
450 require.True(t, controller.OwnsInput())
451
452 // The same input lifetime the supervisor uses for EOF delivers the read to
453 // the picker: the cursor moves locally and nothing is written anywhere.
454 betaKey := pickerRowKeyByLabel(t, controller, "beta")
455 reader.push([]byte("\x1b[B"))
456 require.Eventually(t, func() bool {
457 key, ok := controller.CursorKey()
458 return ok && key == betaKey
459 }, time.Second, time.Millisecond, "the picker consumed the terminal read")
460
461 cancel()
462 require.ErrorIs(t, <-runErr, context.Canceled)
463 require.Empty(t, terminal.out.Bytes(), "the picker never wrote a session or terminal byte")
464}
465
466// TestSupervisorPickerFoldsLaterPublication covers the second-and-later
467// publication: a snapshot delivered after the connection is established must
468// reach the picker through the subscription awaitLoss parks on. This is the
469// core of P5.2b and the fake hub's non-blocking, never-replaced channel is what
470// keeps it from busy-spinning.
471func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
472 ctx, cancel := context.WithCancel(context.Background())
473 defer cancel()
474
475 clock := newSupervisorTestClock()
476 reader := newPickerChunkReader()
477 t.Cleanup(reader.close)
478 terminal := &pickerRecordingTerminal{in: reader}
479
480 service := newSupervisorTestService(ports.BrokerConnectionID{1})
481 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) {
482 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
483 return service, nil
484 })
485 controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
486 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
487
488 runErr := make(chan error, 1)
489 go func() { runErr <- sup.Run(ctx) }()
490
491 require.Eventually(t, func() bool {
492 return controller.Catalogue().Revision() == 1
493 }, time.Second, time.Millisecond, "the first committed publication reaches the picker")
494 require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines()))
495
496 service.publishSnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta"))
497 require.Eventually(t, func() bool {
498 return controller.Catalogue().Revision() == 2
499 }, time.Second, time.Millisecond, "a publication delivered after the connection is established reaches the picker")
500 require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines()))
501
502 cancel()
503 require.ErrorIs(t, <-runErr, context.Canceled)
504}
505
506// TestPickerControllerResolveKeyUsesCommittedRow proves commit and resolve are
507// one action: the key captured with the commit decision resolves the committed
508// row even when a concurrent publication moved the cursor afterwards.
509func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) {
510 controller, clock := pickerTestController(t)
511 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
512 alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
513
514 // The user commits the row under the cursor.
515 require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
516 op, committedKey := controller.TakeOp()
517 require.True(t, op.commit)
518 require.Equal(t, alphaKey, committedKey)
519
520 // A concurrent publication moves the cursor to another row, and the driver
521 // commits a row different from the one the user selected.
522 controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma"))
523 require.True(t, controller.ConsumeTerminalRead([]byte("j")))
524
525 // Plain Resolve describes the row displayed now...
526 displayed, err := controller.Resolve(pickerTestBase())
527 require.NoError(t, err)
528 require.Equal(t, "beta", displayed.Target.SessionName)
529
530 // ...while the captured key resolves exactly the committed row.
531 committed, _, err := controller.ResolveCommit(committedKey, pickerTestBase())
532 require.NoError(t, err)
533 require.Equal(t, "alpha", committed.Target.SessionName)
534}
535
536// TestPickerControllerCommittedKeySurvivesConcurrentApply runs the commit path
537// under -race against a driver that keeps republishing. The captured key must
538// always resolve the row the user committed, never the row a publication left
539// under the cursor.
540func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) {
541 controller, clock := pickerTestController(t)
542 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
543
544 stop := make(chan struct{})
545 var applying sync.WaitGroup
546 applying.Add(1)
547 go func() {
548 defer applying.Done()
549 revision := ports.BrokerRevision(2)
550 for {
551 select {
552 case <-stop:
553 return
554 default:
555 }
556 controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta"))
557 revision++
558 }
559 }()
560
561 for range 200 {
562 require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
563 op, key := controller.TakeOp()
564 if !op.commit || key == "" {
565 continue
566 }
567 request, _, err := controller.ResolveCommit(key, pickerTestBase())
568 if err != nil {
569 continue
570 }
571 require.Equal(t, "alpha", request.Target.SessionName, "the committed key must never resolve another row")
572 }
573 close(stop)
574 applying.Wait()
575}
576
577// TestPickerControllerApplySnapshotRejectsInvalidSnapshot proves the controller
578// keeps the previous projection and surfaces a bounded notice when a publication
579// fails the broker snapshot contract.
580func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
581 controller, clock := pickerTestController(t)
582 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
583 before := controller.Catalogue().Lines()
584
585 invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta")
586 invalid.Daemons[0].Availability = 0
587 controller.ApplySnapshot(invalid)
588
589 require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept")
590 require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())
591
592 active := controller.state().notices
593 require.Equal(t, []string{"broker catalogue update rejected"}, active)
594}
595
596// TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
597// availability-first classification at the controller boundary: an unobserved
598// daemon (Availability Unknown, ProtocolVersion 0) keeps refreshing on its
599// row and surfaces no failure toast.
600func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
601 controller, clock := pickerTestController(t)
602 unobserved := pickerTestUnobservedObservation(clock.Now())
603 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
604
605 require.Empty(t, controller.state().notices)
606 lines := controller.Catalogue().Lines()
607 host, ok := pickerHostLine(lines)
608 require.True(t, ok)
609 require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail)
610 require.NotEqual(t, domain.RemoteReasonVersionMismatch, host.StatusDetail)
611}
612
613// TestPickerControllerRenderNoticeStacksQueuesAndBlanks pins the picker notice
614// stack: the newest notice sits at the top-right corner, a burst beyond the
615// visible slots waits for a free slot, and a box that goes away is blanked.
616func TestPickerControllerRenderNoticeStacksQueuesAndBlanks(t *testing.T) {
617 size := domain.Size{Cols: 80, Rows: 24}
618 notice := func(msg string) domain.Notification {
619 return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeInfo, Message: msg, Scope: msg}
620 }
621 tests := []struct {
622 name string
623 notices []string
624 advance time.Duration
625 wantShown []string
626 wantHidden []string
627 wantBlanked bool
628 }{
629 {name: "distinct subjects stack", notices: []string{"killed session 1", "killed session 2"}, wantShown: []string{"killed session 1", "killed session 2"}},
630 {name: "burst waits for a slot", notices: []string{"killed session 1", "killed session 2", "killed session 3", "killed session 4"}, wantShown: []string{"killed session 1", "killed session 2", "killed session 3"}, wantHidden: []string{"killed session 4"}},
631 {name: "expiry promotes the next notice", notices: []string{"killed session 1", "killed session 2", "killed session 3", "killed session 4"}, advance: pickerNoticeLifetime, wantShown: []string{"killed session 4"}, wantBlanked: true},
632 {name: "expiry of the last notice blanks it", notices: []string{"killed session 1"}, advance: pickerNoticeLifetime, wantHidden: []string{"killed session 1"}, wantBlanked: true},
633 }
634 for _, tt := range tests {
635 t.Run(tt.name, func(t *testing.T) {
636 controller, clock := pickerTestController(t)
637 for _, msg := range tt.notices {
638 controller.Notify(notice(msg))
639 }
640 first := string(controller.RenderNotice(size))
641 if tt.advance > 0 {
642 clock.mu.Lock()
643 clock.now = clock.now.Add(tt.advance)
644 clock.mu.Unlock()
645 }
646 got := first
647 if tt.advance > 0 {
648 got = string(controller.RenderNotice(size))
649 }
650 for _, msg := range tt.wantShown {
651 require.Contains(t, got, msg)
652 }
653 for _, msg := range tt.wantHidden {
654 require.NotContains(t, got, msg)
655 }
656 blank := strings.Repeat(" ", ui.ToastBounds(size, ui.Toast{Message: "killed session 1", Anchor: domain.AnchorTopRight}).Width)
657 require.Equal(t, tt.wantBlanked, tt.advance > 0 && strings.Contains(got, blank))
658 })
659 }
660}