Accept the eighteen browser, volume, media and launch identities in the input vocabulary and both SDKs. Exercise media-key capture on the private Xorg harness, and permit only the new remember-host checkbox in the connection form integration test. Validated: affected-package race tests, Tentacles make check, private native_remote_input.py and native_remote_keys.py. Physical input, live peer interoperability, speech and Braille acceptance remain pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
13 KiB
Go
324 lines
13 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package input
|
|
|
|
import (
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
testInsertSymbol uint32 = 0xff63
|
|
testF11Symbol uint32 = 0xffc8
|
|
codeShift uint8 = 50
|
|
codeControl uint8 = 37
|
|
codeAlt uint8 = 64
|
|
codeInsert uint8 = 118
|
|
codeA uint8 = 38
|
|
codeR uint8 = 27
|
|
codeF11 uint8 = 95
|
|
)
|
|
|
|
var testEscape = CommandChord{Symbol: testF11Symbol}
|
|
|
|
func newTestCaptureRouter(t *testing.T, local CommandBindings) *RemoteCaptureRouter {
|
|
t.Helper()
|
|
router, err := NewRemoteCaptureRouter(testEscape, local, []uint32{testInsertSymbol})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return router
|
|
}
|
|
|
|
type captureStep struct {
|
|
key RemoteCaptureKey
|
|
want RemoteCaptureResult
|
|
}
|
|
|
|
func press(code uint8, symbol uint32) RemoteCaptureKey {
|
|
return RemoteCaptureKey{Keycode: code, Symbol: symbol, Pressed: true}
|
|
}
|
|
func hold(code uint8, symbol uint32) RemoteCaptureKey {
|
|
return RemoteCaptureKey{Keycode: code, Symbol: symbol, Pressed: true, Repeat: true}
|
|
}
|
|
func release(code uint8) RemoteCaptureKey { return RemoteCaptureKey{Keycode: code} }
|
|
func remote(transitions ...RemoteRepeatTransition) RemoteCaptureResult {
|
|
return RemoteCaptureResult{Remote: transitions}
|
|
}
|
|
|
|
func runCaptureSteps(t *testing.T, router *RemoteCaptureRouter, steps []captureStep) {
|
|
t.Helper()
|
|
for index, step := range steps {
|
|
got, err := router.Route(step.key)
|
|
if err != nil {
|
|
t.Fatalf("step %d: %v", index, err)
|
|
}
|
|
if len(got.Remote) == 0 {
|
|
got.Remote = nil
|
|
}
|
|
if len(step.want.Remote) == 0 {
|
|
step.want.Remote = nil
|
|
}
|
|
if !reflect.DeepEqual(got, step.want) {
|
|
t.Fatalf("step %d: result = %+v, want %+v", index, got, step.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureForwardsOrdinaryAndReaderChords(t *testing.T) {
|
|
router := newTestCaptureRouter(t, nil)
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
|
{press(codeA, 'a'), remote(down(codeA))},
|
|
{hold(codeA, 'a'), remote(repeat(codeA))},
|
|
{release(codeA), remote(up(codeA))},
|
|
{release(codeShift), remote(up(codeShift))},
|
|
// A Cthulhu/NVDA modifier chord goes to the remote screen reader.
|
|
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
|
{press(codeA, 'a'), remote(down(codeA))},
|
|
{release(codeA), remote(up(codeA))},
|
|
{release(codeInsert), remote(up(codeInsert))},
|
|
// Releases for keys held before capture are not forwarded.
|
|
{release(codeR), RemoteCaptureResult{}},
|
|
})
|
|
if got := router.Retire(); len(got.Remote) != 0 || len(got.Swallow) != 0 {
|
|
t.Fatalf("retire = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureWithdrawsPrefixForLocalChord(t *testing.T) {
|
|
local := CommandBindings{{Symbol: 'r', Modifiers: controlModifier, CthulhuModifier: true}: "tentacle/remote/toggle-remote-focus"}
|
|
router := newTestCaptureRouter(t, local)
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
|
{press(codeControl, leftControlKeySymbol), remote(down(codeControl))},
|
|
{hold(codeControl, leftControlKeySymbol), remote(repeat(codeControl))},
|
|
// The local chord releases the forwarded prefix remotely, newest first.
|
|
{press(codeR, 'r'), RemoteCaptureResult{Remote: []RemoteRepeatTransition{up(codeControl), up(codeInsert)}, Local: "tentacle/remote/toggle-remote-focus"}},
|
|
{hold(codeR, 'r'), RemoteCaptureResult{}},
|
|
{hold(codeControl, leftControlKeySymbol), RemoteCaptureResult{}},
|
|
{release(codeR), RemoteCaptureResult{}},
|
|
// A forwarded key while the prefix is still held presses it again.
|
|
{press(codeA, 'a'), remote(down(codeInsert), down(codeControl), down(codeA))},
|
|
{release(codeA), remote(up(codeA))},
|
|
{release(codeControl), remote(up(codeControl))},
|
|
{release(codeInsert), remote(up(codeInsert))},
|
|
})
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureWithdrawnReleaseIsConsumed(t *testing.T) {
|
|
local := CommandBindings{{Symbol: 'r', Modifiers: controlModifier}: "tentacle/remote/disconnect"}
|
|
router := newTestCaptureRouter(t, local)
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeControl, rightControlKeySymbol), remote(down(codeControl))},
|
|
{press(codeR, 'r'), RemoteCaptureResult{Remote: []RemoteRepeatTransition{up(codeControl)}, Local: "tentacle/remote/disconnect"}},
|
|
{release(codeControl), RemoteCaptureResult{}}, // Already released remotely.
|
|
{release(codeR), RemoteCaptureResult{}},
|
|
{press(codeR, 'r'), remote(down(codeR))}, // Without Control it is ordinary.
|
|
})
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureEscapeWinsAndRetireSwallows(t *testing.T) {
|
|
// A local binding on the escape chord cannot replace the escape.
|
|
local := CommandBindings{testEscape: "tentacle/remote/other"}
|
|
router := newTestCaptureRouter(t, local)
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
|
{press(codeA, 'a'), remote(down(codeA))},
|
|
{press(codeAlt, 0xffe9), remote(down(codeAlt))},
|
|
// Shift+Alt+F11 is not the bare escape chord: it is forwarded.
|
|
{press(codeF11, testF11Symbol), remote(down(codeF11))},
|
|
{release(codeF11), remote(up(codeF11))},
|
|
{release(codeAlt), remote(up(codeAlt))},
|
|
{release(codeShift), remote(up(codeShift))},
|
|
{press(codeF11, testF11Symbol), RemoteCaptureResult{Escape: true}},
|
|
})
|
|
got := router.Retire()
|
|
want := RemoteCaptureRetirement{Remote: []RemoteRepeatTransition{up(codeA)}, Swallow: []uint8{codeF11, codeA}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("retire = %+v, want %+v", got, want)
|
|
}
|
|
if again := router.Retire(); again.Remote != nil || again.Swallow != nil {
|
|
t.Fatalf("second retire = %+v", again)
|
|
}
|
|
if _, err := router.Route(release(codeA)); !errors.Is(err, ErrRemoteKeyState) {
|
|
t.Fatalf("route after retire = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureRetireReleasesForwardedPrefixes(t *testing.T) {
|
|
local := CommandBindings{{Symbol: 'r', Modifiers: controlModifier}: "tentacle/remote/menu"}
|
|
router := newTestCaptureRouter(t, local)
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
|
{press(codeControl, leftControlKeySymbol), remote(down(codeControl))},
|
|
{press(codeR, 'r'), remote(down(codeR))}, // Shift+Control+r is not bound.
|
|
})
|
|
got := router.Retire()
|
|
want := RemoteCaptureRetirement{
|
|
Remote: []RemoteRepeatTransition{up(codeR), up(codeControl), up(codeShift)},
|
|
Swallow: []uint8{codeR, codeControl, codeShift},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("retire = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureStopsOnInconsistentInput(t *testing.T) {
|
|
for name, keys := range map[string][]RemoteCaptureKey{
|
|
"orphan repeat": {hold(codeA, 'a')},
|
|
"double down": {press(codeA, 'a'), press(codeA, 'a')},
|
|
"invalid code": {press(codeA, 'a'), press(7, 'b')},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
router := newTestCaptureRouter(t, nil)
|
|
var err error
|
|
for _, key := range keys {
|
|
if _, err = router.Route(key); err != nil {
|
|
break
|
|
}
|
|
}
|
|
if err == nil {
|
|
t.Fatal("inconsistent input accepted")
|
|
}
|
|
if _, err := router.Route(release(codeA)); !errors.Is(err, ErrRemoteKeyState) {
|
|
t.Fatalf("stopped router = %v", err)
|
|
}
|
|
got := router.Retire()
|
|
if len(keys) > 1 && !reflect.DeepEqual(got.Remote, []RemoteRepeatTransition{up(codeA)}) {
|
|
t.Fatalf("retire = %+v", got)
|
|
}
|
|
})
|
|
}
|
|
router := newTestCaptureRouter(t, nil)
|
|
for index := 0; index < RemoteHeldKeyLimit; index++ {
|
|
code := uint8(100 + index)
|
|
if _, err := router.Route(press(code, 'a')); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := router.Route(press(200, 'b')); !errors.Is(err, ErrRemoteKeyLimit) {
|
|
t.Fatalf("overflow = %v", err)
|
|
}
|
|
if got := router.Retire(); len(got.Remote) != RemoteHeldKeyLimit || len(got.Swallow) != RemoteHeldKeyLimit {
|
|
t.Fatalf("retire after overflow = %d/%d", len(got.Remote), len(got.Swallow))
|
|
}
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureRejectsInvalidChords(t *testing.T) {
|
|
for name, test := range map[string]struct {
|
|
escape CommandChord
|
|
local CommandBindings
|
|
}{
|
|
"empty escape": {CommandChord{}, nil},
|
|
"modifier escape": {CommandChord{Symbol: leftShiftKeySymbol}, nil},
|
|
"reader escape": {CommandChord{Symbol: testInsertSymbol}, nil},
|
|
"modifier local": {testEscape, CommandBindings{{Symbol: leftControlKeySymbol}: "tentacle/remote/x"}},
|
|
} {
|
|
if _, err := NewRemoteCaptureRouter(test.escape, test.local, []uint32{testInsertSymbol}); !errors.Is(err, ErrRemoteCaptureChord) {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local chords come from resolved bindings filtered to Remote's commands, so a
|
|
// user-assigned shortcut is local while ordinary reader commands are forwarded.
|
|
func TestBehaviorRemoteCaptureHonorsResolvedRemaps(t *testing.T) {
|
|
suggestions := []BindingOverride{{Layout: DesktopKeyboardLayout, Scope: "global", Command: "tentacle/remote/menu", Keys: []string{"Cthulhu+Ctrl+r"}}}
|
|
remap := []BindingOverride{{Layout: DesktopKeyboardLayout, Scope: "global", Command: "tentacle/remote/menu", Keys: []string{"Cthulhu+Alt+r"}}}
|
|
for name, overrides := range map[string][]BindingOverride{
|
|
"suggested": MergeTentacleBindings(nil, suggestions),
|
|
"remapped": MergeTentacleBindings(remap, suggestions),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
resolved, err := ResolveCommandBindings(DesktopKeyboardLayout, false, overrides)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
local := CommandBindings{}
|
|
for chord, command := range resolved {
|
|
if command == "tentacle/remote/menu" {
|
|
local[chord] = command
|
|
}
|
|
}
|
|
router := newTestCaptureRouter(t, local)
|
|
modifierCode, modifierSymbol := codeControl, leftControlKeySymbol
|
|
if name == "remapped" {
|
|
modifierCode, modifierSymbol = codeAlt, uint32(0xffe9)
|
|
}
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
|
{press(modifierCode, modifierSymbol), remote(down(modifierCode))},
|
|
{press(codeR, 'r'), RemoteCaptureResult{Remote: []RemoteRepeatTransition{up(modifierCode), up(codeInsert)}, Local: "tentacle/remote/menu"}},
|
|
})
|
|
// Local reader commands are not Remote commands: Cthulhu+t
|
|
// reaches the remote screen reader.
|
|
other := newTestCaptureRouter(t, local)
|
|
runCaptureSteps(t, other, []captureStep{
|
|
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
|
{press(28, 't'), remote(down(28))},
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// The normalizer and router compose: a classic repeat pair inside a local
|
|
// chord produces no remote traffic and no second local command.
|
|
func TestBehaviorRemoteCaptureComposesWithNormalizer(t *testing.T) {
|
|
normalizer := newTestNormalizer(t)
|
|
router := newTestCaptureRouter(t, CommandBindings{{Symbol: 'r', Modifiers: controlModifier}: "tentacle/remote/menu"})
|
|
symbols := map[uint8]uint32{codeControl: leftControlKeySymbol, codeR: 'r'}
|
|
var remoteOut []RemoteRepeatTransition
|
|
var commands []Command
|
|
now := time.Unix(1, 0)
|
|
for _, event := range []Event{
|
|
{Keycode: codeControl, Pressed: true, Timestamp: 10},
|
|
{Keycode: codeR, Pressed: true, Timestamp: 20},
|
|
{Keycode: codeR, Pressed: false, Timestamp: 700},
|
|
{Keycode: codeR, Pressed: true, Timestamp: 700},
|
|
{Keycode: codeR, Pressed: false, Timestamp: 800},
|
|
{Keycode: codeControl, Pressed: false, Timestamp: 0},
|
|
} {
|
|
transitions, err := normalizer.Observe(event, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, transition := range transitions {
|
|
result, err := router.Route(RemoteCaptureKey{Keycode: transition.Keycode, Symbol: symbols[transition.Keycode], Pressed: transition.Pressed, Repeat: transition.Repeat})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
remoteOut = append(remoteOut, result.Remote...)
|
|
if result.Local != "" {
|
|
commands = append(commands, result.Local)
|
|
}
|
|
}
|
|
}
|
|
if want := []RemoteRepeatTransition{down(codeControl), up(codeControl)}; !reflect.DeepEqual(remoteOut, want) {
|
|
t.Fatalf("remote = %+v, want %+v", remoteOut, want)
|
|
}
|
|
if !reflect.DeepEqual(commands, []Command{"tentacle/remote/menu"}) {
|
|
t.Fatalf("commands = %v", commands)
|
|
}
|
|
}
|
|
|
|
func TestBehaviorRemoteCaptureConsumesUnsupportedKeys(t *testing.T) {
|
|
router := newTestCaptureRouter(t, nil)
|
|
runCaptureSteps(t, router, []captureStep{
|
|
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
|
{RemoteCaptureKey{Keycode: 121, Symbol: 0x1008ff12, Pressed: true, Unsupported: true}, RemoteCaptureResult{}},
|
|
{hold(121, 0x1008ff12), RemoteCaptureResult{}},
|
|
{release(121), RemoteCaptureResult{}},
|
|
{release(codeShift), remote(up(codeShift))},
|
|
})
|
|
for keysym, want := range map[string]string{"Prior": "Page_Up", "KP_Next": "KP_Page_Down", "a": "a", "A": "", "XF86AudioMute": "XF86AudioMute", "XF86Eject": "", "ISO_Level3_Shift": ""} {
|
|
if got := RemoteCaptureKeyName(keysym); got != want {
|
|
t.Fatalf("%s = %q, want %q", keysym, got, want)
|
|
}
|
|
}
|
|
}
|