diff --git a/docs/testing/remote-input-feasibility.md b/docs/testing/remote-input-feasibility.md index b377dcf..c1cd9eb 100644 --- a/docs/testing/remote-input-feasibility.md +++ b/docs/testing/remote-input-feasibility.md @@ -136,6 +136,38 @@ existing internal-UI, learn-mode and injected-approval tests, passed with this test added. The `xset` change must follow the listener's connection because Xvfb resets defaults when its last client exits. +## Capture routing and bypass balancing (2026-11-06) + +`internal/input/remote_capture.go` adds a quarantined `RemoteCaptureRouter` +that consumes normalized transitions for one controller capture lifetime. +Ordinary keys and Cthulhu/NVDA modifier chords are forwarded. Only a required +core escape chord (a non-modifier key; proposed F11) and supplied local chords +stay local. Callers derive local chords from `ResolveCommandBindings` filtered +to Remote's commands, so user-assigned shortcuts are honored instead of a +hard-coded bypass list; the escape always wins over a local binding. Chords use +the same group-zero, level-zero keysym and modifier bits as the command owner. + +Modifiers are forwarded when pressed because they cannot be classified yet. +When a later key completes a local chord, every forwarded modifier is released +remotely first (newest first) and marked withdrawn, so no prefix remains held +on the remote. A forwarded key pressed while withdrawn modifiers are still held +presses them again before itself, so the remote sees the physical chord. +Repeats and releases of local and withdrawn keys are consumed. Releases for +keys held before capture are ignored. Orphan repeats, double downs, invalid +codes and more than 32 held keys stop routing. `Retire` returns remote releases +for forwarded keys in reverse order plus a `Swallow` list of every physically +held key: the local application never received those presses, so the native +owner must consume their releases before ungrabbing. + +Limitation: a withdrawn prefix reaches the remote as a tap of those modifiers. +On Windows a lone Alt tap can activate an application menu bar, and NVDA treats +modifier taps specially; this needs live testing and may need a neutral key +between press and release. The router has no production caller, performs no +I/O and grants no consent or provenance. Nine unit tests cover forwarding, +withdrawal and re-press, consumed releases, escape priority, retirement, +inconsistent input, invalid chords, resolved Tentacle bindings and composition +with the repeat normalizer. + Still pending: Remote capture within the command owner; local escape and balanced bypass chords; remapping, learn/UI/lock/handoff arbitration; production helper parent-death and blocked-pipe tests; held-key collisions with actual diff --git a/internal/input/remote_capture.go b/internal/input/remote_capture.go new file mode 100644 index 0000000..fe1930c --- /dev/null +++ b/internal/input/remote_capture.go @@ -0,0 +1,255 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "errors" + +var ErrRemoteCaptureChord = errors.New("remote capture local shortcut is invalid") + +// RemoteCaptureKey is one normalized captured transition. Symbol is the +// group-zero, level-zero keysym the native owner resolved for a press, the +// same form the command owner matches against bindings; releases and repeats are +// matched by keycode, so a later layout change cannot alter ownership. +type RemoteCaptureKey struct { + Keycode uint8 + Symbol uint32 + Pressed bool + Repeat bool +} + +// RemoteCaptureResult is the routing decision for one captured transition. +// Remote transitions must be delivered in order before Local is dispatched. +// Escape is set only for the core escape chord; Local names a matched +// explicit local command. At most one of them is set. +type RemoteCaptureResult struct { + Remote []RemoteRepeatTransition + Local Command + Escape bool +} + +// RemoteCaptureRetirement transfers cleanup. Remote releases every key +// forwarded and still owned, in reverse down order. Swallow lists every key +// still physically held: the local application never received those presses, +// so the native owner must consume their releases instead of ungrabbing first. +type RemoteCaptureRetirement struct { + Remote []RemoteRepeatTransition + Swallow []uint8 +} + +type remoteCaptureState uint8 + +const ( + remoteCaptureIdle remoteCaptureState = iota + remoteCaptureForwarded // Down sent to the remote. + remoteCaptureWithdrawn // Modifier released remotely after a local chord. + remoteCaptureLocal // Press consumed by a local command. +) + +// RemoteCaptureRouter decides, for a controller capture lifetime, which keys +// go to the remote and which stay local. Ordinary keys and Cthulhu/NVDA +// modifier chords are forwarded. Only the core escape and the supplied local +// chords stay local; callers derive those from resolved bindings so remaps +// are honored rather than a hard-coded list. +// +// Modifiers cannot be classified when pressed, so they are forwarded at once. +// When a later key completes a local chord, forwarded modifiers are released +// remotely first, so no prefix stays held on the remote. While still held they +// are withdrawn; a later forwarded key presses them again before itself, so the +// remote sees the physical chord. Releases of local or withdrawn keys are +// consumed and never reach the remote. +// +// Input must come from RemoteRepeatNormalizer. One serial owner drives an +// instance; it performs no I/O and grants no consent or provenance. +type RemoteCaptureRouter struct { + escape CommandChord + local CommandBindings + cthulhu map[uint32]bool + state [256]remoteCaptureState + modifier [256]bool + mask [256]uint16 + isCthulhu [256]bool + order []uint8 + stopped bool + retired bool +} + +// NewRemoteCaptureRouter requires a non-modifier escape chord. Local chords +// equal to the escape are ignored: the escape always wins. +func NewRemoteCaptureRouter(escape CommandChord, local CommandBindings, cthulhuModifiers []uint32) (*RemoteCaptureRouter, error) { + router := &RemoteCaptureRouter{ + escape: escape, + local: make(CommandBindings, len(local)), + cthulhu: make(map[uint32]bool, len(cthulhuModifiers)), + order: make([]uint8, 0, RemoteHeldKeyLimit), + } + for _, symbol := range cthulhuModifiers { + router.cthulhu[symbol] = true + } + if escape.Symbol == 0 || router.isModifierSymbol(escape.Symbol) { + return nil, ErrRemoteCaptureChord + } + for chord, command := range local { + if chord == escape || command == "" { + continue + } + if chord.Symbol == 0 || router.isModifierSymbol(chord.Symbol) { + return nil, ErrRemoteCaptureChord + } + router.local[chord] = command + } + return router, nil +} + +func remoteModifierMask(symbol uint32) (uint16, bool) { + switch symbol { + case leftShiftKeySymbol, rightShiftKeySymbol: + return 1, true + case leftControlKeySymbol, rightControlKeySymbol: + return controlModifier, true + case 0xffe7, 0xffe8, 0xffe9, 0xffea: // Meta, Alt. + return 8, true + case 0xffeb, 0xffec: // Super. + return 64, true + case 0xffed, 0xffee: // Hyper: a modifier with no chord bit. + return 0, true + } + return 0, false +} + +func (router *RemoteCaptureRouter) isModifierSymbol(symbol uint32) bool { + _, modifier := remoteModifierMask(symbol) + return modifier || router.cthulhu[symbol] +} + +func (router *RemoteCaptureRouter) fail(err error) (RemoteCaptureResult, error) { + router.stopped = true + return RemoteCaptureResult{}, err +} + +// Route handles one transition. An error stops routing permanently while +// retaining ownership for Retire. +func (router *RemoteCaptureRouter) Route(key RemoteCaptureKey) (RemoteCaptureResult, error) { + if router == nil || router.stopped { + return RemoteCaptureResult{}, ErrRemoteKeyState + } + code := key.Keycode + if code < remoteRepeatMinKeycode { + return router.fail(ErrRemoteKeyUnavailable) + } + state := router.state[code] + if !key.Pressed { + if state == remoteCaptureIdle { + return RemoteCaptureResult{}, nil + } + router.forget(code) + if state == remoteCaptureForwarded { + return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code}}}, nil + } + return RemoteCaptureResult{}, nil + } + if key.Repeat { + switch state { + case remoteCaptureIdle: + return router.fail(ErrRemoteKeyCollision) + case remoteCaptureForwarded: + return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code, Pressed: true, Repeat: true}}}, nil + } + return RemoteCaptureResult{}, nil + } + if state != remoteCaptureIdle { + return router.fail(ErrRemoteKeyCollision) + } + if len(router.order) >= RemoteHeldKeyLimit { + return router.fail(ErrRemoteKeyLimit) + } + if mask, modifier := remoteModifierMask(key.Symbol); modifier || router.cthulhu[key.Symbol] { + router.hold(code, remoteCaptureForwarded) + router.modifier[code] = true + router.mask[code] = mask + router.isCthulhu[code] = router.cthulhu[key.Symbol] + return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code, Pressed: true}}}, nil + } + chord := router.chord(key.Symbol) + escape := chord == router.escape + command := Command("") + if !escape { + command = router.local[chord] + } + if escape || command != "" { + var result RemoteCaptureResult + for index := len(router.order) - 1; index >= 0; index-- { + held := router.order[index] + if router.modifier[held] && router.state[held] == remoteCaptureForwarded { + router.state[held] = remoteCaptureWithdrawn + result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: held}) + } + } + router.hold(code, remoteCaptureLocal) + result.Local, result.Escape = command, escape + return result, nil + } + var result RemoteCaptureResult + for _, held := range router.order { + if router.state[held] == remoteCaptureWithdrawn { + router.state[held] = remoteCaptureForwarded + result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: held, Pressed: true}) + } + } + router.hold(code, remoteCaptureForwarded) + result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: code, Pressed: true}) + return result, nil +} + +// Retire stops routing and transfers cleanup once. +func (router *RemoteCaptureRouter) Retire() RemoteCaptureRetirement { + if router == nil || router.retired { + return RemoteCaptureRetirement{} + } + router.stopped = true + router.retired = true + var out RemoteCaptureRetirement + for index := len(router.order) - 1; index >= 0; index-- { + code := router.order[index] + if router.state[code] == remoteCaptureForwarded { + out.Remote = append(out.Remote, RemoteRepeatTransition{Keycode: code}) + } + out.Swallow = append(out.Swallow, code) + } + router.state = [256]remoteCaptureState{} + router.modifier = [256]bool{} + router.order = router.order[:0] + return out +} + +func (router *RemoteCaptureRouter) chord(symbol uint32) CommandChord { + chord := CommandChord{Symbol: symbol} + for _, code := range router.order { + if !router.modifier[code] { + continue + } + chord.Modifiers |= router.mask[code] + chord.CthulhuModifier = chord.CthulhuModifier || router.isCthulhu[code] + } + return chord +} + +func (router *RemoteCaptureRouter) hold(code uint8, state remoteCaptureState) { + router.state[code] = state + router.order = append(router.order, code) +} + +func (router *RemoteCaptureRouter) forget(code uint8) { + router.state[code] = remoteCaptureIdle + router.modifier[code] = false + router.mask[code] = 0 + router.isCthulhu[code] = false + for index, held := range router.order { + if held == code { + copy(router.order[index:], router.order[index+1:]) + router.order = router.order[:len(router.order)-1] + return + } + } +} diff --git a/internal/input/remote_capture_test.go b/internal/input/remote_capture_test.go new file mode 100644 index 0000000..b0885ec --- /dev/null +++ b/internal/input/remote_capture_test.go @@ -0,0 +1,307 @@ +// 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) + } +}