Unify metrics charts on custom SVG renderer
This commit is contained in:
@@ -304,6 +304,124 @@ func TestChartCanvasHeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartTimelineSegmentsForRangeMergesActiveSpansAndIdleGaps(t *testing.T) {
|
||||
start := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
|
||||
end := start.Add(10 * time.Minute)
|
||||
taskWindow := func(offsetStart, offsetEnd time.Duration) Task {
|
||||
s := start.Add(offsetStart)
|
||||
e := start.Add(offsetEnd)
|
||||
return Task{
|
||||
Name: "task",
|
||||
Status: TaskDone,
|
||||
StartedAt: &s,
|
||||
DoneAt: &e,
|
||||
}
|
||||
}
|
||||
segments := chartTimelineSegmentsForRange(start, end, end, []Task{
|
||||
taskWindow(1*time.Minute, 3*time.Minute),
|
||||
taskWindow(2*time.Minute, 5*time.Minute),
|
||||
taskWindow(7*time.Minute, 8*time.Minute),
|
||||
})
|
||||
if len(segments) != 5 {
|
||||
t.Fatalf("segments=%d want 5: %#v", len(segments), segments)
|
||||
}
|
||||
wantActive := []bool{false, true, false, true, false}
|
||||
wantMinutes := [][2]int{{0, 1}, {1, 5}, {5, 7}, {7, 8}, {8, 10}}
|
||||
for i, segment := range segments {
|
||||
if segment.Active != wantActive[i] {
|
||||
t.Fatalf("segment[%d].Active=%v want %v", i, segment.Active, wantActive[i])
|
||||
}
|
||||
if got := int(segment.Start.Sub(start).Minutes()); got != wantMinutes[i][0] {
|
||||
t.Fatalf("segment[%d] start=%d want %d", i, got, wantMinutes[i][0])
|
||||
}
|
||||
if got := int(segment.End.Sub(start).Minutes()); got != wantMinutes[i][1] {
|
||||
t.Fatalf("segment[%d] end=%d want %d", i, got, wantMinutes[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMetricChartSVGIncludesTimelineOverlay(t *testing.T) {
|
||||
start := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
|
||||
labels := []string{"12:00", "12:01", "12:02"}
|
||||
times := []time.Time{start, start.Add(time.Minute), start.Add(2 * time.Minute)}
|
||||
svg, err := renderMetricChartSVG(
|
||||
"System Power",
|
||||
labels,
|
||||
times,
|
||||
[][]float64{{300, 320, 310}},
|
||||
[]string{"Power W"},
|
||||
floatPtr(0),
|
||||
floatPtr(400),
|
||||
360,
|
||||
[]chartTimelineSegment{
|
||||
{Start: start, End: start.Add(time.Minute), Active: false},
|
||||
{Start: start.Add(time.Minute), End: start.Add(2 * time.Minute), Active: true},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(svg)
|
||||
if !strings.Contains(body, `data-role="timeline-overlay"`) {
|
||||
t.Fatalf("svg missing timeline overlay: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `opacity="0.10"`) {
|
||||
t.Fatalf("svg missing idle overlay opacity: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `System Power`) {
|
||||
t.Fatalf("svg missing chart title: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetricsChartSVGRendersCustomSVG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := openMetricsDB(filepath.Join(dir, "metrics.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.db.Close() })
|
||||
|
||||
start := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
|
||||
for i, sample := range []platform.LiveMetricSample{
|
||||
{Timestamp: start, PowerW: 300},
|
||||
{Timestamp: start.Add(time.Minute), PowerW: 320},
|
||||
{Timestamp: start.Add(2 * time.Minute), PowerW: 310},
|
||||
} {
|
||||
if err := db.Write(sample); err != nil {
|
||||
t.Fatalf("write sample %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
globalQueue.mu.Lock()
|
||||
prevTasks := globalQueue.tasks
|
||||
s := start.Add(30 * time.Second)
|
||||
e := start.Add(90 * time.Second)
|
||||
globalQueue.tasks = []*Task{{Name: "Burn", Status: TaskDone, StartedAt: &s, DoneAt: &e}}
|
||||
globalQueue.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
globalQueue.mu.Lock()
|
||||
globalQueue.tasks = prevTasks
|
||||
globalQueue.mu.Unlock()
|
||||
})
|
||||
|
||||
h := &handler{opts: HandlerOptions{ExportDir: dir}, metricsDB: db}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/metrics/chart/server-power.svg", nil)
|
||||
h.handleMetricsChartSVG(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `data-role="timeline-overlay"`) {
|
||||
t.Fatalf("custom svg response missing timeline overlay: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `stroke-linecap="round"`) {
|
||||
t.Fatalf("custom svg response missing custom polyline styling: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFanSeriesHoldsLastPositive(t *testing.T) {
|
||||
got := normalizeFanSeries([]float64{4200, 0, 0, 4300, 0})
|
||||
want := []float64{4200, 4200, 4200, 4300, 4300}
|
||||
@@ -317,21 +435,6 @@ func TestNormalizeFanSeriesHoldsLastPositive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartYAxisOption(t *testing.T) {
|
||||
min := floatPtr(0)
|
||||
max := floatPtr(100)
|
||||
opt := chartYAxisOption(min, max)
|
||||
if opt.Min != min || opt.Max != max {
|
||||
t.Fatalf("chartYAxisOption min/max mismatch: %#v", opt)
|
||||
}
|
||||
if opt.LabelCount != 11 {
|
||||
t.Fatalf("chartYAxisOption labelCount=%d want 11", opt.LabelCount)
|
||||
}
|
||||
if got := opt.ValueFormatter(1000); got != "1к" {
|
||||
t.Fatalf("chartYAxisOption formatter(1000)=%q want 1к", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotFanRingsUsesTimelineLabels(t *testing.T) {
|
||||
r1 := newMetricsRing(4)
|
||||
r2 := newMetricsRing(4)
|
||||
|
||||
Reference in New Issue
Block a user