| 🛟 Updated. 28d5985 k33g 4h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/gdamore/tcell/v2" |
| 9 | |
| 10 | "codeberg.org/turbo-editors/turbo-core/lsp" |
| 11 | "codeberg.org/turbo-editors/turbo-core/ui" |
| 12 | ) |
| 13 | |
| 14 | // sampleItems is a completion list shaped like one gopls returns for "fmt.". |
| 15 | func 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 | |
| 25 | func 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 | |
| 38 | func 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 | |
| 48 | func 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 | |
| 58 | func 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 | |
| 68 | func 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 | |
| 88 | func 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 | |
| 98 | func 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 | |
| 115 | func 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 | |
| 132 | func 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 | |
| 145 | func 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 | |
| 161 | func 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 | |
| 170 | func 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 | |
| 181 | func 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 | |
| 195 | func 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 | |
| 205 | func 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 | |
| 219 | func 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 | |
| 230 | func 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 | |
| 248 | func 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 | |
| 260 | func 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 | |
| 274 | func 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 | |
| 296 | func 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 | |
| 317 | func 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 | |
| 328 | func 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. |
| 345 | func testScreen() ui.Rect { return ui.Rect{W: 80, H: 24} } |
| 346 | |
| 347 | // keyOf builds a bare key press. |
| 348 | func keyOf(key tcell.Key) *tcell.EventKey { |
| 349 | return tcell.NewEventKey(key, 0, tcell.ModNone) |
| 350 | } |
| 351 | |
| 352 | func 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 | |
| 378 | func 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 | |
| 397 | func 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 | |
| 408 | func 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 | |
| 427 | func 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 | |
| 453 | func 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 | |
| 466 | func 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 | |
| 479 | func 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 | } |