Files
fenrir/internal/app/progress_test.go

253 lines
8.9 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"
"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)
}
}