turbo-editors/turbo-corepublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

view_test.go · 492 lines · 15.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 10h ago1package acp_test
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/gdamore/tcell/v2"
9
📦 Turbo Core f3ade8d k33g 2h ago10 "rickub.com/turbo-editors/turbo-core/acp"
11 "rickub.com/turbo-editors/turbo-core/theme"
12 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 10h ago13)
14
15// newView returns a view over a session whose agent never answers, sized to a
16// screen.
17//
18// The agent is a pipe nobody reads, so the conversation is whatever the test
19// puts into it and nothing arrives to race the drawing. The project has been
20// bitten before by tests that asserted on a screen while a live process wrote
21// to it, and that hid a real fault for a whole session.
22func newView(t *testing.T, width, height int) (*acp.View, *fakeAgent) {
23 t.Helper()
24
25 agent, stream := newFakeAgent(t)
26 session := acp.NewSession(stream, acp.Agent{Name: "Bob"}, t.TempDir(), acp.Options{})
27 t.Cleanup(func() { _ = session.Close() })
28
29 view := acp.NewView(session)
30 view.SetBounds(ui.Rect{X: 0, Y: 0, W: width, H: height})
31 return view, agent
32}
33
34// draw paints a view onto a simulation screen and returns the rows.
35func draw(t *testing.T, view *acp.View, width, height int) []string {
36 t.Helper()
37
38 screen := tcell.NewSimulationScreen("UTF-8")
39 if err := screen.Init(); err != nil {
40 t.Fatalf("starting the screen: %v", err)
41 }
42 defer screen.Fini()
43 screen.SetSize(width, height)
44
45 th, err := theme.Load(theme.DefaultName, "")
46 if err != nil {
47 t.Fatalf("loading the theme: %v", err)
48 }
49
50 view.Draw(ui.NewPainter(screen), th)
51 screen.Show()
52
53 cells, w, h := screen.GetContents()
54 rows := make([]string, h)
55 for y := range h {
56 var row strings.Builder
57 for x := range w {
58 row.WriteString(string(cells[y*w+x].Runes))
59 }
60 rows[y] = strings.TrimRight(row.String(), " ")
61 }
62 return rows
63}
64
65func TestTheWindowDrawsTheConversationAboveTheBoxYouTypeIn(t *testing.T) {
66 view, _ := newView(t, 60, 12)
67 view.SetInput("what I am typing")
68
69 rows := draw(t, view, 60, 12)
70 joined := strings.Join(rows, "\n")
71
72 if !strings.Contains(joined, "what I am typing") {
73 t.Errorf("the input is not on the screen:\n%s", joined)
74 }
75 if !strings.Contains(joined, "starting") {
76 t.Errorf("the rule does not say the agent is starting:\n%s", joined)
77 }
78
79 // The input must be *below* the rule, which is what makes the two panes
80 // two panes rather than one list.
81 ruleAt, inputAt := -1, -1
82 for i, row := range rows {
83 if strings.Contains(row, "─") && ruleAt < 0 {
84 ruleAt = i
85 }
86 if strings.Contains(row, "what I am typing") {
87 inputAt = i
88 }
89 }
90 if ruleAt < 0 || inputAt < 0 || inputAt <= ruleAt {
91 t.Errorf("the rule is at %d and the input at %d:\n%s", ruleAt, inputAt, joined)
92 }
93}
94
95func TestTypingGoesIntoTheBoxAndEnterSendsIt(t *testing.T) {
96 view, _ := newView(t, 60, 12)
97
98 for _, r := range "hello" {
99 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
100 }
101 if got := view.Input(); got != "hello" {
102 t.Fatalf("Input() = %q", got)
103 }
104
105 view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
106
107 if got := view.Input(); got != "" {
108 t.Errorf("the box still holds %q after Enter", got)
109 }
110 if got := textOf(view.Session().Entries(), acp.EntryUser); got != "hello" {
111 t.Errorf("the conversation says %q", got)
112 }
113}
114
115func TestAltEnterMakesANewLineInsteadOfSending(t *testing.T) {
116 view, _ := newView(t, 60, 12)
117
118 for _, r := range "one" {
119 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
120 }
121 view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModAlt))
122 for _, r := range "two" {
123 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
124 }
125
126 if got := view.Input(); got != "one\ntwo" {
127 t.Errorf("Input() = %q, want two lines", got)
128 }
129 if len(view.Session().Entries()) != 0 {
130 t.Error("Alt-Enter sent the prompt")
131 }
132}
133
134func TestEnterOnAnEmptyBoxSendsNothing(t *testing.T) {
135 // Enter on a blank line is somebody thinking, not a turn worth spending.
136 view, _ := newView(t, 60, 12)
137
138 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone))
139 view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
140
141 if len(view.Session().Entries()) != 0 {
142 t.Errorf("a blank prompt was sent: %v", view.Session().Entries())
143 }
144}
145
146func TestBackspaceJoinsLinesAtTheStartOfOne(t *testing.T) {
147 view, _ := newView(t, 60, 12)
148 view.SetInput("one\ntwo")
149
150 // The cursor sits at the end of "two"; three backspaces empty the line and
151 // a fourth joins it to the one above.
152 for range 4 {
153 view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone))
154 }
155 if got := view.Input(); got != "one" {
156 t.Errorf("Input() = %q, want the lines joined", got)
157 }
158}
159
160func TestTabMovesBetweenTheTwoPanes(t *testing.T) {
161 view, _ := newView(t, 60, 12)
162 view.SetFocused(true)
163
164 // With the input focused, a letter is typed. After Tab it scrolls instead.
165 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone))
166 if got := view.Input(); got != "a" {
167 t.Fatalf("Input() = %q before Tab", got)
168 }
169
170 view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone))
171 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'b', tcell.ModNone))
172
173 if got := view.Input(); got != "a" {
174 t.Errorf("Input() = %q; typing reached the box although the conversation has the focus", got)
175 }
176}
177
178func TestEscapeIsLeftAloneWhenThereIsNoTurnToStop(t *testing.T) {
179 // Escape is how the rest of the editor closes things. A window that ate it
180 // would be one you could not get out of by habit.
181 view, _ := newView(t, 60, 12)
182
183 if view.HandleKey(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) {
184 t.Error("Escape was swallowed with nothing running")
185 }
186}
187
188func TestScrollingStopsFollowingAndEndTakesItUpAgain(t *testing.T) {
189 // Reading back through what happened must not be interrupted by the agent
190 // still writing.
191 view, _ := newView(t, 60, 8)
192 view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // focus the conversation
193
194 for range 30 {
195 view.Session().Prompt("a line of conversation")
196 }
197 draw(t, view, 60, 8) // lay it out, which is when the total is known
198
199 if !view.Following() {
200 t.Fatal("a fresh window is not following the end")
201 }
202
203 view.HandleKey(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone))
204 if view.Following() {
205 t.Error("scrolling up left the window pinned to the end")
206 }
207
208 view.HandleKey(tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone))
209 if !view.Following() {
210 t.Error("End did not take up following again")
211 }
212}
213
214func TestTheTitleSaysWhatTheAgentIsDoing(t *testing.T) {
215 view, _ := newView(t, 60, 12)
216
217 if got := view.Title(); !strings.Contains(got, "starting") {
218 t.Errorf("Title() = %q before the handshake", got)
219 }
220 if !strings.Contains(view.Title(), "Bob") {
221 t.Errorf("Title() = %q, want the agent's name in it", view.Title())
222 }
223}
224
225func TestAWindowTooSmallToDrawDrawsNothingRatherThanPanicking(t *testing.T) {
226 // A terminal can be dragged to any size, and the layout subtracts four
227 // rows for the box and the rule.
228 for _, size := range []struct{ w, h int }{{0, 0}, {1, 1}, {3, 2}, {10, 4}} {
229 view, _ := newView(t, size.w, size.h)
230 view.SetInput("something")
231 draw(t, view, max(size.w, 1), max(size.h, 1))
232 }
233}
234
235func TestTheSpinnerTurnsWithTheClock(t *testing.T) {
236 // A function of the clock rather than of a counter: nothing has to be
237 // reset when a turn starts, two windows thinking at once turn in step, and
238 // a test can assert on a frame without waiting for one.
239 start := time.Unix(0, 0)
240
241 first := acp.Spinner(start)
242 if got := acp.Spinner(start.Add(acp.SpinnerPeriod / 3)); got != first {
243 t.Errorf("the frame changed within one period: %c then %c", first, got)
244 }
245 if got := acp.Spinner(start.Add(acp.SpinnerPeriod)); got == first {
246 t.Errorf("the frame did not change after a whole period: %c", got)
247 }
248
249 // It comes back round rather than running off the end of the frames.
250 seen := map[rune]bool{}
251 for i := range 40 {
252 seen[acp.Spinner(start.Add(time.Duration(i)*acp.SpinnerPeriod))] = true
253 }
254 if len(seen) < 8 {
255 t.Errorf("only %d distinct frames in forty periods", len(seen))
256 }
257}
258
259func TestTheRuleShowsTheSpinnerWhileTheAgentThinks(t *testing.T) {
260 view, agent := newView(t, 70, 12)
261 agent.handshake()
262 waitFor(t, "ready", view.Session().Ready)
263
264 at := time.Unix(0, 0)
265 view.SetClock(func() time.Time { return at })
266
267 view.Session().Prompt("something")
268 agent.read()
269 waitFor(t, "the turn to start", view.Session().Running)
270
271 rows := draw(t, view, 70, 12)
272 joined := strings.Join(rows, "\n")
273 if !strings.ContainsRune(joined, acp.Spinner(at)) {
274 t.Errorf("the rule shows no spinner while thinking:\n%s", joined)
275 }
276 if !strings.Contains(joined, "thinking") {
277 t.Errorf("the rule does not say what it is doing:\n%s", joined)
278 }
279}
280
281func TestWithNothingSelectedCopyTakesTheBlockUnderTheCursor(t *testing.T) {
282 // The thing somebody wants is almost always a code block, and selecting it
283 // by hand first is work the editor can do for them.
284 view, _ := newView(t, 70, 20)
285 view.Session().Prompt("show me")
286
287 transcript := view.Session()
288 _ = transcript
289 feed(t, view, "Here you are:\n```go\npackage main\n\nfunc main() {}\n```\nand that is all.")
290
291 draw(t, view, 70, 20) // lay it out, which is when the regions exist
292 lines := linesOfView(t, view, 70, 20)
293
294 at := indexOfLine(lines, "package main")
295 if at < 0 {
296 t.Fatalf("the code never appeared:\n%s", strings.Join(lines, "\n"))
297 }
298 view.SetCaretForTest(at)
299
300 text, ok := view.Copy()
301 if !ok {
302 t.Fatal("Copy() found nothing to copy")
303 }
304 if !strings.Contains(text, "package main") || !strings.Contains(text, "func main() {}") {
305 t.Errorf("Copy() = %q, want the whole code block", text)
306 }
307 if strings.Contains(text, "Here you are") || strings.Contains(text, "that is all") {
308 t.Errorf("Copy() = %q, want the block alone rather than the prose around it", text)
309 }
310 if strings.HasPrefix(text, " ") {
311 t.Errorf("Copy() = %q, want the drawing indent taken off", text)
312 }
313}
314
315func TestShiftArrowsSelectLinesAndCtrlCCopiesThem(t *testing.T) {
316 view, _ := newView(t, 70, 20)
317 view.SetFocused(true)
318 feed(t, view, "one\ntwo\nthree")
319 draw(t, view, 70, 20)
320
321 view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // the conversation
322 view.SetCaretForTest(indexOfLine(linesOfView(t, view, 70, 20), "one"))
323
324 view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift))
325 from, to, ok := view.Selection()
326 if !ok || to-from != 1 {
327 t.Fatalf("Selection() = %d..%d ok=%v, want two lines", from, to, ok)
328 }
329
330 var copied string
331 view.OnCopy = func(text string) { copied = text }
332 view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlC, 0, tcell.ModNone))
333
334 if !strings.Contains(copied, "one") || !strings.Contains(copied, "two") {
335 t.Errorf("copied %q, want both selected lines", copied)
336 }
337 if strings.Contains(copied, "three") {
338 t.Errorf("copied %q, want only what was selected", copied)
339 }
340 if _, _, still := view.Selection(); still {
341 t.Error("the selection survived being copied")
342 }
343}
344
345func TestEscapeDropsTheSelectionBeforeItStopsATurn(t *testing.T) {
346 // Two meanings on one key, ordered by how local they are.
347 view, _ := newView(t, 70, 20)
348 view.SetFocused(true)
349 feed(t, view, "one\ntwo")
350 draw(t, view, 70, 20)
351
352 view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone))
353 view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift))
354 if _, _, ok := view.Selection(); !ok {
355 t.Fatal("nothing was selected to begin with")
356 }
357
358 if !view.HandleKey(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) {
359 t.Error("Escape was not claimed although there was a selection")
360 }
361 if _, _, ok := view.Selection(); ok {
362 t.Error("Escape left the selection in place")
363 }
364
365 // With neither a selection nor a turn, Escape belongs to the editor again.
366 if view.HandleKey(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) {
367 t.Error("Escape was swallowed with nothing to do")
368 }
369}
370
371func TestASelectedLineIsDrawnInTheSelectionColour(t *testing.T) {
372 view, _ := newView(t, 70, 20)
373 view.SetFocused(true)
374 feed(t, view, "one\ntwo")
375 draw(t, view, 70, 20)
376
377 view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone))
378 view.SetCaretForTest(indexOfLine(linesOfView(t, view, 70, 20), "one"))
379 view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift))
380
381 th, err := theme.Load(theme.DefaultName, "")
382 if err != nil {
383 t.Fatalf("loading the theme: %v", err)
384 }
385 wantFg, wantBg, _ := th.Style(theme.KeyEditorSelection).Decompose()
386
387 screen := tcell.NewSimulationScreen("UTF-8")
388 if err := screen.Init(); err != nil {
389 t.Fatalf("starting the screen: %v", err)
390 }
391 defer screen.Fini()
392 screen.SetSize(70, 20)
393 view.Draw(ui.NewPainter(screen), th)
394 screen.Show()
395
396 cells, w, _ := screen.GetContents()
397 found := false
398 for y := range 20 {
399 for x := range w {
400 cell := cells[y*w+x]
401 if string(cell.Runes) != "o" {
402 continue
403 }
404 fg, bg, _ := cell.Style.Decompose()
405 if fg == wantFg && bg == wantBg {
406 found = true
407 }
408 }
409 }
410 if !found {
411 t.Error("no cell of the selected line is drawn in the selection colour")
412 }
413}
414
415// feed puts an agent message into a view's conversation without a live agent.
416func feed(t *testing.T, view *acp.View, text string) {
417 t.Helper()
418 view.Session().AddAgentTextForTest(text)
419}
420
421// linesOfView returns the conversation as it was last laid out.
422func linesOfView(t *testing.T, view *acp.View, width, height int) []string {
423 t.Helper()
424
425 var out []string
426 for _, line := range view.LinesForTest() {
427 out = append(out, line.Text)
428 }
429 return out
430}
431
432// indexOfLine returns where a line whose text contains want sits, or -1.
433func indexOfLine(lines []string, want string) int {
434 for i, line := range lines {
435 if strings.Contains(line, want) {
436 return i
437 }
438 }
439 return -1
440}
441
442func TestCopyingABlockLeavesTheSpeakersLabelBehind(t *testing.T) {
443 // "‣ Bob (llama.cpp)" above a code block is furniture. Pasting it into a
444 // source file is never what anybody meant, and it was in the first version
445 // of this — found by copying from the real binary and reading the OSC 52
446 // payload back off the wire.
447 view, _ := newView(t, 70, 20)
448 feed(t, view, "```go\nfunc Reverse(s string) string {\n\treturn s\n}\n```")
449 draw(t, view, 70, 20)
450
451 lines := linesOfView(t, view, 70, 20)
452 at := indexOfLine(lines, "func Reverse")
453 if at < 0 {
454 t.Fatalf("the code never appeared:\n%s", strings.Join(lines, "\n"))
455 }
456 view.SetCaretForTest(at)
457
458 text, ok := view.Copy()
459 if !ok {
460 t.Fatal("Copy() found nothing")
461 }
462 if strings.Contains(text, "‣") {
463 t.Errorf("Copy() = %q, want no speaker label in it", text)
464 }
465 if !strings.HasPrefix(text, "func Reverse") {
466 t.Errorf("Copy() = %q, want it to start at the code", text)
467 }
468}
469
470func TestCopyingAToolsOutputLeavesItsHeadingBehind(t *testing.T) {
471 view, agent := newView(t, 70, 20)
472 id := agent.handshake()
473 waitFor(t, "ready", view.Session().Ready)
474
475 agent.update(id, `{"sessionUpdate":"tool_call","toolCallId":"c1","title":"Shell","status":"completed","rawInput":{"cmd":"ls"},"content":[{"type":"content","content":{"type":"text","text":"one.go\ntwo.go"}}]}`)
476 waitFor(t, "the tool output", func() bool {
477 return strings.Contains(strings.Join(linesOfView(t, view, 70, 20), "\n"), "one.go")
478 })
479 draw(t, view, 70, 20)
480
481 view.SetCaretForTest(indexOfLine(linesOfView(t, view, 70, 20), "one.go"))
482 text, ok := view.Copy()
483 if !ok {
484 t.Fatal("Copy() found nothing")
485 }
486 if strings.Contains(text, "Shell") || strings.Contains(text, "✓") {
487 t.Errorf("Copy() = %q, want the output without the heading", text)
488 }
489 if !strings.Contains(text, "one.go") || !strings.Contains(text, "two.go") {
490 t.Errorf("Copy() = %q, want the whole output", text)
491 }
492}