Files
cthulhu/internal/input/remote_capture_test.go
Chase CichorzandClaude Opus 5.5 6867d901b2 Support Remote browser and media keys and connection history UI
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>
2026-11-06 21:17:01 -05:00

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