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