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