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