package daemon import ( "testing" "github.com/stretchr/testify/require" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/ui" ) // TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a // client whose terminal window lost focus shows the bell of its own tab but // never clears it, while a focused client, or one whose terminal never // reports focus, clears it on its visible paint as before. func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) { tests := []struct { name string focus domain.TerminalFocus wantAcked bool }{ {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true}, {name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true}, {name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d, sess, ac, sends, releases := newManualTabSession(t, 1) defer releases[0]() selectTestAttachmentTab(sess, 0) ac.setTerminalFocus(tt.focus) d.noteAttention(sess, sess.tabs[0]) d.setAttentionFrame(1) d.paint(sess, ac, true, nil) require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell") sess.mu.Lock() defer sess.mu.Unlock() require.Equal(t, !tt.wantAcked, sess.tabs[0].attention) }) } } // TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a // hidden client left pending clears once that client's window is focused and // paints again. func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) { d, sess, ac, sends, releases := newManualTabSession(t, 1) defer releases[0]() selectTestAttachmentTab(sess, 0) ac.setTerminalFocus(domain.TerminalFocusUnfocused) d.noteAttention(sess, sess.tabs[0]) d.setAttentionFrame(1) d.paint(sess, ac, true, nil) mustOutputData(t, sends) require.True(t, sess.anyAttention(), "hidden client keeps the bell") require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused)) require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change") d.paint(sess, ac, true, nil) mustOutputData(t, sends) require.False(t, sess.anyAttention(), "focused client clears the bell") } // TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in // Hello is the attachment's focus from its first paint, so a hidden window // that opens an attachment never clears the bell of the tab it lands on. func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) { tests := []struct { name string focus domain.TerminalFocus wantAcked bool }{ {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true}, {name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true}, {name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d, sess, first, _, releases := newManualTabSession(t, 1) defer releases[0]() first.setTerminalFocus(domain.TerminalFocusUnfocused) d.noteAttention(sess, sess.tabs[0]) tr, sends := newCapturingTransport(t) peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus}) require.NoError(t, err) require.Equal(t, tt.focus, peer.terminalFocus()) require.True(t, sess.switchAttachmentTab(peer, 0)) d.setAttentionFrame(1) d.paint(sess, peer, true, nil) mustOutputData(t, sends) require.Equal(t, !tt.wantAcked, sess.anyAttention()) }) } } // TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report: // only a gain of focus repaints, and the bell a focused window finds on its // tab is drawn for one pulse before a later paint acknowledges it. func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) { d, sess, ac, sends, releases := newManualTabSession(t, 1) defer releases[0]() selectTestAttachmentTab(sess, 0) effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}} d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}) require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus()) require.Empty(t, sends, "losing focus does not repaint") d.noteAttention(sess, sess.tabs[0]) d.setAttentionFrame(0) d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus()) mustOutputData(t, sends) require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame") d.setAttentionFrame(1) d.paint(sess, ac, false, nil) require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell") require.False(t, sess.anyAttention(), "the drawn bell is acknowledged") // The acknowledgement repaints every client; drain those frames. for len(sends) > 0 { <-sends } d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) require.Empty(t, sends, "an unchanged focus does not repaint") } // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window // gains focus becomes the shared geometry claimant, while losing focus leaves // the current claimant in place and a repeated report never claims again. func TestFocusGainClaimsSharedGeometry(t *testing.T) { tests := []struct { name string prior domain.TerminalFocus focus domain.TerminalFocus wantClaimed bool }{ {name: "unknown to focused claims", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusFocused, wantClaimed: true}, {name: "unfocused to focused claims", prior: domain.TerminalFocusUnfocused, focus: domain.TerminalFocusFocused, wantClaimed: true}, {name: "repeated focused report does not claim", prior: domain.TerminalFocusFocused, focus: domain.TerminalFocusFocused, wantClaimed: false}, {name: "focus loss keeps claimant", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusUnfocused, wantClaimed: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) first.setTerminalFocus(tt.prior) second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}) require.True(t, sess.geometry.reconcile(d, sess, second)) requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38}) token := sess.captureAttachmentCapability(first, first.transport()) first.installTestAttachmentCapability(token) effect, admitted := first.beginAttachmentEffect(token) require.True(t, admitted) d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: tt.focus}) effect.End() if tt.wantClaimed { requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22}) return } requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38}) }) } } // TestUnfocusedClaimDoesNotTakeGeometry pins the claim ranking: a claim from // an attachment that reported losing focus (a resumed or tiled background // window) never displaces one that may be seen, but still wins once it is the // only candidate. Without focus reports the newest claim wins as before. func TestUnfocusedClaimDoesNotTakeGeometry(t *testing.T) { tests := []struct { name string firstFocus domain.TerminalFocus lateFocus domain.TerminalFocus wantLate bool }{ {name: "unfocused late claim loses to focused", firstFocus: domain.TerminalFocusFocused, lateFocus: domain.TerminalFocusUnfocused, wantLate: false}, {name: "unfocused late claim loses to unknown", firstFocus: domain.TerminalFocusUnknown, lateFocus: domain.TerminalFocusUnfocused, wantLate: false}, {name: "unknown focus keeps newest-claim rule", firstFocus: domain.TerminalFocusUnknown, lateFocus: domain.TerminalFocusUnknown, wantLate: true}, {name: "focused late claim wins", firstFocus: domain.TerminalFocusUnfocused, lateFocus: domain.TerminalFocusFocused, wantLate: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) first.setTerminalFocus(tt.firstFocus) require.True(t, sess.geometry.reconcile(d, sess, first)) requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22}) late := &attachedClient{output: newOutputStateStream()} late.tr, _ = newCapturingTransport(t) late.output.attachment = late late.initOverlays() late.setGeometry(domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}) late.setTerminalFocus(tt.lateFocus) late.setSession(sess) require.True(t, sess.registerAttachment(late)) // attach and resume claim here sess.geometry.reconcile(d, sess, late) // as a client Resize does if !tt.wantLate { requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22}) // Once the seen claimant leaves, the unfocused claim is the only one. d.clientGone(sess, first, first.transport(), true) sess.geometry.reconcile(d, sess, nil) } requireMultiplexGeometry(t, sess, late, domain.Size{Cols: 120, Rows: 38}) }) } }