479 lines
18 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|