Add domain states and event runtime (T-002)

This commit is contained in:
ila
2026-07-16 15:31:41 +08:00
parent cf9fc01c68
commit 0e20dd76b2
8 changed files with 494 additions and 6 deletions
+51
View File
@@ -0,0 +1,51 @@
package application
// EventType identifies an application event consumed by UI adapters.
type EventType string
const (
EventCatalogRefreshed EventType = "CatalogRefreshed"
EventCatalogRejected EventType = "CatalogRejected"
EventDownloadStarted EventType = "DownloadStarted"
EventDownloadProgress EventType = "DownloadProgress"
EventDownloadPaused EventType = "DownloadPaused"
EventDownloadCompleted EventType = "DownloadCompleted"
EventDownloadFailed EventType = "DownloadFailed"
EventInstallCompleted EventType = "InstallCompleted"
EventInstallRolledBack EventType = "InstallRolledBack"
EventAppStarted EventType = "AppStarted"
EventAppExited EventType = "AppExited"
EventLicenseChanged EventType = "LicenseChanged"
)
var validEventTypes = map[EventType]struct{}{
EventCatalogRefreshed: {},
EventCatalogRejected: {},
EventDownloadStarted: {},
EventDownloadProgress: {},
EventDownloadPaused: {},
EventDownloadCompleted: {},
EventDownloadFailed: {},
EventInstallCompleted: {},
EventInstallRolledBack: {},
EventAppStarted: {},
EventAppExited: {},
EventLicenseChanged: {},
}
// Valid reports whether eventType is part of the documented event contract.
func (eventType EventType) Valid() bool {
_, ok := validEventTypes[eventType]
return ok
}
// Event is the common envelope delivered from background use cases to adapters.
//
// Payload is event-specific and will be replaced by concrete payload types as
// the corresponding use cases are implemented.
type Event struct {
Type EventType
RequestID string
AppID string
Payload any
}
+30
View File
@@ -0,0 +1,30 @@
package application
import "testing"
func TestEventTypeValid(t *testing.T) {
eventTypes := []EventType{
EventCatalogRefreshed,
EventCatalogRejected,
EventDownloadStarted,
EventDownloadProgress,
EventDownloadPaused,
EventDownloadCompleted,
EventDownloadFailed,
EventInstallCompleted,
EventInstallRolledBack,
EventAppStarted,
EventAppExited,
EventLicenseChanged,
}
for _, eventType := range eventTypes {
if !eventType.Valid() {
t.Errorf("event type %q should be valid", eventType)
}
}
if EventType("Unknown").Valid() {
t.Fatal("unknown event type should be invalid")
}
}
+72
View File
@@ -0,0 +1,72 @@
package application
import (
"context"
"errors"
"fmt"
"sync"
)
var (
// ErrInvalidEvent indicates that an event is not part of the event contract.
ErrInvalidEvent = errors.New("invalid application event")
// ErrRuntimeClosed indicates that the runtime no longer accepts events.
ErrRuntimeClosed = errors.New("application runtime closed")
)
// Runtime is the minimal event bus shared by background use cases and adapters.
type Runtime struct {
events chan Event
done chan struct{}
closeOnce sync.Once
}
// NewRuntime creates an event runtime with the requested queue capacity.
func NewRuntime(buffer int) *Runtime {
if buffer < 0 {
panic("application runtime buffer must not be negative")
}
return &Runtime{
events: make(chan Event, buffer),
done: make(chan struct{}),
}
}
// Publish queues an event or returns when the context/runtime is closed.
func (runtime *Runtime) Publish(ctx context.Context, event Event) error {
if !event.Type.Valid() {
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, event.Type)
}
select {
case <-runtime.done:
return ErrRuntimeClosed
default:
}
select {
case <-runtime.done:
return ErrRuntimeClosed
case <-ctx.Done():
return ctx.Err()
case runtime.events <- event:
return nil
}
}
// Events exposes the read-only event stream to adapters.
func (runtime *Runtime) Events() <-chan Event {
return runtime.events
}
// Done is closed when the runtime stops accepting events.
func (runtime *Runtime) Done() <-chan struct{} {
return runtime.done
}
// Close stops future publishes. It is safe to call more than once.
func (runtime *Runtime) Close() {
runtime.closeOnce.Do(func() {
close(runtime.done)
})
}
+66
View File
@@ -0,0 +1,66 @@
package application
import (
"context"
"errors"
"testing"
)
func TestRuntimePublish(t *testing.T) {
runtime := NewRuntime(1)
event := Event{
Type: EventDownloadStarted,
RequestID: "request-1",
AppID: "json-parser",
}
if err := runtime.Publish(context.Background(), event); err != nil {
t.Fatalf("Publish() error = %v", err)
}
got := <-runtime.Events()
if got != event {
t.Fatalf("Events() got %#v, want %#v", got, event)
}
}
func TestRuntimeRejectsInvalidEvent(t *testing.T) {
runtime := NewRuntime(1)
err := runtime.Publish(context.Background(), Event{Type: EventType("Unknown")})
if !errors.Is(err, ErrInvalidEvent) {
t.Fatalf("Publish() error = %v, want %v", err, ErrInvalidEvent)
}
}
func TestRuntimePublishHonorsContextCancellation(t *testing.T) {
runtime := NewRuntime(1)
if err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}); err != nil {
t.Fatalf("first Publish() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := runtime.Publish(ctx, Event{Type: EventDownloadProgress})
if !errors.Is(err, context.Canceled) {
t.Fatalf("blocked Publish() error = %v, want %v", err, context.Canceled)
}
}
func TestRuntimeClose(t *testing.T) {
runtime := NewRuntime(1)
runtime.Close()
runtime.Close()
select {
case <-runtime.Done():
default:
t.Fatal("Done() should be closed")
}
err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted})
if !errors.Is(err, ErrRuntimeClosed) {
t.Fatalf("Publish() error = %v, want %v", err, ErrRuntimeClosed)
}
}