357 lines
12 KiB
Go
357 lines
12 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"
|
|
"os"
|
|
"path/filepath"
|
|
"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/interaction"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
)
|
|
|
|
func settings_ui_application(t *testing.T, keys *fakeKeys, voice *fakeVoice, path string) *application {
|
|
t.Helper()
|
|
a, err := new_application(context.Background(), &fakeSource{}, keys, voice, RunOptions{Settings: config.Defaults(), ConfigPath: path})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(a.ui.close)
|
|
return a
|
|
}
|
|
|
|
func receive_ui(t *testing.T, ui *internalUI) {
|
|
t.Helper()
|
|
select {
|
|
case update := <-ui.updates:
|
|
ui.update(update)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("UI worker did not finish")
|
|
}
|
|
}
|
|
|
|
func TestSettingsMenuCancelSaveAndReopen(t *testing.T) {
|
|
for _, action := range []string{"escape", "cancel", "save"} {
|
|
t.Run(action, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := os.WriteFile(path, []byte("version=1\n[speech]\nrate=40\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys, voice := &fakeKeys{}, &fakeVoice{}
|
|
a := settings_ui_application(t, keys, voice, path)
|
|
ui := a.ui
|
|
ui.open_settings(config.Defaults(), path)
|
|
receive_ui(t, ui)
|
|
if !ui.active || voice.messages[len(voice.messages)-1] != "Fenrir Menu. Presentation tab" {
|
|
t.Fatal(ui.active, voice.messages)
|
|
}
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
ui.handle(interaction.Key{Name: "up"})
|
|
if action == "escape" {
|
|
ui.handle(interaction.Key{Name: "escape"})
|
|
} else {
|
|
for range 13 {
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
}
|
|
if action == "cancel" {
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
}
|
|
ui.handle(interaction.Key{Name: "enter"})
|
|
if action == "save" {
|
|
finish_persistence(t, a, false)
|
|
}
|
|
}
|
|
if ui.active || !reflect.DeepEqual(keys.captures, []bool{true, false}) {
|
|
t.Fatal("capture survived close", ui.active, keys.captures)
|
|
}
|
|
if got := voice.messages[len(voice.messages)-1]; got != "Fenrir Menu closed" {
|
|
t.Fatal("missing close announcement", got)
|
|
}
|
|
settings, _, err := config.Load(path, nil)
|
|
want := 40
|
|
if action == "save" {
|
|
want = 50
|
|
}
|
|
if err != nil || settings.Presentation.Speech.Rate != want {
|
|
t.Fatal("wrong persistence", settings, err)
|
|
}
|
|
ui.open_settings(config.Defaults(), path)
|
|
receive_ui(t, ui)
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
if ui.session.Values()["presentation.speech.rate"].Number != float64(want) {
|
|
t.Fatal("reopen did not reload settings")
|
|
}
|
|
ui.close()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMenuSoundVolumeAppliesOnlyAfterSuccessfulSave(t *testing.T) {
|
|
for _, action := range []string{"lower", "zero", "raise", "cancel", "failed save"} {
|
|
t.Run(action, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
// Put Sound first so traversal starts with its controls.
|
|
if err := os.WriteFile(path, []byte("version=3\n[presentation.sound]\nvolume=0.7\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1, Active: true}}
|
|
keys := &fakeKeys{events: make(chan error, 4)}
|
|
voice := &fakeVoice{}
|
|
sounds := &fakeSounds{volume: 0.7}
|
|
keys.onCapture = func(enabled bool) {
|
|
if !enabled {
|
|
keys.events <- nil
|
|
}
|
|
}
|
|
voice.onSay = func(text string) {
|
|
if strings.HasPrefix(text, "Fenrir Menu.") || strings.HasPrefix(text, "Could not save settings:") {
|
|
keys.events <- nil
|
|
}
|
|
}
|
|
step := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
step++
|
|
key := func(name string, shift bool) { handle(input.Decision{UI: &interaction.Key{Name: name, Shift: shift}}) }
|
|
switch step {
|
|
case 1:
|
|
handle(input.Decision{Command: command.FenrirMenu})
|
|
case 2:
|
|
key("tab", false)
|
|
key("tab", false)
|
|
if action == "raise" {
|
|
key("up", false)
|
|
} else {
|
|
for range 6 {
|
|
key("left", false)
|
|
}
|
|
if action == "zero" {
|
|
key("left", false)
|
|
}
|
|
}
|
|
if sounds.volume != 0.7 {
|
|
t.Fatal("draft changed running volume", sounds.volume)
|
|
}
|
|
if action == "cancel" {
|
|
key("escape", false)
|
|
break
|
|
}
|
|
if action == "failed save" {
|
|
// An external edit must reject the save and retain the draft.
|
|
if err := os.WriteFile(path, []byte("version=3\n[presentation.sound]\nvolume=0.8\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// Move back through Sound enabled, the tab label and Cancel to OK.
|
|
for range 4 {
|
|
key("tab", true)
|
|
}
|
|
key("enter", false)
|
|
case 3:
|
|
if action == "failed save" {
|
|
key("escape", false)
|
|
}
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), Sounds: sounds, ConfigPath: path}); err != nil || step != 3 {
|
|
t.Fatal("menu workflow incomplete", err, step, voice.messages)
|
|
}
|
|
want := 0.1
|
|
switch action {
|
|
case "zero":
|
|
want = 0
|
|
case "raise":
|
|
want = 1
|
|
case "cancel", "failed save":
|
|
want = 0.7
|
|
}
|
|
if sounds.volume != want {
|
|
t.Fatal("running sound volume", sounds.volume, "want", want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMenuSaveFailureRetainsEditsAndCapture(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := config.Save(path, config.Defaults()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := settings_ui_application(t, &fakeKeys{}, &fakeVoice{}, path)
|
|
ui := a.ui
|
|
ui.open_settings(config.Defaults(), path)
|
|
receive_ui(t, ui)
|
|
ui.handle(interaction.Key{Name: "tab"})
|
|
ui.handle(interaction.Key{Name: "space"}) // Speech enabled false.
|
|
other := config.Defaults()
|
|
other.Presentation.Speech.Rate = 12
|
|
if err := config.Save(path, other); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ui.action("save", ui.session)
|
|
finish_persistence(t, a, true)
|
|
if !ui.active || ui.session == nil || ui.session.Values()["presentation.speech.enabled"].Checked {
|
|
t.Fatal("failed save lost draft or capture")
|
|
}
|
|
voice := a.voice.Speaker.(*fakeVoice)
|
|
if !strings.Contains(voice.messages[len(voice.messages)-1], "settings changed") {
|
|
t.Fatal(voice.messages)
|
|
}
|
|
ui.handle(interaction.Key{Name: "escape"})
|
|
settings, _, err := config.Load(path, nil)
|
|
if err != nil || settings.Presentation.Speech.Rate != 12 {
|
|
t.Fatal("external edit overwritten", settings, err)
|
|
}
|
|
}
|
|
|
|
func TestMenuRejectsStaleWorkerAndCaptureFailure(t *testing.T) {
|
|
keys := &fakeKeys{captureErr: errors.New("fixture capture denied")}
|
|
voice := &fakeVoice{}
|
|
ui := new_internal_ui(context.Background(), keys, voice)
|
|
ui.open_settings(config.Defaults(), filepath.Join(t.TempDir(), "settings.toml"))
|
|
if ui.active || !strings.Contains(strings.Join(voice.messages, "|"), "capture denied") {
|
|
t.Fatal(ui.active, voice.messages)
|
|
}
|
|
keys.captureErr = nil
|
|
ui.open_settings(config.Defaults(), filepath.Join(t.TempDir(), "settings.toml"))
|
|
ui.close()
|
|
closedCount := len(voice.messages)
|
|
receive_ui(t, ui)
|
|
if ui.active || ui.session != nil || len(voice.messages) != closedCount {
|
|
t.Fatal("closed UI resurrected")
|
|
}
|
|
}
|
|
|
|
func TestInternalUICloseUsesTitleWithoutLoadedSessionAndOnlyOnce(t *testing.T) {
|
|
for _, title := range []string{"Fenrir settings", "Actions"} {
|
|
keys, voice := &fakeKeys{}, &fakeVoice{}
|
|
ui := new_internal_ui(context.Background(), keys, voice)
|
|
if _, err := ui.begin(title); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ui.handle(interaction.Key{Name: "escape"})
|
|
ui.close()
|
|
if ui.active || !reflect.DeepEqual(voice.messages, []string{title + " closed"}) || !reflect.DeepEqual(keys.captures, []bool{true, false}) {
|
|
t.Fatal(title, ui.active, voice.messages, keys.captures)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMenuOpenFailureClosesWithNameAndReportsError(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := os.WriteFile(path, []byte("not valid TOML"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
voice := &fakeVoice{}
|
|
ui := new_internal_ui(context.Background(), &fakeKeys{}, voice)
|
|
ui.open_settings(config.Defaults(), path)
|
|
receive_ui(t, ui)
|
|
if ui.active || len(voice.messages) != 2 || voice.messages[0] != "Fenrir Menu closed" || !strings.Contains(voice.messages[1], "Could not open Fenrir Menu:") {
|
|
t.Fatal(ui.active, voice.messages)
|
|
}
|
|
}
|
|
|
|
func TestMenuSuppressesBackgroundScreenAndRestoresCommands(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 5, Cells: []rune("first")}, events: make(chan error, 1)}
|
|
keys := &fakeKeys{events: make(chan error, 1)}
|
|
voice := &fakeVoice{}
|
|
step := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
step++
|
|
if step == 1 {
|
|
handle(input.Decision{Command: command.FenrirMenu, Interrupt: true})
|
|
}
|
|
if step == 2 {
|
|
handle(input.Decision{Command: command.PassNext}) // Blocked while UI owns input.
|
|
handle(input.Decision{UI: &interaction.Key{Name: "escape"}, Interrupt: true})
|
|
handle(input.Decision{Command: command.CurrentLine, Interrupt: true})
|
|
handle(input.Decision{Command: command.Quit})
|
|
}
|
|
return nil
|
|
}
|
|
voice.onSay = func(text string) {
|
|
if !strings.HasPrefix(text, "Fenrir Menu.") {
|
|
return
|
|
}
|
|
source.snapshot.Cells = []rune("later")
|
|
source.afterRead = func() { source.afterRead = nil; keys.events <- nil }
|
|
source.events <- nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
err := run(ctx, source, keys, voice, nil, RunOptions{Settings: config.Defaults(), ConfigPath: filepath.Join(t.TempDir(), "settings.toml")})
|
|
if err != nil || step != 2 || keys.passed || !keys.closed {
|
|
t.Fatal(err, step, keys)
|
|
}
|
|
count := 0
|
|
for _, text := range voice.messages {
|
|
if text == "later" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Fatal("background echo escaped or review failed after close", voice.messages)
|
|
}
|
|
if !reflect.DeepEqual(keys.captures, []bool{true, false}) {
|
|
t.Fatal(keys.captures)
|
|
}
|
|
}
|
|
|
|
func TestMenuAbortsOnTerminalChange(t *testing.T) {
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune("x")}}
|
|
keys := &fakeKeys{}
|
|
voice := &fakeVoice{}
|
|
step := 0
|
|
keys.poll = func(_ bool, handle func(input.Decision)) error {
|
|
step++
|
|
if step == 1 {
|
|
handle(input.Decision{Command: command.FenrirMenu})
|
|
source.snapshot.Terminal = 3
|
|
}
|
|
if step == 2 {
|
|
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, RunOptions{Settings: config.Defaults(), ConfigPath: filepath.Join(t.TempDir(), "settings.toml")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(keys.captures, []bool{true, false}) {
|
|
t.Fatal("capture moved between VTs", keys.captures)
|
|
}
|
|
if !strings.Contains(strings.Join(voice.messages, "|"), "Fenrir Menu closed") {
|
|
t.Fatal("terminal change did not announce closure", voice.messages)
|
|
}
|
|
}
|