After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.
This commit is contained in:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -0,0 +1,356 @@
|
||||
// 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 = 41
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user