| 🛟 Updated. 28d5985 k33g 14h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "codeberg.org/turbo-editors/turbo-core/settings" |
| 11 | ) |
| 12 | |
| 13 | // fakeClock is a clock the test moves by hand, so an autosave deadline is |
| 14 | // reached without anything waiting for it. |
| 15 | type fakeClock struct{ at time.Time } |
| 16 | |
| 17 | func (c *fakeClock) now() time.Time { return c.at } |
| 18 | func (c *fakeClock) pass(d time.Duration) { c.at = c.at.Add(d) } |
| 19 | |
| 20 | // newAutosaveApp returns an editor with autosave on, a clock the test drives, |
| 21 | // and a file open in the front window. |
| 22 | func newAutosaveApp(t *testing.T, delay time.Duration) (*App, *fakeClock, string) { |
| 23 | t.Helper() |
| 24 | |
| 25 | a, _ := newTestApp(t) |
| 26 | clock := &fakeClock{at: time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)} |
| 27 | a.now = clock.now |
| 28 | |
| 29 | path := filepath.Join(t.TempDir(), "main.go") |
| 30 | writeTestFile(t, path, "package main\n") |
| 31 | a.Open(path) |
| 32 | a.SetAutosave(true, delay) |
| 33 | |
| 34 | return a, clock, path |
| 35 | } |
| 36 | |
| 37 | // onDisk returns what a file holds right now. |
| 38 | func onDisk(t *testing.T, path string) string { |
| 39 | t.Helper() |
| 40 | |
| 41 | data, err := os.ReadFile(path) |
| 42 | if err != nil { |
| 43 | t.Fatalf("reading %s: %v", path, err) |
| 44 | } |
| 45 | return string(data) |
| 46 | } |
| 47 | |
| 48 | func TestAutosaveIsOffUnlessAProjectAsksForIt(t *testing.T) { |
| 49 | a, _ := newTestApp(t) |
| 50 | |
| 51 | if a.AutosaveEnabled() { |
| 52 | t.Error("autosave is on in a project that never asked for it") |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestAutosaveWritesTheFileOnceTypingStops(t *testing.T) { |
| 57 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 58 | |
| 59 | typeText(a, "// hello") |
| 60 | clock.pass(2 * time.Second) |
| 61 | a.saveDueDocuments() |
| 62 | |
| 63 | if got := onDisk(t, path); !strings.Contains(got, "// hello") { |
| 64 | t.Errorf("the file on disk is %q; autosave did not write it", got) |
| 65 | } |
| 66 | if activeBuffer(t, a).Modified() { |
| 67 | t.Error("the buffer is still marked modified after being saved") |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestAutosaveWaitsWhileTypingContinues(t *testing.T) { |
| 72 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 73 | |
| 74 | // Each keystroke pushes the deadline out again, so a steady typist is |
| 75 | // never interrupted by a write. |
| 76 | for range 5 { |
| 77 | typeText(a, "x") |
| 78 | clock.pass(1900 * time.Millisecond) |
| 79 | a.saveDueDocuments() |
| 80 | } |
| 81 | |
| 82 | if got := onDisk(t, path); got != "package main\n" { |
| 83 | t.Errorf("the file was written mid-typing: %q", got) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestAutosaveDoesNothingBeforeTheDelayHasPassed(t *testing.T) { |
| 88 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 89 | |
| 90 | typeText(a, "// hello") |
| 91 | clock.pass(1999 * time.Millisecond) |
| 92 | a.saveDueDocuments() |
| 93 | |
| 94 | if got := onDisk(t, path); got != "package main\n" { |
| 95 | t.Errorf("the file was written a millisecond early: %q", got) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestAutosaveDoesNothingWhenItIsOff(t *testing.T) { |
| 100 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 101 | a.SetAutosave(false, 0) |
| 102 | |
| 103 | typeText(a, "// hello") |
| 104 | clock.pass(time.Hour) |
| 105 | a.saveDueDocuments() |
| 106 | |
| 107 | if got := onDisk(t, path); got != "package main\n" { |
| 108 | t.Errorf("a file was written with autosave off: %q", got) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestTurningAutosaveOffForgetsThePendingSave(t *testing.T) { |
| 113 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 114 | |
| 115 | typeText(a, "// hello") |
| 116 | a.SetAutosave(false, 0) // the deadline was already set when this ran |
| 117 | clock.pass(time.Hour) |
| 118 | a.saveDueDocuments() |
| 119 | |
| 120 | if got := onDisk(t, path); got != "package main\n" { |
| 121 | t.Errorf("a save pending when autosave was turned off still happened: %q", got) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestAutosaveLeavesAFileThatHasNoNameAlone(t *testing.T) { |
| 126 | a, clock, _ := newAutosaveApp(t, 2*time.Second) |
| 127 | a.NewFile() // untitled: naming it is a question, and autosave asks nothing |
| 128 | |
| 129 | typeText(a, "package main") |
| 130 | clock.pass(2 * time.Second) |
| 131 | a.saveDueDocuments() |
| 132 | |
| 133 | if a.Modals() != 0 { |
| 134 | t.Errorf("autosave opened %d dialogs; it must never ask anything", a.Modals()) |
| 135 | } |
| 136 | if !activeBuffer(t, a).Modified() { |
| 137 | t.Error("an unnamed buffer was somehow saved") |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestAutosaveSavesEveryOpenFile(t *testing.T) { |
| 142 | a, clock, first := newAutosaveApp(t, 2*time.Second) |
| 143 | second := filepath.Join(t.TempDir(), "other.go") |
| 144 | writeTestFile(t, second, "package other\n") |
| 145 | a.Open(second) |
| 146 | |
| 147 | typeText(a, "// two") |
| 148 | a.NextWindow() |
| 149 | typeText(a, "// one") |
| 150 | clock.pass(2 * time.Second) |
| 151 | a.saveDueDocuments() |
| 152 | |
| 153 | if got := onDisk(t, first); !strings.Contains(got, "// one") { |
| 154 | t.Errorf("the first file was not saved: %q", got) |
| 155 | } |
| 156 | if got := onDisk(t, second); !strings.Contains(got, "// two") { |
| 157 | t.Errorf("the second file was not saved: %q", got) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestAFailedAutosaveIsReportedWithoutADialog(t *testing.T) { |
| 162 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 163 | typeText(a, "// hello") |
| 164 | |
| 165 | // A directory cannot be written to as a file, which is the simplest way to |
| 166 | // make the write fail without depending on who is running the test. |
| 167 | if err := os.Remove(path); err != nil { |
| 168 | t.Fatalf("removing the file: %v", err) |
| 169 | } |
| 170 | if err := os.Mkdir(path, 0o755); err != nil { |
| 171 | t.Fatalf("putting a directory in its place: %v", err) |
| 172 | } |
| 173 | |
| 174 | clock.pass(2 * time.Second) |
| 175 | a.saveDueDocuments() |
| 176 | |
| 177 | if a.Modals() != 0 { |
| 178 | t.Errorf("a failed autosave opened %d dialogs", a.Modals()) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestAFailedAutosaveIsNotRetriedUntilTheNextEdit(t *testing.T) { |
| 183 | a, clock, path := newAutosaveApp(t, 2*time.Second) |
| 184 | typeText(a, "// hello") |
| 185 | if err := os.Remove(path); err != nil { |
| 186 | t.Fatalf("removing the file: %v", err) |
| 187 | } |
| 188 | if err := os.Mkdir(path, 0o755); err != nil { |
| 189 | t.Fatalf("putting a directory in its place: %v", err) |
| 190 | } |
| 191 | |
| 192 | clock.pass(2 * time.Second) |
| 193 | a.saveDueDocuments() |
| 194 | |
| 195 | // An attempt that failed must leave nothing pending. A deadline still set |
| 196 | // would come due again on the very next turn of the event loop, and go on |
| 197 | // doing so for as long as the editor is open. |
| 198 | if !a.autosave.due.IsZero() { |
| 199 | t.Error("a failed autosave left its deadline set, so it will retry forever") |
| 200 | } |
| 201 | |
| 202 | // And a further turn of the loop really does nothing, rather than merely |
| 203 | // not being due yet. |
| 204 | clock.pass(time.Hour) |
| 205 | a.saveDueDocuments() |
| 206 | if a.Modals() != 0 { |
| 207 | t.Error("a later turn of the event loop retried the failed save") |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | func TestClosingAWindowWithAutosaveOnAsksNothing(t *testing.T) { |
| 212 | a, _, path := newAutosaveApp(t, 2*time.Second) |
| 213 | |
| 214 | typeText(a, "// hello") |
| 215 | a.CloseFile() |
| 216 | |
| 217 | if a.Modals() != 0 { |
| 218 | t.Errorf("closing asked about work autosave was going to write (%d dialogs)", a.Modals()) |
| 219 | } |
| 220 | if a.Desktop().Count() != 0 { |
| 221 | t.Error("the window did not close") |
| 222 | } |
| 223 | if got := onDisk(t, path); !strings.Contains(got, "// hello") { |
| 224 | t.Errorf("closing discarded the changes instead of saving them: %q", got) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func TestClosingAnUnnamedWindowStillAsksWithAutosaveOn(t *testing.T) { |
| 229 | a, _, _ := newAutosaveApp(t, 2*time.Second) |
| 230 | a.NewFile() |
| 231 | |
| 232 | typeText(a, "package main") |
| 233 | a.CloseFile() |
| 234 | |
| 235 | if a.Modals() != 1 { |
| 236 | t.Errorf("Modals() = %d; work autosave cannot write is still a real question", a.Modals()) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestLeavingWithAutosaveOnSavesInsteadOfAsking(t *testing.T) { |
| 241 | a, _, path := newAutosaveApp(t, 2*time.Second) |
| 242 | |
| 243 | typeText(a, "// hello") |
| 244 | a.Quit() |
| 245 | |
| 246 | if a.Modals() != 0 { |
| 247 | t.Errorf("Quit() asked about work autosave was going to write (%d dialogs)", a.Modals()) |
| 248 | } |
| 249 | if !a.Quitting() { |
| 250 | t.Error("Quit() did not leave") |
| 251 | } |
| 252 | if got := onDisk(t, path); !strings.Contains(got, "// hello") { |
| 253 | t.Errorf("leaving discarded the changes: %q", got) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | func TestLeavingStillAsksAboutAnUnnamedFile(t *testing.T) { |
| 258 | a, _, _ := newAutosaveApp(t, 2*time.Second) |
| 259 | a.NewFile() |
| 260 | typeText(a, "package main") |
| 261 | |
| 262 | a.Quit() |
| 263 | |
| 264 | if a.Quitting() { |
| 265 | t.Error("Quit() left without asking about a file it could not save") |
| 266 | } |
| 267 | if a.Modals() != 1 { |
| 268 | t.Errorf("Modals() = %d, want the question", a.Modals()) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func TestSetAutosaveKeepsTheDelayWhenGivenNone(t *testing.T) { |
| 273 | a, _ := newTestApp(t) |
| 274 | a.SetAutosave(true, 5*time.Second) |
| 275 | |
| 276 | a.SetAutosave(true, 0) // "on", with nothing said about how long |
| 277 | |
| 278 | if a.autosave.delay != 5*time.Second { |
| 279 | t.Errorf("delay = %v, want the 5s already set rather than zero", a.autosave.delay) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func TestANewEditorHasTheDefaultAutosaveDelay(t *testing.T) { |
| 284 | a, _ := newTestApp(t) |
| 285 | |
| 286 | // Zero would mean "save on every keystroke" the moment a project turned |
| 287 | // autosave on without naming a delay. |
| 288 | if a.autosave.delay <= 0 { |
| 289 | t.Errorf("the default delay is %v", a.autosave.delay) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | func TestUseSettingsAppliesAutosaveAndRemembersTheFile(t *testing.T) { |
| 294 | a, _ := newTestApp(t) |
| 295 | |
| 296 | a.UseSettings(settings.Settings{Autosave: true, AutosaveDelay: 5 * time.Second}, "/p/.turbo-go/settings.toml") |
| 297 | |
| 298 | if !a.AutosaveEnabled() { |
| 299 | t.Error("UseSettings() did not turn autosave on") |
| 300 | } |
| 301 | if a.autosave.delay != 5*time.Second { |
| 302 | t.Errorf("delay = %v, want 5s", a.autosave.delay) |
| 303 | } |
| 304 | if a.SettingsPath() != "/p/.turbo-go/settings.toml" { |
| 305 | t.Errorf("SettingsPath() = %q", a.SettingsPath()) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | func TestUseSettingsDoesNotApplyTheTheme(t *testing.T) { |
| 310 | // main resolves the theme, because only it knows whether -theme was given |
| 311 | // and an explicit flag outranks the project's choice. |
| 312 | a, _ := newTestApp(t) |
| 313 | before := a.ThemeName() |
| 314 | |
| 315 | a.UseSettings(settings.Settings{Theme: "turbo-dark"}, "/p/.turbo-go/settings.toml") |
| 316 | |
| 317 | if a.ThemeName() != before { |
| 318 | t.Errorf("ThemeName() = %q; UseSettings must leave the theme to the caller", a.ThemeName()) |
| 319 | } |
| 320 | } |