Files

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)
}
}
}