| 🛟 Updated. 28d5985 k33g 18h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | func 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 | |
| 33 | func 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 | |
| 52 | func 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 | |
| 67 | func 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 | |
| 85 | func 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 | |
| 98 | func 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 | |
| 110 | func 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. |
| 133 | func 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 | |
| 149 | func 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 | |
| 164 | func 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 | |
| 177 | func 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. |
| 195 | func 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. |
| 222 | func 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. |
| 236 | func 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 | |
| 243 | func 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 | |
| 259 | func 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 | |
| 274 | func 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 | |
| 285 | func 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 | |
| 297 | func 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 | |
| 315 | func 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 | |
| 329 | func 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 | |
| 353 | func 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. |
| 381 | func 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 | |
| 393 | func 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 | |
| 435 | func 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 | |
| 450 | func 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 | |
| 464 | func 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 | |
| 479 | func 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 | } |