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