// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "context" "errors" "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/screen" ) func settings_ui_application(t *testing.T, keys *fakeKeys, voice *fakeVoice, path string) *application { t.Helper() a, err := new_application(context.Background(), &fakeSource{}, keys, voice, RunOptions{Settings: config.Defaults(), ConfigPath: path}) if err != nil { t.Fatal(err) } t.Cleanup(a.ui.close) return a } func receive_ui(t *testing.T, ui *internalUI) { t.Helper() select { case update := <-ui.updates: ui.update(update) case <-time.After(time.Second): t.Fatal("UI worker did not finish") } } func TestSettingsMenuCancelSaveAndReopen(t *testing.T) { for _, action := range []string{"escape", "cancel", "save"} { t.Run(action, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") if err := os.WriteFile(path, []byte("version=1\n[speech]\nrate=40\n"), 0600); err != nil { t.Fatal(err) } keys, voice := &fakeKeys{}, &fakeVoice{} a := settings_ui_application(t, keys, voice, path) ui := a.ui ui.open_settings(config.Defaults(), path) receive_ui(t, ui) if !ui.active || voice.messages[len(voice.messages)-1] != "Fenrir Menu. Presentation tab" { t.Fatal(ui.active, voice.messages) } ui.handle(interaction.Key{Name: "tab"}) ui.handle(interaction.Key{Name: "tab"}) ui.handle(interaction.Key{Name: "up"}) if action == "escape" { ui.handle(interaction.Key{Name: "escape"}) } else { for range 13 { ui.handle(interaction.Key{Name: "tab"}) } if action == "cancel" { ui.handle(interaction.Key{Name: "tab"}) } ui.handle(interaction.Key{Name: "enter"}) if action == "save" { finish_persistence(t, a, false) } } if ui.active || !reflect.DeepEqual(keys.captures, []bool{true, false}) { t.Fatal("capture survived close", ui.active, keys.captures) } if got := voice.messages[len(voice.messages)-1]; got != "Fenrir Menu closed" { t.Fatal("missing close announcement", got) } settings, _, err := config.Load(path, nil) want := 40 if action == "save" { want = 50 } if err != nil || settings.Presentation.Speech.Rate != want { t.Fatal("wrong persistence", settings, err) } ui.open_settings(config.Defaults(), path) receive_ui(t, ui) ui.handle(interaction.Key{Name: "tab"}) ui.handle(interaction.Key{Name: "tab"}) if ui.session.Values()["presentation.speech.rate"].Number != float64(want) { t.Fatal("reopen did not reload settings") } ui.close() }) } } func TestMenuSoundVolumeAppliesOnlyAfterSuccessfulSave(t *testing.T) { for _, action := range []string{"lower", "zero", "raise", "cancel", "failed save"} { t.Run(action, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") // Put Sound first so traversal starts with its controls. if err := os.WriteFile(path, []byte("version=3\n[presentation.sound]\nvolume=0.7\n"), 0600); err != nil { t.Fatal(err) } source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}} keys := &fakeKeys{events: make(chan error, 4)} voice := &fakeVoice{} sounds := &fakeSounds{volume: 0.7} 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, shift bool) { handle(input.Decision{UI: &interaction.Key{Name: name, Shift: shift}}) } switch step { case 1: handle(input.Decision{Command: command.FenrirMenu}) case 2: key("tab", false) key("tab", false) if action == "raise" { key("up", false) } else { for range 6 { key("left", false) } if action == "zero" { key("left", false) } } if sounds.volume != 0.7 { t.Fatal("draft changed running volume", sounds.volume) } if action == "cancel" { key("escape", false) break } if action == "failed save" { // An external edit must reject the save and retain the draft. if err := os.WriteFile(path, []byte("version=3\n[presentation.sound]\nvolume=0.8\n"), 0600); err != nil { t.Fatal(err) } } // Move back through Sound enabled, the tab label and Cancel to OK. for range 4 { key("tab", true) } key("enter", false) case 3: if action == "failed save" { key("escape", false) } 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(), Sounds: sounds, ConfigPath: path}); err != nil || step != 3 { t.Fatal("menu workflow incomplete", err, step, voice.messages) } want := 0.1 switch action { case "zero": want = 0 case "raise": want = 1 case "cancel", "failed save": want = 0.7 } if sounds.volume != want { t.Fatal("running sound volume", sounds.volume, "want", want) } }) } } func TestMenuSaveFailureRetainsEditsAndCapture(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") if err := config.Save(path, config.Defaults()); err != nil { t.Fatal(err) } a := settings_ui_application(t, &fakeKeys{}, &fakeVoice{}, path) ui := a.ui ui.open_settings(config.Defaults(), path) receive_ui(t, ui) ui.handle(interaction.Key{Name: "tab"}) ui.handle(interaction.Key{Name: "space"}) // Speech enabled false. other := config.Defaults() other.Presentation.Speech.Rate = 12 if err := config.Save(path, other); err != nil { t.Fatal(err) } ui.action("save", ui.session) finish_persistence(t, a, true) if !ui.active || ui.session == nil || ui.session.Values()["presentation.speech.enabled"].Checked { t.Fatal("failed save lost draft or capture") } voice := a.voice.Speaker.(*fakeVoice) if !strings.Contains(voice.messages[len(voice.messages)-1], "settings changed") { t.Fatal(voice.messages) } ui.handle(interaction.Key{Name: "escape"}) settings, _, err := config.Load(path, nil) if err != nil || settings.Presentation.Speech.Rate != 12 { t.Fatal("external edit overwritten", settings, err) } } func TestMenuRejectsStaleWorkerAndCaptureFailure(t *testing.T) { keys := &fakeKeys{captureErr: errors.New("fixture capture denied")} voice := &fakeVoice{} ui := new_internal_ui(context.Background(), keys, voice) ui.open_settings(config.Defaults(), filepath.Join(t.TempDir(), "settings.toml")) if ui.active || !strings.Contains(strings.Join(voice.messages, "|"), "capture denied") { t.Fatal(ui.active, voice.messages) } keys.captureErr = nil ui.open_settings(config.Defaults(), filepath.Join(t.TempDir(), "settings.toml")) ui.close() closedCount := len(voice.messages) receive_ui(t, ui) if ui.active || ui.session != nil || len(voice.messages) != closedCount { t.Fatal("closed UI resurrected") } } func TestInternalUICloseUsesTitleWithoutLoadedSessionAndOnlyOnce(t *testing.T) { for _, title := range []string{"Fenrir settings", "Actions"} { keys, voice := &fakeKeys{}, &fakeVoice{} ui := new_internal_ui(context.Background(), keys, voice) if _, err := ui.begin(title); err != nil { t.Fatal(err) } ui.handle(interaction.Key{Name: "escape"}) ui.close() if ui.active || !reflect.DeepEqual(voice.messages, []string{title + " closed"}) || !reflect.DeepEqual(keys.captures, []bool{true, false}) { t.Fatal(title, ui.active, voice.messages, keys.captures) } } } func TestMenuOpenFailureClosesWithNameAndReportsError(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") if err := os.WriteFile(path, []byte("not valid TOML"), 0600); err != nil { t.Fatal(err) } voice := &fakeVoice{} ui := new_internal_ui(context.Background(), &fakeKeys{}, voice) ui.open_settings(config.Defaults(), path) receive_ui(t, ui) if ui.active || len(voice.messages) != 2 || voice.messages[0] != "Fenrir Menu closed" || !strings.Contains(voice.messages[1], "Could not open Fenrir Menu:") { t.Fatal(ui.active, voice.messages) } } func TestMenuSuppressesBackgroundScreenAndRestoresCommands(t *testing.T) { source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("first")}, events: make(chan error, 1)} keys := &fakeKeys{events: make(chan error, 1)} voice := &fakeVoice{} step := 0 keys.poll = func(_ bool, handle func(input.Decision)) error { step++ if step == 1 { handle(input.Decision{Command: command.FenrirMenu, Interrupt: true}) } if step == 2 { handle(input.Decision{Command: command.PassNext}) // Blocked while UI owns input. handle(input.Decision{UI: &interaction.Key{Name: "escape"}, Interrupt: true}) handle(input.Decision{Command: command.CurrentLine, Interrupt: true}) handle(input.Decision{Command: command.Quit}) } return nil } voice.onSay = func(text string) { if !strings.HasPrefix(text, "Fenrir Menu.") { return } source.snapshot.Cells = []rune("later") source.afterRead = func() { source.afterRead = nil; keys.events <- nil } source.events <- nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), ConfigPath: filepath.Join(t.TempDir(), "settings.toml")}) if err != nil || step != 2 || keys.passed || !keys.closed { t.Fatal(err, step, keys) } count := 0 for _, text := range voice.messages { if text == "later" { count++ } } if count != 1 { t.Fatal("background echo escaped or review failed after close", voice.messages) } if !reflect.DeepEqual(keys.captures, []bool{true, false}) { t.Fatal(keys.captures) } } func TestMenuAbortsOnTerminalChange(t *testing.T) { source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune("x")}} keys := &fakeKeys{} voice := &fakeVoice{} step := 0 keys.poll = func(_ bool, handle func(input.Decision)) error { step++ if step == 1 { handle(input.Decision{Command: command.FenrirMenu}) source.snapshot.Terminal = 3 } if step == 2 { handle(input.Decision{Command: command.Quit}) } return nil } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := Run(ctx, source, keys, voice, RunOptions{Settings: config.Defaults(), ConfigPath: filepath.Join(t.TempDir(), "settings.toml")}); err != nil { t.Fatal(err) } if !reflect.DeepEqual(keys.captures, []bool{true, false}) { t.Fatal("capture moved between VTs", keys.captures) } if !strings.Contains(strings.Join(voice.messages, "|"), "Fenrir Menu closed") { t.Fatal("terminal change did not announce closure", voice.messages) } }