// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "os" "path/filepath" "reflect" "strings" "syscall" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/control" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/screen" ) func persistence_application(t *testing.T, terminalMode bool) (*application, *fakeVoice) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) t.Cleanup(cancel) base := config.Defaults() base.Presentation.Speech.Rate = 24 base.Screen.IgnoredVTs = []int{2} path := filepath.Join(t.TempDir(), "settings.toml") if err := config.Save(path, base); err != nil { t.Fatal(err) } voice := &fakeVoice{} source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}} keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }} a, err := new_application(ctx, source, keys, voice, RunOptions{Settings: base, ConfigPath: path, TerminalMode: terminalMode, Sounds: &fakeSounds{}}) if err != nil { t.Fatal(err) } t.Cleanup(a.ui.close) if err := a.read_screen(); err != nil { t.Fatal(err) } return a, voice } func persistence_request(a *application, text string) chan string { reply := make(chan string, 1) a.remote_request(control.Request{UID: os.Geteuid(), Text: "SETTING " + text, Reply: reply}) return reply } func persistence_reply(t *testing.T, reply chan string, wantError bool) { t.Helper() select { case text := <-reply: if (text != "") != wantError { t.Fatal("unexpected persistence response", text, wantError) } case <-time.After(time.Second): t.Fatal("persistence reply missing") } } func finish_persistence(t *testing.T, a *application, wantError bool) { t.Helper() select { case err := <-a.saver.results: if (err != nil) != wantError { t.Fatal("unexpected save result", err) } a.settings_save_result(err) case <-time.After(time.Second): t.Fatal("save worker stalled") } } func TestRemoteResetRestoresLoadedValuesAndPreservesSessionState(t *testing.T) { for _, terminalMode := range []bool{false, true} { a, _ := persistence_application(t, terminalMode) base := a.settings.resetBase windowReply := make(chan string, 1) a.remote_request(control.Request{UID: os.Geteuid(), Text: "COMMAND WINDOW 0 0 4 0", Reply: windowReply}) persistence_reply(t, windowReply, false) regionRevision := a.engine.Region_revision() a.settings.saveDraft.Keyboard.Layout = "laptop" for _, id := range []command.ID{command.Mute, command.AutoRead, command.ToggleAttributes} { a.handle_input(input.Decision{Command: id}) } a.handle_input(input.Decision{Command: command.NextQuickValue}) persistence_reply(t, persistence_request(a, "SET speech#rate=0.2;sound#volume=0.3;screen#ignore_screen=7,8;general#punctuation_level=all;keyboard#word_echo=True"), false) persistence_reply(t, persistence_request(a, "RESET"), false) if !a.muted || a.engine.Automatic_reading() || a.settings.live.Presentation.Speech.Rate != base.Presentation.Speech.Rate || !reflect.DeepEqual(a.settings.live.Screen, base.Screen) || a.settings.live.Keyboard.WordEcho || a.settings.live.Presentation.Sound != base.Presentation.Sound || a.settings.live.Reading.PunctuationLevel != base.Reading.PunctuationLevel || a.settings.saveDraft.Keyboard.Layout != "laptop" { t.Fatal("reset lost session overrides, loaded values or next-launch choices", a.settings.live, a.settings.saveDraft) } if got := a.engine.Command(command.ToggleAttributes).Text; got != "announcement of attributes disabled" { t.Fatal("unrelated remote update/reset lost the session attribute toggle", got) } if !a.settings.saveDraft.Presentation.Speech.Enabled || !a.settings.saveDraft.Reading.Automatic || a.quick.changed || len(a.settings.remoteFields) != 0 || a.engine.Region_revision() != regionRevision { t.Fatal("reset persisted unrelated session state or retained overrides") } // A successful menu save becomes the next reset baseline too. menuSaved := a.settings.saveDraft menuSaved.Presentation.Speech.Rate = 44 a.apply_saved_settings(menuSaved, false) persistence_reply(t, persistence_request(a, "SET speech#rate=0.1;speech#enabled=False;reading#automatic=False;sound#enabled=False;sound#progress_monitoring=False;reading#cursor_echo=False;keyboard#char_echo_mode=2;keyboard#echo_on_capslock=True"), false) persistence_reply(t, persistence_request(a, "RESET"), false) if a.muted || !a.engine.Automatic_reading() || a.settings.live.Presentation != menuSaved.Presentation || a.settings.live.Keyboard.CharacterEcho != menuSaved.Keyboard.CharacterEcho || a.settings.live.Keyboard.EchoOnCapsLock != menuSaved.Keyboard.EchoOnCapsLock || a.settings.live.Reading.CursorEcho != menuSaved.Reading.CursorEcho { t.Fatal("reset did not restore explicitly overridden booleans or menu baseline", a.settings.live) } } } func TestRemoteSaveAsRepliesAfterWriteAndRetainsNewerOverrides(t *testing.T) { for _, terminalMode := range []bool{false, true} { a, _ := persistence_application(t, terminalMode) defaultPath := a.options.ConfigPath exportPath := filepath.Join(t.TempDir(), "export with spaces.toml") for _, id := range []command.ID{command.Mute, command.AutoRead} { a.handle_input(input.Decision{Command: id}) } persistence_reply(t, persistence_request(a, "SET speech#rate=0.2;sound#volume=0.3"), false) reply := persistence_request(a, "SAVEAS "+exportPath) select { case response := <-reply: t.Fatal("save acknowledged before result processing", response) default: } persistence_reply(t, persistence_request(a, "RESET"), true) persistence_reply(t, persistence_request(a, "SAVE"), true) // Keep input and later SET requests responsive while disk work runs. a.handle_input(input.Decision{Command: command.CurrentLine}) persistence_reply(t, persistence_request(a, "SET speech#rate=0.8;keyboard#word_echo=True"), false) finish_persistence(t, a, false) persistence_reply(t, reply, false) saved, _, err := config.Load(exportPath, nil) if err != nil || saved.Presentation.Speech.Rate != -60 || saved.Presentation.Sound.Volume != 0.3 || !saved.Presentation.Speech.Enabled || !saved.Reading.Automatic || saved.Keyboard.WordEcho { t.Fatal("save did not retain its admitted snapshot", saved, err) } info, err := os.Stat(exportPath) if err != nil || info.Mode().Perm() != 0600 { t.Fatal("export permissions", info, err) } original, _, err := config.Load(defaultPath, nil) if err != nil || original.Presentation.Speech.Rate != 24 || a.options.ConfigPath != defaultPath { t.Fatal("SAVEAS changed the default destination", original, err) } persistence_reply(t, persistence_request(a, "RESET"), false) if a.settings.live.Presentation.Speech.Rate != -60 || a.settings.live.Keyboard.WordEcho || !a.muted || a.engine.Automatic_reading() { t.Fatal("save completion lost newer changes or reset session state", a.settings.live) } persistence_reply(t, persistence_request(a, "SET speech#rate=0.9"), false) reply = persistence_request(a, "SAVE") // The existing normal quit waits for the admitted write and reply. a.handle_input(input.Decision{Command: command.Quit}) if err := a.loop(nil); err != nil || a.ctx.Err() != nil { t.Fatal("save/quit failed", err, a.ctx.Err()) } persistence_reply(t, reply, false) current, _, err := config.Load(defaultPath, nil) if err != nil || current.Presentation.Speech.Rate != 80 { t.Fatal("SAVE did not use original configured path", current, err) } unchanged, _, err := config.Load(exportPath, nil) if err != nil || !reflect.DeepEqual(unchanged, saved) { t.Fatal("later SAVE overwrote export", unchanged, err) } } } func TestRemotePersistenceFailureAndAdmissionPreserveState(t *testing.T) { a, _ := persistence_application(t, true) base := a.settings.resetBase persistence_reply(t, persistence_request(a, "SET speech#pitch=0.2;speech#volume=0.6;speech#auto_read_incoming=False"), false) for _, text := range []string{"SAVEAS", "SAVE unexpected", "RESET unexpected", "SAVEAS bad\x00path", "SAVEAS bad\npath"} { persistence_reply(t, persistence_request(a, text), true) if a.saver.busy { t.Fatal("malformed operation started a worker", text) } } a.ui.active = true persistence_reply(t, persistence_request(a, "RESET"), true) persistence_reply(t, persistence_request(a, "SAVE"), true) a.ui.active = false a.settings.live.Remote.EnableSettingsRemote = false persistence_reply(t, persistence_request(a, "RESET"), true) persistence_reply(t, persistence_request(a, "SAVEAS "+filepath.Join(t.TempDir(), "denied.toml")), true) a.settings.live.Remote.EnableSettingsRemote = true denied := make(chan string, 1) a.remote_request(control.Request{UID: os.Geteuid() + 1, Text: "SETTING RESET", Reply: denied}) persistence_reply(t, denied, true) if response, deferred := a.remote_settings_request("SAVEAS ignored.toml", control.Request{UID: os.Geteuid() + 1}); response != "SAVEAS requires the reader owner or root" || deferred || a.saver.busy { t.Fatal("SAVEAS granted arbitrary privileged writes", response, deferred) } reply := persistence_request(a, "SAVEAS "+t.TempDir()) // A directory cannot be a TOML file. finish_persistence(t, a, true) select { case response := <-reply: if !strings.HasPrefix(response, "Could not save settings:") { t.Fatal(response) } default: t.Fatal("failed write did not return an error") } if !reflect.DeepEqual(a.settings.resetBase, base) || len(a.settings.remoteFields) == 0 { t.Fatal("failed save advanced the reset baseline") } pipePath := filepath.Join(t.TempDir(), "blocked-settings") if err := syscall.Mkfifo(pipePath, 0600); err != nil { t.Fatal(err) } reply = persistence_request(a, "SAVEAS "+pipePath) finish_persistence(t, a, true) persistence_reply(t, reply, true) persistence_reply(t, persistence_request(a, "RESET"), false) if a.settings.live.Presentation.Speech != base.Presentation.Speech || !a.engine.Automatic_reading() { t.Fatal("failed-save overrides were not reset", a.settings.live) } }