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

project_test.go · 494 lines · 15.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 6h ago1package app
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/gdamore/tcell/v2"
11
12 "codeberg.org/turbo-editors/turbo-core/settings"
13 "codeberg.org/turbo-editors/turbo-core/snippets"
14 "codeberg.org/turbo-editors/turbo-core/theme"
15 "codeberg.org/turbo-editors/turbo-core/tools"
16 "codeberg.org/turbo-editors/turbo-core/ui"
17)
18
19// newProjectApp returns an editor whose working directory is an empty project.
20func newProjectApp(t *testing.T) (*App, string) {
21 t.Helper()
22
23 project := t.TempDir()
24 t.Chdir(project)
25 // The user's own snippets file must never be read by a test: whoever runs
26 // the suite may have one, and it would decide what the menu holds.
27 t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
28
29 a, _ := newTestApp(t)
30 return a, project
31}
32
33func TestCreatingProjectSettingsWritesAndOpensTheFile(t *testing.T) {
34 a, project := newProjectApp(t)
35
36 a.CreateProjectSettings()
37
38 if !settings.Exists(testProfile(), project) {
39 t.Fatal("no settings file was written")
40 }
41 if a.Desktop().Count() != 1 {
42 t.Fatalf("Count() = %d, want the settings file open", a.Desktop().Count())
43 }
44 if got := a.Desktop().Active().Title(); got != settings.FileName {
45 t.Errorf("the window shows %q, want %q", got, settings.FileName)
46 }
47 if a.SettingsPath() != settings.Path(testProfile(), project) {
48 t.Errorf("SettingsPath() = %q, want %q", a.SettingsPath(), settings.Path(testProfile(), project))
49 }
50}
51
52func TestTheCreatedFileRecordsTheThemeInUse(t *testing.T) {
53 a, project := newProjectApp(t)
54 a.setTheme("turbo-dark")
55
56 a.CreateProjectSettings()
57
58 loaded, err := settings.Load(testProfile(), project)
59 if err != nil {
60 t.Fatalf("Load() error = %v", err)
61 }
62 if loaded.Theme != "turbo-dark" {
63 t.Errorf("Theme = %q; creating settings must record the theme in use, not a default", loaded.Theme)
64 }
65}
66
67func TestCreatingProjectSettingsTwiceOpensRatherThanOverwrites(t *testing.T) {
68 a, project := newProjectApp(t)
69 a.CreateProjectSettings()
70 if err := os.WriteFile(settings.Path(testProfile(), project), []byte("[editor]\ntheme = \"mine\"\n"), 0o644); err != nil {
71 t.Fatalf("writing over the created file: %v", err)
72 }
73 a.CloseFile()
74
75 a.CreateProjectSettings()
76
77 if got := readTestFile(t, settings.Path(testProfile(), project)); !strings.Contains(got, `"mine"`) {
78 t.Errorf("the existing file was overwritten:\n%s", got)
79 }
80 if a.Desktop().Count() != 1 {
81 t.Errorf("Count() = %d, want the existing file opened", a.Desktop().Count())
82 }
83}
84
85func TestOpeningProjectSettingsWithoutAFileSaysSo(t *testing.T) {
86 a, _ := newProjectApp(t)
87
88 a.OpenProjectSettings()
89
90 if a.Modals() != 1 {
91 t.Fatalf("Modals() = %d, want the explanation", a.Modals())
92 }
93 if a.Desktop().Count() != 0 {
94 t.Error("a window was opened for a file that does not exist")
95 }
96}
97
98func TestOpeningProjectSettingsIsGreyedOutWithoutAFile(t *testing.T) {
99 a, project := newProjectApp(t)
100
101 if a.HasProjectSettings() {
102 t.Error("HasProjectSettings() is true in a project with no settings")
103 }
104 a.CreateProjectSettings()
105 if !a.HasProjectSettings() {
106 t.Errorf("HasProjectSettings() is false after creating %s", settings.Path(testProfile(), project))
107 }
108}
109
110func TestTheSettingsFileIsColouredAsTOML(t *testing.T) {
111 project := t.TempDir()
112 t.Chdir(project)
113 a, screen := newTestApp(t)
114
115 a.CreateProjectSettings()
116 lines := render(t, a, screen)
117
118 // The created file opens on its own first line, a comment. Finding that
119 // comment drawn in the comment colour is the whole path working: the file
120 // was written, opened, recognised as TOML, scanned and drawn.
121 row, col := findRune(t, lines, "# turbo-test", '#')
122 cells, width, _ := screen.GetContents()
123 got, _, _ := cells[row*width+col].Style.Decompose()
124
125 want, _, _ := a.Theme().Style(theme.KeySyntaxComment).Decompose()
126 if got != want {
127 t.Errorf("the comment is drawn in %v, want the theme's comment colour %v", got, want)
128 }
129}
130
131// findRune returns where a rune sits on the first drawn row containing a
132// piece of text.
133func findRune(t *testing.T, lines []string, within string, r rune) (row, col int) {
134 t.Helper()
135
136 for y, line := range lines {
137 start := strings.Index(line, within)
138 if start < 0 {
139 continue
140 }
141 if offset := strings.IndexRune(line[start:], r); offset >= 0 {
142 return y, start + offset
143 }
144 }
145 t.Fatalf("no drawn row contains %q:\n%s", within, strings.Join(lines, "\n"))
146 return 0, 0
147}
148
149func TestChangingTheThemeWritesItBackWhenTheProjectHasSettings(t *testing.T) {
150 a, project := newProjectApp(t)
151 a.CreateProjectSettings()
152
153 chooseTheme(t, a, "turbo-dark")
154
155 loaded, err := settings.Load(testProfile(), project)
156 if err != nil {
157 t.Fatalf("Load() error = %v", err)
158 }
159 if loaded.Theme != "turbo-dark" {
160 t.Errorf("Theme in the file = %q, want turbo-dark", loaded.Theme)
161 }
162}
163
164func TestChangingTheThemeWritesNothingWithoutASettingsFile(t *testing.T) {
165 a, project := newProjectApp(t)
166
167 chooseTheme(t, a, "turbo-dark")
168
169 if a.ThemeName() != "turbo-dark" {
170 t.Errorf("ThemeName() = %q; the theme should still change", a.ThemeName())
171 }
172 if _, err := os.Stat(settings.Dir(testProfile(), project)); err == nil {
173 t.Error("picking a theme created the editor's directory when nobody asked for it")
174 }
175}
176
177func TestWritingTheThemeBackKeepsTheRestOfTheFile(t *testing.T) {
178 a, project := newProjectApp(t)
179 a.CreateProjectSettings()
180 before := readTestFile(t, settings.Path(testProfile(), project))
181
182 chooseTheme(t, a, "turbo-dark")
183
184 after := readTestFile(t, settings.Path(testProfile(), project))
185 if !strings.Contains(after, "# turbo-test project settings.") {
186 t.Errorf("the comments were lost:\n%s", after)
187 }
188 if strings.Count(after, "\n") != strings.Count(before, "\n") {
189 t.Errorf("the file changed length:\nbefore:\n%s\nafter:\n%s", before, after)
190 }
191}
192
193// chooseTheme drives Options ▸ Theme all the way through its dialog, which is
194// the only path that writes the theme back.
195func chooseTheme(t *testing.T, a *App, name string) {
196 t.Helper()
197
198 names := theme.Available("")
199 from, to := indexOf(names, a.ThemeName()), indexOf(names, name)
200
201 a.ChooseTheme()
202 if a.Modals() != 1 {
203 t.Fatalf("Modals() = %d, want the theme picker", a.Modals())
204 }
205
206 for range max(to-from, 0) {
207 press(a, tcell.KeyDown, 0, tcell.ModNone)
208 }
209 for range max(from-to, 0) {
210 press(a, tcell.KeyUp, 0, tcell.ModNone)
211 }
212 press(a, tcell.KeyEnter, 0, tcell.ModNone)
213
214 if a.ThemeName() != name {
215 t.Fatalf("ThemeName() = %q after choosing %q", a.ThemeName(), name)
216 }
217}
218
219// settingsSaying writes a settings file holding one [editor] body, opens it in
220// the editor, and returns its path. It is the state a user is in when they have
221// the settings file in front of them and are about to change it.
222func settingsSaying(t *testing.T, a *App, project, body string) string {
223 t.Helper()
224
225 path := settings.Path(testProfile(), project)
226 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
227 t.Fatalf("cannot make the project directory: %v", err)
228 }
229 writeTestFile(t, path, "[editor]\n"+body)
230 a.Open(path)
231 return path
232}
233
234// saveSettingsSaying edits the open settings file to a new body and saves it,
235// the way a user would.
236func saveSettingsSaying(t *testing.T, a *App, body string) {
237 t.Helper()
238
239 activeBuffer(t, a).SetText("[editor]\n" + body)
240 a.SaveFile()
241}
242
243func TestSavingTheSettingsFileTurnsAutosaveOnWithoutARestart(t *testing.T) {
244 // The bug this is here for: UseSettings was called once, from main, so a
245 // change to the settings file did nothing until the editor was restarted.
246 a, project := newProjectApp(t)
247 settingsSaying(t, a, project, "autosave = false\n")
248 if a.AutosaveEnabled() {
249 t.Fatal("autosave is on before the file asked for it")
250 }
251
252 saveSettingsSaying(t, a, "autosave = true\n")
253
254 if !a.AutosaveEnabled() {
255 t.Error("saving a settings file that turns autosave on did not turn it on")
256 }
257}
258
259func TestSavingTheSettingsFileTurnsAutosaveOffAgain(t *testing.T) {
260 a, project := newProjectApp(t)
261 settingsSaying(t, a, project, "autosave = true\n")
262 a.SaveFile()
263 if !a.AutosaveEnabled() {
264 t.Fatal("autosave did not come on")
265 }
266
267 saveSettingsSaying(t, a, "autosave = false\n")
268
269 if a.AutosaveEnabled() {
270 t.Error("saving a settings file that turns autosave off left it on")
271 }
272}
273
274func TestSavingTheSettingsFileAppliesTheDelayToo(t *testing.T) {
275 a, project := newProjectApp(t)
276 settingsSaying(t, a, project, "autosave = true\n")
277
278 saveSettingsSaying(t, a, "autosave = true\nautosave_delay = \"90ms\"\n")
279
280 if got := a.autosave.delay; got != 90*time.Millisecond {
281 t.Errorf("the delay is %v, want the 90ms the file asked for", got)
282 }
283}
284
285func TestSavingTheSettingsFileSaysWhatIsNowInForce(t *testing.T) {
286 // "It does not work" was the report, and an invisible fix invites it again.
287 a, project := newProjectApp(t)
288 settingsSaying(t, a, project, "autosave = false\n")
289
290 saveSettingsSaying(t, a, "autosave = true\n")
291
292 if got := a.StatusBar().Message(); !strings.Contains(got, "autosave on") {
293 t.Errorf("the status bar says %q, want it to say autosave is now on", got)
294 }
295}
296
297func TestASettingsFileThatNoLongerParsesSaysSoAndChangesNothing(t *testing.T) {
298 // Saved, but not in force. That is a third thing, distinct from "saved" and
299 // from "cannot save", and it is the only one that leaves the editor
300 // behaving unlike the file on the screen.
301 a, project := newProjectApp(t)
302 settingsSaying(t, a, project, "autosave = true\n")
303 a.SaveFile()
304
305 saveSettingsSaying(t, a, "autosave_delay = \"whenever\"\n")
306
307 if !a.AutosaveEnabled() {
308 t.Error("a settings file that does not parse turned autosave off")
309 }
310 if got := a.StatusBar().Message(); !strings.Contains(got, "not applied") {
311 t.Errorf("the status bar says %q, want it to say the file was not applied", got)
312 }
313}
314
315func TestCreatingTheSettingsFileAndSavingItAppliesIt(t *testing.T) {
316 // Creating leaves the file open in front of you, which is an invitation to
317 // change it. Nothing remembers a path for a project that had no settings
318 // file, so the path is what has to be compared.
319 a, project := newProjectApp(t)
320 a.CreateProjectSettings()
321
322 saveSettingsSaying(t, a, "autosave = true\n")
323
324 if !a.AutosaveEnabled() {
325 t.Errorf("settings created and then saved were not applied (path %s)", settings.Path(testProfile(), project))
326 }
327}
328
329func TestSavingAnOrdinaryFileLeavesTheSettingsAlone(t *testing.T) {
330 a, project := newProjectApp(t)
331 // Autosave is turned on directly, not through the file, so that this test
332 // says nothing about whether saving the settings works and everything about
333 // whether saving anything else leaves them alone.
334 a.SetAutosave(true, 3*time.Second)
335
336 path := filepath.Join(project, "main.go")
337 writeTestFile(t, path, "package main\n")
338 a.Open(path)
339 activeBuffer(t, a).SetText("package other\n")
340 a.SaveFile()
341
342 if !a.AutosaveEnabled() {
343 t.Error("saving an ordinary file re-read the settings and turned autosave off")
344 }
345 if got := a.StatusBar().Message(); strings.Contains(got, "Applied") {
346 t.Errorf("the status bar says %q; an ordinary file is not the settings", got)
347 }
348 if got := a.StatusBar().Message(); !strings.Contains(got, "Saved") {
349 t.Errorf("the status bar says %q, want the ordinary save message", got)
350 }
351}
352
353func TestAutosaveWritingTheSettingsFileAppliesItToo(t *testing.T) {
354 // The reason the two save paths share a tail: this step was added for the
355 // File menu, and autosave would otherwise have been the one place where
356 // saving the settings file still did nothing.
357 a, project := newProjectApp(t)
358 clock := &fakeClock{at: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)}
359 a.now = clock.now
360 settingsSaying(t, a, project, "autosave = true\n")
361 // Turned on directly rather than by saving the file, so that this test
362 // fails for its own reason — autosave never reaching the settings — rather
363 // than because autosave was never on in the first place.
364 a.SetAutosave(true, time.Second)
365
366 activeBuffer(t, a).SetText("[editor]\nautosave = false\n")
367 a.noteEdit()
368 clock.pass(2 * time.Second)
369 a.saveDueDocuments()
370
371 if onDisk(t, settings.Path(testProfile(), project)) != "[editor]\nautosave = false\n" {
372 t.Fatal("autosave did not write the settings file, so there is nothing to check")
373 }
374 if a.AutosaveEnabled() {
375 t.Error("autosave wrote the settings file that turns it off, and stayed on")
376 }
377}
378
379// enabledOf returns whether a menu item is available, and fails the test when
380// no item of that label is in the list.
381func enabledOf(t *testing.T, items []*ui.MenuItem, label string) bool {
382 t.Helper()
383
384 for _, item := range items {
385 if ui.PlainLabel(item.Label) == label {
386 return item.Enabled == nil || item.Enabled()
387 }
388 }
389 t.Fatalf("no item called %q in %v", label, labels(items))
390 return false
391}
392
393func TestCreateAndOpenAreNeverBothAvailable(t *testing.T) {
394 // The rule the three files share: you can create the one you have not got,
395 // and open the one you have. Exactly one of each pair, always.
396 a, project := newProjectApp(t)
397
398 pairs := []struct {
399 file string
400 create, open string
401 items func() []*ui.MenuItem
402 make func()
403 }{
404 {"settings", "Create project settings", "Project settings…",
405 func() []*ui.MenuItem { return a.optionsMenu().Items }, a.CreateProjectSettings},
406 {"tools", "Create tools file", "Open tools file",
407 a.toolItems, a.CreateTools},
408 {"snippets", "Create snippets file", "Open snippets file",
409 a.snippetItems, a.CreateSnippets},
410 }
411
412 for _, pair := range pairs {
413 t.Run(pair.file, func(t *testing.T) {
414 items := pair.items()
415 if !enabledOf(t, items, pair.create) {
416 t.Errorf("%s: create is greyed out with no file to speak of", pair.file)
417 }
418 if enabledOf(t, items, pair.open) {
419 t.Errorf("%s: open is available with no file there", pair.file)
420 }
421
422 pair.make()
423 items = pair.items()
424
425 if enabledOf(t, items, pair.create) {
426 t.Errorf("%s: create is still available once the file exists, in %s", pair.file, project)
427 }
428 if !enabledOf(t, items, pair.open) {
429 t.Errorf("%s: open is greyed out with the file right there", pair.file)
430 }
431 })
432 }
433}
434
435func TestOpeningTheToolsFileOpensIt(t *testing.T) {
436 a, project := newProjectApp(t)
437 a.CreateTools()
438 a.CloseFile()
439
440 a.OpenTools()
441
442 if got := a.Desktop().Active().Title(); got != tools.FileName {
443 t.Errorf("the window shows %q, want %q", got, tools.FileName)
444 }
445 if got := activeBuffer(t, a).Path(); !samePath(got, tools.Path(testProfile(), project)) {
446 t.Errorf("the window holds %q, want the project's tools file", got)
447 }
448}
449
450func TestOpeningTheSnippetsFileOpensTheProjectsOwn(t *testing.T) {
451 // The project's, not the user's. A menu that sometimes opened one file and
452 // sometimes another would be a menu nobody could predict.
453 a, project := newProjectApp(t)
454 a.CreateSnippets()
455 a.CloseFile()
456
457 a.OpenSnippets()
458
459 if got := activeBuffer(t, a).Path(); !samePath(got, snippets.ProjectPath(testProfile(), project)) {
460 t.Errorf("the window holds %q, want the project's snippets file", got)
461 }
462}
463
464func TestOpeningAFileTheProjectHasNotGotSaysWhereToMakeIt(t *testing.T) {
465 // The menu items are greyed out, so this only happens to a caller that is
466 // not a menu — but "nothing happened" is not something anybody can act on.
467 a, _ := newProjectApp(t)
468
469 for _, open := range []func(){a.OpenProjectSettings, a.OpenTools, a.OpenSnippets} {
470 before := a.Modals()
471 open()
472 if a.Modals() != before+1 {
473 t.Fatalf("opening a file that is not there said nothing")
474 }
475 press(a, tcell.KeyEscape, 0, tcell.ModNone)
476 }
477}
478
479func TestOpeningAProjectFileNeverCreatesIt(t *testing.T) {
480 // "Open" that writes would put a directory into somebody's repository for
481 // them, which is what the separate create item exists to avoid.
482 a, project := newProjectApp(t)
483
484 a.OpenProjectSettings()
485 press(a, tcell.KeyEscape, 0, tcell.ModNone)
486 a.OpenTools()
487 press(a, tcell.KeyEscape, 0, tcell.ModNone)
488 a.OpenSnippets()
489 press(a, tcell.KeyEscape, 0, tcell.ModNone)
490
491 if _, err := os.Stat(filepath.Join(project, testProfile().ProjectDir())); !os.IsNotExist(err) {
492 t.Errorf("opening created %s", testProfile().ProjectDir())
493 }
494}