package app import ( "os" "path/filepath" "strings" "testing" "time" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/settings" "codeberg.org/turbo-editors/turbo-core/snippets" "codeberg.org/turbo-editors/turbo-core/theme" "codeberg.org/turbo-editors/turbo-core/tools" "codeberg.org/turbo-editors/turbo-core/ui" ) // newProjectApp returns an editor whose working directory is an empty project. func newProjectApp(t *testing.T) (*App, string) { t.Helper() project := t.TempDir() t.Chdir(project) // The user's own snippets file must never be read by a test: whoever runs // the suite may have one, and it would decide what the menu holds. t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir()) a, _ := newTestApp(t) return a, project } func TestCreatingProjectSettingsWritesAndOpensTheFile(t *testing.T) { a, project := newProjectApp(t) a.CreateProjectSettings() if !settings.Exists(testProfile(), project) { t.Fatal("no settings file was written") } if a.Desktop().Count() != 1 { t.Fatalf("Count() = %d, want the settings file open", a.Desktop().Count()) } if got := a.Desktop().Active().Title(); got != settings.FileName { t.Errorf("the window shows %q, want %q", got, settings.FileName) } if a.SettingsPath() != settings.Path(testProfile(), project) { t.Errorf("SettingsPath() = %q, want %q", a.SettingsPath(), settings.Path(testProfile(), project)) } } func TestTheCreatedFileRecordsTheThemeInUse(t *testing.T) { a, project := newProjectApp(t) a.setTheme("turbo-dark") a.CreateProjectSettings() loaded, err := settings.Load(testProfile(), project) if err != nil { t.Fatalf("Load() error = %v", err) } if loaded.Theme != "turbo-dark" { t.Errorf("Theme = %q; creating settings must record the theme in use, not a default", loaded.Theme) } } func TestCreatingProjectSettingsTwiceOpensRatherThanOverwrites(t *testing.T) { a, project := newProjectApp(t) a.CreateProjectSettings() if err := os.WriteFile(settings.Path(testProfile(), project), []byte("[editor]\ntheme = \"mine\"\n"), 0o644); err != nil { t.Fatalf("writing over the created file: %v", err) } a.CloseFile() a.CreateProjectSettings() if got := readTestFile(t, settings.Path(testProfile(), project)); !strings.Contains(got, `"mine"`) { t.Errorf("the existing file was overwritten:\n%s", got) } if a.Desktop().Count() != 1 { t.Errorf("Count() = %d, want the existing file opened", a.Desktop().Count()) } } func TestOpeningProjectSettingsWithoutAFileSaysSo(t *testing.T) { a, _ := newProjectApp(t) a.OpenProjectSettings() if a.Modals() != 1 { t.Fatalf("Modals() = %d, want the explanation", a.Modals()) } if a.Desktop().Count() != 0 { t.Error("a window was opened for a file that does not exist") } } func TestOpeningProjectSettingsIsGreyedOutWithoutAFile(t *testing.T) { a, project := newProjectApp(t) if a.HasProjectSettings() { t.Error("HasProjectSettings() is true in a project with no settings") } a.CreateProjectSettings() if !a.HasProjectSettings() { t.Errorf("HasProjectSettings() is false after creating %s", settings.Path(testProfile(), project)) } } func TestTheSettingsFileIsColouredAsTOML(t *testing.T) { project := t.TempDir() t.Chdir(project) a, screen := newTestApp(t) a.CreateProjectSettings() lines := render(t, a, screen) // The created file opens on its own first line, a comment. Finding that // comment drawn in the comment colour is the whole path working: the file // was written, opened, recognised as TOML, scanned and drawn. row, col := findRune(t, lines, "# turbo-test", '#') cells, width, _ := screen.GetContents() got, _, _ := cells[row*width+col].Style.Decompose() want, _, _ := a.Theme().Style(theme.KeySyntaxComment).Decompose() if got != want { t.Errorf("the comment is drawn in %v, want the theme's comment colour %v", got, want) } } // findRune returns where a rune sits on the first drawn row containing a // piece of text. func findRune(t *testing.T, lines []string, within string, r rune) (row, col int) { t.Helper() for y, line := range lines { start := strings.Index(line, within) if start < 0 { continue } if offset := strings.IndexRune(line[start:], r); offset >= 0 { return y, start + offset } } t.Fatalf("no drawn row contains %q:\n%s", within, strings.Join(lines, "\n")) return 0, 0 } func TestChangingTheThemeWritesItBackWhenTheProjectHasSettings(t *testing.T) { a, project := newProjectApp(t) a.CreateProjectSettings() chooseTheme(t, a, "turbo-dark") loaded, err := settings.Load(testProfile(), project) if err != nil { t.Fatalf("Load() error = %v", err) } if loaded.Theme != "turbo-dark" { t.Errorf("Theme in the file = %q, want turbo-dark", loaded.Theme) } } func TestChangingTheThemeWritesNothingWithoutASettingsFile(t *testing.T) { a, project := newProjectApp(t) chooseTheme(t, a, "turbo-dark") if a.ThemeName() != "turbo-dark" { t.Errorf("ThemeName() = %q; the theme should still change", a.ThemeName()) } if _, err := os.Stat(settings.Dir(testProfile(), project)); err == nil { t.Error("picking a theme created the editor's directory when nobody asked for it") } } func TestWritingTheThemeBackKeepsTheRestOfTheFile(t *testing.T) { a, project := newProjectApp(t) a.CreateProjectSettings() before := readTestFile(t, settings.Path(testProfile(), project)) chooseTheme(t, a, "turbo-dark") after := readTestFile(t, settings.Path(testProfile(), project)) if !strings.Contains(after, "# turbo-test project settings.") { t.Errorf("the comments were lost:\n%s", after) } if strings.Count(after, "\n") != strings.Count(before, "\n") { t.Errorf("the file changed length:\nbefore:\n%s\nafter:\n%s", before, after) } } // chooseTheme drives Options ▸ Theme all the way through its dialog, which is // the only path that writes the theme back. func chooseTheme(t *testing.T, a *App, name string) { t.Helper() names := theme.Available("") from, to := indexOf(names, a.ThemeName()), indexOf(names, name) a.ChooseTheme() if a.Modals() != 1 { t.Fatalf("Modals() = %d, want the theme picker", a.Modals()) } for range max(to-from, 0) { press(a, tcell.KeyDown, 0, tcell.ModNone) } for range max(from-to, 0) { press(a, tcell.KeyUp, 0, tcell.ModNone) } press(a, tcell.KeyEnter, 0, tcell.ModNone) if a.ThemeName() != name { t.Fatalf("ThemeName() = %q after choosing %q", a.ThemeName(), name) } } // settingsSaying writes a settings file holding one [editor] body, opens it in // the editor, and returns its path. It is the state a user is in when they have // the settings file in front of them and are about to change it. func settingsSaying(t *testing.T, a *App, project, body string) string { t.Helper() path := settings.Path(testProfile(), project) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("cannot make the project directory: %v", err) } writeTestFile(t, path, "[editor]\n"+body) a.Open(path) return path } // saveSettingsSaying edits the open settings file to a new body and saves it, // the way a user would. func saveSettingsSaying(t *testing.T, a *App, body string) { t.Helper() activeBuffer(t, a).SetText("[editor]\n" + body) a.SaveFile() } func TestSavingTheSettingsFileTurnsAutosaveOnWithoutARestart(t *testing.T) { // The bug this is here for: UseSettings was called once, from main, so a // change to the settings file did nothing until the editor was restarted. a, project := newProjectApp(t) settingsSaying(t, a, project, "autosave = false\n") if a.AutosaveEnabled() { t.Fatal("autosave is on before the file asked for it") } saveSettingsSaying(t, a, "autosave = true\n") if !a.AutosaveEnabled() { t.Error("saving a settings file that turns autosave on did not turn it on") } } func TestSavingTheSettingsFileTurnsAutosaveOffAgain(t *testing.T) { a, project := newProjectApp(t) settingsSaying(t, a, project, "autosave = true\n") a.SaveFile() if !a.AutosaveEnabled() { t.Fatal("autosave did not come on") } saveSettingsSaying(t, a, "autosave = false\n") if a.AutosaveEnabled() { t.Error("saving a settings file that turns autosave off left it on") } } func TestSavingTheSettingsFileAppliesTheDelayToo(t *testing.T) { a, project := newProjectApp(t) settingsSaying(t, a, project, "autosave = true\n") saveSettingsSaying(t, a, "autosave = true\nautosave_delay = \"90ms\"\n") if got := a.autosave.delay; got != 90*time.Millisecond { t.Errorf("the delay is %v, want the 90ms the file asked for", got) } } func TestSavingTheSettingsFileSaysWhatIsNowInForce(t *testing.T) { // "It does not work" was the report, and an invisible fix invites it again. a, project := newProjectApp(t) settingsSaying(t, a, project, "autosave = false\n") saveSettingsSaying(t, a, "autosave = true\n") if got := a.StatusBar().Message(); !strings.Contains(got, "autosave on") { t.Errorf("the status bar says %q, want it to say autosave is now on", got) } } func TestASettingsFileThatNoLongerParsesSaysSoAndChangesNothing(t *testing.T) { // Saved, but not in force. That is a third thing, distinct from "saved" and // from "cannot save", and it is the only one that leaves the editor // behaving unlike the file on the screen. a, project := newProjectApp(t) settingsSaying(t, a, project, "autosave = true\n") a.SaveFile() saveSettingsSaying(t, a, "autosave_delay = \"whenever\"\n") if !a.AutosaveEnabled() { t.Error("a settings file that does not parse turned autosave off") } if got := a.StatusBar().Message(); !strings.Contains(got, "not applied") { t.Errorf("the status bar says %q, want it to say the file was not applied", got) } } func TestCreatingTheSettingsFileAndSavingItAppliesIt(t *testing.T) { // Creating leaves the file open in front of you, which is an invitation to // change it. Nothing remembers a path for a project that had no settings // file, so the path is what has to be compared. a, project := newProjectApp(t) a.CreateProjectSettings() saveSettingsSaying(t, a, "autosave = true\n") if !a.AutosaveEnabled() { t.Errorf("settings created and then saved were not applied (path %s)", settings.Path(testProfile(), project)) } } func TestSavingAnOrdinaryFileLeavesTheSettingsAlone(t *testing.T) { a, project := newProjectApp(t) // Autosave is turned on directly, not through the file, so that this test // says nothing about whether saving the settings works and everything about // whether saving anything else leaves them alone. a.SetAutosave(true, 3*time.Second) path := filepath.Join(project, "main.go") writeTestFile(t, path, "package main\n") a.Open(path) activeBuffer(t, a).SetText("package other\n") a.SaveFile() if !a.AutosaveEnabled() { t.Error("saving an ordinary file re-read the settings and turned autosave off") } if got := a.StatusBar().Message(); strings.Contains(got, "Applied") { t.Errorf("the status bar says %q; an ordinary file is not the settings", got) } if got := a.StatusBar().Message(); !strings.Contains(got, "Saved") { t.Errorf("the status bar says %q, want the ordinary save message", got) } } func TestAutosaveWritingTheSettingsFileAppliesItToo(t *testing.T) { // The reason the two save paths share a tail: this step was added for the // File menu, and autosave would otherwise have been the one place where // saving the settings file still did nothing. a, project := newProjectApp(t) clock := &fakeClock{at: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)} a.now = clock.now settingsSaying(t, a, project, "autosave = true\n") // Turned on directly rather than by saving the file, so that this test // fails for its own reason — autosave never reaching the settings — rather // than because autosave was never on in the first place. a.SetAutosave(true, time.Second) activeBuffer(t, a).SetText("[editor]\nautosave = false\n") a.noteEdit() clock.pass(2 * time.Second) a.saveDueDocuments() if onDisk(t, settings.Path(testProfile(), project)) != "[editor]\nautosave = false\n" { t.Fatal("autosave did not write the settings file, so there is nothing to check") } if a.AutosaveEnabled() { t.Error("autosave wrote the settings file that turns it off, and stayed on") } } // enabledOf returns whether a menu item is available, and fails the test when // no item of that label is in the list. func enabledOf(t *testing.T, items []*ui.MenuItem, label string) bool { t.Helper() for _, item := range items { if ui.PlainLabel(item.Label) == label { return item.Enabled == nil || item.Enabled() } } t.Fatalf("no item called %q in %v", label, labels(items)) return false } func TestCreateAndOpenAreNeverBothAvailable(t *testing.T) { // The rule the three files share: you can create the one you have not got, // and open the one you have. Exactly one of each pair, always. a, project := newProjectApp(t) pairs := []struct { file string create, open string items func() []*ui.MenuItem make func() }{ {"settings", "Create project settings", "Project settings…", func() []*ui.MenuItem { return a.optionsMenu().Items }, a.CreateProjectSettings}, {"tools", "Create tools file", "Open tools file", a.toolItems, a.CreateTools}, {"snippets", "Create snippets file", "Open snippets file", a.snippetItems, a.CreateSnippets}, } for _, pair := range pairs { t.Run(pair.file, func(t *testing.T) { items := pair.items() if !enabledOf(t, items, pair.create) { t.Errorf("%s: create is greyed out with no file to speak of", pair.file) } if enabledOf(t, items, pair.open) { t.Errorf("%s: open is available with no file there", pair.file) } pair.make() items = pair.items() if enabledOf(t, items, pair.create) { t.Errorf("%s: create is still available once the file exists, in %s", pair.file, project) } if !enabledOf(t, items, pair.open) { t.Errorf("%s: open is greyed out with the file right there", pair.file) } }) } } func TestOpeningTheToolsFileOpensIt(t *testing.T) { a, project := newProjectApp(t) a.CreateTools() a.CloseFile() a.OpenTools() if got := a.Desktop().Active().Title(); got != tools.FileName { t.Errorf("the window shows %q, want %q", got, tools.FileName) } if got := activeBuffer(t, a).Path(); !samePath(got, tools.Path(testProfile(), project)) { t.Errorf("the window holds %q, want the project's tools file", got) } } func TestOpeningTheSnippetsFileOpensTheProjectsOwn(t *testing.T) { // The project's, not the user's. A menu that sometimes opened one file and // sometimes another would be a menu nobody could predict. a, project := newProjectApp(t) a.CreateSnippets() a.CloseFile() a.OpenSnippets() if got := activeBuffer(t, a).Path(); !samePath(got, snippets.ProjectPath(testProfile(), project)) { t.Errorf("the window holds %q, want the project's snippets file", got) } } func TestOpeningAFileTheProjectHasNotGotSaysWhereToMakeIt(t *testing.T) { // The menu items are greyed out, so this only happens to a caller that is // not a menu — but "nothing happened" is not something anybody can act on. a, _ := newProjectApp(t) for _, open := range []func(){a.OpenProjectSettings, a.OpenTools, a.OpenSnippets} { before := a.Modals() open() if a.Modals() != before+1 { t.Fatalf("opening a file that is not there said nothing") } press(a, tcell.KeyEscape, 0, tcell.ModNone) } } func TestOpeningAProjectFileNeverCreatesIt(t *testing.T) { // "Open" that writes would put a directory into somebody's repository for // them, which is what the separate create item exists to avoid. a, project := newProjectApp(t) a.OpenProjectSettings() press(a, tcell.KeyEscape, 0, tcell.ModNone) a.OpenTools() press(a, tcell.KeyEscape, 0, tcell.ModNone) a.OpenSnippets() press(a, tcell.KeyEscape, 0, tcell.ModNone) if _, err := os.Stat(filepath.Join(project, testProfile().ProjectDir())); !os.IsNotExist(err) { t.Errorf("opening created %s", testProfile().ProjectDir()) } }