a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
364 lines
10 KiB
Go
364 lines
10 KiB
Go
package savings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// appendLegacyEventLine writes one JSONL line the way the flat-file era
|
|
// did — test fixture for the legacy reader.
|
|
func appendLegacyEventLine(t *testing.T, path string, ev Event) {
|
|
t.Helper()
|
|
data, err := json.Marshal(ev)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.Write(append(data, '\n')); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestEventsPathFor(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"", ""},
|
|
{"/tmp/savings.json", "/tmp/savings.jsonl"},
|
|
{"/tmp/whatever.foo", "/tmp/whatever.jsonl"},
|
|
{"./bare", "bare.jsonl"},
|
|
}
|
|
for _, c := range cases {
|
|
got := EventsPathFor(c.in)
|
|
if got != c.want {
|
|
t.Errorf("EventsPathFor(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddObservation_RecordsEvent(t *testing.T) {
|
|
s, err := Open(filepath.Join(t.TempDir(), "sidecar.sqlite"))
|
|
if err == nil {
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.AddObservation(Observation{Repo: "/repo-a", Language: "go", Tool: "get_symbol_source", SessionID: "sess-1", Returned: 100, Saved: 900})
|
|
s.AddObservation(Observation{Repo: "/repo-b", Language: "ts", Tool: "batch_symbols", Returned: 50, Saved: 50})
|
|
|
|
evs, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := len(evs), 2; got != want {
|
|
t.Fatalf("EventsSince = %d, want %d", got, want)
|
|
}
|
|
if evs[0].Tool != "get_symbol_source" || evs[0].Saved != 900 || evs[0].Repo != "/repo-a" {
|
|
t.Errorf("event 0 = %+v", evs[0])
|
|
}
|
|
if evs[0].SessionID != "sess-1" {
|
|
t.Errorf("event 0 session = %q, want sess-1", evs[0].SessionID)
|
|
}
|
|
if evs[1].Tool != "batch_symbols" || evs[1].Returned != 50 || evs[1].Language != "ts" {
|
|
t.Errorf("event 1 = %+v", evs[1])
|
|
}
|
|
}
|
|
|
|
func TestLoadEvents_FiltersSince(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "savings.jsonl")
|
|
|
|
now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
|
|
for i, ts := range []time.Time{
|
|
now.Add(-48 * time.Hour),
|
|
now.Add(-24 * time.Hour),
|
|
now.Add(-1 * time.Hour),
|
|
} {
|
|
appendLegacyEventLine(t, path, Event{TS: ts, Tool: "t", Saved: int64(i+1) * 10})
|
|
}
|
|
|
|
// since = now - 25h should drop the 48h-old one.
|
|
evs, err := LoadEvents(path, now.Add(-25*time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 2 {
|
|
t.Errorf("LoadEvents(since=-25h) = %d, want 2", len(evs))
|
|
}
|
|
|
|
// since = zero returns all.
|
|
evs, err = LoadEvents(path, time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 3 {
|
|
t.Errorf("LoadEvents(since=0) = %d, want 3", len(evs))
|
|
}
|
|
}
|
|
|
|
func TestLoadEvents_SkipsMalformedLines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "savings.jsonl")
|
|
|
|
// One good line, one garbage line, one good line — simulates a
|
|
// crashed mid-write previous process.
|
|
body := `{"ts":"2026-05-18T10:00:00Z","tool":"a","saved":10,"returned":1}` + "\n" +
|
|
`{not json at all}` + "\n" +
|
|
`{"ts":"2026-05-18T11:00:00Z","tool":"b","saved":20,"returned":2}` + "\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
evs, err := LoadEvents(path, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("LoadEvents should tolerate malformed lines, got: %v", err)
|
|
}
|
|
if len(evs) != 2 {
|
|
t.Errorf("LoadEvents = %d, want 2 (malformed line skipped)", len(evs))
|
|
}
|
|
}
|
|
|
|
func TestLoadEvents_MissingFile(t *testing.T) {
|
|
evs, err := LoadEvents("/nonexistent/savings.jsonl", time.Time{})
|
|
if err != nil {
|
|
t.Errorf("missing event log should not error, got: %v", err)
|
|
}
|
|
if len(evs) != 0 {
|
|
t.Errorf("missing event log = %d events, want 0", len(evs))
|
|
}
|
|
}
|
|
|
|
func TestAggregateByTool_SortedDescending(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
events := []Event{
|
|
{TS: now, Tool: "smart_context", Saved: 100, Returned: 10},
|
|
{TS: now, Tool: "get_symbol_source", Saved: 500, Returned: 50},
|
|
{TS: now, Tool: "get_symbol_source", Saved: 200, Returned: 20},
|
|
{TS: now, Tool: "", Saved: 5, Returned: 5},
|
|
}
|
|
total, per := AggregateByTool(events)
|
|
if total.CallsCounted != 4 {
|
|
t.Errorf("total calls = %d, want 4", total.CallsCounted)
|
|
}
|
|
if total.TokensSaved != 805 {
|
|
t.Errorf("total saved = %d, want 805", total.TokensSaved)
|
|
}
|
|
if len(per) != 3 {
|
|
t.Fatalf("per-tool rows = %d, want 3", len(per))
|
|
}
|
|
// Sorted by tokens-saved descending: get_symbol_source(700) → smart_context(100) → (unknown)(5)
|
|
if per[0].Tool != "get_symbol_source" || per[0].TokensSaved != 700 {
|
|
t.Errorf("row 0 = %+v, want get_symbol_source=700", per[0])
|
|
}
|
|
if per[1].Tool != "smart_context" {
|
|
t.Errorf("row 1 = %+v, want smart_context", per[1])
|
|
}
|
|
if per[2].Tool != "(unknown)" {
|
|
t.Errorf("row 2 = %+v, want (unknown) for empty tool", per[2])
|
|
}
|
|
}
|
|
|
|
func TestFilterDay_LocalAndUTC(t *testing.T) {
|
|
// 2026-05-18 03:00 UTC == 2026-05-17 20:00 PDT.
|
|
pdt, err := time.LoadLocation("America/Los_Angeles")
|
|
if err != nil {
|
|
t.Skip("PDT zoneinfo unavailable")
|
|
}
|
|
earlyUTC := time.Date(2026, 5, 18, 3, 0, 0, 0, time.UTC)
|
|
lateUTC := time.Date(2026, 5, 18, 23, 0, 0, 0, time.UTC) // 16:00 PDT same day
|
|
events := []Event{
|
|
{TS: earlyUTC, Tool: "a", Saved: 1, Returned: 0},
|
|
{TS: lateUTC, Tool: "b", Saved: 1, Returned: 0},
|
|
}
|
|
|
|
// In UTC, both fall on 2026-05-18 → both selected.
|
|
got := FilterDay(events, lateUTC, time.UTC)
|
|
if len(got) != 2 {
|
|
t.Errorf("UTC day filter = %d events, want 2", len(got))
|
|
}
|
|
|
|
// In PDT, earlyUTC is 2026-05-17 → only lateUTC matches when we
|
|
// pass "today" as 2026-05-18 PDT.
|
|
dayPDT := time.Date(2026, 5, 18, 12, 0, 0, 0, pdt)
|
|
got = FilterDay(events, dayPDT, pdt)
|
|
if len(got) != 1 || got[0].Tool != "b" {
|
|
t.Errorf("PDT day filter = %+v, want [b]", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildDashboard_BucketsByWindow(t *testing.T) {
|
|
now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
|
|
// 3 today events, 2 within 7d but not today, 1 outside 7d.
|
|
mk := func(ts time.Time, tool string, saved int64) Event {
|
|
return Event{TS: ts, Tool: tool, Saved: saved, Returned: 10}
|
|
}
|
|
events := []Event{
|
|
mk(now.Add(-30*24*time.Hour), "old_tool", 9999), // outside 7d
|
|
mk(now.Add(-5*24*time.Hour), "smart_context", 300),
|
|
mk(now.Add(-3*24*time.Hour), "smart_context", 200),
|
|
mk(now.Add(-3*time.Hour), "get_symbol_source", 150),
|
|
mk(now.Add(-2*time.Hour), "get_symbol_source", 100),
|
|
mk(now.Add(-30*time.Minute), "batch_symbols", 50),
|
|
}
|
|
|
|
all := Totals{TokensSaved: 9999 + 300 + 200 + 50 + 150 + 100, TokensReturned: 60, CallsCounted: 6}
|
|
|
|
_, allPerTool := AggregateByTool(events)
|
|
buckets := BuildDashboard(events, all, allPerTool, now, time.UTC)
|
|
if len(buckets) != 3 {
|
|
t.Fatalf("buckets = %d, want 3", len(buckets))
|
|
}
|
|
|
|
today := buckets[0]
|
|
if today.Label != "Today" {
|
|
t.Errorf("buckets[0].Label = %q, want Today", today.Label)
|
|
}
|
|
if today.Totals.CallsCounted != 3 {
|
|
t.Errorf("Today calls = %d, want 3", today.Totals.CallsCounted)
|
|
}
|
|
if today.Totals.TokensSaved != 300 {
|
|
t.Errorf("Today saved = %d, want 300", today.Totals.TokensSaved)
|
|
}
|
|
|
|
week := buckets[1]
|
|
if week.Label != "Last 7 days" {
|
|
t.Errorf("buckets[1].Label = %q, want Last 7 days", week.Label)
|
|
}
|
|
if week.Totals.CallsCounted != 5 {
|
|
t.Errorf("7d calls = %d, want 5 (today + 2 within 7d)", week.Totals.CallsCounted)
|
|
}
|
|
if week.Totals.TokensSaved != 800 {
|
|
t.Errorf("7d saved = %d, want 800", week.Totals.TokensSaved)
|
|
}
|
|
|
|
all2 := buckets[2]
|
|
if all2.Label != "All time" {
|
|
t.Errorf("buckets[2].Label = %q, want All time", all2.Label)
|
|
}
|
|
if all2.Totals != all {
|
|
t.Errorf("All time totals = %+v, want %+v (from store, not events)", all2.Totals, all)
|
|
}
|
|
// All time per-tool covers the full history, so the 30-day-old
|
|
// event is included.
|
|
gotTools := map[string]bool{}
|
|
for _, tt := range all2.PerTool {
|
|
gotTools[tt.Tool] = true
|
|
}
|
|
if !gotTools["old_tool"] {
|
|
t.Errorf("All-time per-tool missing the >7d event (got %v)", gotTools)
|
|
}
|
|
}
|
|
|
|
func TestSavingsPercent(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in Totals
|
|
want float64
|
|
}{
|
|
{"empty", Totals{}, 0},
|
|
{"only saved", Totals{TokensSaved: 100}, 100},
|
|
{"only returned", Totals{TokensReturned: 100}, 0},
|
|
{"half-half", Totals{TokensSaved: 50, TokensReturned: 50}, 50},
|
|
{"three quarters", Totals{TokensSaved: 75, TokensReturned: 25}, 75},
|
|
}
|
|
for _, c := range cases {
|
|
got := SavingsPercent(c.in)
|
|
if got != c.want {
|
|
t.Errorf("%s: SavingsPercent(%+v) = %.2f, want %.2f", c.name, c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBarString_Width(t *testing.T) {
|
|
cases := []struct {
|
|
pct float64
|
|
cells int
|
|
wantFill int
|
|
}{
|
|
{0, 16, 0},
|
|
{100, 16, 16},
|
|
{50, 16, 8},
|
|
{75, 16, 12},
|
|
{-5, 16, 0},
|
|
{150, 16, 16},
|
|
}
|
|
for _, c := range cases {
|
|
bar := BarString(c.pct, c.cells)
|
|
// Each cell is one rune. Count runes (not bytes — █ and ░ are multi-byte).
|
|
fill := strings.Count(bar, "█")
|
|
empty := strings.Count(bar, "░")
|
|
if fill+empty != c.cells {
|
|
t.Errorf("pct=%.1f cells=%d: total cells = %d, want %d (bar=%q)", c.pct, c.cells, fill+empty, c.cells, bar)
|
|
}
|
|
if fill != c.wantFill {
|
|
t.Errorf("pct=%.1f cells=%d: filled = %d, want %d (bar=%q)", c.pct, c.cells, fill, c.wantFill, bar)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBarString_DefaultsCellsTo16(t *testing.T) {
|
|
bar := BarString(50, 0)
|
|
if got := strings.Count(bar, "█") + strings.Count(bar, "░"); got != 16 {
|
|
t.Errorf("BarString(50, 0) cells = %d, want default 16", got)
|
|
}
|
|
}
|
|
|
|
func TestEvents_ConcurrentWritersAllRecorded(t *testing.T) {
|
|
s, err := Open(filepath.Join(t.TempDir(), "sidecar.sqlite"))
|
|
if err == nil {
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const writers = 8
|
|
const per = 50
|
|
var wg sync.WaitGroup
|
|
for range writers {
|
|
wg.Go(func() {
|
|
for range per {
|
|
s.AddObservation(Observation{Repo: "/r", Language: "go", Tool: "tool", Returned: 1, Saved: 10})
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
evs, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := len(evs), writers*per; got != want {
|
|
t.Errorf("EventsSince = %d, want %d (concurrent observation lost)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestEventHistory_InMemoryStoreStaysInMemory(t *testing.T) {
|
|
s, err := Open("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.AddObservation(Observation{Repo: "/r", Language: "go", Tool: "tool", Returned: 1, Saved: 10})
|
|
if s.sc != nil {
|
|
t.Fatal("empty-path store must not open a sidecar")
|
|
}
|
|
evs, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 1 {
|
|
t.Errorf("in-memory event history = %d, want 1", len(evs))
|
|
}
|
|
}
|