// 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" "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" "git.stormux.org/storm/fenrir/internal/speech" ) func TestQuickSpeechSelectionAdjustmentAndBounds(t *testing.T) { settings := config.Defaults() settings.Presentation.Speech.Rate = 80 // Exercise the upper rate bound. voice := &fakeVoice{} menu := new_quick_settings("speech-dispatcher") for _, testCase := range []struct { id command.ID want string }{ {command.CurrentQuickEntry, "speech rate 80"}, {command.NextQuickValue, "90"}, {command.NextQuickValue, "100"}, {command.NextQuickValue, "100"}, {command.NextQuickEntry, "speech pitch 0"}, {command.PreviousQuickValue, "-10"}, {command.CurrentQuickValue, "-10"}, {command.NextQuickEntry, "speech volume 100"}, {command.PreviousQuickValue, "90"}, {command.NextQuickEntry, "Speech Dispatcher module default"}, {command.NextQuickEntry, "speech language default"}, {command.NextQuickEntry, "speech voice default"}, {command.CurrentQuickValue, "speech voice default"}, {command.NextQuickValue, "speech voice default"}, {command.NextQuickEntry, "speech rate 100"}, {command.PreviousQuickEntry, "speech voice default"}, {command.PreviousQuickEntry, "speech language default"}, {command.PreviousQuickEntry, "Speech Dispatcher module default"}, {command.PreviousQuickEntry, "speech volume 90"}, } { if !menu.command(testCase.id, &settings, voice) || voice.messages[len(voice.messages)-1] != testCase.want { t.Fatal(testCase, voice.messages) } } for range 30 { menu.command(command.PreviousQuickValue, &settings, voice) } if settings.Presentation.Speech.Volume != -100 || !menu.changed || settings.Validate() != nil { t.Fatal(settings, menu.changed) } if voice.parameters[len(voice.parameters)-1] != (speech.Parameters{Rate: 100, Pitch: -10, Volume: -100}) { t.Fatal(voice.parameters) } if menu.command(command.CurrentLine, &settings, voice) { t.Fatal("quick menu consumed an unrelated command") } } func TestHardwareQuickSettingsOnlyOfferSupportedControls(t *testing.T) { for _, driver := range []string{"dectalk", "dectalk-usb", "litetalk", "doubletalk", "tripletalk"} { settings := config.Defaults() settings.Presentation.Speech.Driver = driver settings.Presentation.Speech.HardwareDevice = "/dev/fixture-synth" menu, voice := new_quick_settings(driver), &fakeVoice{} menu.selectSpeech = func(config.Speech, func(error, bool)) { t.Fatal("hardware numeric adjustment tried reopening the device") } for _, want := range []string{"speech pitch 0", "speech volume 100", "speech rate 0"} { menu.command(command.NextQuickEntry, &settings, voice) if got := voice.messages[len(voice.messages)-1]; got != want { t.Fatal(driver, got, want) } } menu.command(command.NextQuickValue, &settings, voice) if settings.Presentation.Speech.Rate != 10 || voice.parameters[len(voice.parameters)-1].Rate != 10 { t.Fatal(settings, voice.parameters) } } } func TestQuickChangesSurviveMenuCancelAndExplicitSaveThenQuit(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") base := config.Defaults() base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_F9"} if err := config.Save(path, base); err != nil { t.Fatal(err) } source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}} voice, keys := &fakeVoice{}, &fakeKeys{} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() keys.poll = func(_ bool, handle func(input.Decision)) error { keys.poll = nil for _, id := range []command.ID{command.NextQuickValue, command.NextQuickEntry, command.PreviousQuickValue, command.AutoRead, command.ToggleTracking, command.SaveSettings, command.Quit} { handle(input.Decision{Command: id, Interrupt: true}) } return nil } if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: base, ConfigPath: path}); err != nil { t.Fatal(err) } saved, _, err := config.Load(path, nil) if err != nil || saved.Presentation.Speech.Rate != 10 || saved.Presentation.Speech.Pitch != -10 || !saved.Reading.Automatic || saved.Reading.Mode != "table" || !reflect.DeepEqual(saved.General, base.General) { t.Fatal(saved, err) } if !strings.Contains(strings.Join(voice.messages, "|"), "Settings saved.") { t.Fatal(voice.messages) } info, err := os.Stat(path) if err != nil || info.Mode().Perm() != 0600 { t.Fatal(info, err) } // Opening the full menu reflects unsaved quick adjustments; cancelling // it leaves the running values and the existing disk file intact. menu := new_quick_settings("speech-dispatcher") menu.command(command.NextQuickValue, &base, voice) ui := new_internal_ui(context.Background(), keys, voice) ui.open_settings(base, path, base) receive_ui(t, ui) if got := ui.session.Values()["presentation.speech.pitch"].Number; got != 0 { t.Fatal(got) } ui.handle(interaction.Key{Name: "escape"}) stillSaved, _, err := config.Load(path, nil) if err != nil || !reflect.DeepEqual(saved, stillSaved) { t.Fatal(stillSaved, err) } } func TestSessionMuteDoesNotPersistAcrossRestart(t *testing.T) { for _, trial := range []struct { name string enabled bool toggles int save bool }{ {name: "quit while muted", enabled: true, toggles: 1}, {name: "save while muted", enabled: true, toggles: 1, save: true}, {name: "save after unmuting", enabled: true, toggles: 2, save: true}, {name: "configured speech disabled", toggles: 1, save: true}, } { t.Run(trial.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") settings := config.Defaults() settings.Presentation.Speech.Enabled = trial.enabled if err := config.Save(path, settings); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() for session := range 2 { voice := &fakeVoice{} var speaker Speaker = voice if !settings.Presentation.Speech.Enabled { speaker = disabledVoice{} } source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}} keys := &fakeKeys{} keys.poll = func(_ bool, handle func(input.Decision)) error { keys.poll = nil if session == 0 { for range trial.toggles { handle(input.Decision{Command: command.Mute, Interrupt: true}) } if trial.enabled && voice.muted != (trial.toggles%2 == 1) { t.Fatal("session mute did not toggle", voice.muted) } if trial.save { handle(input.Decision{Command: command.NextQuickValue}) handle(input.Decision{Command: command.SaveSettings}) } } else if trial.enabled && !strings.Contains(strings.Join(voice.messages, "|"), "Start Fenrir") { t.Fatal("speech did not resume after restart", voice.messages) } handle(input.Decision{Command: command.Quit}) return nil } if err := run(ctx, source, keys, speaker, nil, RunOptions{Settings: settings, ConfigPath: path}); err != nil { t.Fatal(err) } var err error settings, _, err = config.Load(path, nil) if err != nil || settings.Presentation.Speech.Enabled != trial.enabled { t.Fatal("session mute changed saved speech enablement", settings.Presentation.Speech, err) } if trial.save && settings.Presentation.Speech.Rate != 10 { t.Fatal("quick speech adjustment was not saved", settings.Presentation.Speech) } } }) } } func TestSessionAutomaticReadingDoesNotPersistAcrossRestart(t *testing.T) { for _, trial := range []struct { name string automatic bool save bool }{ {"quit with reading off", true, false}, {"save with reading off", true, true}, {"configured reading off, quit with reading on", false, false}, {"configured reading off, save with reading on", false, true}, } { t.Run(trial.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.toml") settings := config.Defaults() settings.Reading.Automatic = trial.automatic if err := config.Save(path, settings); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() for session := range 2 { voice := &fakeVoice{} source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { hasIncomingText := strings.Contains(strings.Join(voice.messages, "|"), "hello") if hasIncomingText != trial.automatic { t.Fatalf("session %d: startup reading=%v, want %v; speech=%v", session, hasIncomingText, trial.automatic, voice.messages) } if session == 0 { handle(input.Decision{Command: command.AutoRead, Interrupt: true}) want := "autoread enabled" if trial.automatic { want = "autoread disabled" } if voice.messages[len(voice.messages)-1] != want { t.Fatal(voice.messages) } if trial.save { handle(input.Decision{Command: command.NextQuickValue}) handle(input.Decision{Command: command.SaveSettings}) } } else { // Even an explicitly disabled automatic-reading default // must leave manual speech available on a new session. voice.messages = nil handle(input.Decision{Command: command.CurrentLine, Interrupt: true}) if !reflect.DeepEqual(voice.messages, []string{"hello"}) { t.Fatal(voice.messages) } } handle(input.Decision{Command: command.Quit}) return nil }} if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path}); err != nil { t.Fatal(err) } var err error settings, _, err = config.Load(path, nil) if err != nil || settings.Reading.Automatic != trial.automatic || !settings.Presentation.Speech.Enabled { t.Fatal("session reading toggle changed saved startup behavior", settings, err) } if trial.save && settings.Presentation.Speech.Rate != 10 { t.Fatal("quick adjustment was not saved", settings.Presentation.Speech) } } }) } } func TestSaveFailureKeepsRunningQuickSettings(t *testing.T) { path := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(path, []byte("fixture"), 0600); err != nil { t.Fatal(err) } settings := config.Defaults() voice := &fakeVoice{} menu := new_quick_settings("speech-dispatcher") menu.command(command.NextQuickValue, &settings, voice) saver := &settingsSaver{results: make(chan error, 1)} saver.save(context.Background(), filepath.Join(path, "settings.toml"), settings) select { case err := <-saver.results: if err == nil { t.Fatal("invalid destination accepted") } saver.result(err, voice) case <-time.After(time.Second): t.Fatal("save hung") } if saver.busy || settings.Presentation.Speech.Rate != 10 || voice.messages[len(voice.messages)-1] != "Failed to save settings." { t.Fatal(settings, voice.messages) } } func TestQuickCategoriesRememberSelectionAndWrap(t *testing.T) { settings := config.Defaults() voice := &fakeVoice{} menu := new_quick_settings("speech-dispatcher") for _, trial := range []struct { id command.ID want string }{ {command.NextQuickEntry, "speech pitch 0"}, {command.NextQuickCategory, "Reading. Punctuation level some"}, {command.NextQuickValue, "most"}, {command.NextQuickValue, "all"}, {command.NextQuickValue, "none"}, {command.PreviousQuickValue, "all"}, {command.PreviousQuickEntry, "Attributes none"}, {command.NextQuickEntry, "Punctuation level all"}, {command.NextQuickEntry, "Indentation none"}, {command.NextQuickCategory, "Keyboard. Character echo checked"}, {command.NextQuickValue, "unchecked"}, {command.PreviousQuickEntry, "Echo on CapsLock unchecked"}, {command.NextQuickCategory, "Speech. speech pitch 0"}, {command.PreviousQuickCategory, "Keyboard. Echo on CapsLock unchecked"}, {command.PreviousQuickCategory, "Reading. Indentation none"}, {command.PreviousQuickCategory, "Speech. speech pitch 0"}, {command.PreviousQuickCategory, "Keyboard. Echo on CapsLock unchecked"}, } { menu.command(trial.id, &settings, voice) if got := voice.messages[len(voice.messages)-1]; got != trial.want { t.Fatalf("%s: got %q, want %q", trial.id, got, trial.want) } } if menu := new_quick_settings("speech-dispatcher"); !menu.command(command.CurrentQuickEntry, &settings, voice) || voice.messages[len(voice.messages)-1] != "speech rate 0" { t.Fatal("new session did not reset selection", voice.messages) } } func TestQuickEchoCombinationsAndMenuCancelOrSave(t *testing.T) { for mask := range 8 { for _, save := range []bool{false, true} { path := filepath.Join(t.TempDir(), "settings.toml") original := config.Defaults() if err := config.Save(path, original); err != nil { t.Fatal(err) } original, _, err := config.Load(path, nil) if err != nil { t.Fatal(err) } settings := original voice := &fakeVoice{} menu := new_quick_settings("speech-dispatcher") menu.command(command.NextQuickCategory, &settings, voice) menu.command(command.PreviousQuickValue, &settings, voice) // punctuation none menu.command(command.NextQuickCategory, &settings, voice) for index := range 3 { entry := menu.categories[menu.category].entries[index] if entry.Value(settings).(bool) != (mask&(1< 1 { keys.events <- nil } } keys.poll = func(_ bool, handle func(input.Decision)) error { polls++ switch polls { case 1: handle(input.Decision{Command: command.PreviousQuickCategory}) for index := range 3 { initial := index == 0 if initial != (mask&(1<