turbo-editors/turbo-pythonpublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-python.git
git clone ssh://git@rickub.com/turbo-editors/turbo-python.git

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

templates_test.go · 483 lines · 16.4 KBGo Blame HistoryRaw
📦 Turbo Python 6fc62ea k33g 9h ago1package pythonlang
2
3import (
4 "fmt"
5 "os"
6 "strings"
7 "testing"
8
9 "rickub.com/turbo-editors/turbo-core/settings"
10 "rickub.com/turbo-editors/turbo-core/snippets"
11 "rickub.com/turbo-editors/turbo-core/syntax"
12 "rickub.com/turbo-editors/turbo-core/tools"
13)
14
15// The starter files Turbo Python writes are the one part of a project's
16// .turbo-python directory that is about Python, so this is where what is *in*
17// them is checked. That the file written is the profile's template at all is
18// turbo-core's test.
19
20// noUserSnippets points the user's own snippets at an empty directory, so a
21// test never reads whoever is running it.
22func noUserSnippets(t *testing.T) {
23 t.Helper()
24 t.Setenv(Profile().SnippetDirEnvVar(), t.TempDir())
25}
26
27// createTools writes a project's tools file and returns the project directory.
28func createTools(t *testing.T) string {
29 t.Helper()
30
31 dir := t.TempDir()
32 if _, err := tools.Create(Profile(), dir); err != nil {
33 t.Fatalf("tools.Create() error = %v", err)
34 }
35 return dir
36}
37
38// createSnippets writes a project's snippets file and returns the directory.
39func createSnippets(t *testing.T) string {
40 t.Helper()
41 noUserSnippets(t)
42
43 dir := t.TempDir()
44 if _, err := snippets.Create(Profile(), dir); err != nil {
45 t.Fatalf("snippets.Create() error = %v", err)
46 }
47 return dir
48}
49
50// createSettings writes a project's settings file and returns the directory.
51func createSettings(t *testing.T) string {
52 t.Helper()
53
54 dir := t.TempDir()
55 if _, err := settings.Create(Profile(), dir, "turbo-classic"); err != nil {
56 t.Fatalf("settings.Create() error = %v", err)
57 }
58 return dir
59}
60
61// loadTools reads a project's tools, failing the test if it cannot.
62func loadTools(t *testing.T, dir string) tools.List {
63 t.Helper()
64
65 list, err := tools.Load(Profile(), dir)
66 if err != nil {
67 t.Fatalf("tools.Load(%q) error = %v", dir, err)
68 }
69 return list
70}
71
72// loadSnippets reads a project's snippets, failing the test if it cannot.
73func loadSnippets(t *testing.T, dir string) snippets.List {
74 t.Helper()
75
76 list, err := snippets.Load(Profile(), dir)
77 if err != nil {
78 t.Fatalf("snippets.Load(%q) error = %v", dir, err)
79 }
80 return list
81}
82
83// readFile returns a file's contents.
84func readFile(t *testing.T, path string) string {
85 t.Helper()
86
87 data, err := os.ReadFile(path)
88 if err != nil {
89 t.Fatalf("reading %s: %v", path, err)
90 }
91 return string(data)
92}
93
94// plain strips the tilde hot-key markers from a label.
95func plain(label string) string { return strings.ReplaceAll(label, "~", "") }
96
97// hotKey returns the character between the tildes, or 0 when there is none.
98func hotKey(label string) rune {
99 first := strings.IndexByte(label, '~')
100 if first < 0 || first+1 >= len(label) {
101 return 0
102 }
103 return rune(label[first+1])
104}
105
106// --- the tools file ---------------------------------------------------------
107
108func TestTheCreatedToolsFileHoldsTheSixCommandsAProjectRuns(t *testing.T) {
109 // These are what a Python project runs on itself, and they are the reason
110 // the file exists at all. Everything goes through uv, so none of them needs
111 // an environment to have been activated first.
112 byName := map[string]string{}
113 for _, tool := range loadTools(t, createTools(t)).In("Python") {
114 byName[plain(tool.Name)] = tool.Command
115 }
116
117 want := map[string]string{
118 "Environment": "uv venv {{directory, usually .venv}}",
119 "Sync": "uv sync",
120 "Format": "uv run ruff format .",
121 "Lint": "uv run ruff check .",
122 "Test": "uv run pytest",
123 "Run": "uv run {{script}}",
124 }
125 for name, command := range want {
126 if got := byName[name]; got != command {
127 t.Errorf("%s runs %q, want %q", name, got, command)
128 }
129 }
130 if len(byName) != len(want) {
131 t.Errorf("the Python menu holds %d tools, want %d: %v", len(byName), len(want), byName)
132 }
133}
134
135// Creating the environment is the step in Python that has to happen before any
136// of the others can, and the one a newcomer to a project most often has not
137// done — so it is the first item in the menu rather than the last.
138func TestCreatingTheEnvironmentIsTheFirstToolInTheMenu(t *testing.T) {
139 python := loadTools(t, createTools(t)).In("Python")
140
141 if len(python) == 0 {
142 t.Fatal("the Python menu is empty")
143 }
144 if got := plain(python[0].Name); got != "Environment" {
145 t.Errorf("the first tool in the menu is %q, want %q", got, "Environment")
146 }
147}
148
149// The directory is asked for rather than fixed: .venv is the usual answer and
150// not the only one, and a tool that asks is also the file's live demonstration
151// that asking is possible.
152func TestTheEnvironmentToolAsksWhereToPutIt(t *testing.T) {
153 for _, tool := range loadTools(t, createTools(t)).Tools() {
154 if plain(tool.Name) != "Environment" {
155 continue
156 }
157
158 asked := tool.Placeholders()
159 if len(asked) != 1 {
160 t.Fatalf("Environment asks for %d values, want 1: %v", len(asked), asked)
161 }
162 if !strings.Contains(asked[0].Label, ".venv") {
163 t.Errorf("it asks for %q, which never mentions .venv", asked[0].Label)
164 }
165 if asked[0].Raw {
166 t.Errorf("it asks for %q unquoted; a directory with a space in it would become two arguments", asked[0].Label)
167 }
168 return
169 }
170 t.Fatal("there is no Environment tool")
171}
172
173func TestOnlyTheTwoToolsThatNeedAValueAskForOne(t *testing.T) {
174 // Every other command is complete as it stands, and a box in front of a
175 // command that has nothing to ask is a keystroke for nothing.
176 asking := map[string]bool{"Environment": true, "Run": true}
177
178 for _, tool := range loadTools(t, createTools(t)).Tools() {
179 name := plain(tool.Name)
180 if got := len(tool.Placeholders()) > 0; got != asking[name] {
181 t.Errorf("%s asks for a value: %t, want %t (%q)", name, got, asking[name], tool.Command)
182 }
183 }
184}
185
186func TestTheCreatedToolsCarryHotKeysUniqueWithinTheirMenu(t *testing.T) {
187 // Six items in a menu are worth reaching with one keystroke each. Two menus
188 // may each have an E, which is why the check is per menu.
189 list := loadTools(t, createTools(t))
190
191 for _, menu := range list.MenuNames() {
192 seen := map[rune]string{}
193 for _, tool := range list.In(menu) {
194 key := hotKey(tool.Name)
195 if key == 0 {
196 t.Errorf("%q in the %s menu has no hot key", tool.Name, menu)
197 continue
198 }
199 if other, clash := seen[key]; clash {
200 t.Errorf("%q and %q in the %s menu both answer to %c", other, tool.Name, menu, key)
201 }
202 seen[key] = tool.Name
203 }
204 }
205}
206
207func TestTheCreatedToolsFileNamesAnOutputForEveryTool(t *testing.T) {
208 // The key is the interesting part of the format, and a file where it only
209 // appears once is a file where nobody notices it exists.
210 for _, tool := range loadTools(t, createTools(t)).Tools() {
211 if tool.Output == "" {
212 t.Errorf("%q leaves its output to the default rather than saying it", tool.Name)
213 }
214 }
215}
216
217func TestRunIsTheOneToolchainCommandInATerminal(t *testing.T) {
218 // A Python script usually reads the keyboard, runs long, or both, and a
219 // popup can answer neither. The Echo example is in a terminal too, but it
220 // is in a menu of its own and is there to demonstrate the menu key.
221 for _, tool := range loadTools(t, createTools(t)).In("Python") {
222 want := tools.OutputPopup
223 if plain(tool.Name) == "Run" {
224 want = tools.OutputTerminal
225 }
226 if got := tool.Where(); got != want {
227 t.Errorf("%s goes to %q, want %q", plain(tool.Name), got, want)
228 }
229 }
230}
231
232func TestTheCreatedToolsFileExplainsItself(t *testing.T) {
233 contents := readFile(t, tools.Path(Profile(), createTools(t)))
234
235 for _, want := range []string{"[[tool]]", "sh -c", "hot key", "popup", "terminal", "editor"} {
236 if !strings.Contains(contents, want) {
237 t.Errorf("the created file never mentions %q:\n%s", want, contents)
238 }
239 }
240}
241
242func TestTheCreatedToolsFileNamesThePythonMenuAndNoOtherEditorsMenu(t *testing.T) {
243 // The comments explain which menu a tool lands in by naming it. Naming the
244 // menu of the editor this one was adapted from is the copy-and-paste
245 // mistake this catches, and it is invisible to every other test.
246 contents := readFile(t, tools.Path(Profile(), createTools(t)))
247
248 if !strings.Contains(contents, "Python menu") {
249 t.Errorf("the created file never names the Python menu:\n%s", contents)
250 }
251 for _, other := range []string{"Go menu", "Rust menu", "turbo-go", "turbo-rust", "cargo"} {
252 if strings.Contains(contents, other) {
253 t.Errorf("the created file still talks about %q:\n%s", other, contents)
254 }
255 }
256}
257
258func TestTheCreatedToolsFileShowsHowToUseAnotherMenu(t *testing.T) {
259 contents := readFile(t, tools.Path(Profile(), createTools(t)))
260
261 for _, want := range []string{"menu says which menu", `menu = "Tools"`} {
262 if !strings.Contains(contents, want) {
263 t.Errorf("the created file never shows %q:\n%s", want, contents)
264 }
265 }
266}
267
268func TestTheCreatedToolsFileExplainsHowToAskForAValue(t *testing.T) {
269 // A parameterised tool is only discoverable if the file people get says the
270 // syntax exists. The double-brace warning is here too, because somebody
271 // reading this file may well have an awk one-liner in mind.
272 contents := readFile(t, tools.Path(Profile(), createTools(t)))
273
274 for _, want := range []string{
275 "{{label}}",
276 "uv add {{package}}",
277 "{{extra flags...}}",
278 "Double braces, not single",
279 } {
280 if !strings.Contains(contents, want) {
281 t.Errorf("the created file never mentions %q:\n%s", want, contents)
282 }
283 }
284}
285
286// --- the snippets file ------------------------------------------------------
287
288func TestTheCreatedSnippetsFileHoldsUsablePythonSnippets(t *testing.T) {
289 groups := loadSnippets(t, createSnippets(t)).Groups(string(Language))
290
291 if len(groups) == 0 {
292 t.Fatal("the created file offers nothing at all in a Python file")
293 }
294 for _, group := range groups {
295 for _, snippet := range group.Snippets {
296 if snippet.Name == "" || snippet.Body == "" {
297 t.Errorf("the created file holds an unusable snippet %+v", snippet)
298 }
299 }
300 }
301}
302
303func TestTheCreatedSnippetsIndentWithFourSpacesTheWayPEP8Does(t *testing.T) {
304 // A tab inserted into a file indented with spaces is an indentation error
305 // in Python, not a formatting quibble: the file stops running.
306 for _, group := range loadSnippets(t, createSnippets(t)).Groups(string(Language)) {
307 for _, snippet := range group.Snippets {
308 if strings.Contains(snippet.Body, "\t") {
309 t.Errorf("%q indents with a tab:\n%q", snippet.Name, snippet.Body)
310 }
311 for _, line := range strings.Split(snippet.Body, "\n") {
312 indent := len(line) - len(strings.TrimLeft(line, " "))
313 if indent%4 != 0 {
314 t.Errorf("%q has a line indented by %d spaces:\n%q", snippet.Name, indent, line)
315 }
316 }
317 }
318 }
319}
320
321// Every snippet must be Python that runs, not Python that looks right — the
322// scanner is the nearest thing to a parser this repository has, and a snippet
323// whose whole body comes out as one colour is a snippet with an unclosed
324// string in it.
325func TestEverySnippetBodyColoursAsMoreThanOneThing(t *testing.T) {
326 for _, group := range loadSnippets(t, createSnippets(t)).Groups(string(Language)) {
327 for _, snippet := range group.Snippets {
328 classes := map[syntax.Class]bool{}
329 for _, line := range Highlight(snippet.Body) {
330 for _, span := range line {
331 classes[span.Class] = true
332 }
333 }
334 if len(classes) < 2 {
335 t.Errorf("%q colours as %d classes:\n%s", snippet.Name, len(classes), snippet.Body)
336 }
337 }
338 }
339}
340
341func TestTheCreatedSnippetsFileExplainsItself(t *testing.T) {
342 contents := readFile(t, snippets.ProjectPath(Profile(), createSnippets(t)))
343
344 for _, want := range []string{"[[snippet]]", "languages", "group", "General", snippets.UserPath(Profile())} {
345 if !strings.Contains(contents, want) {
346 t.Errorf("the created file never mentions %q:\n%s", want, contents)
347 }
348 }
349}
350
351func TestTheCreatedSnippetsFileListsEveryLanguageTheEditorKnows(t *testing.T) {
352 // The comment is where a user finds out what they may write in a languages
353 // key. One that omits a language the editor colours sends them looking for
354 // a feature that is already there. Iterating the registry rather than a
355 // list is what stops the comment falling behind it, as turbo-rust's did
356 // when turbo-core learnt YAML, XML and Dockerfiles.
357 Register()
358 contents := readFile(t, snippets.ProjectPath(Profile(), createSnippets(t)))
359
360 for _, language := range syntax.Registered() {
361 if !strings.Contains(contents, string(language)) {
362 t.Errorf("the created file never mentions the %q language:\n%s", language, contents)
363 }
364 }
365}
366
367// --- the settings file ------------------------------------------------------
368
369func TestTheCreatedSettingsFileExplainsItself(t *testing.T) {
370 contents := readFile(t, settings.Path(Profile(), createSettings(t)))
371
372 for _, want := range []string{"theme", "autosave", "autosave_delay", "-list-themes"} {
373 if !strings.Contains(contents, want) {
374 t.Errorf("the created file never mentions %q:\n%s", want, contents)
375 }
376 }
377}
378
379func TestTheCreatedSettingsFileTurnsAutosaveOn(t *testing.T) {
380 // A project that has gone to the trouble of creating a settings file has
381 // said what it wants. The file is the visible, editable place to say
382 // otherwise, which is why this default lives here and not in the library.
383 project := createSettings(t)
384
385 loaded, err := settings.Load(Profile(), project)
386 if err != nil {
387 t.Fatalf("settings.Load() error = %v", err)
388 }
389 if !loaded.Autosave {
390 t.Errorf("the created settings file leaves autosave off:\n%s", readFile(t, settings.Path(Profile(), project)))
391 }
392 if loaded.AutosaveDelay != settings.DefaultAutosaveDelay {
393 t.Errorf("AutosaveDelay = %v, want the library default %v", loaded.AutosaveDelay, settings.DefaultAutosaveDelay)
394 }
395}
396
397func TestAProjectWithNoSettingsFileStillDoesNotAutosave(t *testing.T) {
398 // The other half of the decision. Turning autosave on for a project that
399 // never opted in would mean the editor writing to disk in any directory it
400 // is started in, which is a different and much larger claim.
401 if settings.Default().Autosave {
402 t.Error("settings.Default() autosaves; a project with no settings file never opted in")
403 }
404}
405
406// --- the contract the three templates are held to ---------------------------
407
408// The three embedded templates and the blanks profile.Templates says each one
409// takes. Kept together so that adding a verb to a .tmpl file without saying so
410// here fails, which is the guard the constants used to get for free by sitting
411// next to the contract.
412var embeddedTemplates = []struct {
413 name string
414 body string
415 verb string
416 blanks int
417 filledBy []any
418}{
419 {"settings.toml.tmpl", settingsTemplate, "%q", 2, []any{"turbo-classic", "2s"}},
420 {"snippets.toml.tmpl", snippetsTemplate, "%s", 2, []any{"General", "/tmp/snippets.toml"}},
421 {"tools.toml.tmpl", toolsTemplate, "%", 0, nil},
422}
423
424func TestEveryTemplateIsEmbeddedAndNotEmpty(t *testing.T) {
425 // go:embed fails to compile when a file is missing, but an empty file
426 // compiles happily and writes an empty starter file into somebody's
427 // project.
428 for _, template := range embeddedTemplates {
429 if len(template.body) == 0 {
430 t.Errorf("%s embedded as nothing", template.name)
431 }
432 }
433}
434
435func TestEveryTemplateTakesTheBlanksItsContractPromises(t *testing.T) {
436 // profile.Templates documents the count and the verb of each. The templates
437 // live in files of their own, so nothing but this notices a verb added,
438 // removed, or changed.
439 for _, template := range embeddedTemplates {
440 if got := strings.Count(template.body, template.verb); got != template.blanks {
441 t.Errorf("%s holds %d %q, want %d", template.name, got, template.verb, template.blanks)
442 }
443 }
444}
445
446func TestFillingATemplateLeavesNoFormattingMarker(t *testing.T) {
447 // Go writes %!q(MISSING) or %!(EXTRA …) into the output rather than
448 // failing, so a template with the wrong number of blanks produces a file
449 // that is written, opened, and wrong.
450 for _, template := range embeddedTemplates {
451 filled := template.body
452 if template.filledBy != nil {
453 filled = fmt.Sprintf(template.body, template.filledBy...)
454 }
455 if strings.Contains(filled, "%!") {
456 t.Errorf("%s filled to:\n%s", template.name, filled)
457 }
458 }
459}
460
461func TestEveryTemplateNamesThisEditorAndNotTheOnesItWasAdaptedFrom(t *testing.T) {
462 // The three templates started as Turbo Rust's. A leftover "turbo-rust" in a
463 // file written into somebody's Python project is the whole class of mistake
464 // this catches.
465 templates := map[string]string{
466 "settings": settingsTemplate,
467 "snippets": snippetsTemplate,
468 "tools": toolsTemplate,
469 }
470
471 for name, template := range templates {
472 t.Run(name, func(t *testing.T) {
473 for _, other := range []string{"turbo-go", "turbo-rust", "cargo", "gopls", "rust-analyzer"} {
474 if strings.Contains(template, other) {
475 t.Errorf("the %s template still says %q:\n%s", name, other, template)
476 }
477 }
478 if !strings.Contains(template, Slug) {
479 t.Errorf("the %s template never names %s:\n%s", name, Slug, template)
480 }
481 })
482 }
483}