1package client
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7
8 renderer "github.com/bnema/vev-vt"
9 ansirenderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/terminalcap"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/usecase/picker"
14 "github.com/stretchr/testify/require"
15)
16
17func TestPickerRendererClearsPreviousBoundsAcrossResizes(t *testing.T) {
18 loop := pickerLoopFixture(t)
19 r := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
20 screen := renderer.NewScreen(100, 30)
21 paintScreen(screen, domain.Size{Cols: 100, Rows: 30}, 'S')
22
23 screen.Write(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
24 require.NotNil(t, r.prevBounds)
25 oldBounds := *r.prevBounds
26
27 screen.Resize(80, 30)
28 screen.Write(r.render(loop, domain.Size{Cols: 80, Rows: 30}, emptyPickerPreview()))
29 require.NotNil(t, r.prevBounds)
30 newBounds := *r.prevBounds
31
32 require.Greater(t, oldBounds.X+oldBounds.Width, newBounds.X+newBounds.Width)
33 snapshot := screen.Snapshot()
34 for y := oldBounds.Y; y < min(oldBounds.Y+oldBounds.Height, snapshot.Rows()); y++ {
35 for x := newBounds.X + newBounds.Width; x < min(oldBounds.X+oldBounds.Width, snapshot.Columns()); x++ {
36 require.Equal(t, ' ', snapshot.Row(y)[x].Rune, "stale modal cell at (%d,%d)", x, y)
37 }
38 }
39 require.Equal(t, 'S', snapshot.Row(0)[0].Rune, "render must preserve session content outside picker damage")
40 require.Equal(t, '┌', snapshot.Row(newBounds.Y)[newBounds.X].Rune, "new picker border must be rendered")
41}
42
43func TestPickerRendererPreservesANSI256ProfileAcrossResize(t *testing.T) {
44 loop := pickerLoopFixture(t)
45 styles := picker.RenderStyles{
46 Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
47 Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
48 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
49 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
50 }
51
52 indexed := newPickerRenderer(ansirenderer.ColorProfileANSI256)
53 indexed.renderStyles = []picker.RenderStyles{styles}
54 for _, size := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 80, Rows: 24}} {
55 output := string(indexed.render(loop, size, emptyPickerPreview()))
56 require.Regexp(t, `(?:38|48);5;`, output, "picker did not emit indexed color at %v", size)
57 require.NotRegexp(t, `(?:38|48|58);2;`, output, "picker emitted truecolor at %v", size)
58 }
59
60 truecolor := newPickerRenderer(ansirenderer.ColorProfileTrueColor)
61 truecolor.renderStyles = []picker.RenderStyles{styles}
62 require.Regexp(t, `(?:38|48);2;`, string(truecolor.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview())))
63}
64
65// TestPickerRenderersFollowTerminalColorProfile guards #280 on the broker
66// client: both the session picker and the move picker render with the
67// terminal's detected color profile, so an ANSI-256 terminal never receives
68// RGB SGR sequences from a picker box.
69func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
70 loop := pickerLoopFixture(t)
71 styles := picker.RenderStyles{
72 Selection: rgbPickerStyle(), SelectionName: rgbPickerStyle(), SelectionMuted: rgbPickerStyle(),
73 Name: rgbPickerStyle(), Detail: rgbPickerStyle(), Background: rgbPickerStyle(), Base: rgbPickerStyle(),
74 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
75 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
76 }
77 renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{
78 "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
79 return NewPicker(nil, 0, color).controller.renderer
80 },
81 "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
82 return newMovePickerOverlay(color).renderer
83 },
84 }
85 for name, build := range renderers {
86 for _, tc := range []struct {
87 color terminalcap.ColorCapabilities
88 profile ansirenderer.ColorProfile
89 want string
90 forbidden string
91 }{
92 {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
93 {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
94 } {
95 t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) {
96 r := build(tc.color)
97 require.Equal(t, tc.profile, r.profile)
98 r.renderStyles = []picker.RenderStyles{styles}
99 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
100 require.Regexp(t, tc.want, output)
101 require.NotRegexp(t, tc.forbidden, output)
102 })
103 }
104 }
105}
106
107func rgbPickerStyle() renderer.Style {
108 style := renderer.DefaultStyle()
109 style.HasForegroundRGB = true
110 style.ForegroundRGB = renderer.RGB{R: 12, G: 120, B: 231}
111 style.HasBackgroundRGB = true
112 style.BackgroundRGB = renderer.RGB{R: 23, G: 45, B: 67}
113 return style
114}
115
116func paintScreen(screen *renderer.Screen, size domain.Size, fill rune) {
117 for y := 0; y < size.Rows; y++ {
118 screen.Write([]byte(fmt.Sprintf("\x1b[%d;1H%s", y+1, strings.Repeat(string(fill), size.Cols))))
119 }
120}
121
122func pickerSnapshotFixture() protocol.PickerSnapshot {
123 return protocol.PickerSnapshot{
124 InteractionID: 11, SourceID: "serving", SourceRevision: 3, Status: protocol.PickerSourceOK,
125 Lines: []protocol.PickerLine{
126 {Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work", Detail: "2 tabs", Focusable: true, Actions: protocol.PickerCanNavigate | protocol.PickerCanKill},
127 {Key: "bb/perso", Kind: protocol.PickerLineSession, Label: "perso", Detail: "stopped", Stopped: true, Focusable: true, Actions: protocol.PickerCanNavigate},
128 {Key: "cc/remote", Kind: protocol.PickerLineSession, Label: "remote@host", Focusable: true, Actions: protocol.PickerCanNavigate},
129 },
130 Cursor: protocol.PickerCursor{Key: "aa/work", Index: 0},
131 }
132}
133
134func pickerLoopFixture(t *testing.T) *pickerLoop {
135 t.Helper()
136 return pickerLoopFromSnapshot(pickerSnapshotFixture(), protocol.PickerIntentNavigation, picker.SortRecent)
137}
138
139func pickedKey(t *testing.T, loop *pickerLoop) string {
140 t.Helper()
141 selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
142 require.True(t, ok)
143 return selection.Key
144}
145
146func TestPickerInteractionAdmitsLatestSourceRevision(t *testing.T) {
147 var rel *pickerInteraction
148 require.False(t, rel.admitSnapshot(pickerSnapshotFixture(), false))
149
150 rel = &pickerInteraction{}
151 snapshot := pickerSnapshotFixture()
152 require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction must not admit")
153
154 rel.setOpen(true, snapshot.InteractionID)
155 require.True(t, rel.admitSnapshot(snapshot, false))
156 require.Equal(t, snapshot.SourceRevision, rel.sourceRevisions[snapshot.SourceID])
157
158 older := snapshot
159 older.SourceRevision = snapshot.SourceRevision - 1
160 require.False(t, rel.admitSnapshot(older, false), "stale source revision must not step backward")
161
162 foreign := snapshot
163 foreign.InteractionID = snapshot.InteractionID + 1
164 require.False(t, rel.admitSnapshot(foreign, false), "foreign interaction must drop")
165
166 rel.setOpen(false, snapshot.InteractionID)
167 require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction drops late snapshots")
168}
169
170func TestPickerInteractionKeepsPerSourceRevisions(t *testing.T) {
171 rel := &pickerInteraction{}
172 snapshot := pickerSnapshotFixture()
173 rel.setOpen(true, snapshot.InteractionID)
174 require.True(t, rel.admitSnapshot(snapshot, false))
175
176 // Another source publishes its own first revision: it must be admitted
177 // even though the serving source is far ahead.
178 other := snapshot
179 other.SourceID = "host-2"
180 other.SourceRevision = 1
181 require.True(t, rel.admitSnapshot(other, false))
182 require.Equal(t, uint64(3), rel.sourceRevisions["serving"])
183 require.Equal(t, uint64(1), rel.sourceRevisions["host-2"])
184}
185
186func TestPickerLoopMergesSourcesAndCommitsOwningIdentity(t *testing.T) {
187 local := pickerSnapshotFixture()
188 local.SourceID = "authority/local"
189 local.Lines = local.Lines[:1]
190 local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
191 loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent)
192
193 remote := local
194 remote.SourceID = "authority/remote-a"
195 remote.SourceRevision = 1
196 remote.Lines = []protocol.PickerLine{{Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work@remote-a", Focusable: true, Actions: protocol.PickerCanNavigate}}
197 remote.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
198 loop.replaceLines(remote)
199
200 loop.down()
201 selection, ok := loop.selection(protocol.PickerActionNavigate, 9)
202 require.True(t, ok)
203 require.Equal(t, "authority/remote-a", selection.SourceID)
204 require.Equal(t, uint64(1), selection.SourceRevision)
205 require.Equal(t, "aa/work", selection.Key)
206}
207
208func TestPickerLoopRefreshesOneSourceWithoutInvalidatingAnother(t *testing.T) {
209 local := pickerSnapshotFixture()
210 local.SourceID = "authority/local"
211 local.Lines = local.Lines[:1]
212 local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
213 loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent)
214
215 remote := local
216 remote.SourceID = "authority/remote-a"
217 remote.SourceRevision = 1
218 remote.Lines = []protocol.PickerLine{{Key: "remote/session", Kind: protocol.PickerLineSession, Label: "remote", Focusable: true, Actions: protocol.PickerCanNavigate}}
219 remote.Cursor = protocol.PickerCursor{Key: "remote/session", Index: 0}
220 loop.replaceLines(remote)
221 loop.down()
222
223 local.SourceRevision++
224 local.Lines[0].Detail = "refreshed"
225 loop.replaceLines(local)
226 selection, ok := loop.selection(protocol.PickerActionNavigate, 0)
227 require.True(t, ok)
228 require.Equal(t, "authority/remote-a", selection.SourceID)
229 require.Equal(t, uint64(1), selection.SourceRevision)
230}
231
232func TestPickerInteractionRetirementIsPermanent(t *testing.T) {
233 snapshot := pickerSnapshotFixture()
234 var rel pickerInteraction
235 rel.setOpen(true, snapshot.InteractionID)
236 require.True(t, rel.admitSnapshot(snapshot, false))
237
238 // Closing retires the namespace: an in-flight snapshot for the same
239 // interaction can never reopen it.
240 rel.setOpen(false, snapshot.InteractionID)
241 require.Equal(t, snapshot.InteractionID, rel.retired)
242 require.False(t, rel.admitSnapshot(snapshot, false))
243 rel.setOpen(true, snapshot.InteractionID)
244 require.False(t, rel.admitSnapshot(snapshot, false), "a retired interaction is never reopened")
245
246 // A newer interaction is a new namespace and admits normally.
247 next := snapshot
248 next.InteractionID = snapshot.InteractionID + 1
249 next.SourceRevision = 1
250 rel.setOpen(true, next.InteractionID)
251 require.True(t, rel.admitSnapshot(next, false))
252 require.False(t, rel.admitSnapshot(snapshot, false), "the retired namespace stays closed")
253}
254
255func TestPickerLoopCursorSearchCommit(t *testing.T) {
256 loop := pickerLoopFixture(t)
257 require.NotNil(t, loop.model)
258
259 // Cursor starts at the published cursor key (first row).
260 require.Equal(t, "aa/work", pickedKey(t, loop))
261
262 // Cursor movement selects the non-initial row: the committed key follows
263 // the client cursor, never the daemon cursor.
264 loop.down()
265 require.Equal(t, "bb/perso", pickedKey(t, loop))
266 loop.down()
267 require.Equal(t, "cc/remote", pickedKey(t, loop))
268 loop.up()
269 require.Equal(t, "bb/perso", pickedKey(t, loop))
270}
271
272func TestPickerLoopSearchExitVsClose(t *testing.T) {
273 loop := pickerLoopFixture(t)
274
275 // Text enters search; Escape exits search without closing.
276 loop.insert('w')
277 require.True(t, loop.model.SearchActive())
278 require.False(t, loop.escape(), "search-exit Escape must not close")
279 require.False(t, loop.model.SearchActive())
280
281 // Escape with no search reports picker-close.
282 require.True(t, loop.escape(), "idle Escape must close")
283
284 // Literal s/x in search are text; backspace deletes.
285 loop.insert('s')
286 loop.insert('x')
287 require.True(t, loop.model.SearchActive())
288 require.Equal(t, "sx", loop.model.Query())
289 loop.backspace()
290 require.Equal(t, "s", loop.model.Query())
291}
292
293func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
294 loop := pickerLoopFixture(t)
295
296 // The first row authorises destruction; its typed kill carries the
297 // displayed source revision and the fatal action.
298 selection, ok := loop.selection(protocol.PickerActionKill, 0)
299 require.True(t, ok)
300 require.Equal(t, protocol.PickerActionKill, selection.Action)
301 require.Equal(t, "aa/work", selection.Key)
302 require.Equal(t, uint64(3), selection.SourceRevision)
303
304 // The stopped row is navigable but not destructible from here.
305 loop.down()
306 require.Equal(t, "bb/perso", pickedKey(t, loop))
307 _, ok = loop.selection(protocol.PickerActionKill, 0)
308 require.False(t, ok, "a row without the kill action must not be destroyed")
309}
310
311// TestPickerDecisionCapturesRowWithDecision proves the key handed to a driver
312// is the row selected when the decision was recorded, and that a commit wins
313// over a kill in the same accumulated decision.
314func TestPickerDecisionCapturesRowWithDecision(t *testing.T) {
315 tests := []struct {
316 name string
317 ops []pickerOp
318 moveAfter bool
319 wantKey string
320 wantAction bool
321 }{
322 {name: "no decision", ops: []pickerOp{{}}},
323 {name: "commit captures cursor row", ops: []pickerOp{{commit: true}}, moveAfter: true, wantKey: "aa/work", wantAction: true},
324 {name: "kill captures cursor row", ops: []pickerOp{{kill: true}}, moveAfter: true, wantKey: "aa/work", wantAction: true},
325 {name: "close carries no row", ops: []pickerOp{{close: true}}, wantAction: true},
326 {name: "commit wins over kill", ops: []pickerOp{{kill: true}, {commit: true}}, wantKey: "aa/work", wantAction: true},
327 }
328 for _, tt := range tests {
329 t.Run(tt.name, func(t *testing.T) {
330 loop := pickerLoopFixture(t)
331 var decision pickerDecision
332 for _, op := range tt.ops {
333 decision.record(op, loop)
334 }
335 if tt.moveAfter {
336 loop.down()
337 }
338 require.Equal(t, tt.wantAction, decision.actionable())
339 require.Equal(t, tt.wantKey, decision.key())
340 })
341 }
342}
343
344func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) {
345 snapshot := pickerSnapshotFixture()
346 loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
347
348 // Every published line commits its own opaque key without parsing display
349 // text, in published order.
350 loop.model.SelectNearestRow(0)
351 keys := []string{pickedKey(t, loop)}
352 for range len(snapshot.Lines) - 1 {
353 loop.down()
354 keys = append(keys, pickedKey(t, loop))
355 }
356 require.Equal(t, []string{"aa/work", "bb/perso", "cc/remote"}, keys)
357}
358
359func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
360 loop := pickerLoopFixture(t)
361
362 // Normal mode: arrows and j/k move; Enter asks to commit; x asks to kill.
363 op, changed := loop.drive([]string{"Down"}, "")
364 require.False(t, op.commit)
365 require.False(t, op.close)
366 require.True(t, changed)
367 require.Equal(t, "bb/perso", pickedKey(t, loop))
368 op, _ = loop.drive([]string{"k"}, "")
369 require.False(t, op.commit)
370 require.Equal(t, "aa/work", pickedKey(t, loop))
371
372 op, _ = loop.drive([]string{"Enter"}, "")
373 require.True(t, op.commit)
374 op, _ = loop.drive([]string{"x"}, "")
375 require.True(t, op.kill)
376 require.False(t, op.close)
377
378 // q and Ctrl+C close in normal mode.
379 for _, key := range []string{"q", "Ctrl+C"} {
380 op, _ = loop.drive([]string{key}, "")
381 require.True(t, op.close, "%s must close the picker", key)
382 }
383
384 // s reorders locally without asking the daemon, and keeps the selection.
385 op, changed = loop.drive([]string{"s"}, "")
386 require.False(t, op.commit)
387 require.False(t, op.kill)
388 require.False(t, op.close)
389 require.True(t, changed)
390 require.Equal(t, picker.SortGrouped, loop.model.SortMode())
391 require.Equal(t, "aa/work", pickedKey(t, loop))
392
393 // While searching, Enter still commits and printable keys are text.
394 loop.insert('w')
395 _, changed = loop.drive([]string{"o", "r"}, "")
396 require.True(t, changed)
397 require.Equal(t, "wor", loop.model.Query())
398}