// 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" "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/screen" ) func TestSharedLoopProgressSoundGatesAndToggle(t *testing.T) { for _, mode := range []string{"enabled", "sound off", "progress off", "toggle off", "toggle on", "muted", "automatic off", "menu"} { t.Run(mode, func(t *testing.T) { settings := config.Defaults() settings.Presentation.Sound.Enabled = mode != "sound off" settings.Presentation.Progress.Enabled = mode != "progress off" && mode != "toggle on" settings.Reading.Automatic = mode != "automatic off" source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 20, Cells: []rune("Download 20% ")}, events: make(chan error, 1)} keys := &fakeKeys{events: make(chan error, 1)} voice, sounds := &fakeVoice{}, &fakeSounds{} 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 { switch mode { case "toggle off", "toggle on": handle(input.Decision{Command: command.ProgressMonitor, Interrupt: true}) case "muted": handle(input.Decision{Command: command.Mute, Interrupt: true}) case "menu": handle(input.Decision{Command: command.FenrirMenu, Interrupt: true}) } source.snapshot.Cells = []rune("Download 30% ") source.events <- nil } else { handle(input.Decision{Command: command.Quit}) } return nil } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil || reads != 2 { t.Fatal(err, reads) } want := []float64{30} if mode == "sound off" || mode == "progress off" || mode == "toggle off" || mode == "menu" { want = nil } if !reflect.DeepEqual(sounds.progress, want) { t.Fatal(mode, sounds.progress, want) } if mode == "toggle off" && !strings.Contains(strings.Join(voice.messages, "|"), "Progress monitoring disabled") { t.Fatal(voice.messages) } if mode == "toggle on" && !strings.Contains(strings.Join(voice.messages, "|"), "Progress monitoring enabled") { t.Fatal(voice.messages) } }) } } type progressSounds struct { fakeSounds onProgress func(float64) } func (f *progressSounds) Play_progress(value float64) bool { f.onProgress(value) return f.fakeSounds.Play_progress(value) } type progressResetSounds struct { fakeSounds clears, discards int } func (f *progressResetSounds) Clear_progress() { f.clears++ } func (f *progressResetSounds) Discard_progress() { f.discards++ } func TestProgressResetDistinguishesPassiveRedrawFromInterruption(t *testing.T) { for _, redraw := range []string{"cursor", "ordinary output", "prompt", "repaint", "input", "menu", "terminal", "inactive", "buffer", "resize"} { t.Run(redraw, func(t *testing.T) { source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 2, Cols: 20, Cells: []rune("Download 20% ")}, events: make(chan error, 1)} keys := &fakeKeys{events: make(chan error, 1)} sounds := &progressResetSounds{} reads, clearsBeforeRedraw, discardsBeforeRedraw := 0, 0, 0 source.afterRead = func() { reads++ if reads >= 2 { keys.events <- nil } } keys.poll = func(_ bool, handle func(input.Decision)) error { switch reads { case 1: source.snapshot.Cells = []rune("Download 30% ") source.events <- nil case 2: clearsBeforeRedraw = sounds.clears discardsBeforeRedraw = sounds.discards switch redraw { case "cursor": source.snapshot.X = 1 case "ordinary output": source.snapshot.Cells = []rune("Done. ") case "prompt": source.snapshot.Cells = []rune("Username@host:~$ ") case "repaint": source.snapshot.Cells = []rune("Working Status ") case "input": handle(input.Decision{Interrupt: true, Keypress: true, Forward: true}) case "menu": handle(input.Decision{Command: command.FenrirMenu, Interrupt: true}) case "terminal": source.snapshot.Terminal++ case "inactive": source.snapshot.Active = false case "buffer": source.snapshot.Generation++ case "resize": source.snapshot.Rows = 1 source.snapshot.Cells = source.snapshot.Cells[:20] } source.events <- nil case 3: handle(input.Decision{Command: command.Quit}) } return nil } settings := config.Defaults() settings.Reading.Automatic = false ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := run(ctx, source, keys, &fakeVoice{}, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil { t.Fatal(err) } if reads != 3 || !reflect.DeepEqual(sounds.progress, []float64{30}) { t.Fatal("fixture missed progress/redraw", reads, sounds.progress) } mustStop := redraw == "input" || redraw == "menu" || redraw == "terminal" || redraw == "inactive" || redraw == "buffer" || redraw == "resize" if (sounds.clears > clearsBeforeRedraw) != mustStop { t.Fatal("wrong playing-tone cancellation", sounds.clears-clearsBeforeRedraw, "must stop", mustStop) } if !mustStop && sounds.discards == discardsBeforeRedraw { t.Fatal("passive redraw retained waiting progress") } }) } } func TestProgressLoopFlushesFinalValueWithoutAnotherScreenEvent(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() settings := config.Defaults() settings.Reading.Automatic = false source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 20, Cells: []rune("Download 20% ")}, events: make(chan error, 2)} keys := &fakeKeys{events: make(chan error, 1)} sounds := &progressSounds{onProgress: func(value float64) { if value == 100 { cancel() } }} reads := 0 source.afterRead = func() { reads++ if reads == 2 { source.snapshot.Cells = []rune("Download 100% ") source.events <- nil } } keys.poll = func(_ bool, _ func(input.Decision)) error { if reads == 1 { source.snapshot.Cells = []rune("Download 30% ") source.events <- nil } return nil } if err := Run(ctx, source, keys, &fakeVoice{}, RunOptions{Settings: settings, Sounds: sounds}); err != nil { t.Fatal(err) } if reads != 3 || !reflect.DeepEqual(sounds.progress, []float64{30, 100}) { t.Fatal("final pending percentage lost", reads, sounds.progress) } } func TestBackspaceThroughSharedRouterDoesNotBeep(t *testing.T) { router, err := input.New_router(input.Layout{Bindings: []input.Binding{{Name: "Quit", Keys: []string{"FENRIR", "Q"}, Command: command.Quit}}}) if err != nil { t.Fatal(err) } source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true, Rows: 1, Cols: 20, X: 11, Cells: []rune("type 2% abc ")}, events: make(chan error, 1)} keys := &fakeKeys{events: make(chan error, 1)} voice, sounds := &fakeVoice{}, &fakeSounds{} 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(router.Key(0, input.KeyBackspace, 1, false, false)) source.snapshot.Cells = []rune("type 2% ab ") source.snapshot.X = 10 source.events <- nil } else { handle(input.Decision{Command: command.Quit}) } return nil } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Sounds: sounds}); err != nil || reads != 2 { t.Fatal(err, reads) } if len(sounds.progress) != 0 { t.Fatal("Backspace beeped", sounds.progress) } if !strings.Contains(strings.Join(voice.messages, "|"), "c") { t.Fatal("deletion feedback lost", voice.messages) } }