internal/chart/chart_test.go
Rama main · réplica de lectura del repositorio original.
package chart
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"armdash/internal/promql"
)
// fakeProm answers query_range through answer and remembers every request.
type fakeProm struct {
mu sync.Mutex
seen []url.Values
}
func (f *fakeProm) last() url.Values {
f.mu.Lock()
defer f.mu.Unlock()
return f.seen[len(f.seen)-1]
}
func serve(t *testing.T, answer func(q url.Values) string) (*fakeProm, *promql.Client) {
t.Helper()
f := &fakeProm{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f.mu.Lock()
f.seen = append(f.seen, q)
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":` + answer(q) + `}}`))
}))
t.Cleanup(srv.Close)
return f, promql.New(func() string { return srv.URL })
}
// byQuery answers from a table keyed by a substring of the query, so each of a
// chart's queries can get a different shape.
func byQuery(bodies map[string]string) func(url.Values) string {
return func(q url.Values) string {
for match, result := range bodies {
if strings.Contains(q.Get("query"), match) {
return result
}
}
return "[]"
}
}
func getRange(t *testing.T, s *Set, metric string, window time.Duration) response {
t.Helper()
rec := httptest.NewRecorder()
target := fmt.Sprintf("/range?metric=%s&window=%d", metric, int(window/time.Second))
s.handleRange(rec, httptest.NewRequest(http.MethodGet, target, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var out response
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decoding response: %v (%s)", err, rec.Body.String())
}
return out
}
// A series with no data for part of the window is missing those points
// entirely, so the lines have to be aligned by timestamp. Aligning by index
// would silently plot the second series an hour to the left.
func TestRangeAlignsSeriesByTimestamp(t *testing.T) {
_, prom := serve(t, byQuery(map[string]string{
"first": `[{"metric":{},"values":[[100,"40"],[160,"41"],[220,"42"]]}]`,
"second": `[{"metric":{"zone":"b"},"values":[[160,"38"],[220,"39"]]}]`,
}))
s := &Set{
Prom: prom,
Charts: []Chart{{Slug: "t", Title: "T", Series: []Series{
{Name: "First", Query: "first"},
{Query: "second"},
}}},
Names: func(context.Context) Namer {
return func(l map[string]string) string { return "zone " + l["zone"] }
},
}
out := getRange(t, s, "t", time.Hour)
if want := []int64{100, 160, 220}; len(out.Time) != 3 || out.Time[0] != want[0] || out.Time[2] != want[2] {
t.Fatalf("time = %v, want %v", out.Time, want)
}
if len(out.Series) != 2 {
t.Fatalf("got %d series, want 2", len(out.Series))
}
if out.Series[0].Name != "First" {
t.Errorf("named line = %q, want First", out.Series[0].Name)
}
second := out.Series[1]
if second.Name != "zone b" {
t.Errorf("series name = %q, want the namer's", second.Name)
}
if second.Data[0] != nil {
t.Errorf("missing sample rendered as %v, want null", *second.Data[0])
}
if second.Data[1] == nil || *second.Data[1] != 38 {
t.Errorf("sample at 160 = %v, want 38", second.Data[1])
}
}
func TestUnnamedLineFallsBackToTheTitle(t *testing.T) {
_, prom := serve(t, byQuery(map[string]string{
"cpu": `[{"metric":{},"values":[[100,"4"]]}]`,
}))
s := &Set{Prom: prom, Charts: []Chart{{Slug: "cpu", Title: "CPU", Query: "cpu"}}}
out := getRange(t, s, "cpu", time.Hour)
if len(out.Series) != 1 || out.Series[0].Name != "CPU" {
t.Errorf("series = %+v, want one line named CPU", out.Series)
}
}
// Prometheus sends NaN and the infinities as values like any other. They are
// not encodable as JSON numbers, and encoding/json fails the whole response
// rather than the one sample, so they have to be dropped on the way out.
func TestRangeDropsNonFiniteSamples(t *testing.T) {
_, prom := serve(t, byQuery(map[string]string{
"memory": `[{"metric":{},"values":[[100,"NaN"],[160,"50"],[220,"+Inf"]]}]`,
}))
s := &Set{Prom: prom, Charts: []Chart{{Slug: "memory", Title: "Memory", Query: "memory", FromZero: true}}}
out := getRange(t, s, "memory", time.Hour)
if len(out.Series) != 1 {
t.Fatalf("got %d series, want 1", len(out.Series))
}
data := out.Series[0].Data
if len(data) != 3 {
t.Fatalf("data has %d slots, want 3", len(data))
}
if data[0] != nil || data[2] != nil {
t.Errorf("non-finite samples survived: %v", data)
}
if data[1] == nil || *data[1] != 50 {
t.Errorf("finite sample = %v, want 50", data[1])
}
if !out.FromZero {
t.Error("the chart asked for a zero based axis")
}
if out.Bucket != 0 {
t.Errorf("a line chart came back with a bucket of %d", out.Bucket)
}
}
// Every window the range selector offers has to come back as bars exactly one
// bucket wide. Stepping by anything but the bucket would count a stretch of
// time in two bars, or in neither.
func TestBarsAreOneBucketEach(t *testing.T) {
now := time.Date(2026, 9, 12, 14, 37, 12, 0, time.Local)
fake, prom := serve(t, func(q url.Values) string {
// A sample at every evaluation point, as Prometheus would answer.
start, _ := strconv.ParseInt(q.Get("start"), 10, 64)
end, _ := strconv.ParseInt(q.Get("end"), 10, 64)
step, _ := strconv.ParseInt(q.Get("step"), 10, 64)
var pts []string
for ts := start; ts <= end; ts += step {
pts = append(pts, fmt.Sprintf(`[%d,"0.25"]`, ts))
}
return `[{"metric":{"ain":"1"},"values":[` + strings.Join(pts, ",") + `]}]`
})
c := Chart{Slug: "energy", Title: "Energy", Unit: "kWh", FromZero: true,
Query: `sum by (ain) (increase(x[$bucket]))`, Bucket: HourOrDay}
s := &Set{Prom: prom, Charts: []Chart{c}, now: func() time.Time { return now }}
_, off := now.Zone()
for _, win := range Windows {
out := getRange(t, s, "energy", win.Span)
req := fake.last()
bucket := int64(HourOrDay(win.Span) / time.Second)
start, _ := strconv.ParseInt(req.Get("start"), 10, 64)
end, _ := strconv.ParseInt(req.Get("end"), 10, 64)
if got := req.Get("step"); got != strconv.FormatInt(bucket, 10) {
t.Errorf("%s: step %s, want the %ds bucket", win.Label, got, bucket)
}
if q := req.Get("query"); !strings.Contains(q, fmt.Sprintf("[%ds]", bucket)) {
t.Errorf("%s: query %q does not increase over one bucket", win.Label, q)
}
if (start+int64(off))%bucket != 0 || (end+int64(off))%bucket != 0 {
t.Errorf("%s: query runs %d to %d, off the %ds grid", win.Label, start, end, bucket)
}
if end <= now.Unix() || end-bucket > now.Unix() {
t.Errorf("%s: last bucket ends at %d, want the one in progress at %d", win.Label, end, now.Unix())
}
if out.Bucket != bucket {
t.Errorf("%s: response bucket %d, want %d", win.Label, out.Bucket, bucket)
}
if want := int(int64(win.Span/time.Second)/bucket) + 1; len(out.Time) != want {
t.Errorf("%s: %d bars, want %d", win.Label, len(out.Time), want)
}
if len(out.Time) > 0 && out.Time[0] != start-bucket {
t.Errorf("%s: first bar starts at %d, want %d", win.Label, out.Time[0], start-bucket)
}
for i := 1; i < len(out.Time); i++ {
if out.Time[i]-out.Time[i-1] != bucket {
t.Errorf("%s: bars %d and %d are %ds apart, want %d", win.Label, i-1, i, out.Time[i]-out.Time[i-1], bucket)
break
}
}
}
}
func TestBarUnitNamesTheBucket(t *testing.T) {
_, prom := serve(t, func(url.Values) string { return "[]" })
s := &Set{Prom: prom, Charts: []Chart{{Slug: "e", Title: "E", Unit: "kWh", Query: "x", Bucket: HourOrDay}}}
if got := getRange(t, s, "e", 24*time.Hour).Unit; got != "kWh per hour" {
t.Errorf("day window unit = %q", got)
}
if got := getRange(t, s, "e", 7*24*time.Hour).Unit; got != "kWh per day" {
t.Errorf("week window unit = %q", got)
}
}
func TestPageOpensOnTheChartsWindow(t *testing.T) {
s := &Set{}
s.Register(http.NewServeMux(), "/s/x/api/")
p := s.Page(Chart{Slug: "energy", Title: "Energy", Window: 24 * time.Hour}, false)
if p.URL != "/s/x/api/range?metric=energy" {
t.Errorf("data URL = %q", p.URL)
}
if sel := string(p.Top.Actions[1]); !strings.Contains(sel, `value="86400" data-refresh="120" selected`) {
t.Errorf("selector does not open on the last day: %s", sel)
}
if sel := string(s.Page(Chart{Slug: "cpu"}, false).Top.Actions[1]); !strings.Contains(sel, `value="3600" data-refresh="30" selected`) {
t.Errorf("selector does not open on the last hour: %s", sel)
}
}
func TestEveryWindowRefreshes(t *testing.T) {
for _, w := range Windows {
if w.Refresh <= 0 || w.Refresh > w.Span/10 {
t.Errorf("%s: refresh %v does not suit a span of %v", w.Label, w.Refresh, w.Span)
}
}
}