Route quarantined Remote capture and balance bypass modifiers

Add RemoteCaptureRouter for normalized controller capture. Ordinary keys
and Cthulhu/NVDA modifier chords are forwarded; only a required core
escape chord and local chords derived from resolved bindings stay local.
A local chord first releases forwarded modifiers remotely; a later
forwarded key presses still-held ones again. Releases of local or
withdrawn keys are consumed, and retirement reports physically held keys
whose releases the native owner must swallow. No production caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chase CichorzandClaude Opus 5.5 committed 2026-11-06 16:06:38 -05:00
1 parent 6b3aa80138
commit c1333c2fdb
3 files changed
+594

No files matched your search

+32
View File
@@ -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
+255
View File
@@ -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
}
}
}
+307
View File
@@ -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)
}
}