feat(automations): 自动化引擎(cron 调度 + 事件触发 + AI 管线动作 + 提醒调度,bus automation.result/reminder.trigger)
This commit is contained in:
1 parent
a3f6f4f687
commit
ff34b62079
10 files changed
+1818
No files matched your search
@@ -0,0 +1,233 @@
|
||||
package automations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
)
|
||||
|
||||
func TestEngine_EventMatchFiresOnlyMatchingRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
eng, err := NewEngine(Config{}, Deps{Bus: b})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
|
||||
var ranA, ranB atomic.Int32
|
||||
actA, err := NewFuncAction("on_device", func(context.Context, any) (any, error) {
|
||||
ranA.Add(1)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction A: %v", err)
|
||||
}
|
||||
actB, err := NewFuncAction("on_temp", func(context.Context, any) (any, error) {
|
||||
ranB.Add(1)
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction B: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := eng.AddEvent(ctx, Rule{ID: "r1", Trigger: TriggerEvent, Expression: "iot.device_update", Action: actA}); err != nil {
|
||||
t.Fatalf("AddEvent r1: %v", err)
|
||||
}
|
||||
if err := eng.AddEvent(ctx, Rule{ID: "r2", Trigger: TriggerEvent, Expression: "iot.room_temperature", Action: actB}); err != nil {
|
||||
t.Fatalf("AddEvent r2: %v", err)
|
||||
}
|
||||
if err := eng.Start(ctx); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
if err := b.Publish(ctx, "iot.device_update", map[string]any{"value": 42}); err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool { return ranA.Load() > 0 })
|
||||
if ranA.Load() != 1 {
|
||||
t.Errorf("matching rule ran %d times, want 1", ranA.Load())
|
||||
}
|
||||
if ranB.Load() != 0 {
|
||||
t.Errorf("non-matching rule ran %d times, want 0", ranB.Load())
|
||||
}
|
||||
if err := eng.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_AddEventValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, err := NewEngine(Config{}, Deps{Bus: bus.New()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction: %v", err)
|
||||
}
|
||||
|
||||
// Wrong trigger for an event rule.
|
||||
err = eng.AddEvent(context.Background(), Rule{ID: "c", Trigger: TriggerCron, Expression: "*/5 * * * *", Action: act})
|
||||
if !errors.Is(err, ErrInvalidRule) {
|
||||
t.Fatalf("wrong trigger: err = %v, want ErrInvalidRule", err)
|
||||
}
|
||||
// Missing id.
|
||||
err = eng.AddEvent(context.Background(), Rule{ID: "", Trigger: TriggerEvent, Expression: "iot.*", Action: act})
|
||||
if !errors.Is(err, ErrInvalidRule) {
|
||||
t.Fatalf("missing id: err = %v, want ErrInvalidRule", err)
|
||||
}
|
||||
// Nil action.
|
||||
err = eng.AddEvent(context.Background(), Rule{ID: "ok", Trigger: TriggerEvent, Expression: "iot.*"})
|
||||
if !errors.Is(err, ErrInvalidRule) {
|
||||
t.Fatalf("nil action: err = %v, want ErrInvalidRule", err)
|
||||
}
|
||||
// Duplicate.
|
||||
if err := eng.AddEvent(context.Background(), Rule{ID: "d", Trigger: TriggerEvent, Expression: "iot.*", Action: act}); err != nil {
|
||||
t.Fatalf("AddEvent d: %v", err)
|
||||
}
|
||||
err = eng.AddEvent(context.Background(), Rule{ID: "d", Trigger: TriggerEvent, Expression: "iot.*", Action: act})
|
||||
if !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("duplicate: err = %v, want ErrDuplicate", err)
|
||||
}
|
||||
// Missing removal.
|
||||
err = eng.RemoveRule(context.Background(), "nope")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("remove missing: err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RemoveRuleStopsFiring(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
eng, err := NewEngine(Config{}, Deps{Bus: b})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var ran atomic.Int32
|
||||
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { ran.Add(1); return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction: %v", err)
|
||||
}
|
||||
if err := eng.AddEvent(ctx, Rule{ID: "r", Trigger: TriggerEvent, Expression: "iot.device_update", Action: act}); err != nil {
|
||||
t.Fatalf("AddEvent: %v", err)
|
||||
}
|
||||
if err := eng.Start(ctx); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := b.Publish(ctx, "iot.device_update", nil); err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
waitFor(t, 2*time.Second, func() bool { return ran.Load() > 0 })
|
||||
|
||||
if err := eng.RemoveRule(ctx, "r"); err != nil {
|
||||
t.Fatalf("RemoveRule: %v", err)
|
||||
}
|
||||
// Give a removed subscription a chance to (incorrectly) receive the next event.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
before := ran.Load()
|
||||
if err := b.Publish(ctx, "iot.device_update", nil); err != nil {
|
||||
t.Fatalf("Publish 2: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := ran.Load(); got != before {
|
||||
t.Fatalf("rule fired after removal: %d -> %d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RulesSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
eng, err := NewEngine(Config{}, Deps{Bus: bus.New()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction: %v", err)
|
||||
}
|
||||
if err := eng.AddEvent(context.Background(), Rule{ID: "a", Trigger: TriggerEvent, Expression: "iot.*", Action: act}); err != nil {
|
||||
t.Fatalf("AddEvent a: %v", err)
|
||||
}
|
||||
if err := eng.AddEvent(context.Background(), Rule{ID: "b", Trigger: TriggerEvent, Expression: "iot.temp", Action: act}); err != nil {
|
||||
t.Fatalf("AddEvent b: %v", err)
|
||||
}
|
||||
rules := eng.Rules()
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("len(Rules) = %d, want 2", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_ConcurrentAddRemoveAndPublish(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
eng, err := NewEngine(Config{}, Deps{Bus: b})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction: %v", err)
|
||||
}
|
||||
|
||||
const n = 50
|
||||
done := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
go func(i int) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
id := "r"
|
||||
_ = eng.AddEvent(ctx, Rule{ID: id, Trigger: TriggerEvent, Expression: "iot.*", Action: act})
|
||||
_ = b.Publish(ctx, "iot.device_update", i)
|
||||
_ = eng.RemoveRule(ctx, id)
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_AddRuleRoutesCronAndRejectsInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := NewService(Config{}, Deps{Bus: bus.New()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
act, err := NewFuncAction("cron", func(context.Context, any) (any, error) { return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("NewFuncAction: %v", err)
|
||||
}
|
||||
if err := s.AddRule(context.Background(), Rule{ID: "c1", Trigger: TriggerCron, Expression: "*/5 * * * *", Action: act}); err != nil {
|
||||
t.Fatalf("valid cron rule: %v", err)
|
||||
}
|
||||
err = s.AddRule(context.Background(), Rule{ID: "c2", Trigger: TriggerCron, Expression: "not a cron", Action: act})
|
||||
if !errors.Is(err, ErrInvalidCron) {
|
||||
t.Fatalf("invalid cron rule: err = %v, want ErrInvalidCron", err)
|
||||
}
|
||||
if err := s.RemoveRule(context.Background(), "c1"); err != nil {
|
||||
t.Fatalf("RemoveRule cron: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user