1package client
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/protocol"
15)
16
17// Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real
18// supervisor, host, foreground, and worker over the in-package broker fakes
19// with the scripted test clock: no sleeps, and every wait is bounded.
20
21// recordOp records one picker decision on the scripted picker, exactly like
22// the controller's coalesced wake.
23func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) {
24 p.mu.Lock()
25 p.op = op
26 p.key = key
27 p.request = request
28 p.mu.Unlock()
29 select {
30 case p.opsReady <- struct{}{}:
31 default:
32 }
33}
34
35// opTaken reports whether the supervisor consumed the recorded decision.
36func (p *attachTestPicker) opTaken() bool {
37 p.mu.Lock()
38 defer p.mu.Unlock()
39 return p.op == (pickerOp{})
40}
41
42// attachLiveSession commits one attachment on the harness and returns its
43// stream once the attachment is presented.
44func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
45 t.Helper()
46 stream := newSessionTestStream()
47 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
48 return stream, nil
49 })
50 picker.commit(sessionTestRequest(true))
51 awaitStreamHello(t, &sync.Mutex{}, &stream)
52 deliverReadyStream(t, stream)
53 awaitAttachedState(t, harness.sup)
54 return stream
55}
56
57func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) {
58 t.Helper()
59 require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want)
60}
61
62// awaitSent waits for the first client message matching match.
63func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage {
64 t.Helper()
65 var found protocol.ClientMessage
66 require.Eventually(t, func() bool {
67 for _, message := range stream.messages() {
68 if match(message) {
69 found = message
70 return true
71 }
72 }
73 return false
74 }, 5*time.Second, time.Millisecond, "client never sent %s", what)
75 return found
76}
77
78func countSent[T protocol.ClientMessage](stream *sessionTestStream) int {
79 count := 0
80 for _, message := range stream.messages() {
81 if _, ok := message.(T); ok {
82 count++
83 }
84 }
85 return count
86}
87
88func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool {
89 _, ok := message.(T)
90 return ok
91}
92
93// laterOutput is a full frame on a later epoch, which the attachment accepts
94// after its initial publication.
95func laterOutput(epoch, publication uint64, data string) protocol.Output {
96 output := sessionTestOutput(publication, data)
97 output.Epoch = epoch
98 return output
99}
100
101func navigationOffer(id uint64) protocol.PickerOffer {
102 return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "}
103}
104
105// TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker
106// over the live attachment, its input and output stay with the picker, and
107// every cancel (and a commit to the attached session) returns to the very same
108// attachment without reconnecting, then asks for an authoritative repaint.
109func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) {
110 tests := []struct {
111 name string
112 op pickerOp
113 key string
114 }{
115 {name: "escape or q cancels", op: pickerOp{close: true}},
116 {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}},
117 {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"},
118 }
119 for _, tt := range tests {
120 t.Run(tt.name, func(t *testing.T) {
121 picker := newAttachTestPicker()
122 harness := startAttachHarness(t, picker)
123 stream := attachLiveSession(t, harness, picker)
124 foreground := harness.sup.attachments.authority.foreground()
125 require.NotNil(t, foreground)
126 require.False(t, picker.owns())
127
128 stream.deliver(navigationOffer(1))
129 awaitPresentation(t, harness.sup, PresentAttachedPicker)
130 require.True(t, picker.owns(), "the picker owns input while it is open")
131
132 // Input belongs to the picker: it is consumed, never sent as session
133 // Input.
134 harness.reader.push([]byte("j"))
135 require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond)
136 require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session")
137
138 // Attachment output is applied and acknowledged, never written over
139 // the picker.
140 acks := countSent[protocol.Ack](stream)
141 stream.deliver(laterOutput(2, 2, "hidden-frame"))
142 require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
143 require.NotContains(t, harness.terminal.written(), "hidden-frame")
144
145 picker.recordOp(tt.op, tt.key, sessionTestRequest(true))
146 awaitPresentation(t, harness.sup, PresentAttached)
147 awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
148
149 require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment")
150 require.False(t, stream.closedNow())
151 require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect")
152 require.Zero(t, countSent[protocol.Detach](stream))
153 require.False(t, picker.owns())
154
155 // The session owns input again.
156 harness.reader.push([]byte("z"))
157 input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
158 require.Equal(t, []byte("z"), input.Data)
159 select {
160 case <-harness.runDone:
161 t.Fatal("cancelling the overlay ended the process")
162 default:
163 }
164 })
165 }
166}
167
168// TestPickerOverlayCommitElsewhereSwapsAttachment pins the swap: a commit to
169// another session detaches the live attachment cleanly and attaches the
170// committed target through Connecting, without passing through the plain
171// picker.
172func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) {
173 picker := newAttachTestPicker()
174 notices := make(chan LifecycleNotice, 8)
175 harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
176 cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
177 })
178 first := attachLiveSession(t, harness, picker)
179 first.deliver(navigationOffer(1))
180 awaitPresentation(t, harness.sup, PresentAttachedPicker)
181
182 var mu sync.Mutex
183 second := newSessionTestStream()
184 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
185 return second, nil
186 })
187 other := sessionTestRequest(true)
188 other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
189 picker.recordOp(pickerOp{commit: true}, "row", other)
190
191 awaitSent(t, first, "Detach", isSent[protocol.Detach])
192 awaitStreamHello(t, &mu, &second)
193 second.deliver(protocol.Welcome{SessionName: "beta"})
194 output := sessionTestOutput(1, "\x1b[Hbeta")
195 output.Context.Route.Target = other.Target
196 second.deliver(output)
197 awaitAttachedState(t, harness.sup)
198
199 opened := harness.service.openedRequests()
200 require.Len(t, opened, 2)
201 require.Equal(t, other.Target, opened[1].Target)
202 require.True(t, first.closedNow(), "the previous attachment is released")
203 select {
204 case notice := <-notices:
205 t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice)
206 default:
207 }
208}
209
210// TestPickerOverlaySettlesUIActions pins UI-driver settlement across the
211// client picker overlay: the action that opened it settles on the daemon
212// receipt fenced on output the overlay applied without writing, an action the
213// picker consumes settles at the supervisor boundary without a daemon fence,
214// and a picker commit elsewhere follows the swap and settles on the
215// destination's first committed publication.
216func TestPickerOverlaySettlesUIActions(t *testing.T) {
217 alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
218 beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
219 type actionOutcome struct {
220 result ports.UIActionResult
221 err error
222 }
223 tests := []struct {
224 name string
225 // overlayFirst opens the overlay before the action is admitted.
226 overlayFirst bool
227 keys []string
228 // commit scripts a picker commit elsewhere for the consumed read.
229 commit bool
230 wantTarget protocol.ExactSessionTarget
231 wantFence bool
232 }{
233 {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true},
234 {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha},
235 {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta},
236 }
237 for _, tt := range tests {
238 t.Run(tt.name, func(t *testing.T) {
239 picker := newAttachTestPicker()
240 var ui *UI
241 harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
242 ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
243 cfg.UI = ui
244 })
245 stream := attachLiveSession(t, harness, picker)
246 attached := sessionTestOutput(2, "\x1b[Hattached")
247 attached.Full, attached.Base, attached.New = false, 1, 2
248 stream.deliver(attached)
249 var snapshot ports.UISnapshot
250 require.Eventually(t, func() bool {
251 var err error
252 snapshot, err = ui.Capture(ui.Handle())
253 return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
254 }, 5*time.Second, time.Millisecond)
255 if tt.overlayFirst {
256 stream.deliver(navigationOffer(1))
257 awaitPresentation(t, harness.sup, PresentAttachedPicker)
258 // The composition paints the overlay with the live attachment's
259 // context, so the attachment stays the actionable generation.
260 overlayContext, ok := ui.OverlayContext()
261 require.True(t, ok)
262 require.Equal(t, ports.UIStatusAttached, overlayContext.Status)
263 require.Equal(t, snapshot.Context.Generation, overlayContext.Generation)
264 require.Equal(t, alpha, overlayContext.Route.Target)
265 }
266 var mu sync.Mutex
267 second := newSessionTestStream()
268 if tt.commit {
269 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
270 return second, nil
271 })
272 other := sessionTestRequest(true)
273 other.Target = beta
274 picker.decideOnConsume(pickerOp{commit: true}, other)
275 }
276 fences := countSent[protocol.UIFence](stream)
277 consumed := picker.consumedCount()
278
279 done := make(chan actionOutcome, 1)
280 go func() {
281 result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys})
282 done <- actionOutcome{result: result, err: err}
283 }()
284
285 if !tt.overlayFirst {
286 // The Enter reaches the daemon, whose command offers the picker;
287 // the palette-close output then arrives under the overlay.
288 fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence)
289 stream.deliver(navigationOffer(1))
290 awaitPresentation(t, harness.sup, PresentAttachedPicker)
291 hidden := sessionTestOutput(3, "\x1b[Hhidden")
292 hidden.Full, hidden.Base, hidden.New = false, 2, 3
293 stream.deliver(hidden)
294 require.Eventually(t, func() bool {
295 ui.mu.Lock()
296 defer ui.mu.Unlock()
297 return ui.suppressed.ViewPublication == 3
298 }, 5*time.Second, time.Millisecond, "the suppressed output was never applied")
299 stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
300 }
301 if tt.commit {
302 awaitSent(t, stream, "Detach", isSent[protocol.Detach])
303 awaitStreamHello(t, &mu, &second)
304 second.deliver(protocol.Welcome{SessionName: "beta"})
305 output := sessionTestOutput(1, "\x1b[Hbeta")
306 output.Context.Route.Target = beta
307 second.deliver(output)
308 }
309
310 var outcome actionOutcome
311 select {
312 case outcome = <-done:
313 case <-time.After(5 * time.Second):
314 t.Fatal("the UI action never settled across the picker overlay")
315 }
316 require.NoError(t, outcome.err)
317 require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
318 require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status)
319 require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target)
320 require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker")
321 if tt.overlayFirst {
322 require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input")
323 }
324 if !tt.wantFence {
325 require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon")
326 require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session")
327 }
328 if tt.commit {
329 require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation")
330 opened := harness.service.openedRequests()
331 require.Len(t, opened, 2)
332 require.Equal(t, beta, opened[1].Target)
333 require.Greater(t, opened[1].Stream, picker.lastResolvedStream(),
334 "the swap opens a stream identity allocated at open time, not the one reserved at commit")
335 }
336 })
337 }
338}
339
340// TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
341// to return to, Escape and q keep the picker (and the process) alive; only the
342// explicit Ctrl+C exit ends the run.
343func TestPickerCloseWithoutAttachment(t *testing.T) {
344 tests := []struct {
345 name string
346 op pickerOp
347 wantExit bool
348 }{
349 {name: "escape or q keeps the picker", op: pickerOp{close: true}},
350 {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true},
351 }
352 for _, tt := range tests {
353 t.Run(tt.name, func(t *testing.T) {
354 picker := newAttachTestPicker()
355 harness := startAttachHarness(t, picker)
356 picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{})
357 if tt.wantExit {
358 require.NoError(t, harness.waitRun(t))
359 return
360 }
361 // Prove the close was consumed, then that the run survived it: a
362 // later commit is still admitted.
363 require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond)
364 stream := newSessionTestStream()
365 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
366 return stream, nil
367 })
368 picker.commit(sessionTestRequest(true))
369 awaitStreamHello(t, &sync.Mutex{}, &stream)
370 select {
371 case <-harness.runDone:
372 t.Fatal("a cancel with no attachment ended the process")
373 default:
374 }
375 })
376 }
377}
378
379// moveSnapshot is the two-destination publication a move interaction opens
380// with; both rows authorise a move.
381func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
382 return protocol.PickerSnapshot{
383 InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK,
384 Lines: []protocol.PickerLine{
385 {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove},
386 {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove},
387 },
388 Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
389 }
390}
391
392func moveOffer(interaction uint64) protocol.PickerOffer {
393 return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "}
394}
395
396// openMovePicker opens one move interaction on a live attachment and waits
397// until its box is drawn.
398func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) {
399 t.Helper()
400 stream.deliver(moveOffer(interaction))
401 stream.deliver(moveSnapshot(interaction, 1))
402 require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn")
403}
404
405// TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer
406// opens a working destination picker whose input never reaches the session,
407// and every exit path answers with the typed replies and hands input back.
408func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
409 tests := []struct {
410 name string
411 keys []string
412 check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream)
413 }{
414 {
415 name: "enter commits the cursor destination",
416 keys: []string{"\x1b[B", "\r"},
417 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
418 selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection)
419 require.Equal(t, uint64(7), selection.InteractionID)
420 require.Equal(t, "serving", selection.SourceID)
421 require.Equal(t, uint64(1), selection.SourceRevision)
422 require.Equal(t, "bb/second", selection.Key)
423 require.Equal(t, protocol.PickerActionMove, selection.Action)
424 // The daemon closes the interaction; the terminal returns to the
425 // attachment with an authoritative repaint.
426 stream.deliver(protocol.PickerClosed{InteractionID: 7})
427 awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
428 },
429 },
430 {
431 name: "q cancels with a typed close",
432 keys: []string{"q"},
433 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
434 closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
435 require.Equal(t, uint64(7), closed.InteractionID)
436 awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
437 },
438 },
439 {
440 name: "a lone escape cancels once its window elapses",
441 keys: []string{"\x1b"},
442 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
443 // The input path first withholds the Escape as a possible
444 // DECRQM reply prefix; its ambiguity window hands it on.
445 var marker *supervisorTestTimer
446 require.Eventually(t, func() bool {
447 for _, timer := range harness.clock.snapshotTimers() {
448 if timer.delay == paletteMarkerAmbiguityDeadline && !timer.stopped() {
449 marker = timer
450 return true
451 }
452 }
453 return false
454 }, 5*time.Second, time.Millisecond, "the marker ambiguity window was never armed")
455 marker.fire()
456 var escape *supervisorTestTimer
457 require.Eventually(t, func() bool {
458 for _, timer := range harness.clock.snapshotTimers() {
459 if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() {
460 escape = timer
461 return true
462 }
463 }
464 return false
465 }, 5*time.Second, time.Millisecond, "the escape window was never armed")
466 require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early")
467 escape.fire()
468 closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
469 require.Equal(t, uint64(7), closed.InteractionID)
470 },
471 },
472 }
473 for _, tt := range tests {
474 t.Run(tt.name, func(t *testing.T) {
475 picker := newAttachTestPicker()
476 harness := startAttachHarness(t, picker)
477 stream := attachLiveSession(t, harness, picker)
478 openMovePicker(t, harness, stream, 7)
479
480 // Output under the box is applied and acknowledged, never written.
481 acks := countSent[protocol.Ack](stream)
482 stream.deliver(laterOutput(2, 2, "hidden-frame"))
483 require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
484 require.NotContains(t, harness.terminal.written(), "hidden-frame")
485
486 for _, key := range tt.keys {
487 harness.reader.push([]byte(key))
488 }
489 tt.check(t, harness, stream)
490 require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session")
491
492 // Input is never swallowed after the picker ended: the next key is
493 // session Input again.
494 harness.reader.push([]byte("z"))
495 input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
496 require.Equal(t, []byte("z"), input.Data)
497 require.Equal(t, PresentAttached, harness.sup.State().Presentation)
498 })
499 }
500}
501
502// TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the
503// move picker cannot be presented: the daemon's interaction is closed at once
504// instead of swallowing input with nothing on screen.
505func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) {
506 picker := newAttachTestPicker()
507 harness := startAttachHarness(t, picker)
508 stream := attachLiveSession(t, harness, picker)
509 stream.deliver(navigationOffer(1))
510 awaitPresentation(t, harness.sup, PresentAttachedPicker)
511
512 stream.deliver(moveOffer(9))
513 closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
514 require.Equal(t, uint64(9), closed.InteractionID)
515 require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation)
516}
517
518// TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and
519// the paint fence: only a committed attachment hosts the overlay, cancel
520// returns to Attached, and a settled attachment falls back to the plain
521// picker.
522func TestSupervisorReducerPickerOverlay(t *testing.T) {
523 tests := []struct {
524 name string
525 from Presentation
526 event supervisorEventKind
527 want Presentation
528 wantPaint bool
529 }{
530 {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true},
531 {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting},
532 {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true},
533 {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached},
534 {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached},
535 {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true},
536 {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting},
537 }
538 for _, tt := range tests {
539 t.Run(tt.name, func(t *testing.T) {
540 state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event})
541 require.Equal(t, tt.want, state.Presentation)
542 require.Equal(t, ConnectivityReady, state.Connectivity)
543 require.Equal(t, tt.wantPaint, PickerPresentation(state))
544 })
545 }
546}
547
548// TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal
549// presentation pin: the move picker is a bordered box over the session, never
550// a full-screen takeover, so it neither clears the screen nor spans it.
551func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) {
552 picker := newAttachTestPicker()
553 harness := startAttachHarness(t, picker)
554 stream := attachLiveSession(t, harness, picker)
555 before := len(harness.terminal.written())
556 openMovePicker(t, harness, stream, 7)
557
558 frame := harness.terminal.written()[before:]
559 require.Contains(t, frame, "first", "the destination rows must be drawn")
560 require.Contains(t, frame, "─", "the modal border must be drawn")
561 require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it")
562 require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box")
563}
564
565// TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape
566// keypress (one lone byte) cancels once the disambiguation window elapses on
567// the controller clock, and that a following byte disarms the window instead.
568func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) {
569 tests := []struct {
570 name string
571 follow []byte
572 wantClose bool
573 }{
574 {name: "window elapses into Escape", wantClose: true},
575 {name: "a following byte resolves the prefix itself", follow: []byte("[B")},
576 }
577 for _, tt := range tests {
578 t.Run(tt.name, func(t *testing.T) {
579 controller, clock := pickerTestController(t)
580 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
581
582 require.True(t, controller.ConsumeTerminalRead([]byte("\x1b")))
583 escape := clock.awaitTimer(t)
584 require.Equal(t, pickerEscapeDeadline, escape.delay)
585 if tt.follow != nil {
586 require.True(t, controller.ConsumeTerminalRead(tt.follow))
587 require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window")
588 op, _ := controller.TakeOp()
589 require.False(t, op.close)
590 return
591 }
592 escape.fire()
593 select {
594 case <-controller.OpsReady():
595 case <-time.After(5 * time.Second):
596 t.Fatal("the elapsed window never woke the supervisor")
597 }
598 op, _ := controller.TakeOp()
599 require.Equal(t, tt.wantClose, op.close)
600 require.False(t, op.exit, "Escape is a cancel, never the explicit exit")
601 })
602 }
603}