// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "os" "path/filepath" "reflect" "strings" "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" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" ) func TestQuickAttributesChoicesMenuCancelSaveAndRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") base := config.Defaults() base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_META"} if err := config.Save(path, base); err != nil { t.Fatal(err) } voice := &fakeVoice{} quick := new_quick_settings("speech-dispatcher") quick.command(command.NextQuickCategory, &base, voice) for range 2 { quick.command(command.NextQuickEntry, &base, voice) } for _, trial := range []struct { id command.ID want string }{ {command.CurrentQuickEntry, "Attributes none"}, {command.NextQuickValue, "speech"}, {command.NextQuickValue, "beeps"}, {command.NextQuickValue, "speech and beeps"}, {command.NextQuickValue, "none"}, {command.PreviousQuickValue, "speech and beeps"}, } { quick.command(trial.id, &base, voice) if voice.messages[len(voice.messages)-1] != trial.want { t.Fatal(voice.messages) } } if len(voice.parameters) != 0 || base.Reading.Attributes != "both" { t.Fatal(base, voice.parameters) } ui := new_internal_ui(context.Background(), &fakeKeys{}, voice) ui.open_settings(base, path, base) receive_ui(t, ui) control := ui.session.Values()["reading.attributes"] if control.Choices[control.Selected].ID != "both" { t.Fatal("menu lost quick choice", control) } ui.handle(interaction.Key{Name: "escape"}) saved, _, err := config.Load(path, nil) if err != nil || saved.Reading.Attributes != "none" { t.Fatal("cancel saved choice", saved.Reading, err) } for session := range 2 { voice = &fakeVoice{} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { handle(input.Decision{Command: command.NextQuickCategory}) for range 2 { handle(input.Decision{Command: command.NextQuickEntry}) } if session == 0 { for range 3 { handle(input.Decision{Command: command.NextQuickValue}) } handle(input.Decision{Command: command.SaveSettings}) } else if voice.messages[len(voice.messages)-1] != "Attributes speech and beeps" { t.Fatal(voice.messages) } handle(input.Decision{Command: command.Quit}) return nil }} source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 4, Cells: []rune("root")}} ctx, cancel := context.WithTimeout(context.Background(), time.Second) err = run(ctx, source, keys, voice, nil, RunOptions{Settings: saved, ConfigPath: path}) cancel() if err != nil { t.Fatal(err) } saved, _, err = config.Load(path, nil) if err != nil || saved.Reading.Attributes != "both" || !reflect.DeepEqual(saved.General, base.General) { t.Fatal(saved, err) } } } func TestAttributeSoundSpeechFallbackMuteAndTemporarySilence(t *testing.T) { for _, mode := range []string{"none", "speech", "beeps", "both"} { for _, state := range []string{"enabled", "disabled", "zero volume", "missing", "muted", "temporary silence"} { t.Run(mode+"/"+state, func(t *testing.T) { settings := config.Defaults() settings.Reading.Attributes = mode settings.Reading.AttributeFormat = "fenrirBold" settings.Presentation.Sound.Enabled = state != "disabled" if state == "zero volume" { settings.Presentation.Sound.Volume = 0 } sounds, voice := &fakeSounds{}, &fakeVoice{} if state == "missing" { sounds.missing = map[presentation.Cue]bool{presentation.HasAttributes: true} } source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 1, Cells: []rune("ab"), Attributes: []screen.Attribute{{Foreground: screen.ColorDefault, Background: screen.ColorDefault}, {Style: screen.Bold}}}} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { if state == "muted" { handle(input.Decision{Command: command.Mute}) } if state == "temporary silence" { handle(input.Decision{Command: command.TempDisableSpeech}) } voice.messages, sounds.cues = nil, nil handle(input.Decision{Command: command.NextLine}) handle(input.Decision{Command: command.CurrentLine}) handle(input.Decision{Command: command.Quit}) return nil }} if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil { t.Fatal(err) } count := 0 for _, cue := range sounds.cues { if cue == presentation.HasAttributes { count++ } } wantCue := state == "enabled" && (mode == "beeps" || mode == "both") if (count == 1) != wantCue { t.Fatal("cue", sounds.cues) } count = 0 for _, text := range voice.messages { if text == "bold" || text == "has attribute" { count++ } } wantSpeech := mode != "none" && (mode != "beeps" || state != "enabled") && state != "muted" && state != "temporary silence" if (count == 1) != wantSpeech { t.Fatal("speech", voice.messages) } }) } } } func TestMenuAttributesAppliesOnlyAfterSuccessfulSave(t *testing.T) { for _, action := range []string{"save", "cancel", "failed save"} { t.Run(action, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") if err := os.WriteFile(path, []byte("version=3\n[reading]\nattributes='none'\nattribute_format='fenrirBold'\n"), 0600); err != nil { t.Fatal(err) } source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 8, Cells: []rune("root leaf"), Attributes: []screen.Attribute{{}, {}, {}, {}, {}, {}, {}, {}, {Style: screen.Bold}, {}, {}, {}, {}, {}, {}, {}}}} voice, keys := &fakeVoice{}, &fakeKeys{events: make(chan error, 4)} keys.onCapture = func(enabled bool) { if !enabled { keys.events <- nil } } voice.onSay = func(text string) { if strings.HasPrefix(text, "Fenrir Menu.") || strings.HasPrefix(text, "Could not save settings:") { keys.events <- nil } } step := 0 keys.poll = func(_ bool, handle func(input.Decision)) error { step++ key := func(name string) { handle(input.Decision{UI: &interaction.Key{Name: name}}) } switch step { case 1: handle(input.Decision{Command: command.FenrirMenu}) case 2: for range 10 { key("tab") } if voice.messages[len(voice.messages)-1] != "Attributes combo box none" { t.Fatal(voice.messages) } key("down") if action == "cancel" { key("escape") break } if action == "failed save" { if err := os.WriteFile(path, []byte("version=3\n[reading]\nattributes='beeps'\n"), 0600); err != nil { t.Fatal(err) } } for range 32 { key("tab") if voice.messages[len(voice.messages)-1] == "OK button" { break } } key("enter") case 3: if action == "failed save" { key("escape") } voice.messages = nil handle(input.Decision{Command: command.NextLine}) if strings.Contains(strings.Join(voice.messages, "|"), "bold") != (action == "save") { t.Fatal(action, voice.messages) } handle(input.Decision{Command: command.Quit}) } return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), ConfigPath: path}); err != nil || step != 3 { t.Fatal(err, step) } }) } } func TestAttributeSessionToggleQuickSaveAndFreshStartup(t *testing.T) { for _, mode := range []string{"none", "beeps", "speech", "both"} { t.Run(mode, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") settings := config.Defaults() settings.Reading.Attributes = mode settings.Reading.AttributeFormat = "fenrirBold" settings.Presentation.Sound.Enabled = false if err := config.Save(path, settings); err != nil { t.Fatal(err) } for session := range 2 { voice := &fakeVoice{} source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 1, Cells: []rune("ab"), Attributes: []screen.Attribute{{Foreground: screen.ColorDefault, Background: screen.ColorDefault}, {Style: screen.Bold}}}} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { if session == 0 { handle(input.Decision{Command: command.ToggleAttributes}) // Every quick command reapplies configuration; it must // preserve the session override and still save the default. handle(input.Decision{Command: command.NextQuickValue}) handle(input.Decision{Command: command.SaveSettings}) } voice.messages = nil handle(input.Decision{Command: command.NextLine}) feedback := false for _, text := range voice.messages { if text == "has attribute" || text == "bold" { feedback = true } } wantFeedback := mode != "none" if session == 0 { wantFeedback = !wantFeedback } if feedback != wantFeedback { t.Fatal(session, mode, voice.messages) } handle(input.Decision{Command: command.Quit}) return nil }} ctx, cancel := context.WithTimeout(context.Background(), time.Second) err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path}) cancel() if err != nil { t.Fatal(err) } saved, _, err := config.Load(path, nil) if err != nil || saved.Reading.Attributes != mode || saved.Reading.AttributeFormat != "fenrirBold" || saved.Presentation.Speech.Rate != 10 { t.Fatal("temporary toggle persisted", saved, err) } settings = saved } }) } }