Files
fenrir/internal/app/quick_settings_test.go

479 lines
18 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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<<index) != 0) {
menu.command(command.NextQuickValue, &settings, voice)
}
menu.command(command.NextQuickEntry, &settings, voice)
}
want := settings
if settings.Keyboard.CharacterEcho != (mask&1 != 0) || settings.Keyboard.WordEcho != (mask&2 != 0) || settings.Keyboard.EchoOnCapsLock != (mask&4 != 0) {
t.Fatal(mask, settings.Keyboard)
}
a := settings_ui_application(t, &fakeKeys{}, voice, path)
ui := a.ui
ui.open_settings(settings, path, settings)
receive_ui(t, ui)
values := ui.session.Values()
if values["keyboard.character_echo"].Checked != settings.Keyboard.CharacterEcho || values["keyboard.word_echo"].Checked != settings.Keyboard.WordEcho || values["keyboard.echo_on_capslock"].Checked != settings.Keyboard.EchoOnCapsLock || values["reading.punctuation_level"].Choices[values["reading.punctuation_level"].Selected].ID != "none" {
t.Fatal("form lost unsaved quick choices", values)
}
if save {
ui.action("save", ui.session)
finish_persistence(t, a, false)
} else {
ui.handle(interaction.Key{Name: "escape"})
}
disk, _, err := config.Load(path, nil)
if !save {
want = original
}
if err != nil || !reflect.DeepEqual(disk.Keyboard, want.Keyboard) {
t.Fatal(mask, save, disk.Keyboard, err)
}
if disk.Reading.PunctuationLevel != want.Reading.PunctuationLevel || settings.Reading.PunctuationLevel != "none" {
t.Fatal("save/cancel changed punctuation incorrectly", save, disk.Reading)
}
}
}
}
func TestQuickEchoAppliesLiveWithAutomaticReadingOff(t *testing.T) {
for mask := range 8 {
for _, capsLock := range []bool{false, true} {
settings := config.Defaults()
settings.Reading.Automatic = false
settings.Reading.Spellcheck = false
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 6, X: 2, CapsLock: capsLock, Cells: []rune("$ ")}, events: make(chan error, 1)}
voice := &fakeVoice{}
keys := &fakeKeys{events: make(chan error, 1)}
reads, polls := 0, 0
source.afterRead = func() {
reads++
if reads > 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<<index) != 0) {
handle(input.Decision{Command: command.NextQuickValue})
}
handle(input.Decision{Command: command.NextQuickEntry})
}
voice.messages = nil
handle(input.Decision{Forward: true, Interrupt: true, Keypress: true, Typing: true})
source.snapshot.Cells = []rune("$ a ")
source.snapshot.X = 3
source.events <- nil
case 2:
var want []string
if mask&1 != 0 || mask&4 != 0 && capsLock {
want = []string{"a"}
}
if !reflect.DeepEqual(voice.messages, want) {
t.Fatal("live character echo", mask, capsLock, voice.messages, want)
}
voice.messages = nil
handle(input.Decision{Forward: true, Interrupt: true, Keypress: true, Typing: true, WordBoundary: true})
source.snapshot.X = 4
source.events <- nil
case 3:
var want []string
if mask&2 != 0 {
want = []string{"a"}
}
if !reflect.DeepEqual(voice.messages, want) {
t.Fatal("live word echo", mask, capsLock, voice.messages, want)
}
handle(input.Decision{Command: command.SaveSettings})
handle(input.Decision{Command: command.Quit})
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
path := filepath.Join(t.TempDir(), "settings.toml")
err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path})
cancel()
if err != nil || polls != 3 {
t.Fatal(err, polls)
}
saved, _, err := config.Load(path, nil)
if err != nil || saved.Keyboard.CharacterEcho != (mask&1 != 0) || saved.Keyboard.WordEcho != (mask&2 != 0) || saved.Keyboard.EchoOnCapsLock != (mask&4 != 0) || saved.Reading.Automatic {
t.Fatal(mask, saved, err)
}
}
}
}