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