Files

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)
}
})
}
}