// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "reflect" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/interaction" ) func admit_menu_save(t *testing.T, a *application) { t.Helper() a.open_settings() receive_ui(t, a.ui) a.ui.handle(interaction.Key{Name: "tab"}) a.ui.handle(interaction.Key{Name: "tab"}) a.ui.handle(interaction.Key{Name: "right"}) // Rate 24 -> 25. a.ui.action("save", a.ui.session) if !a.saver.busy || a.ui.session != nil { t.Fatal("menu save was not admitted") } } func committed_menu_result(t *testing.T, a *application) error { t.Helper() select { case err := <-a.saver.results: return err case <-time.After(time.Second): t.Fatal("menu save worker stalled") return nil } } func TestMenuCommitSurvivesCloseAndHandoff(t *testing.T) { for _, terminalMode := range []bool{false, true} { for _, closeAction := range []string{"escape", "terminal", "focus", "other menu"} { t.Run(closeAction+map[bool]string{false: "/tty", true: "/x11"}[terminalMode], func(t *testing.T) { a, voice := persistence_application(t, terminalMode) admit_menu_save(t, a) err := committed_menu_result(t, a) // Gate completion after disk commit. if err != nil { t.Fatal(err) } switch closeAction { case "terminal", "focus": source := a.source.(*fakeSource) if closeAction == "terminal" { source.snapshot.Terminal++ } else { source.snapshot.Active = false } if err := a.read_screen(); err != nil { t.Fatal(err) } default: a.ui.handle(interaction.Key{Name: "escape"}) } if a.ui.active || a.keyboards.(*fakeKeys).captures[1] { t.Fatal("close did not release capture immediately") } if closeAction == "other menu" { a.ui.begin("Other menu") } generation, speechCount := a.ui.generation, len(voice.messages) a.settings_save_result(err) saved, _, err := config.Load(a.options.ConfigPath, nil) if err != nil || saved.Presentation.Speech.Rate != 25 || a.settings.live.Presentation.Speech.Rate != 25 || !reflect.DeepEqual(a.settings.saveDraft, saved) || !reflect.DeepEqual(a.settings.resetBase, saved) { t.Fatal("committed menu settings diverged", saved, a.settings, err) } if a.ui.generation != generation || len(voice.messages) != speechCount || a.ui.active != (closeAction == "other menu") { t.Fatal("stale save changed modal presentation") } configured := len(voice.parameters) a.settings_save_result(nil) if len(voice.parameters) != configured { t.Fatal("duplicate result applied twice") } if closeAction == "other menu" { a.ui.close() } a.open_settings() receive_ui(t, a.ui) if a.ui.session.Values()["presentation.speech.rate"].Number != 25 { t.Fatal("reopened form lost the commit") } }) } } } func TestMenuCommitPreservesNewerOverridesAndCoordinatesSaves(t *testing.T) { for _, terminalMode := range []bool{false, true} { a, _ := persistence_application(t, terminalMode) admit_menu_save(t, a) err := committed_menu_result(t, a) if err != nil { t.Fatal(err) } a.ui.close() a.open_settings() if a.ui.active { t.Fatal("settings menu reopened during an admitted save") } persistence_reply(t, persistence_request(a, "SAVE"), true) persistence_reply(t, persistence_request(a, "SAVEAS ignored.toml"), true) persistence_reply(t, persistence_request(a, "RESET"), true) a.save_settings() // Cannot replace the pending menu snapshot. persistence_reply(t, persistence_request(a, "SET speech#pitch=0.8;keyboard#word_echo=True"), false) for _, id := range []command.ID{command.NextQuickValue, command.PreviousQuickValue, command.ProgressMonitor, command.Mute, command.AutoRead} { a.handle_input(input.Decision{Command: id}) } // Returning rate to its old value still counts as a later adjustment. a.settings_save_result(err) if a.settings.live.Presentation.Speech.Rate != 24 || a.settings.live.Presentation.Speech.Pitch != 60 || !a.settings.live.Keyboard.WordEcho || a.settings.live.Presentation.Progress.Enabled == a.settings.resetBase.Presentation.Progress.Enabled || !a.muted || a.engine.Automatic_reading() || !a.quick.changed { t.Fatal("menu completion overwrote a later/session change", a.settings.live) } if a.settings.saveDraft.Presentation.Speech.Rate != 24 || a.settings.saveDraft.Presentation.Speech.Pitch != 60 || !a.settings.saveDraft.Keyboard.WordEcho || a.settings.resetBase.Presentation.Speech.Rate != 25 { t.Fatal("save draft or reset baseline diverged", a.settings) } persistence_reply(t, persistence_request(a, "RESET"), false) if a.settings.live.Presentation.Speech.Rate != 25 || a.settings.live.Presentation.Speech.Pitch != a.settings.resetBase.Presentation.Speech.Pitch || a.settings.live.Keyboard.WordEcho || !a.muted || a.engine.Automatic_reading() { t.Fatal("RESET did not restore committed values and preserve session toggles") } } } func TestMenuFailureAfterCloseIsSilentAndDoesNotCommit(t *testing.T) { a, voice := persistence_application(t, true) base := a.settings.resetBase a.open_settings() receive_ui(t, a.ui) external := base external.Presentation.Speech.Rate = 70 if err := config.Save(a.options.ConfigPath, external); err != nil { t.Fatal(err) } a.ui.action("save", a.ui.session) err := committed_menu_result(t, a) if err == nil { t.Fatal("external edit was overwritten") } a.ui.close() count := len(voice.messages) a.settings_save_result(err) if a.ui.active || a.saver.busy || len(voice.messages) != count || !reflect.DeepEqual(a.settings.resetBase, base) || !reflect.DeepEqual(a.settings.saveDraft, base) { t.Fatal("closed failure changed state or presented into another session") } } func TestQuitWaitsForMenuCommitAfterClose(t *testing.T) { a, _ := persistence_application(t, true) admit_menu_save(t, a) a.ui.close() a.handle_input(input.Decision{Command: command.Quit}) if a.quitting || !a.quitAfterSave { t.Fatal("quit did not wait for menu save") } if err := a.loop(nil); err != nil || a.ctx.Err() != nil || a.settings.resetBase.Presentation.Speech.Rate != 25 || a.saver.busy { t.Fatal("quit lost admitted commit", err, a.ctx.Err()) } }