836 lines
30 KiB
Go
836 lines
30 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"
|
|
"errors"
|
|
"io"
|
|
"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/presentation"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
"git.stormux.org/storm/fenrir/internal/speech"
|
|
"git.stormux.org/storm/fenrir/internal/terminal"
|
|
)
|
|
|
|
func TestTerminalExitDoesNotHideOtherServiceEOF(t *testing.T) {
|
|
for _, problem := range []error{terminal.ErrClosed, io.EOF} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}, readErr: problem}
|
|
keys := &fakeKeys{}
|
|
err := run(context.Background(), source, keys, &fakeVoice{}, nil)
|
|
if problem == terminal.ErrClosed && err != nil || problem == io.EOF && !errors.Is(err, io.EOF) || !keys.closed {
|
|
t.Fatalf("source %v: got %v, closed=%v", problem, err, keys.closed)
|
|
}
|
|
}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
|
|
keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
|
|
voice := &fakeVoice{errors: make(chan error, 1)}
|
|
voice.errors <- io.EOF
|
|
if err := run(context.Background(), source, keys, voice, nil); !errors.Is(err, io.EOF) || !keys.closed {
|
|
t.Fatal("speech EOF was hidden by normal terminal exit handling", err, keys.closed)
|
|
}
|
|
}
|
|
|
|
func TestApplicationNavigationAfterReviewResumesReading(t *testing.T) {
|
|
for _, forwarded := range []bool{true, false} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, Cells: []rune("alphaomega")}, events: make(chan error, 1)}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
voice := &fakeVoice{}
|
|
reads, polls := 0, 0
|
|
source.afterRead = func() {
|
|
reads++
|
|
if reads == 2 {
|
|
keys.events <- nil
|
|
}
|
|
}
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
polls++
|
|
if polls == 1 {
|
|
handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
|
|
handle(input.Decision{Command: command.NextLine, Interrupt: true})
|
|
if forwarded {
|
|
handle(input.Decision{Forward: true, Interrupt: true, Keypress: true})
|
|
} else {
|
|
handle(input.Decision{Command: command.PasteClipboard, Interrupt: true, Keypress: true})
|
|
}
|
|
voice.messages = nil
|
|
source.snapshot.Y = 1
|
|
source.events <- nil
|
|
} else {
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err := run(ctx, source, keys, voice, nil)
|
|
cancel()
|
|
if err != nil || reads != 2 || polls != 2 {
|
|
t.Fatal("fixture did not process navigation", err, reads, polls)
|
|
}
|
|
var want []string
|
|
if forwarded {
|
|
want = []string{"omega"}
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, want) {
|
|
t.Fatal("review exit did not distinguish application input from reader commands", forwarded, voice.messages)
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeSounds struct {
|
|
volume float64
|
|
progress []float64
|
|
indentation []int
|
|
cues []presentation.Cue
|
|
errors chan error
|
|
missing map[presentation.Cue]bool
|
|
}
|
|
|
|
func (f *fakeSounds) Set_volume(volume float64) { f.volume = volume }
|
|
|
|
func (f *fakeSounds) Play_wait(_ context.Context, cue presentation.Cue) error {
|
|
f.Play(cue)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSounds) Play(cue presentation.Cue) bool {
|
|
if f.missing[cue] {
|
|
return false
|
|
}
|
|
f.cues = append(f.cues, cue)
|
|
return true
|
|
}
|
|
func (f *fakeSounds) Play_progress(value float64) bool {
|
|
f.progress = append(f.progress, value)
|
|
return true
|
|
}
|
|
func (f *fakeSounds) Play_indentation(level int) bool {
|
|
f.indentation = append(f.indentation, level)
|
|
return true
|
|
}
|
|
func (f *fakeSounds) Clear_progress() {}
|
|
func (f *fakeSounds) Discard_progress() {}
|
|
func (f *fakeSounds) Errors() <-chan error { return f.errors }
|
|
|
|
func TestSoundFailureDoesNotStopInputOrSpeech(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
sounds := &fakeSounds{errors: make(chan error)}
|
|
voice := &fakeVoice{}
|
|
calls := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
calls++
|
|
if calls == 2 {
|
|
handle(input.Decision{Command: command.Unbound})
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
go func() {
|
|
select {
|
|
case sounds.errors <- errors.New("fixture sound connection closed"):
|
|
keys.events <- nil
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Sounds: sounds}); err != nil {
|
|
t.Fatal("sound failure stopped reader", err)
|
|
}
|
|
if calls != 2 || !keys.closed {
|
|
t.Fatal("sound failure lost input/speech", calls, keys.closed, voice.messages)
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, []string{"unbound"}) {
|
|
t.Fatal("failed sound did not fall back to speech", voice.messages)
|
|
}
|
|
}
|
|
|
|
func TestAlertsUseSoundOrSpeech(t *testing.T) {
|
|
for _, mode := range []string{"enabled", "disabled", "unavailable", "missing cue"} {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
action command.ID
|
|
x, y int
|
|
cells string
|
|
text string
|
|
cue presentation.Cue
|
|
}{
|
|
{"blank row", command.CurrentLine, 0, 1, "blank ", "blank", presentation.Blank},
|
|
{"literal blank", command.CurrentLine, 0, 0, "blank ", "blank", ""},
|
|
{"previous row", command.PreviousLine, 0, 0, "blank ", "start of screen", presentation.Top},
|
|
{"next row", command.NextLine, 0, 1, "blank ", "end of screen", presentation.Bottom},
|
|
{"previous word", command.PreviousWord, 0, 0, "blank ", "start of screen", presentation.Top},
|
|
{"next word", command.NextWord, 0, 1, "blank ", "end of screen", presentation.Bottom},
|
|
{"previous character", command.PreviousChar, 0, 0, "blank ", "start of screen", presentation.Top},
|
|
{"next character", command.NextChar, 4, 1, "blank ", "end of screen", presentation.Bottom},
|
|
{"character above", command.ReviewUp, 2, 1, "alphaomega", "p", ""},
|
|
{"character below", command.ReviewDown, 2, 0, "alphaomega", "e", ""},
|
|
{"character above screen", command.ReviewUp, 2, 0, "alphaomega", "start of screen", presentation.Top},
|
|
{"character below screen", command.ReviewDown, 2, 1, "alphaomega", "end of screen", presentation.Bottom},
|
|
{"empty screen", command.ReadScreen, 0, 0, " ", "screen is empty", presentation.Blank},
|
|
{"blank screen before", command.ScreenBeforeCursor, 2, 1, " ", "blank", presentation.Blank},
|
|
{"blank screen after", command.ScreenAfterCursor, 2, 0, " ", "blank", presentation.Blank},
|
|
{"literal blank screen range", command.ScreenAfterCursor, 0, 0, "blank ", "blank\n", ""},
|
|
{"screen before", command.ScreenBeforeCursor, 2, 0, "alphaomega", "alp", ""},
|
|
{"screen after", command.ScreenAfterCursor, 2, 0, "alphaomega", "pha\nomega", ""},
|
|
{"explicit top", command.Top, 0, 1, "blank ", "Top", ""},
|
|
{"explicit bottom", command.Bottom, 0, 0, "blank ", "Bottom", ""},
|
|
{"automatic reading", command.AutoRead, 0, 0, "blank ", "autoread disabled", ""},
|
|
{"unbound", command.Unbound, 0, 0, "blank ", "unbound", presentation.Error},
|
|
} {
|
|
t.Run(mode+"/"+testCase.name, func(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, X: testCase.x, Y: testCase.y, Cells: []rune(testCase.cells)}}
|
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
voice.messages, sounds.cues = nil, nil // Inspect only this command.
|
|
handle(input.Decision{Command: testCase.action, Interrupt: true})
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
settings := config.Defaults()
|
|
settings.Presentation.Sound.Enabled = mode != "disabled"
|
|
var player SoundPlayer = sounds
|
|
if mode == "unavailable" {
|
|
player = nil
|
|
} else if mode == "missing cue" {
|
|
sounds.missing = map[presentation.Cue]bool{testCase.cue: true}
|
|
}
|
|
if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: player}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wantSounds []presentation.Cue
|
|
var wantSpeech []string
|
|
if mode == "enabled" && testCase.cue != "" {
|
|
wantSounds = []presentation.Cue{testCase.cue}
|
|
} else {
|
|
wantSpeech = []string{testCase.text}
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) || voice.interrupts != 1 {
|
|
t.Fatalf("speech=%v, sounds=%v, interrupts=%d; want speech=%v, sounds=%v, interrupts=1", voice.messages, sounds.cues, voice.interrupts, wantSpeech, wantSounds)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStartupAndUnmuteUseSoundOrSpeech(t *testing.T) {
|
|
for _, enabled := range []bool{false, true} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
|
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
handle(input.Decision{Command: command.Mute})
|
|
handle(input.Decision{Command: command.Mute})
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
settings := config.Defaults()
|
|
settings.Presentation.Sound.Enabled = enabled
|
|
if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantSpeech := []string{"hello"}
|
|
var wantSounds []presentation.Cue
|
|
if enabled {
|
|
wantSounds = []presentation.Cue{presentation.Ready, presentation.TTYChanged, presentation.SpeechOff, presentation.SpeechOn}
|
|
} else {
|
|
wantSpeech = []string{"Start Fenrir", "screen 2", "hello", "speech enabled"}
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) {
|
|
t.Fatalf("sound=%v: speech=%v, cues=%v; want speech=%v, cues=%v", enabled, voice.messages, sounds.cues, wantSpeech, wantSounds)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemporarySilenceDropsOutputUntilFreshKeypress(t *testing.T) {
|
|
for _, enabled := range []bool{false, true} {
|
|
for _, restoreKey := range []uint16{input.KeyShift, input.KeyKP8, 30} {
|
|
bindings, _, err := input.Load_bindings([]string{"../../config/keyboard/desktop.toml"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router, err := input.New_router(bindings)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}, events: make(chan error, 1)}
|
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
|
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:
|
|
voice.messages, sounds.cues = nil, nil
|
|
handle(router.Key(0, input.KeyKPEnter, 1, false, false))
|
|
handle(router.Key(0, input.KeyKPEnter, 2, false, false))
|
|
handle(router.Key(0, input.KeyKPEnter, 0, false, false))
|
|
handle(router.Key(1, input.KeyShift, 1, false, true))
|
|
handle(router.Key(1, input.KeyShift, 0, false, true))
|
|
voice.messages, sounds.cues = nil, nil
|
|
source.snapshot.Cells = []rune("quiet")
|
|
source.events <- nil
|
|
case 2:
|
|
if len(voice.messages) != 0 || len(sounds.cues) != 0 {
|
|
t.Fatal("speech resumed without fresh input", voice.messages, sounds.cues)
|
|
}
|
|
handle(router.Key(0, restoreKey, 1, false, false))
|
|
handle(router.Key(0, restoreKey, 0, false, false))
|
|
source.snapshot.Cells = []rune("after")
|
|
source.events <- nil
|
|
case 3:
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
settings := config.Defaults()
|
|
settings.Presentation.Sound.Enabled = enabled
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err = run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds})
|
|
cancel()
|
|
if err != nil || polls != 3 {
|
|
t.Fatal("temporary silence event loop", err, polls)
|
|
}
|
|
wantSpeech := []string{"after"}
|
|
if restoreKey == input.KeyKP8 {
|
|
wantSpeech = []string{"quiet", "after"}
|
|
}
|
|
var wantSounds []presentation.Cue
|
|
if enabled {
|
|
wantSounds = []presentation.Cue{presentation.SpeechOn}
|
|
} else {
|
|
wantSpeech = append([]string{"speech enabled"}, wantSpeech...)
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) {
|
|
t.Fatal("resume lost fresh output or replayed suppressed output", enabled, restoreKey, voice.messages, sounds.cues)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemporarySilenceCueAndPersistentMute(t *testing.T) {
|
|
for _, soundMode := range []string{"enabled", "disabled", "missing"} {
|
|
for _, persistentMute := range []bool{false, true} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("hello")}}
|
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
if persistentMute {
|
|
handle(input.Decision{Command: command.Mute, Interrupt: true})
|
|
}
|
|
voice.messages, sounds.cues = nil, nil
|
|
handle(input.Decision{Command: command.TempDisableSpeech, Interrupt: true, Keypress: true})
|
|
if persistentMute {
|
|
handle(input.Decision{Keypress: true})
|
|
}
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
settings := config.Defaults()
|
|
settings.Presentation.Sound.Enabled = soundMode != "disabled"
|
|
if soundMode == "missing" {
|
|
sounds.missing = map[presentation.Cue]bool{presentation.SpeechOff: true}
|
|
}
|
|
if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wantSpeech []string
|
|
var wantSounds []presentation.Cue
|
|
if !persistentMute {
|
|
if soundMode == "enabled" {
|
|
wantSounds = []presentation.Cue{presentation.SpeechOff}
|
|
} else {
|
|
wantSpeech = []string{"speech temporary disabled"}
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) || voice.muted != persistentMute {
|
|
t.Fatal("temporary silence changed persistent mute or lost its cue", soundMode, persistentMute, voice.messages, sounds.cues, voice.muted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAutomaticAlertsUseSoundOrSpeech(t *testing.T) {
|
|
for _, enabled := range []bool{false, true} {
|
|
for _, automatic := range []bool{false, true} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, Cells: []rune("blank ")}, events: make(chan error, 1)}
|
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
calls := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
calls++
|
|
switch calls {
|
|
case 1:
|
|
voice.messages, sounds.cues = nil, nil
|
|
source.snapshot.Y = 1
|
|
source.events <- nil
|
|
case 2:
|
|
source.snapshot.Terminal = 3
|
|
source.events <- nil
|
|
default:
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
reads := 0
|
|
source.afterRead = func() {
|
|
reads++
|
|
if reads > 1 {
|
|
keys.events <- nil
|
|
}
|
|
}
|
|
settings := config.Defaults()
|
|
settings.Presentation.Sound.Enabled = enabled
|
|
settings.Reading.Automatic = automatic
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds})
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wantSpeech []string
|
|
var wantSounds []presentation.Cue
|
|
if enabled {
|
|
wantSounds = []presentation.Cue{presentation.TTYChanged}
|
|
if automatic {
|
|
wantSounds = []presentation.Cue{presentation.Blank, presentation.TTYChanged, presentation.Blank}
|
|
}
|
|
} else if automatic {
|
|
wantSpeech = []string{"blank", "screen 3", "blank"}
|
|
}
|
|
if calls != 3 || !reflect.DeepEqual(voice.messages, wantSpeech) || !reflect.DeepEqual(sounds.cues, wantSounds) {
|
|
t.Fatalf("sound=%v, automatic=%v: calls=%d, speech=%v, cues=%v; want speech=%v, cues=%v", enabled, automatic, calls, voice.messages, sounds.cues, wantSpeech, wantSounds)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCuesFollowTypedReviewAndMuteEvents(t *testing.T) {
|
|
for _, enabled := range []bool{false, true} {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("blank")}}
|
|
voice, sounds := &fakeVoice{}, &fakeSounds{}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
handle(input.Decision{Command: command.CurrentLine}) // Literal "blank" is text, not a blank-row cue.
|
|
handle(input.Decision{Command: command.PreviousLine})
|
|
handle(input.Decision{Command: command.Mute})
|
|
handle(input.Decision{Command: command.Mute})
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
settings := config.Defaults()
|
|
settings.Presentation.Sound.Enabled = enabled
|
|
if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []presentation.Cue{presentation.Ready, presentation.TTYChanged, presentation.Top, presentation.SpeechOff, presentation.SpeechOn}
|
|
if !enabled {
|
|
want = nil
|
|
}
|
|
if !reflect.DeepEqual(sounds.cues, want) {
|
|
t.Fatalf("enabled=%v: got %v want %v", enabled, sounds.cues, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeSource struct {
|
|
snapshot screen.Snapshot
|
|
readErr, stateErr error
|
|
events chan error
|
|
afterRead func()
|
|
}
|
|
|
|
func (f *fakeSource) Events() <-chan error { return f.events }
|
|
|
|
func (f *fakeSource) State() (int, bool, bool, error) {
|
|
return f.snapshot.Terminal, f.snapshot.Active, f.snapshot.NumLock, f.stateErr
|
|
}
|
|
func (f *fakeSource) Read() (screen.Snapshot, error) {
|
|
snapshot := f.snapshot
|
|
if f.afterRead != nil {
|
|
f.afterRead()
|
|
}
|
|
return snapshot, f.readErr
|
|
}
|
|
|
|
type fakeKeys struct {
|
|
updateModifiers func([]string, []string) (bool, error)
|
|
poll func(bool, func(input.Decision)) error
|
|
closed, passed bool
|
|
events chan error
|
|
captures []bool
|
|
captureErr error
|
|
onCapture func(bool)
|
|
}
|
|
|
|
func (f *fakeKeys) Events() <-chan error { return f.events }
|
|
|
|
func (f *fakeKeys) Poll(textMode, numLock bool, handle func(input.Decision)) error {
|
|
return f.poll(textMode, handle)
|
|
}
|
|
func (f *fakeKeys) Scan() error { return nil }
|
|
func (f *fakeKeys) Capture(enabled bool) error {
|
|
f.captures = append(f.captures, enabled)
|
|
if f.onCapture != nil {
|
|
f.onCapture(enabled)
|
|
}
|
|
return f.captureErr
|
|
}
|
|
func (f *fakeKeys) Update_modifiers(reader, script []string) (bool, error) {
|
|
if f.updateModifiers != nil {
|
|
return f.updateModifiers(reader, script)
|
|
}
|
|
return true, nil
|
|
}
|
|
func (f *fakeKeys) Pass_next() { f.passed = true }
|
|
func (f *fakeKeys) Close() error { f.closed = true; return nil }
|
|
|
|
type fakeVoice struct {
|
|
parameters []speech.Parameters
|
|
messages []string
|
|
interrupts int
|
|
muted bool
|
|
errors chan error
|
|
onSay func(string)
|
|
}
|
|
|
|
func (f *fakeVoice) Configure(parameters speech.Parameters) {
|
|
f.parameters = append(f.parameters, parameters)
|
|
}
|
|
|
|
func (f *fakeVoice) Say_with_pitch(text string, pitch int) {
|
|
f.Say(text)
|
|
}
|
|
|
|
func (f *fakeVoice) Say_after(text string, before func(context.Context)) {
|
|
if !f.muted {
|
|
before(context.Background())
|
|
f.Say(text)
|
|
}
|
|
}
|
|
|
|
func (f *fakeVoice) Say(text string) {
|
|
if !f.muted {
|
|
f.messages = append(f.messages, text)
|
|
if f.onSay != nil {
|
|
f.onSay(text)
|
|
}
|
|
}
|
|
}
|
|
func (f *fakeVoice) Interrupt() { f.interrupts++ }
|
|
func (f *fakeVoice) Toggle_mute() bool { f.muted = !f.muted; return f.muted }
|
|
func (f *fakeVoice) Errors() <-chan error { return f.errors }
|
|
func (f *fakeVoice) Close() {}
|
|
|
|
func TestForegroundReviewGraphicsHandoffMuteAndQuit(t *testing.T) {
|
|
text := screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 5, Y: 1, Cells: []rune("alphaomega")}
|
|
source := &fakeSource{snapshot: text}
|
|
voice := &fakeVoice{}
|
|
keys := &fakeKeys{}
|
|
var modes []bool
|
|
step := 0
|
|
keys.poll = func(active bool, handle func(input.Decision)) error {
|
|
modes = append(modes, active)
|
|
step++
|
|
switch step {
|
|
case 1:
|
|
handle(input.Decision{Command: command.PreviousLine, Interrupt: true})
|
|
source.snapshot = screen.Snapshot{Terminal: 1}
|
|
case 2:
|
|
source.snapshot = text
|
|
source.snapshot.Terminal = 3
|
|
case 3:
|
|
handle(input.Decision{Command: command.PassNext, Interrupt: true})
|
|
handle(input.Decision{Command: command.Mute, Interrupt: true})
|
|
handle(input.Decision{Command: command.ReadScreen, Interrupt: true})
|
|
handle(input.Decision{Command: command.Mute, Interrupt: true})
|
|
handle(input.Decision{Command: command.Quit, Interrupt: true})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := Run(ctx, source, keys, voice); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !keys.closed || !keys.passed {
|
|
t.Fatal("input was not released or pass-next was lost")
|
|
}
|
|
if !reflect.DeepEqual(modes, []bool{true, false, true}) {
|
|
t.Fatal("wrong graphical handoff", modes)
|
|
}
|
|
joined := strings.Join(voice.messages, "|")
|
|
for _, expected := range []string{"Start Fenrir", "screen 2", "alpha", "screen 3", "speech enabled"} {
|
|
if !strings.Contains(joined, expected) {
|
|
t.Fatalf("missing %q in %v", expected, voice.messages)
|
|
}
|
|
}
|
|
if strings.Contains(joined, "alpha\nomega") {
|
|
t.Fatal("mute leaked screen read")
|
|
}
|
|
}
|
|
|
|
func TestGraphicalStartupWaitsAndFollowsTextVTs(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
|
|
voice := &fakeVoice{}
|
|
keys := &fakeKeys{}
|
|
var modes []bool
|
|
step := 0
|
|
keys.poll = func(active bool, handle func(input.Decision)) error {
|
|
modes = append(modes, active)
|
|
step++
|
|
switch step {
|
|
case 1:
|
|
if len(voice.messages) != 0 {
|
|
t.Fatalf("graphical startup announced speech: %v", voice.messages)
|
|
}
|
|
source.snapshot = screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}
|
|
case 2:
|
|
handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
|
|
source.snapshot = screen.Snapshot{Terminal: 1}
|
|
case 3:
|
|
source.snapshot = screen.Snapshot{Terminal: 3, Active: true, Rows: 1, Cols: 5, Cells: []rune("omega")}
|
|
case 4:
|
|
handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
|
|
handle(input.Decision{Command: command.Quit, Interrupt: true})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := Run(ctx, source, keys, voice); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(modes, []bool{false, true, false, true}) {
|
|
t.Fatalf("wrong capture modes: %v", modes)
|
|
}
|
|
if !keys.closed {
|
|
t.Fatal("input was not closed")
|
|
}
|
|
for _, expected := range []string{"screen 2", "alpha", "screen 3", "omega"} {
|
|
if !strings.Contains(strings.Join(voice.messages, "|"), expected) {
|
|
t.Fatalf("missing %q: %v", expected, voice.messages)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInputReleasedOnSourceInputAndSpeechFailures(t *testing.T) {
|
|
for _, failure := range []string{"initial screen", "VT state", "keyboard", "speech"} {
|
|
t.Run(failure, func(t *testing.T) {
|
|
problem := errors.New("fixture failure")
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune{'a'}}}
|
|
keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
|
|
voice := &fakeVoice{errors: make(chan error, 1)}
|
|
switch failure {
|
|
case "initial screen":
|
|
source.readErr = problem
|
|
case "VT state":
|
|
source.stateErr = problem
|
|
case "keyboard":
|
|
keys.poll = func(bool, func(input.Decision)) error { return problem }
|
|
case "speech":
|
|
voice.errors <- problem
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := Run(ctx, source, keys, voice); !errors.Is(err, problem) {
|
|
t.Fatalf("wrong error: %v", err)
|
|
}
|
|
if !keys.closed {
|
|
t.Fatal("keyboard left open after fatal error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInputReadinessRunsWithoutMaintenanceTicks(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("alpha")}}
|
|
voice := &fakeVoice{}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
calls := 0
|
|
keys.poll = func(active bool, handle func(input.Decision)) error {
|
|
calls++
|
|
if calls == 1 {
|
|
keys.events <- nil
|
|
return nil
|
|
}
|
|
handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
|
|
handle(input.Decision{Command: command.Quit, Interrupt: true})
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
// A nil tick channel proves input is handled without any periodic tick.
|
|
if err := run(ctx, source, keys, voice, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != 2 || !keys.closed || voice.interrupts != 2 {
|
|
t.Fatalf("input readiness did not dispatch and quit: calls=%d, closed=%v, interrupts=%d", calls, keys.closed, voice.interrupts)
|
|
}
|
|
}
|
|
|
|
func TestWatcherFailureReleasesKeyboards(t *testing.T) {
|
|
problem := errors.New("watcher fixture failure")
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
|
|
keys := &fakeKeys{events: make(chan error, 1), poll: func(bool, func(input.Decision)) error { return nil }}
|
|
keys.events <- problem
|
|
if err := run(context.Background(), source, keys, &fakeVoice{}, nil); !errors.Is(err, problem) {
|
|
t.Fatal("lost watcher failure", err)
|
|
}
|
|
if !keys.closed {
|
|
t.Fatal("watcher failure left keyboards open")
|
|
}
|
|
}
|
|
|
|
func TestRapidTypingEchoDoesNotWaitForScreenSampling(t *testing.T) {
|
|
const letters = "abcdefghijklmnop"
|
|
makeScreen := func(text string) screen.Snapshot {
|
|
return screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 20, X: len(text), Cells: []rune(text + strings.Repeat(" ", 20-len(text)))}
|
|
}
|
|
source := &fakeSource{snapshot: makeScreen("$ "), events: make(chan error, 1)}
|
|
voice := &fakeVoice{}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
typed := 0
|
|
keys.poll = func(active bool, handle func(input.Decision)) error {
|
|
if typed == len(letters) {
|
|
handle(input.Decision{Command: command.Quit, Interrupt: true})
|
|
return nil
|
|
}
|
|
handle(input.Decision{Interrupt: true})
|
|
typed++
|
|
source.snapshot = makeScreen("$ " + letters[:typed])
|
|
source.events <- nil
|
|
source.afterRead = func() { keys.events <- nil }
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
// Disable all maintenance ticks: every character must arrive through the
|
|
// kernel-readiness path, without sleeping or waiting for a screen timer.
|
|
if err := run(ctx, source, keys, voice, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"Start Fenrir", "screen 2", "$"}
|
|
for _, char := range letters {
|
|
want = append(want, string(char))
|
|
}
|
|
if !reflect.DeepEqual(voice.messages, want) {
|
|
t.Fatalf("typing echo fell behind: got %v, want %v", voice.messages, want)
|
|
}
|
|
}
|
|
|
|
func TestPendingInputInterruptsBeforeItsScreenEcho(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 3, X: 2, Cells: []rune("$ ")}, events: make(chan error, 1)}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
voice := &fakeVoice{}
|
|
voice.onSay = func(text string) {
|
|
if text == "a" {
|
|
if voice.interrupts != 1 {
|
|
t.Fatal("screen echo ran before its pending key interrupted old speech")
|
|
}
|
|
cancel()
|
|
}
|
|
}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
first := true
|
|
keys.poll = func(active bool, handle func(input.Decision)) error {
|
|
if first {
|
|
first = false
|
|
source.snapshot.Cells = []rune("$ a")
|
|
source.events <- nil
|
|
keys.events <- nil
|
|
} else {
|
|
handle(input.Decision{Interrupt: true})
|
|
}
|
|
return nil
|
|
}
|
|
if err := run(ctx, source, keys, voice, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(voice.messages) == 0 || voice.messages[len(voice.messages)-1] != "a" {
|
|
t.Fatal("screen echo was not delivered", voice.messages)
|
|
}
|
|
}
|
|
|
|
func TestScreenWatcherFailureReleasesKeyboards(t *testing.T) {
|
|
problem := errors.New("screen watcher fixture failure")
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}, events: make(chan error, 1)}
|
|
source.events <- problem
|
|
keys := &fakeKeys{poll: func(bool, func(input.Decision)) error { return nil }}
|
|
if err := run(context.Background(), source, keys, &fakeVoice{}, nil); !errors.Is(err, problem) {
|
|
t.Fatal("lost screen watcher failure", err)
|
|
}
|
|
if !keys.closed {
|
|
t.Fatal("screen watcher failure left keyboards open")
|
|
}
|
|
}
|
|
|
|
func TestStartupReadinessFollowsInitialScreenAndInput(t *testing.T) {
|
|
for _, failure := range []string{"", "screen", "input", "notification"} {
|
|
t.Run(failure, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
problem := errors.New("startup fixture failure")
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}}
|
|
keys := &fakeKeys{}
|
|
read, polled, ready := false, false, false
|
|
source.afterRead = func() { read = true }
|
|
if failure == "screen" {
|
|
source.readErr = problem
|
|
}
|
|
keys.poll = func(bool, func(input.Decision)) error {
|
|
polled = true
|
|
if failure == "input" {
|
|
return problem
|
|
}
|
|
return nil
|
|
}
|
|
opts := RunOptions{Settings: config.Defaults(), Ready: func() error {
|
|
if !read || !polled {
|
|
t.Fatal("readiness preceded initial screen or input")
|
|
}
|
|
ready = true
|
|
cancel()
|
|
if failure == "notification" {
|
|
return problem
|
|
}
|
|
return nil
|
|
}}
|
|
err := run(ctx, source, keys, &fakeVoice{}, nil, opts)
|
|
if failure != "" && !errors.Is(err, problem) || failure == "" && err != nil {
|
|
t.Fatal("unexpected startup result", err)
|
|
}
|
|
if ready != (failure == "" || failure == "notification") || !keys.closed {
|
|
t.Fatal("startup failure reported readiness or left input open", ready, keys.closed)
|
|
}
|
|
})
|
|
}
|
|
}
|