gui/pkg/events/service_test.go
Claude 18a455b460
Some checks failed
Security Scan / security (push) Failing after 25s
refactor: migrate entire gui to Core v0.8.0 API
- Import paths: forge.lthn.ai/core/go → dappco.re/go/core
- Import paths: forge.lthn.ai/core/go-log → dappco.re/go/core/log
- Import paths: forge.lthn.ai/core/go-io → dappco.re/go/core/io
- RegisterTask → c.Action("name", handler) across all 15 services
- QueryHandler signature: (any, bool, error) → core.Result
- PERFORM(task) → Action.Run(ctx, opts)
- QUERY returns single core.Result (not 3 values)
- All 17 packages build and test clean on v0.8.0-alpha.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:14:19 +01:00

358 lines
9.1 KiB
Go

// pkg/events/service_test.go
package events
import (
"context"
"sync"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Mock Platform ---
type mockPlatform struct {
mu sync.Mutex
listeners map[string][]*mockListener
emitted []CustomEvent
resetCalled bool
}
type mockListener struct {
callback func(*CustomEvent)
counter int // -1 = persistent
}
func newMockPlatform() *mockPlatform {
return &mockPlatform{
listeners: make(map[string][]*mockListener),
}
}
func (m *mockPlatform) Emit(name string, data ...any) bool {
event := &CustomEvent{Name: name}
if len(data) == 1 {
event.Data = data[0]
} else if len(data) > 1 {
event.Data = data
}
m.mu.Lock()
m.emitted = append(m.emitted, *event)
active := make([]*mockListener, len(m.listeners[name]))
copy(active, m.listeners[name])
m.mu.Unlock()
for _, listener := range active {
listener.callback(event)
}
return false
}
func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() {
listener := &mockListener{callback: callback, counter: -1}
m.mu.Lock()
m.listeners[name] = append(m.listeners[name], listener)
m.mu.Unlock()
return func() {
m.mu.Lock()
defer m.mu.Unlock()
updated := m.listeners[name][:0]
for _, existing := range m.listeners[name] {
if existing != listener {
updated = append(updated, existing)
}
}
m.listeners[name] = updated
}
}
func (m *mockPlatform) Off(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.listeners, name)
}
func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) {
m.mu.Lock()
defer m.mu.Unlock()
m.listeners[name] = append(m.listeners[name], &mockListener{callback: callback, counter: counter})
}
func (m *mockPlatform) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.listeners = make(map[string][]*mockListener)
m.resetCalled = true
}
// simulateEvent fires all registered listeners for the given event name with optional data.
func (m *mockPlatform) simulateEvent(name string, data any) {
event := &CustomEvent{Name: name, Data: data}
m.mu.Lock()
active := make([]*mockListener, len(m.listeners[name]))
copy(active, m.listeners[name])
m.mu.Unlock()
for _, listener := range active {
listener.callback(event)
}
}
// listenerCount returns the total number of registered listeners across all event names.
func (m *mockPlatform) listenerCount() int {
m.mu.Lock()
defer m.mu.Unlock()
total := 0
for _, listeners := range m.listeners {
total += len(listeners)
}
return total
}
// --- Test helpers ---
func newTestService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
t.Helper()
mock := newMockPlatform()
c := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
svc := core.MustServiceFor[*Service](c, "events")
return svc, c, mock
}
func taskRun(c *core.Core, name string, task any) core.Result {
return c.Action(name).Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: task},
))
}
// --- Good path tests ---
func TestRegister_Good(t *testing.T) {
svc, _, _ := newTestService(t)
assert.NotNil(t, svc)
}
func TestTaskEmit_Good(t *testing.T) {
_, c, mock := newTestService(t)
r := taskRun(c, "events.emit", TaskEmit{Name: "user:login", Data: "alice"})
require.True(t, r.OK)
assert.Equal(t, false, r.Value) // not cancelled
assert.Len(t, mock.emitted, 1)
assert.Equal(t, "user:login", mock.emitted[0].Name)
assert.Equal(t, "alice", mock.emitted[0].Data)
}
func TestTaskEmit_NoData_Good(t *testing.T) {
_, c, mock := newTestService(t)
r := taskRun(c, "events.emit", TaskEmit{Name: "ping"})
require.True(t, r.OK)
assert.Len(t, mock.emitted, 1)
assert.Nil(t, mock.emitted[0].Data)
}
func TestTaskOn_Good(t *testing.T) {
_, c, mock := newTestService(t)
var received []ActionEventFired
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if fired, ok := msg.(ActionEventFired); ok {
received = append(received, fired)
}
return core.Result{OK: true}
})
r := taskRun(c, "events.on", TaskOn{Name: "theme:changed"})
require.True(t, r.OK)
mock.simulateEvent("theme:changed", "dark")
assert.Len(t, received, 1)
assert.Equal(t, "theme:changed", received[0].Event.Name)
assert.Equal(t, "dark", received[0].Event.Data)
}
func TestTaskOff_Good(t *testing.T) {
_, c, mock := newTestService(t)
// Register via IPC then remove
r := taskRun(c, "events.on", TaskOn{Name: "file:saved"})
require.True(t, r.OK)
assert.Equal(t, 1, mock.listenerCount())
r2 := taskRun(c, "events.off", TaskOff{Name: "file:saved"})
require.True(t, r2.OK)
assert.Equal(t, 0, mock.listenerCount())
}
func TestQueryListeners_Good(t *testing.T) {
_, c, _ := newTestService(t)
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
require.True(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK)
require.True(t, taskRun(c, "events.on", TaskOn{Name: "theme:changed"}).OK)
r := c.QUERY(QueryListeners{})
require.True(t, r.OK)
infos := r.Value.([]ListenerInfo)
counts := make(map[string]int)
for _, info := range infos {
counts[info.EventName] = info.Count
}
assert.Equal(t, 2, counts["user:login"])
assert.Equal(t, 1, counts["theme:changed"])
}
func TestQueryListeners_Empty_Good(t *testing.T) {
_, c, _ := newTestService(t)
r := c.QUERY(QueryListeners{})
require.True(t, r.OK)
infos := r.Value.([]ListenerInfo)
assert.Empty(t, infos)
}
func TestOnShutdown_CancelsAll_Good(t *testing.T) {
svc, _, mock := newTestService(t)
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "a:b"}).OK)
require.True(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "c:d"}).OK)
assert.Equal(t, 2, mock.listenerCount())
require.True(t, svc.OnShutdown(context.Background()).OK)
assert.Equal(t, 0, mock.listenerCount())
}
func TestActionEventFired_BroadcastOnSimulate_Good(t *testing.T) {
_, c, mock := newTestService(t)
var receivedEvents []CustomEvent
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if fired, ok := msg.(ActionEventFired); ok {
receivedEvents = append(receivedEvents, fired.Event)
}
return core.Result{OK: true}
})
require.True(t, taskRun(c, "events.on", TaskOn{Name: "data:ready"}).OK)
mock.simulateEvent("data:ready", map[string]any{"rows": 42})
require.Len(t, receivedEvents, 1)
assert.Equal(t, "data:ready", receivedEvents[0].Name)
}
// --- Bad path tests ---
func TestTaskOn_EmptyName_Bad(t *testing.T) {
_, c, _ := newTestService(t)
r := taskRun(c, "events.on", TaskOn{Name: ""})
assert.False(t, r.OK)
}
func TestTaskEmit_UnknownEvent_Bad(t *testing.T) {
// Emitting an event with no listeners is valid — returns not-cancelled.
_, c, mock := newTestService(t)
r := taskRun(c, "events.emit", TaskEmit{Name: "no:listeners"})
require.True(t, r.OK)
assert.Equal(t, false, r.Value)
assert.Len(t, mock.emitted, 1) // still recorded as emitted
}
func TestQueryListeners_NoService_Bad(t *testing.T) {
// No events service registered — query is not handled.
c := core.New(core.WithServiceLock())
r := c.QUERY(QueryListeners{})
assert.False(t, r.OK)
}
func TestTaskEmit_NoService_Bad(t *testing.T) {
c := core.New(core.WithServiceLock())
r := c.Action("events.emit").Run(context.Background(), core.NewOptions())
assert.False(t, r.OK)
}
// --- Ugly path tests ---
func TestTaskOff_NeverRegistered_Ugly(t *testing.T) {
// Off on a name that was never registered is a no-op — must not panic.
_, c, _ := newTestService(t)
r := taskRun(c, "events.off", TaskOff{Name: "nonexistent:event"})
assert.True(t, r.OK)
}
func TestTaskOn_MultipleListeners_Ugly(t *testing.T) {
// Multiple IPC listeners for the same event each receive ActionEventFired.
_, c, mock := newTestService(t)
var mu sync.Mutex
var fireCount int
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if _, ok := msg.(ActionEventFired); ok {
mu.Lock()
fireCount++
mu.Unlock()
}
return core.Result{OK: true}
})
taskRun(c, "events.on", TaskOn{Name: "flood"})
taskRun(c, "events.on", TaskOn{Name: "flood"})
taskRun(c, "events.on", TaskOn{Name: "flood"})
mock.simulateEvent("flood", nil)
mu.Lock()
count := fireCount
mu.Unlock()
assert.Equal(t, 3, count)
}
func TestTaskOff_ThenEmit_Ugly(t *testing.T) {
// After Off, simulating the event must not trigger any IPC actions.
_, c, mock := newTestService(t)
var received bool
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if _, ok := msg.(ActionEventFired); ok {
received = true
}
return core.Result{OK: true}
})
taskRun(c, "events.on", TaskOn{Name: "transient"})
taskRun(c, "events.off", TaskOff{Name: "transient"})
mock.simulateEvent("transient", "late-data")
assert.False(t, received)
}
func TestQueryListeners_AfterOff_Ugly(t *testing.T) {
// After Off, the event name must not appear in QueryListeners results.
_, c, _ := newTestService(t)
taskRun(c, "events.on", TaskOn{Name: "ephemeral"})
taskRun(c, "events.off", TaskOff{Name: "ephemeral"})
r := c.QUERY(QueryListeners{})
infos := r.Value.([]ListenerInfo)
for _, info := range infos {
assert.NotEqual(t, "ephemeral", info.EventName)
}
}