Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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