348 lines
12 KiB
Go
348 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 preferences
|
|
|
|
import (
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/config"
|
|
"git.stormux.org/storm/fenrir/internal/interaction"
|
|
)
|
|
|
|
func TestSettingsFormCoversSchemaAndReturnsIsolatedDraft(t *testing.T) {
|
|
base := config.Defaults()
|
|
base.Keyboard.Devices = []string{"/dev/input/event-test"}
|
|
session, err := New_session(base, []string{"presentation", "presentation.speech", "presentation.sound"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := session.Open(); got != "Fenrir Menu. Presentation tab" {
|
|
t.Fatal(got)
|
|
}
|
|
values := session.Values()
|
|
if len(values) != 64 {
|
|
t.Fatal("missing settings or buttons", len(values))
|
|
}
|
|
unchanged, err := Settings(session, base)
|
|
if err != nil || !reflect.DeepEqual(unchanged, base) {
|
|
t.Fatal(unchanged, err)
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
session.Handle(interaction.Key{Name: "space"})
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
session.Handle(interaction.Key{Name: "up"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if draft.Presentation.Speech.Enabled || draft.Presentation.Speech.Rate != 10 || !base.Presentation.Speech.Enabled || base.Presentation.Speech.Rate != 0 {
|
|
t.Fatal("draft isolation", draft, base)
|
|
}
|
|
}
|
|
|
|
func TestSpeechRateArrowSteps(t *testing.T) {
|
|
for _, application := range []bool{false, true} {
|
|
for _, trial := range []struct {
|
|
start int
|
|
key string
|
|
want int
|
|
}{
|
|
{-3, "up", 7}, {-3, "down", -13},
|
|
{-3, "right", -2}, {-3, "left", -4},
|
|
{96, "up", 100}, {-96, "down", -100},
|
|
{100, "up", 100}, {-100, "down", -100},
|
|
{100, "right", 100}, {-100, "left", -100},
|
|
{100, "down", 90}, {-100, "up", -90},
|
|
{100, "left", 99}, {-100, "right", -99},
|
|
} {
|
|
base := config.Defaults()
|
|
base.Presentation.Speech.Rate = trial.start
|
|
session, err := new_session(base, []string{"presentation"}, MenuTitle, application)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"}) // Speech enabled.
|
|
session.Handle(interaction.Key{Name: "tab"}) // Rate.
|
|
result := session.Handle(interaction.Key{Name: trial.key})
|
|
if want := "Rate " + strconv.Itoa(trial.want); result.Speech != want {
|
|
t.Fatalf("application=%v, rate=%d, key=%s: speech=%q, want %q", application, trial.start, trial.key, result.Speech, want)
|
|
}
|
|
var draft config.Settings
|
|
if application {
|
|
draft, err = Application_settings(session, base)
|
|
} else {
|
|
draft, err = Settings(session, base)
|
|
}
|
|
if err != nil || draft.Presentation.Speech.Rate != trial.want || base.Presentation.Speech.Rate != trial.start {
|
|
t.Fatalf("application=%v, trial=%+v: draft rate=%d, base rate=%d, err=%v", application, trial, draft.Presentation.Speech.Rate, base.Presentation.Speech.Rate, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadingModeIsConstrainedAndSavedInDraft(t *testing.T) {
|
|
base := config.Defaults()
|
|
session, err := New_session(base, []string{"reading"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Tracking mode combo box cursor tracking" {
|
|
t.Fatal(got)
|
|
}
|
|
session.Handle(interaction.Key{Name: "down"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil || draft.Reading.Mode != "table" || base.Reading.Mode != "cursor" {
|
|
t.Fatal("mode not saved or draft leaked", draft, err)
|
|
}
|
|
}
|
|
|
|
func TestCapitalCheckboxesRoundTripIndependently(t *testing.T) {
|
|
for choices := range 8 {
|
|
base := config.Defaults()
|
|
base.Reading.RaisePitchForCapitals = choices&1 != 0
|
|
base.Reading.BeepForCapitals = choices&2 != 0
|
|
base.Reading.SayCapForCapitals = choices&4 != 0
|
|
session, err := New_session(base, []string{"reading"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
values := session.Values()
|
|
for path, want := range map[string]bool{
|
|
"reading.raise_pitch_for_capitals": base.Reading.RaisePitchForCapitals,
|
|
"reading.beep_for_capitals": base.Reading.BeepForCapitals,
|
|
"reading.say_cap_for_capitals": base.Reading.SayCapForCapitals,
|
|
} {
|
|
if got := values[path]; got.Kind != interaction.CheckBox || got.Checked != want {
|
|
t.Fatal(path, got)
|
|
}
|
|
}
|
|
for range 6 {
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
}
|
|
session.Handle(interaction.Key{Name: "space"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil || draft.Reading.RaisePitchForCapitals == base.Reading.RaisePitchForCapitals || draft.Reading.BeepForCapitals != base.Reading.BeepForCapitals || draft.Reading.SayCapForCapitals != base.Reading.SayCapForCapitals {
|
|
t.Fatal(draft.Reading, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidDeviceEntryDoesNotSubmit(t *testing.T) {
|
|
session, err := New_session(config.Defaults(), []string{"keyboard"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
session.Handle(interaction.Key{Ctrl: true, Text: "a"})
|
|
session.Handle(interaction.Key{Text: "invalid value"})
|
|
if _, err := Settings(session, config.Defaults()); err == nil {
|
|
t.Fatal("invalid devices accepted")
|
|
}
|
|
}
|
|
|
|
func TestKeyboardLayoutComboBox(t *testing.T) {
|
|
for _, saved := range []string{"desktop", "laptop", "/tmp/custom.toml"} {
|
|
base := config.Defaults()
|
|
base.Keyboard.Layout = saved
|
|
session, err := New_session(base, []string{"keyboard"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
control := session.Values()["keyboard.layout"]
|
|
if control.Kind != interaction.ComboBox || control.Choices[0].ID != "desktop" || control.Choices[1].ID != "laptop" || control.Choices[control.Selected].ID != saved {
|
|
t.Fatal(control)
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
session.Handle(interaction.Key{Ctrl: true, Text: "a"})
|
|
session.Handle(interaction.Key{Text: "old.conf"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil || draft.Keyboard.Layout != saved {
|
|
t.Fatal(draft.Keyboard.Layout, err)
|
|
}
|
|
if saved == "desktop" {
|
|
session.Handle(interaction.Key{Name: "down"})
|
|
draft, err = Settings(session, base)
|
|
if err != nil || draft.Keyboard.Layout != "laptop" {
|
|
t.Fatal(draft.Keyboard.Layout, err)
|
|
}
|
|
if base.Keyboard.Layout != "desktop" {
|
|
t.Fatal("draft changed original settings")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyboardEchoCheckboxesSaveIndependently(t *testing.T) {
|
|
base := config.Defaults()
|
|
session, err := New_session(base, []string{"keyboard"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"Devices", "Layout", "Character echo checked"} {
|
|
if got := session.Handle(interaction.Key{Name: "tab"}).Speech; !strings.Contains(got, want) {
|
|
t.Fatal("echo checkbox traversal", got, want)
|
|
}
|
|
}
|
|
for _, want := range []string{"Character echo unchecked", "Word echo checked", "Echo on CapsLock checked"} {
|
|
if got := session.Handle(interaction.Key{Name: "space"}).Speech; got != want {
|
|
t.Fatal("echo checkbox did not toggle", got, want)
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
}
|
|
draft, err := Settings(session, base)
|
|
if err != nil || draft.Keyboard.CharacterEcho || !draft.Keyboard.WordEcho || !draft.Keyboard.EchoOnCapsLock || !draft.Reading.CursorEcho || !base.Keyboard.CharacterEcho {
|
|
t.Fatal("echo settings not independent", draft, err)
|
|
}
|
|
}
|
|
|
|
func TestClipboardSyncCheckboxPreservesAdvancedSettings(t *testing.T) {
|
|
base := config.Defaults()
|
|
base.Clipboard.SyncDisplay, base.Clipboard.ExportPath, base.Clipboard.NumberOfClipboards = ":42", "/tmp/fixture-clipboard", 3
|
|
session, err := New_session(base, []string{"clipboard"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Clipboard sync unchecked" {
|
|
t.Fatal(got)
|
|
}
|
|
session.Handle(interaction.Key{Name: "space"})
|
|
settings, err := Settings(session, base)
|
|
if err != nil || !settings.Clipboard.Sync || settings.Clipboard.SyncDisplay != ":42" || settings.Clipboard.ExportPath != base.Clipboard.ExportPath || settings.Clipboard.NumberOfClipboards != 3 {
|
|
t.Fatal(settings, err)
|
|
}
|
|
}
|
|
|
|
func TestPresentationGroupsFollowSchemaAndFileOrder(t *testing.T) {
|
|
base := config.Defaults()
|
|
session, err := New_session(base, []string{"reading", "presentation", "presentation.sound", "presentation.speech"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.Open() != "Fenrir Menu. Reading tab" {
|
|
t.Fatal(session.Open())
|
|
}
|
|
if got := session.Handle(interaction.Key{Name: "right"}).Speech; got != "Presentation tab" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Sound. Sound enabled checked" {
|
|
t.Fatal(got)
|
|
}
|
|
for range 3 {
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
}
|
|
if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != "Speech. Speech enabled checked" {
|
|
t.Fatal(got)
|
|
}
|
|
values := session.Values()
|
|
for _, definition := range config.Definitions() {
|
|
if _, ok := values[definition.Path]; !ok {
|
|
t.Fatal("missing schema control", definition.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConstrainedChoicesKeepStableIDsAndIgnoreTextEditing(t *testing.T) {
|
|
base := config.Defaults()
|
|
base.Presentation.Speech.Module = "fixture-module"
|
|
base.Keyboard.Devices = []string{}
|
|
session, err := New_session(base, nil, Choices{"presentation.speech.module": {
|
|
{ID: "", Label: "default"}, {ID: "fixture-module", Label: "Fixture module", Unavailable: "fixture server offline"},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 3 {
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
}
|
|
session.Handle(interaction.Key{Ctrl: true, Text: "a"})
|
|
session.Handle(interaction.Key{Text: "invented-driver"})
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
if !strings.Contains(session.Describe(), "Fixture module. unavailable: fixture server offline") {
|
|
t.Fatal(session.Describe())
|
|
}
|
|
unchanged, err := Settings(session, base)
|
|
if err != nil || !reflect.DeepEqual(unchanged, base) {
|
|
t.Fatal(unchanged, err)
|
|
}
|
|
session.Handle(interaction.Key{Name: "home"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil || draft.Presentation.Speech.Module != "" || draft.Presentation.Speech.Driver != "speech-dispatcher" {
|
|
t.Fatal(draft, err)
|
|
}
|
|
}
|
|
|
|
func TestSoundChoicesPreserveUnavailableThemeAndFileOnlyDevice(t *testing.T) {
|
|
base := config.Defaults()
|
|
base.Presentation.Sound.Theme = "missing-fixture-theme"
|
|
base.Presentation.Sound.Device = "plughw:CARD=Generic,DEV=0"
|
|
session, err := New_session(base, []string{"presentation", "presentation.sound"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 3 {
|
|
session.Handle(interaction.Key{Name: "tab"})
|
|
}
|
|
if got := session.Values()["presentation.sound.driver"]; got.Selected != 0 {
|
|
t.Fatal(got)
|
|
}
|
|
session.Handle(interaction.Key{Name: "down"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil || draft.Presentation.Sound.Driver != "alsa" || draft.Presentation.Sound.Theme != base.Presentation.Sound.Theme || draft.Presentation.Sound.Device != base.Presentation.Sound.Device {
|
|
t.Fatal(draft.Presentation.Sound, err)
|
|
}
|
|
}
|
|
|
|
func TestInternalUIOptionsAndApplicationIsolation(t *testing.T) {
|
|
base := config.Defaults()
|
|
session, err := New_session(base, []string{"internal_ui"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.Describe() != "Internal UI tab" {
|
|
t.Fatal(session.Describe())
|
|
}
|
|
for _, label := range []string{"Announce grid row and column", "Announce item position"} {
|
|
if got := session.Handle(interaction.Key{Name: "tab"}).Speech; got != label+" checked" {
|
|
t.Fatal(got)
|
|
}
|
|
session.Handle(interaction.Key{Name: "space"})
|
|
draft, err := Settings(session, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if draft.InternalUI.AnnounceGridCoordinates || draft.InternalUI.AnnounceItemPosition != (label == "Announce grid row and column") {
|
|
t.Fatal("options did not save independently", draft.InternalUI)
|
|
}
|
|
}
|
|
application, err := New_application_session(base, nil, "Application settings")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for path := range application.Values() {
|
|
if strings.HasPrefix(path, "internal_ui.") {
|
|
t.Fatal("global preference in application form", path)
|
|
}
|
|
}
|
|
}
|