// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "path/filepath" "slices" "strings" "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 TestMenuRemoteAndQuickSavePreserveNextLaunchChoices(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() path := filepath.Join(t.TempDir(), "settings.toml") base := config.Defaults() source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}} voice := &fakeVoice{} keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }} a, err := new_application(ctx, source, keys, voice, RunOptions{Settings: base, ConfigPath: path}) if err != nil { t.Fatal(err) } defer a.ui.close() // Exercise the successful menu-save callback with both live and next-launch // choices, then interleave session commands, remote changes and a quick save. menuSaved := base menuSaved.Keyboard.Layout = "laptop" menuSaved.Presentation.Speech.Module = "next-module" menuSaved.Presentation.Speech.Rate = 60 menuSaved.Review.LineBreak = false menuSaved.Presentation.Sound.Driver = "alsa" menuSaved.Presentation.Sound.Theme = "fixture-pack" menuSaved.Presentation.Sound.Device = "null" if err := config.Save(path, menuSaved); err != nil { t.Fatal(err) } a.apply_saved_settings(menuSaved, false) if a.settings.live.Keyboard.Layout != base.Keyboard.Layout || a.settings.live.Presentation.Speech.Module != base.Presentation.Speech.Module || a.settings.live.Review != base.Review || a.settings.live.Presentation.Sound != base.Presentation.Sound { t.Fatal("next-launch choices changed the running configuration") } if len(voice.parameters) == 0 || voice.parameters[len(voice.parameters)-1].Rate != 60 { t.Fatal("menu speech adjustment did not apply live", voice.parameters) } for _, id := range []command.ID{command.Mute, command.AutoRead, command.ToggleAttributes} { a.handle_input(input.Decision{Command: id}) } for _, request := range []struct { text string want string }{ {"SETTING SET speech#enabled=True;unknown#setting=True", "Unsupported remote setting"}, {"SETTING SET keyboard#word_echo=True", ""}, } { replies := make(chan string, 1) a.remote_request(control.Request{UID: 0, Text: request.text, Reply: replies}) if reply := <-replies; !strings.HasPrefix(reply, request.want) || request.want == "" && reply != "" { t.Fatal("unexpected remote response", reply) } if !voice.muted || a.engine.Automatic_reading() { t.Fatal("remote assignment changed unrelated session state") } } for _, id := range []command.ID{command.NextQuickValue, command.SaveSettings, command.Quit} { a.handle_input(input.Decision{Command: id}) } if err := a.loop(nil); err != nil || ctx.Err() != nil { t.Fatal("save and quit failed", err, ctx.Err()) } saved, _, err := config.Load(path, nil) if err != nil { t.Fatal(err) } want := menuSaved want.Keyboard.WordEcho = true want.Presentation.Speech.Rate = 70 if saved.Keyboard.Layout != want.Keyboard.Layout || saved.Presentation.Speech.Module != want.Presentation.Speech.Module || saved.Review != want.Review || saved.Presentation.Speech.Rate != want.Presentation.Speech.Rate || !saved.Keyboard.WordEcho || saved.Presentation.Sound != want.Presentation.Sound { t.Fatal("mixed settings paths lost saved choices", saved) } if !saved.Presentation.Speech.Enabled || !saved.Reading.Automatic || saved.Reading.Attributes != base.Reading.Attributes { t.Fatal("session overrides leaked into the save", saved) } freshVoice := &fakeVoice{} fresh, err := new_application(ctx, source, &fakeKeys{}, freshVoice, RunOptions{Settings: saved}) if err != nil { t.Fatal(err) } defer fresh.ui.close() if err := fresh.read_screen(); err != nil || !slices.Contains(freshVoice.messages, "alpha") { t.Fatal("saved defaults did not restore startup reading", err, freshVoice.messages) } } func TestRemoteCannotEnableSpeechWithoutStartupConnection(t *testing.T) { base := config.Defaults() base.Presentation.Speech.Enabled = false a, err := new_application(context.Background(), &fakeSource{}, &fakeKeys{}, disabledVoice{}, RunOptions{Settings: base}) if err != nil { t.Fatal(err) } defer a.ui.close() response := a.apply_remote_settings("SET speech#enabled=True;keyboard#word_echo=True") if response != "Speech was disabled at startup; enable it in settings and relaunch" { t.Fatal(response) } if !a.muted || a.settings.live.Presentation.Speech.Enabled || a.settings.live.Keyboard.WordEcho || a.settings.saveDraft.Presentation.Speech.Enabled || a.settings.saveDraft.Keyboard.WordEcho { t.Fatal("rejected request partially changed live or saved settings") } }