Ctrl K
ComunidadGuías…

internal/chart/chart_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
		}
	}
}