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:
parent
ab0722d19e
commit
0f6400052c
4 changed files with 132 additions and 2 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue