feat(gateway): automation rule engine and reminder scheduler
- engine: 5-field cron parser (*/n, ranges, lists) firing at most once per minute; device-state triggers with edge detection; manual trigger via POST /automation/rules/:id/trigger; actions run through the AI pipeline so rules can call any tool (IoT control, search, ...) - reminders: due-scan scheduler delivering via WebSocket broadcast, optional LLM-polished reminder text with plain-title fallback; Reminder gains Done flag with ListDue/MarkDone - tests: cron matching and validation, once-per-minute dedup, device edge triggering, manual trigger, reminder delivery
This commit is contained in:
1 parent
9f30cdd086
commit
d80710d85a
7 files changed
+679
-2
No files matched your search
@@ -0,0 +1,201 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||
)
|
||||
|
||||
// --- cron ---
|
||||
|
||||
func TestParseCronMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
at time.Time
|
||||
want bool
|
||||
}{
|
||||
{"* * * * *", time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local), true},
|
||||
{"30 10 * * *", time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local), true},
|
||||
{"31 10 * * *", time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local), false},
|
||||
{"*/15 * * * *", time.Date(2026, 8, 21, 10, 45, 0, 0, time.Local), true},
|
||||
{"*/15 * * * *", time.Date(2026, 8, 21, 10, 46, 0, 0, time.Local), false},
|
||||
{"0 8-20/2 * * *", time.Date(2026, 8, 21, 10, 0, 0, 0, time.Local), true},
|
||||
{"0 8-20/2 * * *", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), false},
|
||||
{"0 9 21 8 *", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), true},
|
||||
{"0 9 * * 5", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), true}, // 2026-08-21 is Friday
|
||||
{"0 9 * * 1", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), false},
|
||||
{"0,30 9 * * *", time.Date(2026, 8, 21, 9, 30, 0, 0, time.Local), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
s, err := ParseCron(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCron(%q): %v", tt.expr, err)
|
||||
}
|
||||
if got := s.Match(tt.at); got != tt.want {
|
||||
t.Errorf("Match(%q, %v) = %v, want %v", tt.expr, tt.at, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronInvalid(t *testing.T) {
|
||||
for _, expr := range []string{"* * * *", "61 * * * *", "a b c d e", "*/0 * * * *", "5-1 * * * *"} {
|
||||
if _, err := ParseCron(expr); err == nil {
|
||||
t.Errorf("ParseCron(%q) should fail", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- engine ---
|
||||
|
||||
type fakeRuleSource struct{ rules []Rule }
|
||||
|
||||
func (f fakeRuleSource) List() []Rule { return f.rules }
|
||||
|
||||
type fakeDevices struct{ devices []plugin.Device }
|
||||
|
||||
func (f fakeDevices) ListDevices(context.Context) ([]plugin.Device, error) { return f.devices, nil }
|
||||
|
||||
type firedRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *firedRecorder) record(_ context.Context, sessionID, _, message string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, sessionID+"|"+message)
|
||||
}
|
||||
|
||||
func (f *firedRecorder) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
// eventually waits until the recorder reaches n calls (fires are async).
|
||||
func (f *firedRecorder) eventually(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if f.count() >= n {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expected %d fires, got %d", n, f.count())
|
||||
}
|
||||
|
||||
// stillSmall waits briefly and asserts the count stays at n.
|
||||
func (f *firedRecorder) stillAt(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if f.count() != n {
|
||||
t.Fatalf("expected count to stay at %d, got %d", n, f.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRuleFiresOncePerMinute(t *testing.T) {
|
||||
now := time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local)
|
||||
src := fakeRuleSource{rules: []Rule{{
|
||||
ID: "r1", Name: "晨检", Trigger: "time", Enabled: true,
|
||||
Condition: map[string]string{"cron": "30 10 * * *"},
|
||||
Action: "检查一下设备",
|
||||
}}}
|
||||
rec := &firedRecorder{}
|
||||
e := New(src, rec.record, nil, time.Minute)
|
||||
|
||||
e.check(context.Background(), now)
|
||||
e.check(context.Background(), now) // same minute — must not refire
|
||||
rec.eventually(t, 1)
|
||||
|
||||
e.check(context.Background(), now.Add(time.Minute))
|
||||
rec.stillAt(t, 1)
|
||||
}
|
||||
|
||||
func TestDeviceRuleEdgeTrigger(t *testing.T) {
|
||||
src := fakeRuleSource{rules: []Rule{{
|
||||
ID: "r2", Name: "灯开联动", Trigger: "event", Enabled: true,
|
||||
Condition: map[string]string{"device_id": "light-1", "field": "power", "value": "true"},
|
||||
Action: "灯开了,说点什么",
|
||||
}}}
|
||||
devices := &fakeDevices{devices: []plugin.Device{
|
||||
{ID: "light-1", State: map[string]any{"power": false}},
|
||||
}}
|
||||
rec := &firedRecorder{}
|
||||
e := New(src, rec.record, devices, time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
e.check(ctx, time.Now()) // off → no fire
|
||||
rec.stillAt(t, 0)
|
||||
|
||||
devices.devices[0].State["power"] = true
|
||||
e.check(ctx, time.Now()) // edge → fire
|
||||
rec.eventually(t, 1)
|
||||
|
||||
e.check(ctx, time.Now()) // held → no refire
|
||||
rec.stillAt(t, 1)
|
||||
|
||||
devices.devices[0].State["power"] = false
|
||||
e.check(ctx, time.Now())
|
||||
devices.devices[0].State["power"] = true
|
||||
e.check(ctx, time.Now()) // new edge → fire again
|
||||
rec.eventually(t, 2)
|
||||
}
|
||||
|
||||
func TestManualTrigger(t *testing.T) {
|
||||
src := fakeRuleSource{rules: []Rule{{
|
||||
ID: "r3", Name: "手动", Trigger: "webhook", Enabled: false, Action: "执行",
|
||||
}}}
|
||||
rec := &firedRecorder{}
|
||||
e := New(src, rec.record, nil, time.Minute)
|
||||
|
||||
if err := e.Trigger(context.Background(), "r3"); err != nil {
|
||||
t.Fatalf("Trigger: %v", err)
|
||||
}
|
||||
rec.eventually(t, 1)
|
||||
if err := e.Trigger(context.Background(), "nope"); err == nil {
|
||||
t.Error("expected error for unknown rule")
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminder scheduler ---
|
||||
|
||||
type fakeReminderSource struct {
|
||||
reminders []Reminder
|
||||
done []string
|
||||
}
|
||||
|
||||
func (f *fakeReminderSource) ListDue(now time.Time) []Reminder {
|
||||
var out []Reminder
|
||||
for _, r := range f.reminders {
|
||||
if !r.DueAt.After(now) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeReminderSource) MarkDone(id string) { f.done = append(f.done, id) }
|
||||
|
||||
func TestReminderDelivery(t *testing.T) {
|
||||
src := &fakeReminderSource{reminders: []Reminder{
|
||||
{ID: "rem1", Title: "吃药", DueAt: time.Now().Add(-time.Minute), UserID: "u1"},
|
||||
{ID: "rem2", Title: "未来的事", DueAt: time.Now().Add(time.Hour), UserID: "u1"},
|
||||
}}
|
||||
var notified []string
|
||||
sched := NewReminderScheduler(src, func(userID, title, body string) {
|
||||
notified = append(notified, userID+"|"+title+"|"+body)
|
||||
}, nil, time.Minute)
|
||||
|
||||
sched.deliver(context.Background(), time.Now())
|
||||
|
||||
if len(notified) != 1 || notified[0] != "u1|吃药|吃药" {
|
||||
t.Errorf("unexpected notifications: %v", notified)
|
||||
}
|
||||
if len(src.done) != 1 || src.done[0] != "rem1" {
|
||||
t.Errorf("expected rem1 marked done, got %v", src.done)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user