turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
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.

code_test.go · 542 lines · 17.5 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 20h ago1package app
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/gdamore/tcell/v2"
10
11 "codeberg.org/turbo-editors/turbo-core/buffer"
12 "codeberg.org/turbo-editors/turbo-core/editor"
13 "codeberg.org/turbo-editors/turbo-core/lsp"
14 "codeberg.org/turbo-editors/turbo-core/ui"
15)
16
17// newCodeApp returns an editor with a file open, a fake language server
18// attached, and a second file on disk for a location to point into.
19func newCodeApp(t *testing.T) (*App, *fakeLSP, string) {
20 t.Helper()
21
22 project := t.TempDir()
23 t.Chdir(project)
24 t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
25
26 a, _ := newTestApp(t)
27 client, server := newFakeLanguage(t, a)
28 connectLanguage(a, client)
29
30 writeTestFile(t, filepath.Join(project, "main.go"), "package main\n\nfunc main() {}\n")
31 writeTestFile(t, filepath.Join(project, "other.go"), "package main\n\n// here it is\nfunc Other() {}\n")
32 a.Open(filepath.Join(project, "main.go"))
33
34 return a, server, project
35}
36
37// locationIn builds a location pointing at a line of a file in the project.
38func locationIn(project, name string, line int) lsp.Location {
39 return lsp.Location{
40 URI: lsp.PathToURI(filepath.Join(project, name)),
41 Range: lsp.Range{Start: lsp.Position{Line: line}},
42 }
43}
44
45// codeQuestions are the four requests that answer with places in the code: the
46// method each sends, and how to ask it of an app.
47var codeQuestions = []struct {
48 method string
49 ask func(*App)
50 absent string
51}{
52 {"textDocument/definition", (*App).GoToDefinition, "No definition found"},
53 {"textDocument/typeDefinition", (*App).GoToTypeDefinition, "No type definition found"},
54 {"textDocument/implementation", (*App).FindImplementations, "No implementations found"},
55 {"textDocument/references", (*App).FindReferences, "No references found"},
56}
57
58func TestOneResultJumpsStraightThere(t *testing.T) {
59 // A list of one is a dialog nobody wants: it asks a question with one
60 // answer already in it.
61 for _, question := range codeQuestions {
62 t.Run(question.method, func(t *testing.T) {
63 a, server, project := newCodeApp(t)
64 server.setAnswer(t, question.method, []lsp.Location{locationIn(project, "other.go", 3)})
65
66 question.ask(a)
67
68 if a.Modals() != 0 {
69 t.Errorf("a single result opened a dialog")
70 }
71 if got := activeBuffer(t, a).Path(); filepath.Base(got) != "other.go" {
72 t.Errorf("the front window holds %q, want other.go", got)
73 }
74 if got := activeBuffer(t, a).Cursor().Line; got != 3 {
75 t.Errorf("the cursor is on line %d, want 3", got)
76 }
77 })
78 }
79}
80
81func TestSeveralResultsOfferTheChoice(t *testing.T) {
82 // The defect this whole step exists for: GoToDefinition took locations[0]
83 // and threw the rest away, so an interface with four implementations sent
84 // you to one of them, chosen by the server's ordering.
85 for _, question := range codeQuestions {
86 t.Run(question.method, func(t *testing.T) {
87 a, server, project := newCodeApp(t)
88 server.setAnswer(t, question.method, []lsp.Location{
89 locationIn(project, "other.go", 3),
90 locationIn(project, "main.go", 2),
91 })
92
93 question.ask(a)
94
95 if a.Modals() != 1 {
96 t.Fatalf("Modals() = %d, want the list of places", a.Modals())
97 }
98 })
99 }
100}
101
102func TestChoosingAPlaceGoesToIt(t *testing.T) {
103 a, server, project := newCodeApp(t)
104 server.setAnswer(t, "textDocument/references", []lsp.Location{
105 locationIn(project, "main.go", 2),
106 locationIn(project, "other.go", 3),
107 })
108
109 a.FindReferences()
110 press(a, tcell.KeyDown, 0, tcell.ModNone)
111 press(a, tcell.KeyEnter, 0, tcell.ModNone)
112
113 if got := activeBuffer(t, a).Path(); filepath.Base(got) != "other.go" {
114 t.Errorf("the front window holds %q, want the second entry's file", got)
115 }
116 if got := activeBuffer(t, a).Cursor().Line; got != 3 {
117 t.Errorf("the cursor is on line %d, want 3", got)
118 }
119}
120
121func TestNoResultsSaysSoInTheQuestionsOwnWords(t *testing.T) {
122 // "Nothing found" for every question would leave a user unsure which
123 // question was even asked.
124 for _, question := range codeQuestions {
125 t.Run(question.method, func(t *testing.T) {
126 a, _, _ := newCodeApp(t)
127
128 question.ask(a)
129
130 if got := a.StatusBar().Message(); got != question.absent {
131 t.Errorf("the status bar says %q, want %q", got, question.absent)
132 }
133 })
134 }
135}
136
137func TestAServerThatIsNotReadySaysThatRatherThanNothingFound(t *testing.T) {
138 // The most confusing way completion fails, inherited here for free if
139 // nobody tells the two apart: a server still indexing answers nothing, and
140 // "no references" is a lie that makes a user stop looking.
141 a, _ := newTestApp(t)
142 writeTestFile(t, filepath.Join(t.TempDir(), "main.go"), "package main\n")
143
144 a.NewFile()
145 a.FindReferences()
146
147 if got := a.StatusBar().Message(); strings.Contains(got, "No references found") {
148 t.Errorf("the status bar says %q; the server was never connected", got)
149 }
150}
151
152func TestTheListShowsTheLineSoTheEntriesCanBeToldApart(t *testing.T) {
153 // Twelve entries reading "handler.go:42" say nothing about which one
154 // anybody wants.
155 a, _, project := newCodeApp(t)
156
157 labels := a.locationLabels([]lsp.Location{locationIn(project, "other.go", 2)})
158
159 if len(labels) != 1 {
160 t.Fatalf("got %d labels", len(labels))
161 }
162 if !strings.Contains(labels[0], "other.go:3") {
163 t.Errorf("label = %q, want the file and the one-based line", labels[0])
164 }
165 if !strings.Contains(labels[0], "here it is") {
166 t.Errorf("label = %q, want the text of the line", labels[0])
167 }
168}
169
170func TestTheListPrefersAnOpenWindowOverTheDisk(t *testing.T) {
171 // A file edited and not saved would otherwise be listed with text it no
172 // longer has, beside line numbers that follow the edits.
173 a, _, project := newCodeApp(t)
174 activeBuffer(t, a).SetText("package main\n\nfunc changed() {}\n")
175
176 labels := a.locationLabels([]lsp.Location{locationIn(project, "main.go", 2)})
177
178 if !strings.Contains(labels[0], "func changed()") {
179 t.Errorf("label = %q, want the unsaved text from the window", labels[0])
180 }
181}
182
183func TestAFileThatCannotBeReadStillGetsALine(t *testing.T) {
184 // A dialog that refuses to open because one of forty files moved is worse
185 // than one with a bare line number in it.
186 a, _, project := newCodeApp(t)
187 gone := filepath.Join(project, "gone.go")
188 if err := os.Remove(filepath.Join(project, "other.go")); err != nil {
189 t.Fatalf("cannot remove the file: %v", err)
190 }
191
192 labels := a.locationLabels([]lsp.Location{
193 {URI: lsp.PathToURI(gone), Range: lsp.Range{Start: lsp.Position{Line: 7}}},
194 locationIn(project, "other.go", 1),
195 })
196
197 if len(labels) != 2 {
198 t.Fatalf("got %d labels, want one per location", len(labels))
199 }
200 if !strings.Contains(labels[0], "gone.go:8") {
201 t.Errorf("label = %q, want the file and line even with nothing to read", labels[0])
202 }
203}
204
205// menuLabelsOf returns the plain labels of one menu on the bar.
206func menuLabelsOf(t *testing.T, a *App, name string) []string {
207 t.Helper()
208
209 for _, menu := range a.menu.Menus() {
210 if ui.PlainLabel(menu.Label) == name {
211 if menu.OnOpen != nil {
212 menu.OnOpen()
213 }
214 return labels(menu.Items)
215 }
216 }
217 t.Fatalf("there is no %q menu on the bar; the bar is %v", name, barLabels(a))
218 return nil
219}
220
221func TestTheCodeMenuHoldsEverythingAskedOfTheServer(t *testing.T) {
222 a, _ := newTestApp(t)
223
224 got := menuLabelsOf(t, a, "Code")
225
226 for _, want := range []string{
227 "Describe symbol", "Go to definition", "Go to type definition",
228 "Find implementations…", "Find references…",
229 "Symbol in file…", "Symbol in project…", "Problems…",
230 } {
231 if !containsString(got, want) {
232 t.Errorf("the Code menu has no %q: %v", want, got)
233 }
234 }
235}
236
237func TestTheTwoMovedItemsLeftTheMenusTheyWereIn(t *testing.T) {
238 // Listed twice is worse than moved: two paths to one action, and a reader
239 // who finds one of them stops looking for the menu that has the rest.
240 a, _ := newTestApp(t)
241
242 if got := menuLabelsOf(t, a, "Run"); containsString(got, "Describe symbol") {
243 t.Errorf("Describe symbol is still in Run: %v", got)
244 }
245 if got := menuLabelsOf(t, a, "Search"); containsString(got, "Go to definition") {
246 t.Errorf("Go to definition is still in Search: %v", got)
247 }
248}
249
250func TestTheMovedItemsKeptTheirKeys(t *testing.T) {
251 // Where an item is listed may change; what a user's fingers do may not.
252 a, _ := newTestApp(t)
253 items := menuLabelsOf(t, a, "Code")
254 _ = items
255
256 shortcuts := map[string]string{}
257 for _, menu := range a.menu.Menus() {
258 if ui.PlainLabel(menu.Label) != "Code" {
259 continue
260 }
261 for _, item := range menu.Items {
262 shortcuts[ui.PlainLabel(item.Label)] = item.Shortcut
263 }
264 }
265
266 for label, want := range map[string]string{
267 "Describe symbol": "F1",
268 "Go to definition": "F12",
269 "Find references…": "Shift-F12",
270 "Symbol in project…": "Ctrl-T",
271 "Symbol in file…": "",
272 } {
273 if shortcuts[label] != want {
274 t.Errorf("%q shows %q, want %q", label, shortcuts[label], want)
275 }
276 }
277}
278
279func TestEveryCodeItemNeedsAFileExceptTheProjectWideOnes(t *testing.T) {
280 // Asking about the symbol under the cursor with no cursor is not a
281 // question. Searching the project, and listing its problems, are.
282 a, _ := newTestApp(t)
283
284 needsFile := map[string]bool{
285 "Describe symbol": true, "Go to definition": true, "Go to type definition": true,
286 "Find implementations…": true, "Find references…": true, "Symbol in file…": true,
287 "Symbol in project…": false, "Problems…": false,
288 }
289
290 for _, menu := range a.menu.Menus() {
291 if ui.PlainLabel(menu.Label) != "Code" {
292 continue
293 }
294 for _, item := range menu.Items {
295 if item.Separator {
296 continue
297 }
298 label := ui.PlainLabel(item.Label)
299 available := item.Enabled == nil || item.Enabled()
300 if want, known := needsFile[label]; known && available == want {
301 t.Errorf("%q is available=%v with no window open, want %v", label, available, !want)
302 }
303 }
304 }
305}
306
307func TestSymbolInFileListsWhatTheFileDeclares(t *testing.T) {
308 a, server, _ := newCodeApp(t)
309 server.setAnswer(t, "textDocument/documentSymbol", []map[string]any{{
310 "name": "main", "kind": 12,
311 "range": map[string]any{"start": map[string]int{"line": 2, "character": 0}},
312 "selectionRange": map[string]any{"start": map[string]int{"line": 2, "character": 5}},
313 }})
314
315 a.SymbolInFile()
316
317 if a.Modals() != 1 {
318 t.Fatalf("Modals() = %d, want the list of symbols", a.Modals())
319 }
320}
321
322func TestASingleSymbolStillGetsTheList(t *testing.T) {
323 // Unlike a single definition: one match for a name is an answer worth
324 // reading — it says which thing has that name — where a single definition
325 // is somewhere you simply wanted to be taken.
326 a, server, project := newCodeApp(t)
327 server.setAnswer(t, "workspace/symbol", []map[string]any{{
328 "name": "Other", "kind": 12, "containerName": "main",
329 "location": map[string]any{"uri": lsp.PathToURI(filepath.Join(project, "other.go"))},
330 }})
331
332 a.SymbolInProject()
333 typeInto(a, "Other")
334 press(a, tcell.KeyEnter, 0, tcell.ModNone)
335
336 if a.Modals() != 1 {
337 t.Fatalf("Modals() = %d, want the list of matches", a.Modals())
338 }
339}
340
341func TestAnEmptySymbolQueryAsksForNothing(t *testing.T) {
342 // Some servers answer an empty query with the whole project and some with
343 // nothing. Neither is what was meant.
344 a, server, _ := newCodeApp(t)
345
346 a.SymbolInProject()
347 press(a, tcell.KeyEnter, 0, tcell.ModNone)
348
349 if got := server.methodCount("workspace/symbol"); got != 0 {
350 t.Errorf("the editor asked %d times with an empty query", got)
351 }
352 if got := a.StatusBar().Message(); got != "Nothing to look for" {
353 t.Errorf("the status bar says %q", got)
354 }
355}
356
357func TestProblemsListsEveryFileTheServerSpokeAbout(t *testing.T) {
358 // The file with the error is very often not the file being edited, which
359 // is exactly when a list is worth having.
360 a, _, project := newCodeApp(t)
361 a.language.receiveDiagnostics(filepath.Join(project, "other.go"), []lsp.Diagnostic{
362 {Message: "undefined: x", Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 3}}},
363 })
364 a.language.receiveDiagnostics(filepath.Join(project, "main.go"), []lsp.Diagnostic{
365 {Message: "unused import", Severity: lsp.SeverityWarning, Range: lsp.Range{Start: lsp.Position{Line: 1}}},
366 })
367
368 problems := a.language.AllDiagnostics()
369
370 if len(problems) != 2 {
371 t.Fatalf("got %d problems, want one per file", len(problems))
372 }
373 if filepath.Base(problems[0].Path) != "main.go" {
374 t.Errorf("the list starts with %q, want it sorted by file", problems[0].Path)
375 }
376}
377
378func TestProblemsWithNothingToSaySaysWhichNothing(t *testing.T) {
379 // "No problems" and "the server has not looked yet" are the same empty
380 // list and very different news.
381 a, _ := newTestApp(t)
382
383 a.ShowProblems()
384
385 if got := a.StatusBar().Message(); got == "No problems reported" {
386 t.Errorf("the status bar says %q with no server connected", got)
387 }
388}
389
390func TestAProblemLabelKeepsItsWholeMessage(t *testing.T) {
391 // A borrow-checker error runs to a paragraph, and the part that names the
392 // variable is not the first part.
393 long := "cannot borrow `config` as mutable more than once at a time\nsecond mutable borrow occurs here"
394 label := problemLabel(FileDiagnostic{
395 Path: "/tmp/p/main.rs",
396 Diagnostic: lsp.Diagnostic{Message: long, Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 9}}},
397 })
398
399 if !strings.Contains(label, "second mutable borrow occurs here") {
400 t.Errorf("label = %q, want the whole message", label)
401 }
402 if strings.Contains(label, "\n") {
403 t.Errorf("label = %q, want it on one line", label)
404 }
405 if !strings.Contains(label, "main.rs:10") {
406 t.Errorf("label = %q, want the file and the one-based line", label)
407 }
408}
409
410func TestTheCodeKeysReachTheirActions(t *testing.T) {
411 // A menu that shows a key which does nothing is a menu that lies. F1 and
412 // F12 were already wired; Shift-F12 and Ctrl-T are new, and Shift-F12 sits
413 // in front of F12 in the same switch — the order matters.
414 a, server, project := newCodeApp(t)
415 server.setAnswer(t, "textDocument/references", []lsp.Location{
416 locationIn(project, "other.go", 3),
417 locationIn(project, "main.go", 1),
418 })
419
420 press(a, tcell.KeyF12, 0, tcell.ModShift)
421
422 if a.Modals() != 1 {
423 t.Fatalf("Shift-F12 opened %d modals, want the references list", a.Modals())
424 }
425 press(a, tcell.KeyEscape, 0, tcell.ModNone)
426
427 press(a, tcell.KeyCtrlT, 0, tcell.ModNone)
428 if a.Modals() != 1 {
429 t.Errorf("Ctrl-T opened %d modals, want the symbol prompt", a.Modals())
430 }
431}
432
433func TestShiftF12DoesNotAlsoGoToTheDefinition(t *testing.T) {
434 // The two cases share a key and are told apart by the modifier alone. With
435 // them the other way round, Shift-F12 would jump instead of listing and
436 // nothing would look broken.
437 a, server, project := newCodeApp(t)
438 server.setAnswer(t, "textDocument/definition", []lsp.Location{locationIn(project, "other.go", 3)})
439 server.setAnswer(t, "textDocument/references", []lsp.Location{})
440
441 press(a, tcell.KeyF12, 0, tcell.ModShift)
442
443 if got := filepath.Base(activeBuffer(t, a).Path()); got != "main.go" {
444 t.Errorf("Shift-F12 moved to %q; it asked for the definition", got)
445 }
446 if got := a.StatusBar().Message(); got != "No references found" {
447 t.Errorf("the status bar says %q, want the references answer", got)
448 }
449}
450
451func TestALineWithSeveralProblemsIsMarkedWithItsWorst(t *testing.T) {
452 // The gutter has one column, and a line that is both an error and a hint
453 // is a line you want to know is an error.
454 marks := marksFor([]lsp.Diagnostic{
455 {Severity: lsp.SeverityHint, Range: lsp.Range{Start: lsp.Position{Line: 4}}},
456 {Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 4}}},
457 {Severity: lsp.SeverityWarning, Range: lsp.Range{Start: lsp.Position{Line: 4}}},
458 })
459
460 if got := marks[4]; got != editor.MarkError {
461 t.Errorf("line 5 is marked %v, want the error", got)
462 }
463}
464
465func TestADiagnosticWithNoSeverityIsAnError(t *testing.T) {
466 // The specification leaves it to the client, and a problem nobody graded
467 // is not one to draw quietly.
468 marks := marksFor([]lsp.Diagnostic{{Range: lsp.Range{Start: lsp.Position{Line: 0}}}})
469
470 if got := marks[0]; got != editor.MarkError {
471 t.Errorf("an ungraded diagnostic is marked %v, want the error", got)
472 }
473}
474
475func TestAFileWithNoProblemsHasNoMarks(t *testing.T) {
476 if got := marksFor(nil); got != nil {
477 t.Errorf("marksFor(nil) = %v, want no map at all", got)
478 }
479}
480
481func TestTheMarksFollowTheDiagnosticsToTheWindow(t *testing.T) {
482 // The wiring: diagnostics arrive off the read loop, and the window in
483 // front has to end up showing them without anyone asking it to.
484 a, _, project := newCodeApp(t)
485 a.language.receiveDiagnostics(filepath.Join(project, "main.go"), []lsp.Diagnostic{
486 {Message: "undefined: x", Severity: lsp.SeverityError, Range: lsp.Range{Start: lsp.Position{Line: 2}}},
487 })
488
489 a.Tick()
490
491 marks := a.ActiveView().Marks()
492 if got := marks[2]; got != editor.MarkError {
493 t.Errorf("line 3 of the front window is marked %v, want the error the server reported", got)
494 }
495}
496
497func TestTheEditMenuHoldsTheLineCommands(t *testing.T) {
498 a, _ := newTestApp(t)
499
500 shortcuts := map[string]string{}
501 for _, menu := range a.menu.Menus() {
502 if ui.PlainLabel(menu.Label) != "Edit" {
503 continue
504 }
505 for _, item := range menu.Items {
506 shortcuts[ui.PlainLabel(item.Label)] = item.Shortcut
507 }
508 }
509
510 for label, want := range map[string]string{
511 "Insert line": "Ctrl-N",
512 "Delete line": "Ctrl-Y",
513 "Redo": "Ctrl-R",
514 "Undo": "Ctrl-Z",
515 } {
516 got, listed := shortcuts[label]
517 if !listed {
518 t.Errorf("the Edit menu has no %q", label)
519 continue
520 }
521 if got != want {
522 t.Errorf("%q shows %q, want %q", label, got, want)
523 }
524 }
525}
526
527func TestTheLineCommandsReachTheBuffer(t *testing.T) {
528 a, _ := newTestApp(t)
529 a.NewFile()
530 activeBuffer(t, a).SetText("one\ntwo\nthree\n")
531 activeBuffer(t, a).SetCursor(buffer.Position{Line: 1})
532
533 a.InsertLine()
534 if got := activeBuffer(t, a).Text(); got != "one\n\ntwo\nthree\n" {
535 t.Fatalf("after InsertLine Text() = %q", got)
536 }
537
538 a.DeleteLine()
539 if got := activeBuffer(t, a).Text(); got != "one\n\nthree\n" {
540 t.Errorf("after DeleteLine Text() = %q", got)
541 }
542}