package web
import (
"encoding/json"
"html"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// The hour in these tests ends at 13:04 UTC, so slot i starts at
// 12:05 + i minutes and the last slot, 59, is the minute 13:04.
const hourEnd = "2026-11-15T13:04:00Z"
func hourSlotAt(i int) string {
end, _ := time.Parse(time.RFC3339, hourEnd)
return end.Add(-time.Duration(59-i) * time.Minute).Format(time.RFC3339)
}
// hourOps is an opsView carrying a full hour. Each series is a 60-long
// slice the caller may change; the process was up for all of it unless
// the caller says otherwise.
func hourOps(t *testing.T, edit func(sig map[string]any)) opsView {
t.Helper()
ints := func(f func(i int) int) []int {
out := make([]int, 60)
for i := range out {
out[i] = f(i)
}
return out
}
up := make([]bool, 60)
for i := range up {
up[i] = true
}
sig := map[string]any{"minutes": 60, "end": hourEnd,
"renders": ints(func(i int) int { return 10 + i%7 }),
"slow": ints(func(int) int { return 0 }),
"busy": ints(func(i int) int { return i % 4 }),
"errors": ints(func(int) int { return 0 }),
"up": up}
if edit != nil {
edit(sig)
}
var v opsView
remarshal(map[string]any{"liveSubprocess": 2, "peakSubprocess": 3, "routesOver": 0, "routes": 9, "budgetMs": 500,
"lanes": []map[string]any{{"name": "store subprocesses", "bound": 8}},
"signals": sig}, &v)
return v
}
func renderOpsPage(t *testing.T, v opsView) string {
t.Helper()
var buf strings.Builder
if err := noHooks.template(opsPage).Execute(&buf, pageData{Title: "Operations", Ops: v, OpsHourURL: "/assets/ophour.test.js"}); err != nil {
t.Fatal(err)
}
return buf.String()
}
// chartsOf splits a page into its charts, keyed by measure and by which
// view they are in: the tiles' come first, the shared panel's after.
func chartsOf(body string) (tiles, rows map[string]string) {
tiles, rows = map[string]string{}, map[string]string{}
panel := strings.Index(body, `data-hook="ops-hour"`)
parts := strings.Split(body, `data-hook="ops-chart"`)
pos := len(parts[0])
for _, p := range parts[1:] {
m := regexp.MustCompile(`data-measure="(\w+)"`).FindStringSubmatch(p)
if m == nil {
continue
}
if pos < panel {
tiles[m[1]] = p
} else {
rows[m[1]] = p
}
pos += len(`data-hook="ops-chart"`) + len(p)
}
return tiles, rows
}
func attr(s, name string) string {
m := regexp.MustCompile(name + `="([^"]*)"`).FindStringSubmatch(s)
if m == nil {
return ""
}
return m[1]
}
// Every tile is a chart that starts at zero and says its scale: the
// figures it was drawn to travel as attributes, so a reader or a test
// does not have to measure pixels. Rates are a line with a dot on the
// current value, counts are bars, and the store lane's limit is drawn.
func TestTheTilesDrawChartsFromZeroWithAScale(t *testing.T) {
v := hourOps(t, func(sig map[string]any) {
slow, errs, busy, rend := make([]int, 60), make([]int, 60), make([]int, 60), make([]int, 60)
slow[40], errs[50], errs[51] = 3, 1, 2
busy[58], busy[20] = 6, 4
for i := range rend {
rend[i] = 20
}
rend[30] = 42
sig["slow"], sig["errors"], sig["busy"], sig["renders"] = slow, errs, busy, rend
})
tiles, _ := chartsOf(renderOpsPage(t, v))
if len(tiles) != 4 {
t.Fatalf("FAIL: %d tile charts, want 4", len(tiles))
}
for k, c := range tiles {
if !strings.Contains(c, `data-hook="ops-zero"`) || !strings.Contains(c, `d="M0 100H600"`) {
t.Errorf("FAIL: %s: no baseline at zero", k)
}
if !strings.Contains(c, `viewBox="0 0 600 100"`) || !strings.Contains(c, `role="img"`) || attr(c, "aria-label") == "" {
t.Errorf("FAIL: %s: not a labelled image", k)
}
if attr(c, "data-top") == "" || attr(c, "data-now") == "" {
t.Errorf("FAIL: %s: no scale or current value", k)
}
if n := strings.Count(c, `<span style="top:`); n < 2 {
t.Errorf("FAIL: %s: %d scale labels", k, n)
}
}
// The top is the next of 1, 2, 5 times a power of ten over the peak.
for k, want := range map[string]string{"renders": "50", "slow": "5", "errors": "2"} {
if got := attr(tiles[k], "data-top"); got != want {
t.Errorf("FAIL: %s top %s, want %s", k, got, want)
}
}
// Rates: a path and a dot; counts: bars, one a minute with something in it.
for _, k := range []string{"renders", "busy"} {
c := tiles[k]
if attr(c, "data-kind") != "line" || !strings.Contains(c, `data-hook="ops-line"`) || !strings.Contains(c, `data-hook="ops-dot"`) || strings.Contains(c, `data-hook="ops-bar"`) {
t.Errorf("FAIL: %s is not drawn as a line with a dot", k)
}
}
for k, want := range map[string]int{"slow": 1, "errors": 2} {
c := tiles[k]
if attr(c, "data-kind") != "bars" || strings.Contains(c, `data-hook="ops-line"`) || strings.Count(c, `data-hook="ops-bar"`) != want {
t.Errorf("FAIL: %s: kind %s with %d bars, want bars and %d", k, attr(c, "data-kind"), strings.Count(c, `data-hook="ops-bar"`), want)
}
}
// The store lane's limit is the top and is drawn and labelled.
b := tiles["busy"]
if attr(b, "data-limit") != "8" || attr(b, "data-top") != "8" || !strings.Contains(b, `data-hook="ops-limit"`) {
t.Errorf("FAIL: the limit is not the scale and a line: %s", b[:min(300, len(b))])
}
if strings.Contains(tiles["renders"], `data-hook="ops-limit"`) || attr(tiles["renders"], "data-limit") != "" {
t.Error("FAIL: a measure with no limit draws one")
}
// A bar for one error is a bar, and sits on the baseline, not above it.
if m := regexp.MustCompile(`data-hook="ops-bar" data-minute="50" data-value="1" x="[\d.]+" y="([\d.]+)" width="[\d.]+" height="([\d.]+)"`).FindStringSubmatch(tiles["errors"]); m == nil {
t.Error("FAIL: no bar for the minute with one error")
} else if m[1] == "100.0" || m[2] == "0.0" {
t.Errorf("FAIL: a one-error bar has no height: y=%s h=%s", m[1], m[2])
}
if !t.Failed() {
t.Log("ok")
}
}
// The renders line stops at the last whole minute, the same minute the
// figure beside it reads, so the dot and the number agree.
func TestTheRatesLineEndsAtTheFigure(t *testing.T) {
v := hourOps(t, func(sig map[string]any) {
r := make([]int, 60)
for i := range r {
r[i] = 10
}
r[58], r[59] = 30, 2 // the current minute, still filling, is low
sig["renders"] = r
})
tiles, _ := chartsOf(renderOpsPage(t, v))
c := tiles["renders"]
if attr(c, "data-now") != "30" || attr(c, "data-peak") != "30" {
t.Errorf("FAIL: now %s peak %s, want 30 and 30", attr(c, "data-now"), attr(c, "data-peak"))
}
// The dot is at the middle of slot 58 (588+5 of 600), and the figure is that minute's.
if !strings.Contains(c, `data-hook="ops-dot" d="M585.0`) {
t.Errorf("FAIL: the dot is not on the last whole minute: %s", regexp.MustCompile(`ops-dot[^>]*`).FindString(c))
}
if !t.Failed() {
t.Log("ok")
}
}
// A restart inside the hour is a dashed mark in the minute it fell in,
// in every tile and once through the shared panel; two are two, and one
// outside the hour is not drawn.
func TestARestartIsMarkedAtItsMinute(t *testing.T) {
for name, tc := range map[string]struct {
starts []string
minutes []string
}{
"none": {nil, nil},
"one": {[]string{"2026-11-15T12:42:20Z"}, []string{"37"}},
"two": {[]string{"2026-11-15T12:20:00Z", "2026-11-15T12:42:20Z"}, []string{"15", "37"}},
"stale": {[]string{"2026-11-15T11:30:00Z", "2026-11-15T12:42:20Z"}, []string{"37"}},
} {
v := hourOps(t, func(sig map[string]any) { sig["starts"] = tc.starts })
body := renderOpsPage(t, v)
tiles, rows := chartsOf(body)
for k, c := range tiles {
got := regexp.MustCompile(`data-hook="ops-deploy" data-minute="(\d+)"`).FindAllStringSubmatch(c, -1)
if len(got) != len(tc.minutes) {
t.Errorf("FAIL: %s: %s tile has %d deploy marks, want %d", name, k, len(got), len(tc.minutes))
continue
}
for i, m := range got {
if m[1] != tc.minutes[i] {
t.Errorf("FAIL: %s: %s mark %d is in minute %s, want %s", name, k, i, m[1], tc.minutes[i])
}
}
if attr(c, "data-deploys") != strconv.Itoa(len(tc.minutes)) {
t.Errorf("FAIL: %s: %s chart counts %q deploys, want %d", name, k, attr(c, "data-deploys"), len(tc.minutes))
}
}
for k, c := range rows {
if strings.Contains(c, `data-hook="ops-deploy"`) {
t.Errorf("FAIL: %s: the %s row draws its own deploy; the panel has one band for all", name, k)
}
}
panel := body[strings.Index(body, `data-hook="ops-hour"`):]
bands := regexp.MustCompile(`data-hook="ops-hour-deploy" data-minute="(\d+)"`).FindAllStringSubmatch(panel, -1)
if len(bands) != len(tc.minutes) {
t.Errorf("FAIL: %s: %d panel bands, want %d", name, len(bands), len(tc.minutes))
}
for i, m := range bands {
if i < len(tc.minutes) && m[1] != tc.minutes[i] {
t.Errorf("FAIL: %s: band %d in minute %s, want %s", name, i, m[1], tc.minutes[i])
}
}
}
if !t.Failed() {
t.Log("ok")
}
}
// The shared hour is four rows on one axis: one clock under all of
// them, not one each, and the rows draw the same sixty minutes.
func TestTheSharedHourIsFourRowsOnOneAxis(t *testing.T) {
body := renderOpsPage(t, hourOps(t, nil))
panel := body[strings.Index(body, `data-hook="ops-hour"`):]
if n := strings.Count(panel, `data-hook="ops-row"`); n != 4 {
t.Fatalf("FAIL: %d rows, want 4", n)
}
if n := strings.Count(panel, `data-hook="ops-axis"`); n != 1 {
t.Errorf("FAIL: %d time axes in the panel, want the one shared", n)
}
if n := strings.Count(panel, `data-hook="ops-hour-axis"`); n != 1 {
t.Errorf("FAIL: %d shared axes", n)
}
// Ticks every fifteen minutes: five, and the times are the clock's.
ax := panel[strings.Index(panel, `data-hook="ops-hour-axis"`):]
ax = ax[:strings.Index(ax, "</div>")]
spans := regexp.MustCompile(`<span[^>]*>([^<]+)</span>`).FindAllStringSubmatch(ax, -1)
var got []string
for _, s := range spans {
got = append(got, s[1])
}
if want := []string{"12:05", "12:19", "12:34", "12:49", "13:04"}; strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("FAIL: ticks %v, want %v", got, want)
}
_, rows := chartsOf(body)
for _, k := range hourKeys {
if !strings.Contains(rows[k], `viewBox="0 0 600 100"`) {
t.Errorf("FAIL: the %s row is not on the common drawing box", k)
}
}
// What the pointer reads: sixty worded minutes and four rows.
grid := regexp.MustCompile(`data-hook="ops-hour-grid"\s+data-hover="([^"]*)"`).FindStringSubmatch(panel)
if grid == nil {
t.Fatal("FAIL: the panel carries no minute-by-minute data")
}
var data struct {
At []string `json:"at"`
Rows []struct {
K string `json:"k"`
V []string `json:"v"`
} `json:"rows"`
}
if err := json.Unmarshal([]byte(html.UnescapeString(grid[1])), &data); err != nil {
t.Fatal(err)
}
if len(data.At) != 60 || len(data.Rows) != 4 || len(data.Rows[0].V) != 60 {
t.Errorf("FAIL: hover data has %d minutes and %d rows", len(data.At), len(data.Rows))
}
// A row is a keyboard-reachable group, and the readout is a status.
if !strings.Contains(panel, `tabindex="0"`) || !strings.Contains(panel, `role="status"`) {
t.Error("FAIL: the panel cannot be reached by keyboard or announces nothing")
}
if !t.Failed() {
t.Log("ok")
}
}
// Without script the page still opens the shared hour: the control is a
// form button, and ?hour= decides which view the server writes shown. A
// measure's name picks its row out.
func TestTheControlWorksWithNoScript(t *testing.T) {
for name, tc := range map[string]struct{ q, view, focus string }{
"default": {"", "tiles", ""},
"zero": {"0", "tiles", ""},
"open": {"1", "hour", ""},
"row": {"errors", "hour", "errors"},
"junk": {"nonsense", "hour", ""},
} {
v := hourOps(t, nil)
v.HourOpen, v.HourFocus = hourParam(url.Values{"hour": {tc.q}})
body := renderOpsPage(t, v)
if got := attr(body, "data-view"); got != tc.view {
t.Errorf("FAIL: %s: view %q, want %q", name, got, tc.view)
}
if got := attr(body, "data-focus"); got != tc.focus {
t.Errorf("FAIL: %s: focus %q, want %q", name, got, tc.focus)
}
// Both views are always in the page.
if strings.Count(body, `data-hook="ops-row"`) != 4 || strings.Count(body, `data-hook="ops-tile"`) != 4 {
t.Errorf("FAIL: %s: a view is missing from the page", name)
}
}
body := renderOpsPage(t, hourOps(t, nil))
if n := len(regexp.MustCompile(`<form class="ops-hs-f[^"]*" method="get" action="/fleet/ops">`).FindAllString(body, -1)); n != 2 {
t.Errorf("FAIL: %d GET forms, want the control's and the tiles'", n)
}
// The two buttons, one shown at a time, each true to its own state.
for hook, want := range map[string]string{"ops-compare": "false", "ops-compare-back": "true"} {
re := regexp.MustCompile(`<button type="submit" name="hour" value="\w" class="[^"]*" aria-expanded="` + want + `" aria-controls="ops-hour" data-hook="` + hook + `">`)
if !re.MatchString(body) {
t.Errorf("FAIL: %s is not a submit button with aria-expanded=%s", hook, want)
}
}
// Each tile is a button that opens the panel with its row picked out.
for _, k := range hourKeys {
if !strings.Contains(body, `<button type="submit" name="hour" value="`+k+`" class="ops-met-go"`) {
t.Errorf("FAIL: the %s tile is not a button", k)
}
}
// A sorted table stays sorted through the reload.
v := hourOps(t, nil)
v.Query = url.Values{"stores": {"name"}, "hour": {"1"}}
if body := renderOpsPage(t, v); !strings.Contains(body, `<input type="hidden" name="stores" value="name">`) || strings.Contains(body, `<input type="hidden" name="hour"`) {
t.Error("FAIL: the form drops the page's other parameters or repeats its own")
}
if !strings.Contains(body, `<script src="/assets/ophour.test.js" defer nonce="`) {
t.Error("FAIL: the page does not load its script")
}
if !t.Failed() {
t.Log("ok")
}
}
// Minutes no process was running in are a gap: nothing is drawn through
// them, no bar and no line, and they are said in the label.
func TestTheDownTimeIsAGapNotZeros(t *testing.T) {
v := hourOps(t, func(sig map[string]any) {
up := make([]bool, 60)
errs := make([]int, 60)
for i := range up {
up[i] = i < 20 || i >= 25 // down for minutes 20 to 24
}
errs[22], errs[30] = 4, 1 // a stray count in the gap must not be drawn
sig["up"], sig["errors"] = up, errs
sig["starts"] = []string{hourSlotAt(25)}
})
tiles, _ := chartsOf(renderOpsPage(t, v))
e := tiles["errors"]
if !strings.Contains(e, `data-hook="ops-gap" data-from="20" data-to="25"`) {
t.Errorf("FAIL: no gap over minutes 20 to 24: %s", regexp.MustCompile(`ops-gap[^>]*`).FindString(e))
}
if strings.Contains(e, `data-minute="22"`) || !strings.Contains(e, `data-minute="30"`) {
t.Error("FAIL: a bar was drawn in the gap, or the one after it was lost")
}
r := tiles["renders"]
if n := strings.Count(r, `data-hook="ops-line"`); n != 2 {
t.Errorf("FAIL: the renders line is %d paths, want two with the gap between", n)
}
if g := strings.Count(r, `data-hook="ops-gap"`); g != 1 {
t.Errorf("FAIL: %d gaps in the renders chart, want 1", g)
}
// The forge's first minutes are not a gap: nothing was running before it.
v = hourOps(t, func(sig map[string]any) {
up := make([]bool, 60)
for i := 40; i < 60; i++ {
up[i] = true
}
sig["up"] = up
})
tiles, _ = chartsOf(renderOpsPage(t, v))
if strings.Contains(tiles["renders"], `data-hook="ops-gap"`) {
t.Error("FAIL: the hour before the first process is drawn as a gap")
}
if n := strings.Count(tiles["renders"], `data-hook="ops-line"`); n != 1 {
t.Errorf("FAIL: %d lines for one run", n)
}
if !t.Failed() {
t.Log("ok")
}
}
// Colour carries a count only where there is one: a quiet hour draws no
// bar at all, and the warning colours are the bars' alone, never the
// line's.
func TestQuietHoursStayNeutral(t *testing.T) {
body := renderOpsPage(t, hourOps(t, nil))
tiles, _ := chartsOf(body)
for _, k := range []string{"slow", "errors"} {
if strings.Contains(tiles[k], `data-hook="ops-bar"`) {
t.Errorf("FAIL: a quiet %s hour draws a bar", k)
}
}
for cls, tok := range map[string]string{"warn": "--warn", "bad": "--del"} {
if !regexp.MustCompile(`\.ops-ch-bar\.` + cls + `\{[^}]*var\(` + tok + `\)`).MatchString(sheet) {
t.Errorf("FAIL: bars of class %s do not take %s", cls, tok)
}
}
if regexp.MustCompile(`\.ops-ch-(line|fill|dot)[^{]*\{[^}]*var\(--(warn|del)\)`).MatchString(sheet) {
t.Error("FAIL: a line takes a warning colour")
}
if !regexp.MustCompile(`\.ops-hr-grid\{[^}]*touch-action:pan-y`).MatchString(sheet) {
t.Error("FAIL: the shared hour does not leave vertical scrolling to the page")
}
if !t.Failed() {
t.Log("ok")
}
}
func TestHourTopIsOneTwoOrFiveTimesAPowerOfTen(t *testing.T) {
for in, want := range map[float64]float64{0: 1, 1: 1, 2: 2, 3: 5, 5: 5, 6: 10, 7: 10, 12: 20, 41: 50, 100: 100, 101: 200, 730: 1000} {
if got := hourTop(in); got != want {
t.Errorf("FAIL: top of %v is %v, want %v", in, got, want)
}
}
if !t.Failed() {
t.Log("ok")
}
}
// The strip is the four golden signals, each with the hour behind it.
// Routes over budget is the latency figure; the rate is the last whole
// minute, since the current one is still filling. While the stores come
// up the strip is off: the panels read as an empty forge.
func TestTheStripDrawsTheFourSignals(t *testing.T) {
v := hourOps(t, func(sig map[string]any) {
r := make([]int, 60)
r[57], r[58], r[59] = 1, 4, 2
sig["renders"] = r
})
v.RoutesOver, v.Routes = 2, 9
body := renderOpsPage(t, v)
tiles, _ := chartsOf(body)
if len(tiles) != 4 {
t.Fatalf("FAIL: want 4 charts in the strip, got %d", len(tiles))
}
if !strings.Contains(body, `data-over="2"`) || !strings.Contains(body, `data-hook="renders-figure">4</span>`) {
t.Errorf("FAIL: the strip's figures are off")
}
var starting opsView
remarshal(map[string]any{"phase": map[string]any{"name": "starting", "repos": 24, "ready": 9}}, &starting)
if strings.Contains(renderOpsPage(t, starting), `data-hook="ops-strip"`) {
t.Error("FAIL: the strip is drawn while the forge is starting")
}
if !t.Failed() {
t.Log("ok")
}
}
// Restarts too close together for two words share one label that counts
// them; the lines are still one each.
func TestCloseRestartsShareALabel(t *testing.T) {
v := hourOps(t, func(sig map[string]any) {
sig["starts"] = []string{"2026-11-15T12:40:10Z", "2026-11-15T12:44:30Z", "2026-11-15T13:02:00Z"}
})
h := v.HourView()
ls := h.deployLabels()
if len(ls) != 2 || ls[0].Count != 2 || ls[1].Count != 1 || ls[1].Side != " r" {
t.Errorf("FAIL: labels %+v", ls)
}
if n := strings.Count(string(h.DeployBands()), `data-hook="ops-hour-deploy"`); n != 3 {
t.Errorf("FAIL: %d lines, want 3", n)
}
if !t.Failed() {
t.Log("ok")
}
}
// The renders figure and the dot at the end of its line are one value:
// whatever the last whole minute held, or nothing at all where no
// process was running in it. (The first version read the figure off the
// last bucket and drew the dot off the last drawn minute, which after a
// restart were two different minutes.)
func TestTheRendersFigureIsTheDot(t *testing.T) {
figure := func(body string) string {
m := regexp.MustCompile(`data-hook="renders-figure">([^<]*)<`).FindStringSubmatch(body)
if m == nil {
t.Fatal("FAIL: no renders figure")
}
return m[1]
}
for name, tc := range map[string]struct {
downAt int // a minute no process ran in, -1 for none
wantDot bool
wantFig string
wantDotY string
}{
"up": {-1, true, "36", "28.0"},
"restarted": {58, false, "\u2014", ""},
"older gap": {40, true, "36", "28.0"},
"just up": {57, true, "36", "28.0"},
} {
v := hourOps(t, func(sig map[string]any) {
r := make([]int, 60)
for i := range r {
r[i] = 20
}
r[58], r[59] = 36, 3
up := make([]bool, 60)
for i := range up {
up[i] = i != tc.downAt
}
sig["renders"], sig["up"] = r, up
})
body := renderOpsPage(t, v)
if got := figure(body); got != tc.wantFig {
t.Errorf("FAIL: %s: figure %q, want %q", name, got, tc.wantFig)
}
tiles, rows := chartsOf(body)
for where, c := range map[string]string{"tile": tiles["renders"], "row": rows["renders"]} {
full := c
c = c[:strings.Index(c, "\n")] // the chart's own opening tag
dot := regexp.MustCompile(`data-hook="ops-dot" d="M[\d.]+ ([\d.]+)L`).FindStringSubmatch(full)
_ = regexp.MustCompile(`data-hook="ops-dot" d="M[\d.]+ ([\d.]+)L`).FindStringSubmatch(c)
if (dot != nil) != tc.wantDot {
t.Errorf("FAIL: %s %s: dot present=%v, want %v", name, where, dot != nil, tc.wantDot)
} else if dot != nil && dot[1] != tc.wantDotY {
t.Errorf("FAIL: %s %s: dot at y=%s, want %s (the figure's value on a top of 50)", name, where, dot[1], tc.wantDotY)
}
if tc.wantFig == "36" && attr(c, "data-now") != "36" {
t.Errorf("FAIL: %s %s: data-now %q", name, where, attr(c, "data-now"))
}
if !tc.wantDot && attr(c, "data-now") != "" {
t.Errorf("FAIL: %s %s: data-now %q on a minute with no data", name, where, attr(c, "data-now"))
}
}
}
if !t.Failed() {
t.Log("ok")
}
}
// The limit's label starts where no deploy line crosses it.
func TestTheLimitLabelAvoidsTheDeployLines(t *testing.T) {
for name, starts := range map[string][]string{
"none": nil,
"near left": {"2026-11-15T12:10:00Z"},
"near right": {"2026-11-15T13:02:00Z"},
"both ends": {"2026-11-15T12:10:00Z", "2026-11-15T13:02:00Z"},
} {
v := hourOps(t, func(sig map[string]any) { sig["starts"] = starts })
h := v.HourView()
at := h.limitLabelAt()
for _, d := range h.Deploys {
if p := d.Pct(h.N); p > at-1 && p < at+24 {
t.Errorf("FAIL: %s: the limit label at %.0f%% is crossed by a deploy at %.1f%%", name, at, p)
}
}
if !strings.Contains(string(hourChart(h, "busy", true)), `data-hook="ops-limit-label"`) {
t.Errorf("FAIL: %s: no limit label", name)
}
}
if !t.Failed() {
t.Log("ok")
}
}
// With no error series the errors measure is unavailable, not an hour of
// zeros: a dash for the figure, no bars, the row's values unread, and the
// tile not tinted by a fallback total.
func TestErrorsAreUnavailableNotZeroWhenNothingWasRead(t *testing.T) {
v := hourOps(t, func(sig map[string]any) { delete(sig, "errors") })
v.ErrorTotal = 9
body := renderOpsPage(t, v)
tiles, rows := chartsOf(body)
for where, c := range map[string]string{"tile": tiles["errors"], "row": rows["errors"]} {
head := c[:strings.Index(c, "\n")]
if attr(head, "data-unavailable") != "true" || attr(head, "data-now") != "" {
t.Errorf("FAIL: %s: not marked unavailable: %s", where, head)
}
if strings.Contains(c, `data-hook="ops-bar"`) {
t.Errorf("FAIL: %s: bars drawn from no data", where)
}
}
if m := regexp.MustCompile(`data-hook="errors-hour">([^<]*)<`).FindStringSubmatch(body); m == nil || m[1] != "\u2014" {
t.Errorf("FAIL: the figure reads %v, want a dash", m)
}
if regexp.MustCompile(`ops-met bad[^>]*data-measure="errors"`).MatchString(body) {
t.Error("FAIL: the tile is tinted from a total it was not drawn from")
}
var data struct {
Rows []struct {
K string `json:"k"`
V []string `json:"v"`
} `json:"rows"`
}
grid := regexp.MustCompile(`data-hook="ops-hour-grid"\s+data-hover="([^"]*)"`).FindStringSubmatch(body)
if err := json.Unmarshal([]byte(html.UnescapeString(grid[1])), &data); err != nil {
t.Fatal(err)
}
for _, r := range data.Rows {
if r.K == "errors" && r.V[59] == "0" {
t.Error("FAIL: the readout says zero errors for a minute nobody read")
}
}
if !t.Failed() {
t.Log("ok")
}
}