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

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

templates_test.go · 507 lines · 17.1 KBGo Blame HistoryRaw
📦 Turbo Go 3d7798b k33g 11h ago1package golang
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "slices"
8 "strings"
9 "testing"
10
11 "rickub.com/turbo-editors/turbo-core/acp"
12 "rickub.com/turbo-editors/turbo-core/settings"
13 "rickub.com/turbo-editors/turbo-core/snippets"
14 "rickub.com/turbo-editors/turbo-core/syntax"
15 "rickub.com/turbo-editors/turbo-core/tools"
16)
17
18// The starter files Turbo Go writes are the one part of a project's .turbo-go
19// directory that is about Go, so this is where what is *in* them is checked.
20// That the file written is the profile's template at all is turbo-core's test.
21
22// noUserSnippets points the user's own snippets at an empty directory, so a
23// test never reads whoever is running it.
24func noUserSnippets(t *testing.T) {
25 t.Helper()
26 t.Setenv(Profile().SnippetDirEnvVar(), t.TempDir())
27}
28
29// loadTools reads a project's tools, failing the test if it cannot.
30func loadTools(t *testing.T, dir string) tools.List {
31 t.Helper()
32
33 list, err := tools.Load(Profile(), dir)
34 if err != nil {
35 t.Fatalf("tools.Load(%q) error = %v", dir, err)
36 }
37 return list
38}
39
40// loadSnippets reads a project's snippets, failing the test if it cannot.
41func loadSnippets(t *testing.T, dir string) snippets.List {
42 t.Helper()
43
44 list, err := snippets.Load(Profile(), dir)
45 if err != nil {
46 t.Fatalf("snippets.Load(%q) error = %v", dir, err)
47 }
48 return list
49}
50
51// readFile returns a file's contents.
52func readFile(t *testing.T, path string) string {
53 t.Helper()
54
55 data, err := os.ReadFile(path)
56 if err != nil {
57 t.Fatalf("reading %s: %v", path, err)
58 }
59 return string(data)
60}
61
62// plain strips the tilde hot-key markers from a label.
63func plain(label string) string { return strings.ReplaceAll(label, "~", "") }
64
65// hotKey returns the character between the tildes, or 0 when there is none.
66func hotKey(label string) rune {
67 first := strings.IndexByte(label, '~')
68 if first < 0 || first+1 >= len(label) {
69 return 0
70 }
71 return rune(label[first+1])
72}
73
74func TestTheCreatedToolsFileHoldsTheGoCommandsAndTheExamplesThatTeachTheFormat(t *testing.T) {
75 // The first five are what a Go project runs before it commits, and they are
76 // the reason the file exists at all. The three after them are there to
77 // teach the format itself — a value the editor asks for, a menu of the
78 // tool's own, an output that is not the default — and a starter file that
79 // only listed the five would leave all three undiscoverable.
80 dir := t.TempDir()
81 if _, err := tools.Create(Profile(), dir); err != nil {
82 t.Fatalf("tools.Create() error = %v", err)
83 }
84
85 byName := map[string]string{}
86 for _, tool := range loadTools(t, dir).Tools() {
87 byName[plain(tool.Name)] = tool.Command
88 }
89
90 want := map[string]string{
91 "Format": "gofmt -l -w .",
92 "Lint": "go vet ./...",
93 "Build": "go build ./...",
94 "Test": "go test ./...",
95 "Run": "go run .",
96 "Grep": "grep -rn {{pattern}} --include='*.go' .",
97 "Init module": "go mod init {{module path}}",
98 "Echo": "echo 🎉 tada!",
99 }
100 for name, command := range want {
101 if got := byName[name]; got != command {
102 t.Errorf("%s runs %q, want %q", name, got, command)
103 }
104 }
105 for name := range byName {
106 if _, ok := want[name]; !ok {
107 t.Errorf("the created file holds a tool this test does not know about: %q", name)
108 }
109 }
110}
111
112func TestTheCreatedToolsCarryHotKeys(t *testing.T) {
113 // Five items in a menu are worth reaching with one keystroke each.
114 dir := t.TempDir()
115 if _, err := tools.Create(Profile(), dir); err != nil {
116 t.Fatalf("tools.Create() error = %v", err)
117 }
118
119 seen := map[rune]string{}
120 for _, tool := range loadTools(t, dir).Tools() {
121 key := hotKey(tool.Name)
122 if key == 0 {
123 t.Errorf("%q has no hot key", tool.Name)
124 continue
125 }
126 if other, clash := seen[key]; clash {
127 t.Errorf("%q and %q both answer to %c", other, tool.Name, key)
128 }
129 seen[key] = tool.Name
130 }
131}
132
133func TestTheCreatedToolsFileDoesNotClaimEverythingRunsInATerminal(t *testing.T) {
134 // The header said so before output existed, and left the file contradicting
135 // itself two lines above the key that says otherwise.
136 dir := t.TempDir()
137 if _, err := tools.Create(Profile(), dir); err != nil {
138 t.Fatalf("tools.Create() error = %v", err)
139 }
140
141 contents := readFile(t, tools.Path(Profile(), dir))
142 if strings.Contains(contents, "runs it in a terminal window of its own") {
143 t.Errorf("the header still claims every tool runs in a terminal:\n%s", contents)
144 }
145}
146
147func TestTheCreatedToolsFileExplainsItself(t *testing.T) {
148 dir := t.TempDir()
149 if _, err := tools.Create(Profile(), dir); err != nil {
150 t.Fatalf("tools.Create() error = %v", err)
151 }
152
153 contents := readFile(t, tools.Path(Profile(), dir))
154 for _, want := range []string{"[[tool]]", "sh -c", "hot key", "popup", "terminal", "editor", "./..."} {
155 if !strings.Contains(contents, want) {
156 t.Errorf("the created file never mentions %q:\n%s", want, contents)
157 }
158 }
159}
160
161func TestTheCreatedToolsFileNamesAnOutputForEveryTool(t *testing.T) {
162 // The key is the interesting part of the format, and a file where it only
163 // appears once is a file where nobody notices it exists.
164 dir := t.TempDir()
165 if _, err := tools.Create(Profile(), dir); err != nil {
166 t.Fatalf("tools.Create() error = %v", err)
167 }
168
169 for _, tool := range loadTools(t, dir).Tools() {
170 if tool.Output == "" {
171 t.Errorf("%q leaves its output to the default rather than saying it", tool.Name)
172 }
173 }
174}
175
176func TestEachToolGoesWhereItsOwnOutputBelongs(t *testing.T) {
177 // All three destinations appear, because a starter file is where somebody
178 // finds out that the key has more than one value. Run and Echo are
179 // terminals: a program that reads the keyboard has to be able to be
180 // answered, and a popup cannot do that. Grep prints a list worth keeping
181 // beside the code and searching with Ctrl-F, which is what an editing
182 // window is for. The rest say something short and are read once.
183 dir := t.TempDir()
184 if _, err := tools.Create(Profile(), dir); err != nil {
185 t.Fatalf("tools.Create() error = %v", err)
186 }
187
188 want := map[string]tools.Output{
189 "Format": tools.OutputPopup,
190 "Lint": tools.OutputPopup,
191 "Build": tools.OutputPopup,
192 "Test": tools.OutputPopup,
193 "Run": tools.OutputTerminal,
194 "Grep": tools.OutputEditor,
195 "Init module": tools.OutputPopup,
196 "Echo": tools.OutputTerminal,
197 }
198 for _, tool := range loadTools(t, dir).Tools() {
199 name := plain(tool.Name)
200 if got := tool.Where(); got != want[name] {
201 t.Errorf("%s goes to %q, want %q", name, got, want[name])
202 }
203 }
204}
205
206func TestTheCreatedToolsFileShowsHowToUseAnotherMenu(t *testing.T) {
207 dir := t.TempDir()
208 if _, err := tools.Create(Profile(), dir); err != nil {
209 t.Fatalf("tools.Create() error = %v", err)
210 }
211
212 contents := readFile(t, tools.Path(Profile(), dir))
213 for _, want := range []string{"menu says which menu", `menu = "Tools"`} {
214 if !strings.Contains(contents, want) {
215 t.Errorf("the created file never shows %q:\n%s", want, contents)
216 }
217 }
218}
219
220func TestTheCreatedSnippetsFilesTabsSurviveTOML(t *testing.T) {
221 // A Go snippet is indented with tabs, and every step between the template
222 // and the editor is somewhere one can be lost: the multi-line TOML string,
223 // the decoder, and the editor's own re-indentation on insertion. Asserting
224 // on a tab the body is known to contain is what catches that.
225 noUserSnippets(t)
226 dir := t.TempDir()
227 if _, err := snippets.Create(Profile(), dir); err != nil {
228 t.Fatalf("snippets.Create() error = %v", err)
229 }
230
231 for _, group := range loadSnippets(t, dir).Groups("go") {
232 for _, snippet := range group.Snippets {
233 if snippet.Name != "main" {
234 continue
235 }
236 if !strings.Contains(snippet.Body, "\tfmt.Println") {
237 t.Errorf("the body is %q; the tab did not survive", snippet.Body)
238 }
239 return
240 }
241 }
242 t.Fatal("the created file has no \"main\" snippet")
243}
244
245func TestTheCreatedSnippetsFileExplainsItself(t *testing.T) {
246 noUserSnippets(t)
247 dir := t.TempDir()
248 if _, err := snippets.Create(Profile(), dir); err != nil {
249 t.Fatalf("snippets.Create() error = %v", err)
250 }
251
252 contents := readFile(t, snippets.ProjectPath(Profile(), dir))
253 for _, want := range []string{"[[snippet]]", "languages", "group", "General", snippets.UserPath(Profile())} {
254 if !strings.Contains(contents, want) {
255 t.Errorf("the created file never mentions %q:\n%s", want, contents)
256 }
257 }
258}
259
260func TestTheCreatedSettingsFileExplainsItself(t *testing.T) {
261 project := t.TempDir()
262 if _, err := settings.Create(Profile(), project, "turbo-classic"); err != nil {
263 t.Fatalf("settings.Create() error = %v", err)
264 }
265
266 contents := readFile(t, settings.Path(Profile(), project))
267 for _, want := range []string{"-list-themes", "autosave_delay", "-theme flag"} {
268 if !strings.Contains(contents, want) {
269 t.Errorf("the created file never mentions %q:\n%s", want, contents)
270 }
271 }
272}
273
274func TestTheCreatedToolsFileExplainsHowToAskForAValue(t *testing.T) {
275 // A parameterised tool is only discoverable if the file people get says the
276 // syntax exists. The double-brace warning is here too, because somebody
277 // reading this file may well have an awk one-liner in mind.
278 dir := t.TempDir()
279 if _, err := tools.Create(Profile(), dir); err != nil {
280 t.Fatalf("tools.Create() error = %v", err)
281 }
282
283 contents := readFile(t, tools.Path(Profile(), dir))
284 for _, want := range []string{
285 "{{label}}",
286 "go mod init {{module path}}",
287 "{{extra flags...}}",
288 "Double braces, not single",
289 } {
290 if !strings.Contains(contents, want) {
291 t.Errorf("the created file never mentions %q:\n%s", want, contents)
292 }
293 }
294}
295
296func TestTheCreatedToolsFileStillLoadsWithItsPlaceholderExamples(t *testing.T) {
297 // Two of the starter tools ask for a value, on purpose: a syntax explained
298 // only in a comment is a syntax nobody tries. What they ask for is checked
299 // here rather than left to the prose, because the braces also appear in the
300 // file's *comments* — `{{label}}`, `{{extra flags...}}`, and an awk
301 // one-liner warning against single ones — and a loader that read those as
302 // tools would ask for something nobody wrote a command for.
303 dir := t.TempDir()
304 if _, err := tools.Create(Profile(), dir); err != nil {
305 t.Fatalf("tools.Create() error = %v", err)
306 }
307
308 want := map[string][]tools.Placeholder{
309 "Grep": {{Label: "pattern"}},
310 "Init module": {{Label: "module path"}},
311 }
312 for _, tool := range loadTools(t, dir).Tools() {
313 name := plain(tool.Name)
314 got := tool.Placeholders()
315 if !slices.Equal(got, want[name]) {
316 t.Errorf("%q asks for %v, want %v", name, got, want[name])
317 }
318 }
319}
320
321func TestTheCreatedSnippetsFileListsEveryLanguageTheEditorKnows(t *testing.T) {
322 // The comment is where a user finds out what they may write in a languages
323 // key. One that omits a language the editor colours sends them looking for
324 // a feature that is already there.
325 noUserSnippets(t)
326 dir := t.TempDir()
327 if _, err := snippets.Create(Profile(), dir); err != nil {
328 t.Fatalf("snippets.Create() error = %v", err)
329 }
330
331 contents := readFile(t, snippets.ProjectPath(Profile(), dir))
332 for _, language := range syntax.Registered() {
333 if !strings.Contains(contents, string(language)) {
334 t.Errorf("the created file never mentions the %q language:\n%s", language, contents)
335 }
336 }
337}
338
339func TestTheCreatedSettingsFileTurnsAutosaveOn(t *testing.T) {
340 // A project that has gone to the trouble of creating a settings file has
341 // said what it wants. The file is the visible, editable place to say
342 // otherwise, which is why the default lives here and not in the library.
343 project := t.TempDir()
344 if _, err := settings.Create(Profile(), project, "turbo-classic"); err != nil {
345 t.Fatalf("settings.Create() error = %v", err)
346 }
347
348 loaded, err := settings.Load(Profile(), project)
349 if err != nil {
350 t.Fatalf("settings.Load() error = %v", err)
351 }
352 if !loaded.Autosave {
353 t.Errorf("the created settings file leaves autosave off:\n%s", readFile(t, settings.Path(Profile(), project)))
354 }
355 if loaded.AutosaveDelay != settings.DefaultAutosaveDelay {
356 t.Errorf("AutosaveDelay = %v, want the library default %v", loaded.AutosaveDelay, settings.DefaultAutosaveDelay)
357 }
358}
359
360func TestAProjectWithNoSettingsFileStillDoesNotAutosave(t *testing.T) {
361 // The other half of the decision. Turning autosave on for a project that
362 // never opted in would mean the editor writing to disk in any directory it
363 // is started in, which is a different and much larger claim.
364 if settings.Default().Autosave {
365 t.Error("settings.Default() autosaves; a project with no settings file never opted in")
366 }
367}
368
369// The three embedded templates and the blanks profile.Templates says each one
370// takes. Kept together so that adding a verb to a .tmpl file without saying so
371// here fails, which is the guard the constants used to get for free by sitting
372// next to the contract.
373var embeddedTemplates = []struct {
374 name string
375 body string
376 verb string
377 blanks int
378 filledBy []any
379}{
380 {"settings.toml.tmpl", settingsTemplate, "%q", 2, []any{"turbo-classic", "2s"}},
381 {"snippets.toml.tmpl", snippetsTemplate, "%s", 2, []any{"General", "/tmp/snippets.toml"}},
382 {"tools.toml.tmpl", toolsTemplate, "%", 0, nil},
383}
384
385func TestEveryTemplateIsEmbeddedAndNotEmpty(t *testing.T) {
386 // go:embed fails to compile when a file is missing, but an empty file
387 // compiles happily and writes an empty starter file into somebody's
388 // project.
389 for _, template := range embeddedTemplates {
390 if len(template.body) == 0 {
391 t.Errorf("%s embedded as nothing", template.name)
392 }
393 }
394}
395
396func TestEveryTemplateTakesTheBlanksItsContractPromises(t *testing.T) {
397 // profile.Templates documents the count and the verb of each. The
398 // templates now live in files of their own, so nothing but this notices a
399 // verb added, removed, or changed.
400 for _, template := range embeddedTemplates {
401 if got := strings.Count(template.body, template.verb); got != template.blanks {
402 t.Errorf("%s holds %d %q, want %d", template.name, got, template.verb, template.blanks)
403 }
404 }
405}
406
407func TestFillingATemplateLeavesNoFormattingMarker(t *testing.T) {
408 // Go writes %!q(MISSING) or %!(EXTRA …) into the output rather than
409 // failing, so a template with the wrong number of blanks produces a file
410 // that is written, opened, and wrong.
411 for _, template := range embeddedTemplates {
412 filled := template.body
413 if template.filledBy != nil {
414 filled = fmt.Sprintf(template.body, template.filledBy...)
415 }
416 if strings.Contains(filled, "%!") {
417 t.Errorf("%s filled to:\n%s", template.name, filled)
418 }
419 }
420}
421
422func TestTheCreatedAgentsFileFillsBothOfItsBlanks(t *testing.T) {
423 // The template takes two different values — the project directory, which
424 // the example agent's arguments point into, and the user's own file, which
425 // a comment names. Go writes %!s(MISSING) into the output rather than
426 // failing, so a miscounted verb produces a starter file that is written,
427 // opened, and wrong.
428 dir := t.TempDir()
429 if _, err := acp.Create(Profile(), dir); err != nil {
430 t.Fatalf("acp.Create() error = %v", err)
431 }
432
433 contents := readFile(t, acp.ProjectPath(Profile(), dir))
434 if strings.Contains(contents, "%!") {
435 t.Errorf("the created file has an unfilled verb in it:\n%s", contents)
436 }
437 if want := Profile().ProjectDir() + "/agent.yaml"; !strings.Contains(contents, want) {
438 t.Errorf("the example agent does not point at %q:\n%s", want, contents)
439 }
440 if want := acp.UserPath(Profile()); want != "" && !strings.Contains(contents, want) {
441 t.Errorf("the created file never names the user's own file %q:\n%s", want, contents)
442 }
443}
444
445func TestTheCreatedAgentsFileLoadsBackAsOneAgent(t *testing.T) {
446 // The file is mostly comments, and a comment carrying a [[agent]] example
447 // that the loader read as real would put an agent nobody configured into
448 // the menu.
449 dir := t.TempDir()
450 if _, err := acp.Create(Profile(), dir); err != nil {
451 t.Fatalf("acp.Create() error = %v", err)
452 }
453
454 list, err := acp.Load(Profile(), dir)
455 if err != nil {
456 t.Fatalf("acp.Load() error = %v", err)
457 }
458 if list.Len() != 1 {
459 t.Fatalf("the created file holds %d agents, want 1: %v", list.Len(), list.Agents())
460 }
461
462 agent := list.Agents()[0]
463 if agent.Command != "docker" {
464 t.Errorf("the example agent runs %q, want docker", agent.Command)
465 }
466 if want := "agent serve acp"; !strings.Contains(agent.CommandLine(), want) {
467 t.Errorf("the example command line is %q, want %q in it", agent.CommandLine(), want)
468 }
469}
470
471func TestTheCreatedAgentsFileExplainsItself(t *testing.T) {
472 // The keys and the window's keyboard are both invisible otherwise: this is
473 // the only document a user is handed by the editor itself.
474 dir := t.TempDir()
475 if _, err := acp.Create(Profile(), dir); err != nil {
476 t.Fatalf("acp.Create() error = %v", err)
477 }
478
479 contents := readFile(t, acp.ProjectPath(Profile(), dir))
480 for _, want := range []string{
481 "[[agent]]", "name", "command", "args", "env", "cwd",
482 "agentclientprotocol.com",
483 "Alt-Enter", "Ctrl-W", "Esc",
484 } {
485 if !strings.Contains(contents, want) {
486 t.Errorf("the created file never mentions %q:\n%s", want, contents)
487 }
488 }
489}
490
491func TestCreatingAgentsTwiceLeavesTheFirstAlone(t *testing.T) {
492 dir := t.TempDir()
493 path, err := acp.Create(Profile(), dir)
494 if err != nil {
495 t.Fatalf("acp.Create() error = %v", err)
496 }
497 if err := os.WriteFile(path, []byte("# mine\n"), 0o644); err != nil {
498 t.Fatalf("writing over it: %v", err)
499 }
500
501 if _, err := acp.Create(Profile(), dir); !errors.Is(err, acp.ErrExists) {
502 t.Errorf("acp.Create() error = %v, want ErrExists", err)
503 }
504 if got := readFile(t, path); got != "# mine\n" {
505 t.Errorf("the file was overwritten: %q", got)
506 }
507}