After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+477
View File
@@ -0,0 +1,477 @@
// 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()
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 80"} {
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 != 90 || voice.parameters[len(voice.parameters)-1].Rate != 90 {
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 != 90 || 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 != 90 {
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 != 90 {
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 != 90 || 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 80" {
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)
}
}
}
}