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) } }