feat(keybinding): add TaskProcess, error sentinels, concurrency test

- Platform.Process(accelerator) for programmatic trigger
- TaskProcess IPC task with ActionTriggered broadcast
- ErrorNotRegistered sentinel for remove/process on unknown bindings
- 5 new tests: process good/bad/ugly, remove sentinel, concurrent adds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-31 14:55:01 +01:00
parent ab0722d19e
commit 0f6400052c
No known key found for this signature in database
GPG key ID: AF404715446AEB41
4 changed files with 132 additions and 2 deletions

View file

@ -3,6 +3,7 @@ package keybinding
import "errors"
var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered")
var ErrorNotRegistered = errors.New("keybinding: accelerator not registered")
// BindingInfo describes a registered global key binding.
type BindingInfo struct {
@ -19,11 +20,19 @@ type TaskAdd struct {
Description string `json:"description"`
}
// TaskRemove unregisters a global key binding by accelerator.
// TaskRemove unregisters a global key binding by accelerator. Error: ErrorNotRegistered if not found.
type TaskRemove struct {
Accelerator string `json:"accelerator"`
}
// TaskProcess triggers a registered key binding programmatically.
// Returns ActionTriggered if the accelerator was handled, ErrorNotRegistered if not found.
//
// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"})
type TaskProcess struct {
Accelerator string `json:"accelerator"`
}
// ActionTriggered is broadcast when a registered key binding fires.
type ActionTriggered struct {
Accelerator string `json:"accelerator"`

View file

@ -12,6 +12,12 @@ type Platform interface {
// Remove unregisters a previously registered keyboard shortcut.
Remove(accelerator string) error
// Process triggers the registered handler for the given accelerator programmatically.
// Returns true if a handler was found and invoked, false if not registered.
//
// handled := platform.Process("Ctrl+S")
Process(accelerator string) bool
// GetAll returns all currently registered accelerator strings.
// Used for adapter-level reconciliation only — not read by QueryList.
GetAll() []string

View file

@ -53,6 +53,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskAdd(t)
case TaskRemove:
return nil, true, s.taskRemove(t)
case TaskProcess:
return nil, true, s.taskProcess(t)
default:
return nil, false, nil
}
@ -80,7 +82,7 @@ func (s *Service) taskAdd(t TaskAdd) error {
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil)
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, ErrorNotRegistered)
}
err := s.platform.Remove(t.Accelerator)
@ -91,3 +93,20 @@ func (s *Service) taskRemove(t TaskRemove) error {
delete(s.registeredBindings, t.Accelerator)
return nil
}
// taskProcess triggers the registered handler for the given accelerator programmatically.
// Broadcasts ActionTriggered if handled; returns ErrorNotRegistered if the accelerator is unknown.
//
// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"})
func (s *Service) taskProcess(t TaskProcess) error {
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskProcess", "not registered: "+t.Accelerator, ErrorNotRegistered)
}
handled := s.platform.Process(t.Accelerator)
if !handled {
return coreerr.E("keybinding.taskProcess", "platform did not handle: "+t.Accelerator, nil)
}
return nil
}

View file

@ -37,6 +37,17 @@ func (m *mockPlatform) Remove(accelerator string) error {
return nil
}
func (m *mockPlatform) Process(accelerator string) bool {
m.mu.Lock()
h, ok := m.handlers[accelerator]
m.mu.Unlock()
if ok && h != nil {
h()
return true
}
return false
}
func (m *mockPlatform) GetAll() []string {
m.mu.Lock()
defer m.mu.Unlock()
@ -199,3 +210,88 @@ func TestQueryList_Bad_NoService(t *testing.T) {
_, handled, _ := c.QUERY(QueryList{})
assert.False(t, handled)
}
// --- TaskProcess tests ---
func TestTaskProcess_Good(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
var triggered ActionTriggered
var mu sync.Mutex
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionTriggered); ok {
mu.Lock()
triggered = a
mu.Unlock()
}
return nil
})
_, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"})
require.NoError(t, err)
assert.True(t, handled)
mu.Lock()
assert.Equal(t, "Ctrl+P", triggered.Accelerator)
mu.Unlock()
}
func TestTaskProcess_Bad_NotRegistered(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
_, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrorNotRegistered)
}
func TestTaskProcess_Ugly_RemovedBinding(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
_, _, _ = c.PERFORM(TaskRemove{Accelerator: "Ctrl+P"})
// After remove, process should fail with ErrorNotRegistered
_, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrorNotRegistered)
}
// --- TaskRemove ErrorNotRegistered sentinel tests ---
func TestTaskRemove_Bad_ErrorSentinel(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
_, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrorNotRegistered)
}
// --- QueryList Ugly: concurrent adds ---
func TestQueryList_Ugly_ConcurrentAdds(t *testing.T) {
mp := newMockPlatform()
_, c := newTestKeybindingService(t, mp)
accelerators := []string{"Ctrl+1", "Ctrl+2", "Ctrl+3", "Ctrl+4", "Ctrl+5"}
var wg sync.WaitGroup
for _, accelerator := range accelerators {
wg.Add(1)
go func(acc string) {
defer wg.Done()
_, _, _ = c.PERFORM(TaskAdd{Accelerator: acc, Description: acc})
}(accelerator)
}
wg.Wait()
result, handled, err := c.QUERY(QueryList{})
require.NoError(t, err)
assert.True(t, handled)
list := result.([]BindingInfo)
assert.Len(t, list, len(accelerators))
}