1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
|
package storage
import (
"strconv"
"testing"
"time"
"github.com/thepeterstone/claudomator/internal/task"
)
// mustCreateTask is a small helper mirroring the pattern used in db_test.go
// (TestCreateTask_AndGetTask) — a minimal valid task row so executions can
// reference a real task_id.
func mustCreateTask(t *testing.T, db *DB, id string) {
t.Helper()
now := time.Now().UTC().Truncate(time.Second)
tk := &task.Task{
ID: id,
Name: "dashboard-test-" + id,
State: task.StatePending,
DependsOn: []string{},
CreatedAt: now,
UpdatedAt: now,
}
if err := db.CreateTask(tk); err != nil {
t.Fatalf("CreateTask(%s): %v", id, err)
}
}
func mustCreateExecution(t *testing.T, db *DB, id, taskID, agent string, rung int, cost float64, start time.Time, status string) {
t.Helper()
exec := &Execution{
ID: id,
TaskID: taskID,
StartTime: start,
EndTime: start.Add(time.Minute),
Status: status,
CostUSD: cost,
Agent: agent,
EscalationRung: rung,
}
if err := db.CreateExecution(exec); err != nil {
t.Fatalf("CreateExecution(%s): %v", id, err)
}
}
func TestQueryEscalationFunnel_AggregatesByRungAndAgent(t *testing.T) {
db := testDB(t)
mustCreateTask(t, db, "t1")
now := time.Now().UTC()
// Rung 0: 2 local executions.
mustCreateExecution(t, db, "e1", "t1", "local", 0, 0, now.Add(-3*time.Hour), "COMPLETED")
mustCreateExecution(t, db, "e2", "t1", "local", 0, 0, now.Add(-2*time.Hour), "COMPLETED")
// Rung 1: 1 anthropic execution.
mustCreateExecution(t, db, "e3", "t1", "anthropic", 1, 0.05, now.Add(-1*time.Hour), "COMPLETED")
// Still in flight — must be excluded.
mustCreateExecution(t, db, "e4", "t1", "anthropic", 1, 0, now, "RUNNING")
// Too old — outside the window.
mustCreateExecution(t, db, "e5", "t1", "local", 0, 0, now.Add(-48*time.Hour), "COMPLETED")
buckets, err := db.QueryEscalationFunnel(now.Add(-24 * time.Hour))
if err != nil {
t.Fatalf("QueryEscalationFunnel: %v", err)
}
want := map[string]struct {
count int
cost float64
}{
"0|local": {2, 0},
"1|anthropic": {1, 0.05},
}
if len(buckets) != len(want) {
t.Fatalf("expected %d buckets, got %d: %+v", len(want), len(buckets), buckets)
}
for _, b := range buckets {
key := strconv.Itoa(b.Rung) + "|" + b.Agent
w, ok := want[key]
if !ok {
t.Errorf("unexpected bucket %+v", b)
continue
}
if b.Count != w.count {
t.Errorf("bucket %s: count = %d, want %d", key, b.Count, w.count)
}
if b.CostUSD != w.cost {
t.Errorf("bucket %s: cost = %v, want %v", key, b.CostUSD, w.cost)
}
}
}
func TestQueryEscalationFunnel_Empty_ReturnsEmptySlice(t *testing.T) {
db := testDB(t)
buckets, err := db.QueryEscalationFunnel(time.Now().Add(-24 * time.Hour))
if err != nil {
t.Fatalf("QueryEscalationFunnel: %v", err)
}
if buckets == nil || len(buckets) != 0 {
t.Errorf("expected empty non-nil slice, got %v", buckets)
}
}
func TestQuerySpendTimeseries_HourlyBucketsByProvider(t *testing.T) {
db := testDB(t)
mustCreateTask(t, db, "t1")
// Pin two executions to the exact same hour bucket for two providers.
hourStart := time.Now().UTC().Truncate(time.Hour)
mustCreateExecution(t, db, "e1", "t1", "local", 0, 0.10, hourStart.Add(5*time.Minute), "COMPLETED")
mustCreateExecution(t, db, "e2", "t1", "anthropic", 0, 0.20, hourStart.Add(10*time.Minute), "COMPLETED")
mustCreateExecution(t, db, "e3", "t1", "anthropic", 0, 0.05, hourStart.Add(40*time.Minute), "COMPLETED")
points, err := db.QuerySpendTimeseries(hourStart.Add(-time.Hour), true)
if err != nil {
t.Fatalf("QuerySpendTimeseries: %v", err)
}
totals := map[string]float64{}
for _, p := range points {
totals[p.Agent] += p.CostUSD
}
if totals["local"] != 0.10 {
t.Errorf("local total = %v, want 0.10", totals["local"])
}
if got, want := totals["anthropic"], 0.25; got < want-1e-9 || got > want+1e-9 {
t.Errorf("anthropic total = %v, want %v", got, want)
}
}
func TestQuerySpendTimeseries_DailyBucketing(t *testing.T) {
db := testDB(t)
mustCreateTask(t, db, "t1")
today := time.Now().UTC()
mustCreateExecution(t, db, "e1", "t1", "local", 0, 1.5, today, "COMPLETED")
points, err := db.QuerySpendTimeseries(today.Add(-24*time.Hour), false)
if err != nil {
t.Fatalf("QuerySpendTimeseries: %v", err)
}
if len(points) != 1 {
t.Fatalf("expected 1 point, got %d: %+v", len(points), points)
}
if points[0].Bucket != today.Format("2006-01-02") {
t.Errorf("bucket = %q, want %q", points[0].Bucket, today.Format("2006-01-02"))
}
if points[0].CostUSD != 1.5 {
t.Errorf("cost = %v, want 1.5", points[0].CostUSD)
}
}
|