212 lines
5.7 KiB
Go
212 lines
5.7 KiB
Go
package dream
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fixedNow returns a func yielding t, for the Now seam.
|
|
func fixedNow(t time.Time) func() time.Time { return func() time.Time { return t } }
|
|
|
|
// writeDueState seeds a state.json under memoryRoot with a LastRunAt old enough
|
|
// that Due(cfg, now) is true for the default interval.
|
|
func writeDueState(t *testing.T, memoryRoot string, lastRun time.Time) {
|
|
t.Helper()
|
|
if err := SaveState(memoryRoot, State{LastRunAt: lastRun, LastStatus: "ok"}); err != nil {
|
|
t.Fatalf("SaveState: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSchedulerDue(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 1, 20, 12, 0, 0, 0, time.UTC)
|
|
cfg := NewConfig(nil, 7, 20) // enabled, 7-day interval
|
|
|
|
var s Scheduler
|
|
|
|
// Disabled → never due, and cheap (no state read needed).
|
|
disabled := NewConfig(boolPtr(false), 7, 20)
|
|
if s.Due(root, disabled, now) {
|
|
t.Fatal("disabled config must not be due")
|
|
}
|
|
|
|
// No state file (never run) → not due (first run is manual, spec §11.1).
|
|
if s.Due(root, cfg, now) {
|
|
t.Fatal("never-run state must not be due")
|
|
}
|
|
|
|
// Last run within the interval → not due.
|
|
writeDueState(t, root, now.Add(-3*24*time.Hour))
|
|
if s.Due(root, cfg, now) {
|
|
t.Fatal("run 3d ago with 7d interval must not be due")
|
|
}
|
|
|
|
// Last run older than the interval → due.
|
|
writeDueState(t, root, now.Add(-8*24*time.Hour))
|
|
if !s.Due(root, cfg, now) {
|
|
t.Fatal("run 8d ago with 7d interval must be due")
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunBackground_NotSpawnedWhenDisabled(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeDueState(t, root, time.Now().Add(-30*24*time.Hour)) // would be due if enabled
|
|
|
|
var spawned bool
|
|
launched := Scheduler{}.MaybeRunBackground(context.Background(), BackgroundDeps{
|
|
MemoryRoot: root,
|
|
Config: NewConfig(boolPtr(false), 7, 20),
|
|
Now: fixedNow(time.Now()),
|
|
Spawn: func(context.Context, string) (Report, error) {
|
|
spawned = true
|
|
return Report{}, nil
|
|
},
|
|
})
|
|
if launched {
|
|
t.Fatal("MaybeRunBackground returned true for disabled dream")
|
|
}
|
|
if spawned {
|
|
t.Fatal("subprocess must not be spawned when disabled")
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunBackground_NotSpawnedWhenNotDue(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC)
|
|
writeDueState(t, root, now.Add(-1*24*time.Hour)) // 1 day ago, interval 7d → not due
|
|
|
|
var spawned bool
|
|
launched := Scheduler{}.MaybeRunBackground(context.Background(), BackgroundDeps{
|
|
MemoryRoot: root,
|
|
Config: NewConfig(nil, 7, 20),
|
|
Now: fixedNow(now),
|
|
Spawn: func(context.Context, string) (Report, error) {
|
|
spawned = true
|
|
return Report{}, nil
|
|
},
|
|
})
|
|
if launched || spawned {
|
|
t.Fatalf("not-due run must not launch/spawn (launched=%v spawned=%v)", launched, spawned)
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunBackground_SpawnsAndNoticesOnChanges(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 2, 10, 9, 0, 0, 0, time.UTC)
|
|
writeDueState(t, root, now.Add(-10*24*time.Hour)) // due
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
gotDir string
|
|
reported *Report
|
|
done = make(chan struct{})
|
|
)
|
|
launched := Scheduler{}.MaybeRunBackground(context.Background(), BackgroundDeps{
|
|
MemoryRoot: root,
|
|
ProjectDir: "/proj/x",
|
|
Config: NewConfig(nil, 7, 20),
|
|
Now: fixedNow(now),
|
|
Spawn: func(_ context.Context, dir string) (Report, error) {
|
|
mu.Lock()
|
|
gotDir = dir
|
|
mu.Unlock()
|
|
return Report{Merged: 2, Deduped: 1}, nil
|
|
},
|
|
OnReport: func(r Report) {
|
|
mu.Lock()
|
|
reported = &r
|
|
mu.Unlock()
|
|
close(done)
|
|
},
|
|
})
|
|
if !launched {
|
|
t.Fatal("due run must launch a background spawn")
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("OnReport not called within timeout")
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if gotDir != "/proj/x" {
|
|
t.Fatalf("spawn got dir %q, want /proj/x", gotDir)
|
|
}
|
|
if reported == nil || reported.Merged != 2 {
|
|
t.Fatalf("OnReport got %+v, want Merged=2", reported)
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunBackground_SkippedRunIsSilent(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)
|
|
writeDueState(t, root, now.Add(-10*24*time.Hour)) // due
|
|
|
|
spawnDone := make(chan struct{})
|
|
var noticed bool
|
|
var mu sync.Mutex
|
|
Scheduler{}.MaybeRunBackground(context.Background(), BackgroundDeps{
|
|
MemoryRoot: root,
|
|
Config: NewConfig(nil, 7, 20),
|
|
Now: fixedNow(now),
|
|
Spawn: func(context.Context, string) (Report, error) {
|
|
// A skipped/lock-held run emits an all-zero report with no error.
|
|
defer close(spawnDone)
|
|
return Report{}, nil
|
|
},
|
|
OnReport: func(Report) {
|
|
mu.Lock()
|
|
noticed = true
|
|
mu.Unlock()
|
|
},
|
|
})
|
|
<-spawnDone
|
|
// Give the goroutine a moment past Spawn to (not) call OnReport.
|
|
time.Sleep(20 * time.Millisecond)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if noticed {
|
|
t.Fatal("all-zero (skipped/no-op) report must not produce a notice")
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunBackground_FailureIsSilent(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := time.Date(2026, 3, 5, 9, 0, 0, 0, time.UTC)
|
|
writeDueState(t, root, now.Add(-10*24*time.Hour)) // due
|
|
|
|
spawnDone := make(chan struct{})
|
|
var noticed bool
|
|
var mu sync.Mutex
|
|
Scheduler{}.MaybeRunBackground(context.Background(), BackgroundDeps{
|
|
MemoryRoot: root,
|
|
Config: NewConfig(nil, 7, 20),
|
|
Now: fixedNow(now),
|
|
Spawn: func(context.Context, string) (Report, error) {
|
|
defer close(spawnDone)
|
|
return Report{Merged: 5}, errors.New("boom")
|
|
},
|
|
OnReport: func(Report) {
|
|
mu.Lock()
|
|
noticed = true
|
|
mu.Unlock()
|
|
},
|
|
})
|
|
<-spawnDone
|
|
time.Sleep(20 * time.Millisecond)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if noticed {
|
|
t.Fatal("a failed background run must be silent (no notice)")
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunBackground_NilSpawn(t *testing.T) {
|
|
if (Scheduler{}).MaybeRunBackground(context.Background(), BackgroundDeps{}) {
|
|
t.Fatal("nil Spawn must yield false (no-op)")
|
|
}
|
|
}
|