package metrics import ( "errors" "testing" "time" ) // referenceTimeForTests ist der feste Bezugszeitpunkt aller Tests. var referenceTimeForTests = time.Date(2026, time.August, 12, 12, 0, 0, 0, time.UTC) // TestEveryRangeYieldsUsableBuckets prueft die Aufloesung aller Zeitraeume. // // Die Bucket-Breite folgt dem Zeitraum. Ein Jahr in Minutenschritten ergaebe // ueber eine halbe Million Punkte — kein Diagramm stellt das dar, und kein // Browser laedt es gern. func TestEveryRangeYieldsUsableBuckets(testInstance *testing.T) { allRanges := []TimeRange{ RangeLastHour, RangeLastDay, RangeLastWeek, RangeLastMonth, RangeLastQuarter, RangeLastYear, } for _, timeRange := range allRanges { resolvedWindow, resolveError := ResolveWindow(timeRange, time.Time{}, time.Time{}, referenceTimeForTests) if resolveError != nil { testInstance.Fatalf("der Zeitraum %s liess sich nicht aufloesen: %v", timeRange, resolveError) } bucketCount := resolvedWindow.BucketCount() if bucketCount < 10 { testInstance.Errorf("der Zeitraum %s ergibt nur %d Punkte; das ist keine Kurve", timeRange, bucketCount) } if bucketCount > maximumBucketCount { testInstance.Errorf("der Zeitraum %s ergibt %d Punkte, erlaubt sind %d", timeRange, bucketCount, maximumBucketCount) } if !resolvedWindow.To.After(resolvedWindow.From) { testInstance.Errorf("der Zeitraum %s hat kein gueltiges Fenster", timeRange) } } } // TestCustomRangeRejectsUnusableInput prueft die Ablehnung unbrauchbarer Zeitraeume. func TestCustomRangeRejectsUnusableInput(testInstance *testing.T) { testCases := []struct { name string from time.Time to time.Time }{ {"ohne Anfang", time.Time{}, referenceTimeForTests}, {"ohne Ende", referenceTimeForTests, time.Time{}}, {"Ende vor Anfang", referenceTimeForTests, referenceTimeForTests.Add(-time.Hour)}, {"Anfang gleich Ende", referenceTimeForTests, referenceTimeForTests}, } for _, testCase := range testCases { if _, resolveError := ResolveWindow(RangeCustom, testCase.from, testCase.to, referenceTimeForTests); !errors.Is(resolveError, ErrInvalidRange) { testInstance.Errorf("%s wurde angenommen, Fehler war: %v", testCase.name, resolveError) } } } // TestCustomRangeLimitsPointCount prueft die Punktbegrenzung eigener Zeitraeume. func TestCustomRangeLimitsPointCount(testInstance *testing.T) { // Zehn Jahre: Ohne Begrenzung entstuenden Millionen Punkte. resolvedWindow, resolveError := ResolveWindow(RangeCustom, referenceTimeForTests.AddDate(-10, 0, 0), referenceTimeForTests, referenceTimeForTests) if resolveError != nil { testInstance.Fatalf("der Zeitraum liess sich nicht aufloesen: %v", resolveError) } if resolvedWindow.BucketCount() > maximumBucketCount { testInstance.Errorf("es entstanden %d Punkte, erlaubt sind %d", resolvedWindow.BucketCount(), maximumBucketCount) } } // TestUnknownRangeIsRejected prueft die Ablehnung unbekannter Zeitraeume. func TestUnknownRangeIsRejected(testInstance *testing.T) { if _, resolveError := ResolveWindow("42 Wochen", time.Time{}, time.Time{}, referenceTimeForTests); !errors.Is(resolveError, ErrInvalidRange) { testInstance.Fatalf("ein unbekannter Zeitraum wurde angenommen, Fehler war: %v", resolveError) } } // TestEmptyBucketsCarryNoValue ist der Kerntest des Pakets. // // Ein Zeitfenster ohne Messung hat **keinen** Wert. Wuerde es eine Null tragen, // faellt jede Kurve nachts auf den Boden, und die Anlage sieht aus, als waere // ihre Leistung eingebrochen — obwohl sie nur nichts zu tun hatte. func TestEmptyBucketsCarryNoValue(testInstance *testing.T) { resolvedWindow, _ := ResolveWindow(RangeLastDay, time.Time{}, time.Time{}, referenceTimeForTests) // Eine einzige Messung, mitten im Zeitraum. measurementTime := resolvedWindow.From.Add(5 * time.Hour) filledSeries := fillSeries("test", "Test", UnitCount, resolvedWindow, []bucketedRow{ {BucketStart: measurementTime.Truncate(resolvedWindow.BucketWidth), Value: 7, SampleCount: 1}, }) if filledSeries.PointsWithValue() != 1 { testInstance.Fatalf("%d Punkte tragen einen Wert, erwartet wurde genau einer", filledSeries.PointsWithValue()) } // Alle uebrigen Punkte existieren, tragen aber keinen Wert. Sie zu // **entfernen** waere ebenso falsch: Dann saehe eine zweistuendige Luecke // aus wie ein Sprung, weil der Zeitabstand der Punkte ungleich wuerde. if len(filledSeries.Points) != resolvedWindow.BucketCount() { testInstance.Errorf("die Reihe hat %d Punkte, das Fenster %d", len(filledSeries.Points), resolvedWindow.BucketCount()) } for _, dataPoint := range filledSeries.Points { if !dataPoint.HasValue && dataPoint.Value != 0 { testInstance.Error("ein Punkt ohne Wert traegt trotzdem einen Wert") } } } // TestPointsOutsideWindowAreIgnored prueft die Fenstergrenzen. // // Ohne die Pruefung landete ein Ereignis knapp ausserhalb des Zeitraums im // ersten oder letzten Fenster und verfaelschte es. func TestPointsOutsideWindowAreIgnored(testInstance *testing.T) { resolvedWindow, _ := ResolveWindow(RangeLastDay, time.Time{}, time.Time{}, referenceTimeForTests) filledSeries := fillSeries("test", "Test", UnitCount, resolvedWindow, []bucketedRow{ {BucketStart: resolvedWindow.From.Add(-time.Hour), Value: 99, SampleCount: 1}, {BucketStart: resolvedWindow.To.Add(time.Hour), Value: 99, SampleCount: 1}, }) if filledSeries.PointsWithValue() != 0 { testInstance.Error("ein Ereignis ausserhalb des Zeitraums wurde einem Fenster zugeschlagen") } } // TestChartWithoutDataSaysSo prueft den Hinweis bei leerer Auswertung. func TestChartWithoutDataSaysSo(testInstance *testing.T) { resolvedWindow, _ := ResolveWindow(RangeLastWeek, time.Time{}, time.Time{}, referenceTimeForTests) emptyChart := &Chart{ Metric: "test", Window: resolvedWindow, Series: []Series{fillSeries("test", "Test", UnitCount, resolvedWindow, nil)}, } emptyChart.AddAssessmentNote() if emptyChart.HasAnyData() { testInstance.Fatal("ein leeres Diagramm meldete Daten") } if emptyChart.Note == "" { testInstance.Fatal("das leere Diagramm wurde nicht erklaert") } // Der Hinweis muss den Unterschied benennen — leer heisst nicht null. if !contains(emptyChart.Note, "keine Daten") { testInstance.Errorf("der Hinweis nennt den Unterschied nicht: %q", emptyChart.Note) } } // TestChartWithTooFewPointsSaysSo prueft den Hinweis bei duennen Reihen. // // Zwei Punkte sehen aus wie ein Trend und sind keiner. func TestChartWithTooFewPointsSaysSo(testInstance *testing.T) { resolvedWindow, _ := ResolveWindow(RangeLastWeek, time.Time{}, time.Time{}, referenceTimeForTests) thinChart := &Chart{ Metric: "test", Window: resolvedWindow, Series: []Series{fillSeries("test", "Test", UnitCount, resolvedWindow, []bucketedRow{ {BucketStart: resolvedWindow.From.Add(time.Hour), Value: 1, SampleCount: 1}, {BucketStart: resolvedWindow.From.Add(2 * time.Hour), Value: 2, SampleCount: 1}, })}, } thinChart.AddAssessmentNote() if thinChart.Note == "" { testInstance.Fatal("eine Reihe aus zwei Messungen wurde nicht eingeordnet") } } // TestChartWithEnoughPointsHasNoNote prueft, dass belastbare Reihen ohne Hinweis auskommen. func TestChartWithEnoughPointsHasNoNote(testInstance *testing.T) { resolvedWindow, _ := ResolveWindow(RangeLastWeek, time.Time{}, time.Time{}, referenceTimeForTests) collectedRows := make([]bucketedRow, 0, 10) for rowIndex := 0; rowIndex < 10; rowIndex++ { collectedRows = append(collectedRows, bucketedRow{ BucketStart: resolvedWindow.From.Add(time.Duration(rowIndex) * time.Hour), Value: float64(rowIndex), SampleCount: 1, }) } solidChart := &Chart{ Metric: "test", Window: resolvedWindow, Series: []Series{fillSeries("test", "Test", UnitCount, resolvedWindow, collectedRows)}, } solidChart.AddAssessmentNote() if solidChart.Note != "" { testInstance.Errorf("eine belastbare Reihe bekam einen Hinweis: %q", solidChart.Note) } } // TestCatalogNamesEveryPlannedChart prueft die Vollstaendigkeit des Katalogs. // // Der Plan (§15) nennt zwoelf Diagramme. Fehlt eines im Katalog, ist es nicht // „noch nicht gebaut", sondern vergessen — und niemand sieht es. func TestCatalogNamesEveryPlannedChart(testInstance *testing.T) { chartCatalog := Catalog() // Geprueft wird die **Vollstaendigkeit**, nicht die Anzahl: Spaetere Phasen // duerfen Diagramme ergaenzen (die Sicherheitsbewertung aus Phase 15 ist so // eines). Fehlen darf keines — ein weggelassenes ist nicht „noch nicht // gebaut", sondern vergessen. plannedCharts := []string{ MetricBackupOutcome, MetricBackupDuration, MetricBackupThroughput, MetricBackupVolume, MetricStorageGrowth, MetricDeduplication, MetricCompression, MetricRepositoryCapacity, MetricAgentResources, MetricRecoveryPointAge, MetricRecoveryDuration, MetricVerificationOutcome, } if len(plannedCharts) != 12 { testInstance.Fatalf("die Liste der geplanten Diagramme hat %d Eintraege, der Plan nennt zwoelf", len(plannedCharts)) } for _, plannedChart := range plannedCharts { if _, wasFound := FindChartDefinition(plannedChart); !wasFound { testInstance.Errorf("das geplante Diagramm %s fehlt im Katalog", plannedChart) } } for _, chartDefinition := range chartCatalog { if chartDefinition.Title == "" || chartDefinition.Description == "" { testInstance.Errorf("das Diagramm %s ist nicht beschrieben", chartDefinition.Metric) } // Ein nicht verfuegbares Diagramm muss sagen, was fehlt. if !chartDefinition.Available && chartDefinition.UnavailableReason == "" { testInstance.Errorf("das Diagramm %s ist nicht verfuegbar und erklaert sich nicht", chartDefinition.Metric) } if chartDefinition.Available && chartDefinition.UnavailableReason != "" { testInstance.Errorf("das verfuegbare Diagramm %s traegt einen Nichtverfuegbarkeitsgrund", chartDefinition.Metric) } } } // contains meldet, ob eine Zeichenkette eine andere enthaelt. func contains(haystack string, needle string) bool { return len(haystack) >= len(needle) && findSubstring(haystack, needle) >= 0 } // findSubstring liefert die Position einer Teilzeichenkette. func findSubstring(haystack string, needle string) int { for startIndex := 0; startIndex+len(needle) <= len(haystack); startIndex++ { if haystack[startIndex:startIndex+len(needle)] == needle { return startIndex } } return -1 } // TestUnavailableChartsAreNamed prueft die Diagramme ohne Datengrundlage. // // Ein Diagramm als verfuegbar zu fuehren, das nie Daten haben kann, ist die // unangenehmste Sorte Fehler: Es sieht funktionsfaehig aus und bleibt fuer immer // leer. Genau das war beim Kompressionsdiagramm der Fall — die Spalte // compressed_bytes wird von keiner Stelle beschrieben. func TestUnavailableChartsAreNamed(testInstance *testing.T) { expectedUnavailable := map[string]bool{ MetricAgentResources: true, MetricCompression: true, } for _, chartDefinition := range Catalog() { if expectedUnavailable[chartDefinition.Metric] && chartDefinition.Available { testInstance.Errorf("das Diagramm %s wird als verfuegbar gefuehrt, hat aber keine Datengrundlage", chartDefinition.Metric) } if !expectedUnavailable[chartDefinition.Metric] && !chartDefinition.Available { testInstance.Errorf("das Diagramm %s wurde ohne Not als nicht verfuegbar gefuehrt", chartDefinition.Metric) } } } // TestLastPartialBucketIsKept prueft, dass der Rest eines Zeitraums nicht ausfaellt. // // Der Fall stammt aus dem Nachweis: Beim Jahreszeitraum meldete das Diagramm // null Messungen, obwohl am selben Tag mehrere Laeufe stattgefunden hatten. Ein // Jahr geteilt durch sieben Tage ergibt 52,14 Fenster; abgerundet fielen die // letzten Tage heraus — ausgerechnet die juengsten Ereignisse. func TestLastPartialBucketIsKept(testInstance *testing.T) { resolvedWindow, resolveError := ResolveWindow(RangeLastYear, time.Time{}, time.Time{}, referenceTimeForTests) if resolveError != nil { testInstance.Fatalf("der Zeitraum liess sich nicht aufloesen: %v", resolveError) } // Ein Ereignis von heute — im letzten, unvollstaendigen Zeitfenster. // // Der Fensterbeginn wird so gerechnet wie in der Abfrage: vom Anfang des // Zeitraums aus, nicht ab der Unix-Epoche. `time.Truncate` tut Letzteres und // traefe damit ein ganz anderes Fenster — der Test liefe durch, ohne etwas // zu pruefen. Genau das war beim ersten Anlauf der Fall. recentEvent := referenceTimeForTests.Add(-time.Hour) elapsedSinceStart := recentEvent.Sub(resolvedWindow.From) bucketStart := resolvedWindow.From.Add( (elapsedSinceStart / resolvedWindow.BucketWidth) * resolvedWindow.BucketWidth) filledSeries := fillSeries("test", "Test", UnitCount, resolvedWindow, []bucketedRow{ {BucketStart: bucketStart, Value: 5, SampleCount: 5}, }) if filledSeries.PointsWithValue() != 1 { testInstance.Fatalf("das juengste Ereignis fiel aus der Auswertung: %d Punkte tragen einen Wert", filledSeries.PointsWithValue()) } // Das letzte Fenster beginnt vor dem Ende des Zeitraums; es ist nur kuerzer. lastPoint := filledSeries.Points[len(filledSeries.Points)-1] if !lastPoint.Timestamp.Before(resolvedWindow.To) { testInstance.Error("das letzte Zeitfenster beginnt nach dem Ende des Zeitraums") } }