282 lines
9.8 KiB
Go
282 lines
9.8 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"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/presentation"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
)
|
|
|
|
func TestQuickAttributesChoicesMenuCancelSaveAndRestart(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
base := config.Defaults()
|
|
base.General.ScriptKeys = []string{"KEY_COMPOSE", "KEY_META"}
|
|
if err := config.Save(path, base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
voice := &fakeVoice{}
|
|
quick := new_quick_settings("speech-dispatcher")
|
|
quick.command(command.NextQuickCategory, &base, voice)
|
|
for range 2 {
|
|
quick.command(command.NextQuickEntry, &base, voice)
|
|
}
|
|
for _, trial := range []struct {
|
|
id command.ID
|
|
want string
|
|
}{
|
|
{command.CurrentQuickEntry, "Attributes none"},
|
|
{command.NextQuickValue, "speech"}, {command.NextQuickValue, "beeps"},
|
|
{command.NextQuickValue, "speech and beeps"}, {command.NextQuickValue, "none"},
|
|
{command.PreviousQuickValue, "speech and beeps"},
|
|
} {
|
|
quick.command(trial.id, &base, voice)
|
|
if voice.messages[len(voice.messages)-1] != trial.want {
|
|
t.Fatal(voice.messages)
|
|
}
|
|
}
|
|
if len(voice.parameters) != 0 || base.Reading.Attributes != "both" {
|
|
t.Fatal(base, voice.parameters)
|
|
}
|
|
ui := new_internal_ui(context.Background(), &fakeKeys{}, voice)
|
|
ui.open_settings(base, path, base)
|
|
receive_ui(t, ui)
|
|
control := ui.session.Values()["reading.attributes"]
|
|
if control.Choices[control.Selected].ID != "both" {
|
|
t.Fatal("menu lost quick choice", control)
|
|
}
|
|
ui.handle(interaction.Key{Name: "escape"})
|
|
saved, _, err := config.Load(path, nil)
|
|
if err != nil || saved.Reading.Attributes != "none" {
|
|
t.Fatal("cancel saved choice", saved.Reading, err)
|
|
}
|
|
|
|
for session := range 2 {
|
|
voice = &fakeVoice{}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
handle(input.Decision{Command: command.NextQuickCategory})
|
|
for range 2 {
|
|
handle(input.Decision{Command: command.NextQuickEntry})
|
|
}
|
|
if session == 0 {
|
|
for range 3 {
|
|
handle(input.Decision{Command: command.NextQuickValue})
|
|
}
|
|
handle(input.Decision{Command: command.SaveSettings})
|
|
} else if voice.messages[len(voice.messages)-1] != "Attributes speech and beeps" {
|
|
t.Fatal(voice.messages)
|
|
}
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 4, Cells: []rune("root")}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err = run(ctx, source, keys, voice, nil, RunOptions{Settings: saved, ConfigPath: path})
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, _, err = config.Load(path, nil)
|
|
if err != nil || saved.Reading.Attributes != "both" || !reflect.DeepEqual(saved.General, base.General) {
|
|
t.Fatal(saved, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAttributeSoundSpeechFallbackMuteAndTemporarySilence(t *testing.T) {
|
|
for _, mode := range []string{"none", "speech", "beeps", "both"} {
|
|
for _, state := range []string{"enabled", "disabled", "zero volume", "missing", "muted", "temporary silence"} {
|
|
t.Run(mode+"/"+state, func(t *testing.T) {
|
|
settings := config.Defaults()
|
|
settings.Reading.Attributes = mode
|
|
settings.Reading.AttributeFormat = "fenrirBold"
|
|
settings.Presentation.Sound.Enabled = state != "disabled"
|
|
if state == "zero volume" {
|
|
settings.Presentation.Sound.Volume = 0
|
|
}
|
|
sounds, voice := &fakeSounds{}, &fakeVoice{}
|
|
if state == "missing" {
|
|
sounds.missing = map[presentation.Cue]bool{presentation.HasAttributes: true}
|
|
}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 1, Cells: []rune("ab"), Attributes: []screen.Attribute{{Foreground: screen.ColorDefault, Background: screen.ColorDefault}, {Style: screen.Bold}}}}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
if state == "muted" {
|
|
handle(input.Decision{Command: command.Mute})
|
|
}
|
|
if state == "temporary silence" {
|
|
handle(input.Decision{Command: command.TempDisableSpeech})
|
|
}
|
|
voice.messages, sounds.cues = nil, nil
|
|
handle(input.Decision{Command: command.NextLine})
|
|
handle(input.Decision{Command: command.CurrentLine})
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, Sounds: sounds}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
count := 0
|
|
for _, cue := range sounds.cues {
|
|
if cue == presentation.HasAttributes {
|
|
count++
|
|
}
|
|
}
|
|
wantCue := state == "enabled" && (mode == "beeps" || mode == "both")
|
|
if (count == 1) != wantCue {
|
|
t.Fatal("cue", sounds.cues)
|
|
}
|
|
count = 0
|
|
for _, text := range voice.messages {
|
|
if text == "bold" || text == "has attribute" {
|
|
count++
|
|
}
|
|
}
|
|
wantSpeech := mode != "none" && (mode != "beeps" || state != "enabled") && state != "muted" && state != "temporary silence"
|
|
if (count == 1) != wantSpeech {
|
|
t.Fatal("speech", voice.messages)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMenuAttributesAppliesOnlyAfterSuccessfulSave(t *testing.T) {
|
|
for _, action := range []string{"save", "cancel", "failed save"} {
|
|
t.Run(action, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
if err := os.WriteFile(path, []byte("version=3\n[reading]\nattributes='none'\nattribute_format='fenrirBold'\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 8, Cells: []rune("root leaf"), Attributes: []screen.Attribute{{}, {}, {}, {}, {}, {}, {}, {}, {Style: screen.Bold}, {}, {}, {}, {}, {}, {}, {}}}}
|
|
voice, keys := &fakeVoice{}, &fakeKeys{events: make(chan error, 4)}
|
|
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) { handle(input.Decision{UI: &interaction.Key{Name: name}}) }
|
|
switch step {
|
|
case 1:
|
|
handle(input.Decision{Command: command.FenrirMenu})
|
|
case 2:
|
|
for range 10 {
|
|
key("tab")
|
|
}
|
|
if voice.messages[len(voice.messages)-1] != "Attributes combo box none" {
|
|
t.Fatal(voice.messages)
|
|
}
|
|
key("down")
|
|
if action == "cancel" {
|
|
key("escape")
|
|
break
|
|
}
|
|
if action == "failed save" {
|
|
if err := os.WriteFile(path, []byte("version=3\n[reading]\nattributes='beeps'\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for range 32 {
|
|
key("tab")
|
|
if voice.messages[len(voice.messages)-1] == "OK button" {
|
|
break
|
|
}
|
|
}
|
|
key("enter")
|
|
case 3:
|
|
if action == "failed save" {
|
|
key("escape")
|
|
}
|
|
voice.messages = nil
|
|
handle(input.Decision{Command: command.NextLine})
|
|
if strings.Contains(strings.Join(voice.messages, "|"), "bold") != (action == "save") {
|
|
t.Fatal(action, voice.messages)
|
|
}
|
|
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(), ConfigPath: path}); err != nil || step != 3 {
|
|
t.Fatal(err, step)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAttributeSessionToggleQuickSaveAndFreshStartup(t *testing.T) {
|
|
for _, mode := range []string{"none", "beeps", "speech", "both"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.toml")
|
|
settings := config.Defaults()
|
|
settings.Reading.Attributes = mode
|
|
settings.Reading.AttributeFormat = "fenrirBold"
|
|
settings.Presentation.Sound.Enabled = false
|
|
if err := config.Save(path, settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for session := range 2 {
|
|
voice := &fakeVoice{}
|
|
source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 2, Cols: 1, Cells: []rune("ab"), Attributes: []screen.Attribute{{Foreground: screen.ColorDefault, Background: screen.ColorDefault}, {Style: screen.Bold}}}}
|
|
keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error {
|
|
if session == 0 {
|
|
handle(input.Decision{Command: command.ToggleAttributes})
|
|
// Every quick command reapplies configuration; it must
|
|
// preserve the session override and still save the default.
|
|
handle(input.Decision{Command: command.NextQuickValue})
|
|
handle(input.Decision{Command: command.SaveSettings})
|
|
}
|
|
voice.messages = nil
|
|
handle(input.Decision{Command: command.NextLine})
|
|
feedback := false
|
|
for _, text := range voice.messages {
|
|
if text == "has attribute" || text == "bold" {
|
|
feedback = true
|
|
}
|
|
}
|
|
wantFeedback := mode != "none"
|
|
if session == 0 {
|
|
wantFeedback = !wantFeedback
|
|
}
|
|
if feedback != wantFeedback {
|
|
t.Fatal(session, mode, voice.messages)
|
|
}
|
|
handle(input.Decision{Command: command.Quit})
|
|
return nil
|
|
}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err := run(ctx, source, keys, voice, nil, RunOptions{Settings: settings, ConfigPath: path})
|
|
cancel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saved, _, err := config.Load(path, nil)
|
|
if err != nil || saved.Reading.Attributes != mode || saved.Reading.AttributeFormat != "fenrirBold" || saved.Presentation.Speech.Rate != 10 {
|
|
t.Fatal("temporary toggle persisted", saved, err)
|
|
}
|
|
settings = saved
|
|
}
|
|
})
|
|
}
|
|
}
|