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.

completion_test.go · 501 lines · 14.2 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 21h ago1package app
2
3import (
4 "path/filepath"
5 "strings"
6 "testing"
7
8 "github.com/gdamore/tcell/v2"
9
📦 Turbo Core f3ade8d k33g 13h ago10 "rickub.com/turbo-editors/turbo-core/lsp"
11 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 21h ago12)
13
14// sampleItems is a completion list shaped like one gopls returns for "fmt.".
15func sampleItems() []lsp.CompletionItem {
16 return []lsp.CompletionItem{
17 {Label: "Println", Kind: lsp.KindFunction, Detail: "func(a ...any)"},
18 {Label: "Printf", Kind: lsp.KindFunction},
19 {Label: "Print", Kind: lsp.KindFunction},
20 {Label: "Sprintf", Kind: lsp.KindFunction},
21 {Label: "Errorf", Kind: lsp.KindFunction},
22 }
23}
24
25func TestShowFiltersOnThePrefix(t *testing.T) {
26 var box CompletionBox
27
28 box.Show(sampleItems(), "Print", 10, 5, testScreen())
29
30 if !box.Visible() {
31 t.Fatal("Visible() = false after Show with matching items")
32 }
33 if got := box.Count(); got != 3 {
34 t.Errorf("Count() = %d, want the three items starting with Print", got)
35 }
36}
37
38func TestFilteringIgnoresCase(t *testing.T) {
39 var box CompletionBox
40
41 box.Show(sampleItems(), "prin", 10, 5, testScreen())
42
43 if got := box.Count(); got != 3 {
44 t.Errorf("Count() = %d, want a lower-case prefix to match capitals", got)
45 }
46}
47
48func TestShowWithNothingMatchingStaysClosed(t *testing.T) {
49 var box CompletionBox
50
51 box.Show(sampleItems(), "zzz", 10, 5, testScreen())
52
53 if box.Visible() {
54 t.Error("a popup with no entries was opened; it must stay shut")
55 }
56}
57
58func TestShowWithNoItemsStaysClosed(t *testing.T) {
59 var box CompletionBox
60
61 box.Show(nil, "", 10, 5, testScreen())
62
63 if box.Visible() {
64 t.Error("a popup with no items was opened")
65 }
66}
67
68func TestSetPrefixNarrowsThenClosesTheList(t *testing.T) {
69 var box CompletionBox
70 box.Show(sampleItems(), "", 10, 5, testScreen())
71
72 box.SetPrefix("Print")
73 if got := box.Count(); got != 3 {
74 t.Errorf("Count() = %d, want 3", got)
75 }
76
77 box.SetPrefix("Printl")
78 if got := box.Count(); got != 1 {
79 t.Errorf("Count() = %d, want 1", got)
80 }
81
82 box.SetPrefix("Printlz")
83 if box.Visible() {
84 t.Error("the popup stayed open with nothing left to show")
85 }
86}
87
88func TestSetPrefixOnAClosedPopupDoesNothing(t *testing.T) {
89 var box CompletionBox
90
91 box.SetPrefix("anything") // must not panic
92
93 if box.Visible() {
94 t.Error("SetPrefix opened a popup that was never shown")
95 }
96}
97
98func TestArrowsWalkTheListAndStopAtItsEnds(t *testing.T) {
99 var box CompletionBox
100 box.Show(sampleItems(), "", 10, 5, testScreen())
101
102 box.HandleKey(keyOf(tcell.KeyUp))
103 if item, _ := box.Selected(); item.Label != "Println" {
104 t.Errorf("the selection is %q, want it to stay on the first item", item.Label)
105 }
106
107 for range 20 {
108 box.HandleKey(keyOf(tcell.KeyDown))
109 }
110 if item, _ := box.Selected(); item.Label != "Errorf" {
111 t.Errorf("the selection is %q, want it to stop at the last item", item.Label)
112 }
113}
114
115func TestEnterAcceptsTheSelectionAndClosesThePopup(t *testing.T) {
116 var accepted lsp.CompletionItem
117 var box CompletionBox
118 box.OnAccept = func(item lsp.CompletionItem) { accepted = item }
119 box.Show(sampleItems(), "", 10, 5, testScreen())
120
121 box.HandleKey(keyOf(tcell.KeyDown))
122 box.HandleKey(keyOf(tcell.KeyEnter))
123
124 if accepted.Label != "Printf" {
125 t.Errorf("the accepted item is %q, want Printf", accepted.Label)
126 }
127 if box.Visible() {
128 t.Error("the popup stayed open after an item was accepted")
129 }
130}
131
132func TestTabAcceptsToo(t *testing.T) {
133 accepted := ""
134 var box CompletionBox
135 box.OnAccept = func(item lsp.CompletionItem) { accepted = item.Label }
136 box.Show(sampleItems(), "", 10, 5, testScreen())
137
138 box.HandleKey(keyOf(tcell.KeyTab))
139
140 if accepted != "Println" {
141 t.Errorf("the accepted item is %q", accepted)
142 }
143}
144
145func TestEscapeDismissesThePopupWithoutAccepting(t *testing.T) {
146 accepted := false
147 var box CompletionBox
148 box.OnAccept = func(lsp.CompletionItem) { accepted = true }
149 box.Show(sampleItems(), "", 10, 5, testScreen())
150
151 box.HandleKey(keyOf(tcell.KeyEscape))
152
153 if box.Visible() {
154 t.Error("Escape did not close the popup")
155 }
156 if accepted {
157 t.Error("Escape accepted an item")
158 }
159}
160
161func TestTypingFallsThroughToTheEditor(t *testing.T) {
162 var box CompletionBox
163 box.Show(sampleItems(), "", 10, 5, testScreen())
164
165 if box.HandleKey(tcell.NewEventKey(tcell.KeyRune, 'l', tcell.ModNone)) {
166 t.Error("the popup swallowed a printable character; typing must keep reaching the editor")
167 }
168}
169
170func TestAClosedPopupHandlesNothing(t *testing.T) {
171 var box CompletionBox
172
173 if box.HandleKey(keyOf(tcell.KeyDown)) {
174 t.Error("a closed popup claimed a key")
175 }
176 if box.HandleMouse(tcell.NewEventMouse(1, 1, tcell.Button1, tcell.ModNone)) {
177 t.Error("a closed popup claimed a click")
178 }
179}
180
181func TestThePopupOpensBelowTheCursorAndFlipsAboveWhenItWouldNotFit(t *testing.T) {
182 var box CompletionBox
183
184 box.Show(sampleItems(), "", 10, 5, testScreen())
185 if got := box.Bounds().Y; got <= 5 {
186 t.Errorf("the popup is at row %d, want it below the cursor at row 5", got)
187 }
188
189 box.Show(sampleItems(), "", 10, 22, testScreen())
190 if got := box.Bounds(); got.Bottom() > 24 {
191 t.Errorf("the popup is %+v, want it kept on screen", got)
192 }
193}
194
195func TestThePopupStaysOnScreenNearTheRightEdge(t *testing.T) {
196 var box CompletionBox
197
198 box.Show(sampleItems(), "", 78, 5, testScreen())
199
200 if got := box.Bounds(); got.Right() > 80 {
201 t.Errorf("the popup is %+v, want it pulled back inside the screen", got)
202 }
203}
204
205func TestClickingAnEntryAcceptsIt(t *testing.T) {
206 accepted := ""
207 var box CompletionBox
208 box.OnAccept = func(item lsp.CompletionItem) { accepted = item.Label }
209 box.Show(sampleItems(), "", 10, 5, testScreen())
210
211 bounds := box.Bounds()
212 box.HandleMouse(tcell.NewEventMouse(bounds.X+2, bounds.Y+2, tcell.Button1, tcell.ModNone))
213
214 if accepted != "Printf" {
215 t.Errorf("the accepted item is %q, want the second one", accepted)
216 }
217}
218
219func TestClickingOutsideDismissesThePopup(t *testing.T) {
220 var box CompletionBox
221 box.Show(sampleItems(), "", 10, 5, testScreen())
222
223 box.HandleMouse(tcell.NewEventMouse(0, 0, tcell.Button1, tcell.ModNone))
224
225 if box.Visible() {
226 t.Error("a click elsewhere did not dismiss the popup")
227 }
228}
229
230func TestThePopupDrawsItsEntriesAndKinds(t *testing.T) {
231 a, screen := newTestApp(t)
232 a.NewFile()
233 a.Completion().Show(sampleItems(), "", 10, 5, a.screenRect())
234
235 lines := render(t, a, screen)
236
237 found := false
238 for _, line := range lines {
239 if strings.Contains(line, "Println") && strings.Contains(line, "func") {
240 found = true
241 }
242 }
243 if !found {
244 t.Errorf("the popup does not show an entry with its kind:\n%s", strings.Join(lines[4:14], "\n"))
245 }
246}
247
248func TestAcceptingACompletionReplacesTheHalfTypedWord(t *testing.T) {
249 a, _ := newTestApp(t)
250 a.NewFile()
251 typeText(a, "fmt.Prin")
252
253 a.acceptCompletion(lsp.CompletionItem{Label: "Println"})
254
255 if got := activeBuffer(t, a).Text(); got != "fmt.Println" {
256 t.Errorf("the buffer holds %q, want %q", got, "fmt.Println")
257 }
258}
259
260func TestAskingForCompletionWithoutALanguageServerSaysSo(t *testing.T) {
261 a, _ := newTestApp(t)
262 a.NewFile()
263
264 a.RequestCompletion()
265
266 if !strings.Contains(a.StatusBar().Message(), "LSP") {
267 t.Errorf("the status bar says %q, want the language server's state", a.StatusBar().Message())
268 }
269 if a.Completion().Visible() {
270 t.Error("a popup opened with no language server to fill it")
271 }
272}
273
274func TestTheEditorKeepsWorkingWithoutALanguageServer(t *testing.T) {
275 a, _ := newTestApp(t)
276 a.NewFile()
277
278 // Every one of these goes through the Language wrapper, which must be a
279 // no-op rather than a nil dereference when nothing is connected.
280 typeText(a, "package main")
281 a.Language().DidOpen("main.go", "package main")
282 a.Language().DidChange("main.go", "package main\n")
283 a.Language().DidSave("main.go", "package main\n")
284 a.Language().DidClose("main.go")
285 a.GoToDefinition()
286 a.DescribeSymbol()
287
288 if got := activeBuffer(t, a).Text(); got != "package main" {
289 t.Errorf("the buffer holds %q, want editing to have carried on regardless", got)
290 }
291 if a.Language().Ready() {
292 t.Error("Ready() = true with no server connected")
293 }
294}
295
296func TestDiagnosticsAreRememberedPerFile(t *testing.T) {
297 language := NewLanguage(testProfile().Server, testProfile().Name)
298
299 language.receiveDiagnostics("main.go", []lsp.Diagnostic{
300 {Message: "undefined: foo", Severity: lsp.SeverityError},
301 {Message: "unused variable", Severity: lsp.SeverityWarning},
302 })
303
304 if got := len(language.Diagnostics("main.go")); got != 2 {
305 t.Errorf("Diagnostics() returned %d, want 2", got)
306 }
307 if got := len(language.Diagnostics("other.go")); got != 0 {
308 t.Errorf("Diagnostics() for another file returned %d, want none", got)
309 }
310
311 first, ok := language.FirstError("main.go")
312 if !ok || first.Message != "undefined: foo" {
313 t.Errorf("FirstError() = %+v, want the error rather than the warning", first)
314 }
315}
316
317func TestClosingAFileForgetsItsDiagnostics(t *testing.T) {
318 language := NewLanguage(testProfile().Server, testProfile().Name)
319 language.receiveDiagnostics("main.go", []lsp.Diagnostic{{Message: "x", Severity: lsp.SeverityError}})
320
321 language.DidClose("main.go")
322
323 if got := len(language.Diagnostics("main.go")); got != 0 {
324 t.Errorf("Diagnostics() returned %d after the file was closed, want none", got)
325 }
326}
327
328func TestAnErrorIsShownOnTheStatusBar(t *testing.T) {
329 a, screen := newTestApp(t)
330 path := "/tmp/turbo-go-test-main.go"
331 a.NewFile()
332 activeBuffer(t, a).SetPath(path)
333 a.Language().receiveDiagnostics(path, []lsp.Diagnostic{
334 {Message: "undefined: foo", Severity: lsp.SeverityError},
335 })
336
337 lines := render(t, a, screen)
338
339 if !strings.Contains(lines[23], "undefined: foo") {
340 t.Errorf("the status bar is %q, want the error on it", lines[23])
341 }
342}
343
344// testScreen returns an 80×24 terminal rectangle for the popup to fit into.
345func testScreen() ui.Rect { return ui.Rect{W: 80, H: 24} }
346
347// keyOf builds a bare key press.
348func keyOf(key tcell.Key) *tcell.EventKey {
349 return tcell.NewEventKey(key, 0, tcell.ModNone)
350}
351
352func TestFilesOpenedBeforeTheServerAreAnnouncedWhenItBecomesReady(t *testing.T) {
353 // The regression this pins: main() opens the files named on the command
354 // line and *then* starts gopls, so DidOpen at that moment reaches nothing.
355 // Without a second announcement the server never learns the file exists,
356 // and every completion comes back empty.
357 a, _ := newTestApp(t)
358 client, server := newFakeLanguage(t, a)
359
360 path := filepath.Join(t.TempDir(), "main.go")
361 writeTestFile(t, path, "package main\n")
362 a.Open(path) // opened while a.language has no client at all
363
364 if got := server.methodCount("textDocument/didOpen"); got != 0 {
365 t.Fatalf("the server saw %d didOpen before it existed, want 0", got)
366 }
367
368 connectLanguage(a, client)
369
370 // No event is delivered here on purpose. The announcement must not depend
371 // on one: tcell's queue is bounded, PostEvent drops what does not fit, and
372 // start-up is exactly when it is fullest.
373 a.announceOpenDocuments()
374
375 waitForMethod(t, server, "textDocument/didOpen")
376}
377
378func TestTheAnnouncementHappensOnceAndOnlyOnce(t *testing.T) {
379 a, _ := newTestApp(t)
380 client, server := newFakeLanguage(t, a)
381
382 path := filepath.Join(t.TempDir(), "main.go")
383 writeTestFile(t, path, "package main\n")
384 a.Open(path)
385 connectLanguage(a, client)
386
387 for range 5 {
388 a.announceOpenDocuments()
389 }
390 waitForMethod(t, server, "textDocument/didOpen")
391
392 if got := server.methodCount("textDocument/didOpen"); got != 1 {
393 t.Errorf("the server saw %d didOpen, want exactly 1", got)
394 }
395}
396
397func TestNothingIsAnnouncedWhileThereIsNoServer(t *testing.T) {
398 a, _ := newTestApp(t)
399 a.NewFile()
400
401 a.announceOpenDocuments() // must not mark the work as done
402
403 if a.announced {
404 t.Error("the editor recorded an announcement it never made")
405 }
406}
407
408func TestAnEmptyCompletionExplainsItselfWhenTheFileDoesNotCompile(t *testing.T) {
409 a, _ := newTestApp(t)
410 path := "/tmp/turbo-go-test-clash.go"
411 a.NewFile()
412 activeBuffer(t, a).SetPath(path)
413 a.Language().receiveDiagnostics(path, []lsp.Diagnostic{
414 {Message: "main redeclared in this block", Severity: lsp.SeverityError},
415 })
416
417 got := a.noCompletionsReason(path)
418
419 if !strings.Contains(got, "main redeclared") {
420 t.Errorf("the message is %q, want it to name the problem the server reported", got)
421 }
422 if got := a.noCompletionsReason("/tmp/some-other-file.go"); got != "No completions here" {
423 t.Errorf("with nothing reported the message is %q", got)
424 }
425}
426
427func TestTheStatusBoxExplainsAFileThatDoesNotCompile(t *testing.T) {
428 a, _ := newTestApp(t)
429 client, _ := newFakeLanguage(t, a)
430 path := filepath.Join(t.TempDir(), "clash.go")
431 writeTestFile(t, path, "package main\n")
432 a.Open(path)
433 connectLanguage(a, client)
434 a.announceOpenDocuments()
435
436 a.Language().receiveDiagnostics(path, []lsp.Diagnostic{
437 {Message: "main redeclared in this block", Severity: lsp.SeverityError},
438 })
439
440 report := strings.Join(a.languageReport(), "\n")
441
442 if !strings.Contains(report, "clash.go") {
443 t.Errorf("the report does not name the file:\n%s", report)
444 }
445 if !strings.Contains(report, "main redeclared") {
446 t.Errorf("the report does not give the reason:\n%s", report)
447 }
448 if !strings.Contains(report, "package to compile") {
449 t.Errorf("the report does not say what completion needs:\n%s", report)
450 }
451}
452
453func TestTheStatusBoxSaysWhenAWindowHasNoFileYet(t *testing.T) {
454 a, _ := newTestApp(t)
455 client, _ := newFakeLanguage(t, a)
456 a.NewFile()
457 connectLanguage(a, client)
458
459 report := strings.Join(a.languageReport(), "\n")
460
461 if !strings.Contains(report, "no file yet") {
462 t.Errorf("the report does not explain an untitled window:\n%s", report)
463 }
464}
465
466func TestAnnouncingSkipsWindowsWithNoFile(t *testing.T) {
467 a, _ := newTestApp(t)
468 client, server := newFakeLanguage(t, a)
469 a.NewFile() // untitled: it has no path to announce
470
471 connectLanguage(a, client)
472 a.announceOpenDocuments()
473
474 if got := server.methodCount("textDocument/didOpen"); got != 0 {
475 t.Errorf("the server saw %d didOpen for an untitled window, want 0", got)
476 }
477}
478
479func TestTheCompletionPopupIsAnchoredWhereTheCursorReallyIs(t *testing.T) {
480 a, screen := newTestApp(t)
481 a.NewFile()
482 typeText(a, "package main")
483 render(t, a, screen)
484
485 // The ground truth is where tcell put the terminal cursor. An anchor that
486 // forgot the gutter, or the horizontal scroll, would not match it.
487 wantX, wantY, visible := screen.GetCursor()
488 if !visible {
489 t.Fatal("the terminal cursor was not placed")
490 }
491
492 x, y := a.activeView().CursorScreenPosition()
493
494 if x != wantX || y != wantY {
495 t.Errorf("the popup anchors at (%d, %d), but the cursor is at (%d, %d)", x, y, wantX, wantY)
496 }
497 if x <= a.activeView().Bounds().X {
498 t.Errorf("the anchor is at column %d, at or left of the view's edge %d — the gutter was not counted",
499 x, a.activeView().Bounds().X)
500 }
501}