// 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 input import ( "os" "path/filepath" "reflect" "strings" "testing" "git.stormux.org/storm/fenrir/internal/command" ) func desktop_data(t *testing.T) []byte { t.Helper() data, err := os.ReadFile("../../config/keyboard/desktop.toml") if err != nil { t.Fatal(err) } return data } func desktop_router(t *testing.T) Router { t.Helper() bindings, err := Decode_bindings(desktop_data(t)) if err != nil { t.Fatal(err) } router, err := New_router(bindings) if err != nil { t.Fatal(err) } return router } func TestRuntimeLayoutsAreIndependentAndRetainedAcrossReset(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "custom.toml") if err := os.WriteFile(path, []byte(strings.Replace(string(desktop_data(t)), `keys = ["FENRIR", "F"]`, `keys = ["FENRIR", "G"]`, 1)), 0600); err != nil { t.Fatal(err) } bindings, selected, err := Load_bindings([]string{path}) if err != nil || selected != path { t.Fatal(selected, err) } custom, err := New_router(bindings) if err != nil { t.Fatal(err) } desktop := desktop_router(t) for range 2 { custom.Key(0, KeyInsert, 1, false, false) if got := custom.Key(0, 34, 1, false, false); got.Command != command.FenrirMenu || got.Forward { t.Fatal("custom binding unused", got) } if got := custom.Key(0, KeyF, 1, false, false); got.Command != command.Unbound || got.Forward { t.Fatal("desktop merged into custom", got) } custom.Reset() } desktop.Key(0, KeyInsert, 1, false, false) if got := desktop.Key(0, KeyF, 1, false, false); got.Command != command.FenrirMenu { t.Fatal("custom mutated another router", got) } // Editing the file affects the next load, without rebuilding or mutating // an already running router. if err := os.WriteFile(path, desktop_data(t), 0600); err != nil { t.Fatal(err) } updated, _, err := Load_bindings([]string{path}) if err != nil { t.Fatal(err) } reloaded, err := New_router(updated) if err != nil { t.Fatal(err) } reloaded.Key(0, KeyInsert, 1, false, false) if got := reloaded.Key(0, KeyF, 1, false, false); got.Command != command.FenrirMenu { t.Fatal("disk edit not loaded", got) } } func TestBothLayoutsProvideAllClipboardCommands(t *testing.T) { want := []command.ID{command.SetMark, command.RemoveMarks, command.MarkedText, command.CopyMarked, command.CopyLastEcho, command.CurrentClipboard, command.PreviousClipboard, command.NextClipboard, command.FirstClipboard, command.LastClipboard, command.ClearClipboard, command.PasteClipboard, command.ImportClipboard, command.ExportClipboard, command.ImportXClipboard, command.ExportXClipboard} for _, layout := range []string{"desktop", "laptop"} { bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"}) if err != nil { t.Fatal(err) } counts := make(map[command.ID]int) for _, binding := range bindings.Bindings { counts[binding.Command]++ } for _, id := range want { if counts[id] != 1 { t.Fatal(layout, id, counts[id]) } } } } func TestPythonTrackingChordsConsumePressRepeatAndBothReleases(t *testing.T) { for _, fixture := range []struct { layout string key uint16 }{{"desktop", 55}, {"laptop", 21}} { bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + fixture.layout + ".toml"}) if err != nil { t.Fatal(err) } for _, modifierFirst := range []bool{false, true} { for _, numLock := range []bool{false, true} { router, err := New_router(bindings) if err != nil { t.Fatal(err) } router.Key(0, KeyInsert, 1, numLock, false) if got := router.Key(0, fixture.key, 1, numLock, false); got.Command != command.ToggleTracking || got.Forward { t.Fatal(fixture.layout, got) } if got := router.Key(0, fixture.key, 2, numLock, false); got.Command != "" || got.Forward { t.Fatal("mode repeated", got) } keys := []uint16{fixture.key, KeyInsert} if modifierFirst { keys[0], keys[1] = keys[1], keys[0] } for _, key := range keys { if got := router.Key(0, key, 0, numLock, false); got.Forward { t.Fatal("mode release leaked", got) } } } } } } func TestLaptopRuntimeLayoutAndModifierChords(t *testing.T) { bindings, _, err := Load_bindings([]string{"../../config/keyboard/laptop.toml"}) if err != nil { t.Fatal(err) } for _, fixture := range []struct { key, modifier uint16 want command.ID }{ {23, 0, command.CurrentLine}, {22, KeyShift, command.Top}, {36, KeyCtrl, command.FirstText}, {52, KeyShift, command.Position}, {KeyF, 0, command.FenrirMenu}, } { router, err := New_router(bindings) if err != nil { t.Fatal(err) } router.Key(0, KeyInsert, 1, false, false) if fixture.modifier != 0 { router.Key(1, fixture.modifier, 1, false, false) } if got := router.Key(0, fixture.key, 1, false, false); got.Command != fixture.want || got.Forward { t.Fatal(fixture, got) } router.Key(0, KeyInsert, 0, false, false) if got := router.Key(0, fixture.key, 0, false, false); got.Forward { t.Fatal("laptop release leaked", got) } } } func TestLayoutPathSelection(t *testing.T) { home, bundle := t.TempDir(), t.TempDir() paths, err := Layout_paths("laptop", home, 1000, bundle) if err != nil || !reflect.DeepEqual(paths, []string{filepath.Join(home, ".local/share/stormux/fenrirscreenreader/keyboard/laptop.toml"), "/etc/fenrirscreenreader/keyboard/laptop.toml", filepath.Join(bundle, "laptop.toml")}) { t.Fatal(paths, err) } paths, err = Layout_paths("desktop.toml", home, 0, bundle) if err != nil || len(paths) != 2 || paths[0] != "/etc/fenrirscreenreader/keyboard/desktop.toml" { t.Fatal(paths, err) } path := filepath.Join(t.TempDir(), "custom.toml") paths, err = Layout_paths(path, home, 1000, bundle) if err != nil || !reflect.DeepEqual(paths, []string{path}) { t.Fatal(paths, err) } for _, value := range []string{"", "desktop.conf", "../desktop", "../desktop.conf", "\x00"} { if _, err := Layout_paths(value, home, 1000, bundle); err == nil { t.Fatal("invalid selection", value) } } } func TestInvalidLayoutNeverFallsThroughOrMerges(t *testing.T) { root := t.TempDir() bad, good := filepath.Join(root, "bad.toml"), filepath.Join(root, "desktop.toml") if err := os.WriteFile(good, desktop_data(t), 0600); err != nil { t.Fatal(err) } for _, data := range []string{"version=1\n", "version=1\nunknown=true\n", strings.Repeat("#", (1<<20)+1)} { if err := os.WriteFile(bad, []byte(data), 0600); err != nil { t.Fatal(err) } if _, selected, err := Load_bindings([]string{bad, good}); err == nil || selected != bad { t.Fatal("bad layout ignored", selected, err) } } if _, _, err := Load_bindings([]string{filepath.Join(root, "missing.toml")}); err == nil { t.Fatal("missing layout accepted") } if _, selected, err := Load_bindings([]string{filepath.Join(root, "missing.toml"), good}); err != nil || selected != good { t.Fatal(selected, err) } base := "version=1\n[[bindings]]\nname='Example'\nkeys=%s\ncommand='help'\n" for _, keys := range []string{`['FENRIR','FENRIR','F']`, `['CTRL','F']`, `['A']`, `['FENRIR','UNKNOWN','F']`} { if _, err := Decode_bindings([]byte(strings.Replace(base, "%s", keys, 1))); err == nil { t.Fatal("unsupported chord accepted", keys) } } duplicate := strings.Replace(base, "%s", "['FENRIR','SHIFT','F']", 1) + "[[bindings]]\nname='Duplicate'\nkeys=['SHIFT','FENRIR','F']\ncommand='quit'\n" if _, err := Decode_bindings([]byte(duplicate)); err == nil { t.Fatal("reordered duplicate modifiers accepted") } } func TestQuickSettingsChordsInBothLayouts(t *testing.T) { for _, layout := range []string{"desktop", "laptop"} { bindings, _, err := Load_bindings([]string{"../../config/keyboard/" + layout + ".toml"}) if err != nil { t.Fatal(err) } for _, binding := range bindings.Bindings { if binding.Command == command.CurrentQuickValue { t.Fatal("bundled layout retains the redundant value-only shortcut", layout, binding) } } for _, testCase := range []struct { key uint16 ctrl, repeats bool want command.ID }{ {KeySpace, false, false, command.CurrentQuickEntry}, {KeyRight, true, true, command.NextQuickCategory}, {KeyLeft, true, true, command.PreviousQuickCategory}, {KeyRight, false, true, command.NextQuickEntry}, {KeyLeft, false, true, command.PreviousQuickEntry}, {KeyUp, false, true, command.NextQuickValue}, {KeyDown, false, true, command.PreviousQuickValue}, {31, true, false, command.SaveSettings}, } { router, err := New_router(bindings) if err != nil { t.Fatal(err) } router.Key(0, KeyInsert, 1, false, false) if testCase.ctrl { router.Key(0, KeyCtrl, 1, false, false) } if got := router.Key(0, testCase.key, 1, false, false); got.Command != testCase.want || got.Forward { t.Fatal(layout, testCase, got) } repeat := router.Key(0, testCase.key, 2, false, false) if repeat.Forward || (testCase.repeats && repeat.Command != testCase.want) || (!testCase.repeats && repeat.Command != "") { t.Fatal(testCase, repeat) } router.Key(0, KeyInsert, 0, false, false) if got := router.Key(0, testCase.key, 0, false, false); got.Forward { t.Fatal("release escaped", got) } router.Key(0, KeyCtrl, 0, false, false) if got := router.Key(0, 31, 1, false, false); !got.Forward { t.Fatal("modifiers survived", got) } } } }