diff --git a/runner/importer/service.go b/runner/importer/service.go index e771abb..10f616c 100644 --- a/runner/importer/service.go +++ b/runner/importer/service.go @@ -375,8 +375,8 @@ func (s *Service) MergeMetadata(srcMetadata, destMetadata *benchmark.RunGroup, s srcRuns = s.markRunsAsComplete(srcRuns) // Apply tags to source and destination runs - srcRuns = s.ApplyTags(srcRuns, srcTag) // Apply destination tag to imported runs - destRuns := s.FillMissingSourceTags(destMetadata.Runs, destTag) // Fill missing source tags without overwriting + srcRuns = s.ApplyTags(srcRuns, destTag) // Apply destination tag to imported runs + destRuns := s.FillMissingSourceTags(destMetadata.Runs, srcTag) // Fill missing source tags on existing runs without overwriting // Apply BenchmarkRun strategy to imported runs srcRuns, err := s.applyBenchmarkRunStrategy(srcRuns, destMetadata, benchmarkRunOpt) diff --git a/runner/importer/service_test.go b/runner/importer/service_test.go new file mode 100644 index 0000000..81ff607 --- /dev/null +++ b/runner/importer/service_test.go @@ -0,0 +1,125 @@ +package importer + +import ( + "testing" + "time" + + "github.com/base/base-bench/benchmark/config" + "github.com/base/base-bench/runner/benchmark" + "github.com/stretchr/testify/require" +) + +// stubLogger implements the go-ethereum log.Logger interface with no-op methods for testing. +type stubLogger struct{} + +func (stubLogger) Trace(string, ...interface{}) {} +func (stubLogger) Debug(string, ...interface{}) {} +func (stubLogger) Info(string, ...interface{}) {} +func (stubLogger) Warn(string, ...interface{}) {} +func (stubLogger) Error(string, ...interface{}) {} +func (stubLogger) Crit(string, ...interface{}) {} + +func TestMergeMetadata_TagSemanticsAndBenchmarkRunReuse(t *testing.T) { + now := time.Now() + srcCreated := now.Add(2 * time.Minute) + + destMetadata := &benchmark.RunGroup{ + Runs: []benchmark.Run{ + { + ID: "existing", + TestConfig: map[string]interface{}{ + benchmark.BenchmarkRunTag: "BR-123", + }, + CreatedAt: &now, + }, + { + ID: "prefilled", + TestConfig: map[string]interface{}{ + "instance": "keep-me", + }, + }, + }, + } + + srcMetadata := &benchmark.RunGroup{ + CreatedAt: &srcCreated, + Runs: []benchmark.Run{ + {ID: "imported-1"}, + }, + } + + srcTag := &config.TagConfig{Key: "instance", Value: "existing-instance"} + destTag := &config.TagConfig{Key: "instance", Value: "imported-instance"} + + svc := &Service{config: &config.ImportCmdConfig{}, log: stubLogger{}} + + merged, summary := svc.MergeMetadata(srcMetadata, destMetadata, srcTag, destTag, BenchmarkRunAddToLast) + + require.Len(t, merged.Runs, 3) + require.Equal(t, 1, summary.ImportedRunsCount) + require.Equal(t, 2, summary.ExistingRunsCount) + + var imported benchmark.Run + var existing benchmark.Run + var prefilled benchmark.Run + for _, run := range merged.Runs { + switch run.ID { + case "imported-1": + imported = run + case "existing": + existing = run + case "prefilled": + prefilled = run + } + } + + // Imported runs should receive dest-tag and reuse the last BenchmarkRun ID + require.NotNil(t, imported.TestConfig) + require.Equal(t, destTag.Value, imported.TestConfig[destTag.Key]) + require.Equal(t, "BR-123", imported.TestConfig[benchmark.BenchmarkRunTag]) + require.NotNil(t, imported.CreatedAt) + require.True(t, imported.CreatedAt.Equal(srcCreated)) + require.NotNil(t, imported.Result) + require.True(t, imported.Result.Complete) + + // Existing runs should have src-tag filled only when missing + require.Equal(t, srcTag.Value, existing.TestConfig[srcTag.Key]) + require.Equal(t, "keep-me", prefilled.TestConfig[srcTag.Key]) +} + +func TestMergeMetadata_CreateNewBenchmarkRunAndTags(t *testing.T) { + srcMetadata := &benchmark.RunGroup{ + Runs: []benchmark.Run{ + {ID: "new-run"}, + {ID: "new-run-2"}, + }, + } + destMetadata := &benchmark.RunGroup{} + + destTag := &config.TagConfig{Key: "instance", Value: "imported"} + + svc := &Service{config: &config.ImportCmdConfig{}, log: stubLogger{}} + + merged, summary := svc.MergeMetadata(srcMetadata, destMetadata, nil, destTag, BenchmarkRunCreateNew) + + require.Len(t, merged.Runs, 2) + require.Equal(t, 2, summary.ImportedRunsCount) + require.Equal(t, 0, summary.ExistingRunsCount) + + // All imported runs should have a BenchmarkRun ID and dest-tag applied + var benchmarkRunID string + for _, run := range merged.Runs { + require.NotNil(t, run.TestConfig) + tag, ok := run.TestConfig[benchmark.BenchmarkRunTag].(string) + require.True(t, ok) + require.NotEmpty(t, tag) + if benchmarkRunID == "" { + benchmarkRunID = tag + } else { + require.Equal(t, benchmarkRunID, tag) + } + require.Equal(t, destTag.Value, run.TestConfig[destTag.Key]) + require.NotNil(t, run.Result) + require.True(t, run.Result.Complete) + } +} diff --git a/runner/utils/format.go b/runner/utils/format.go new file mode 100644 index 0000000..bbb7449 --- /dev/null +++ b/runner/utils/format.go @@ -0,0 +1,301 @@ +package utils + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +// FormatDuration formats a duration to a human-readable string. +// Examples: "1h 23m 45s", "5m 30s", "45s", "123ms" +func FormatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + var parts []string + if hours > 0 { + parts = append(parts, fmt.Sprintf("%dh", hours)) + } + if minutes > 0 { + parts = append(parts, fmt.Sprintf("%dm", minutes)) + } + if seconds > 0 || len(parts) == 0 { + parts = append(parts, fmt.Sprintf("%ds", seconds)) + } + + return strings.Join(parts, " ") +} + +// FormatBytes formats a byte count to a human-readable string with appropriate units. +// Examples: "1.5 GB", "256 MB", "64 KB", "128 B" +func FormatBytes(bytes uint64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + TB = GB * 1024 + ) + + switch { + case bytes >= TB: + return fmt.Sprintf("%.2f TB", float64(bytes)/float64(TB)) + case bytes >= GB: + return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// FormatGas formats a gas value to a human-readable string. +// Examples: "50 Ggas", "1.5 Mgas", "500 Kgas" +func FormatGas(gas uint64) string { + const ( + Kgas = 1000 + Mgas = Kgas * 1000 + Ggas = Mgas * 1000 + ) + + switch { + case gas >= Ggas: + return fmt.Sprintf("%.2f Ggas", float64(gas)/float64(Ggas)) + case gas >= Mgas: + return fmt.Sprintf("%.2f Mgas", float64(gas)/float64(Mgas)) + case gas >= Kgas: + return fmt.Sprintf("%.2f Kgas", float64(gas)/float64(Kgas)) + default: + return fmt.Sprintf("%d gas", gas) + } +} + +// FormatPercentage formats a ratio as a percentage string. +// Example: FormatPercentage(0.856, 1) returns "85.6%" +func FormatPercentage(ratio float64, precision int) string { + percentage := ratio * 100 + return fmt.Sprintf("%.*f%%", precision, percentage) +} + +// ParseDuration parses a human-readable duration string. +// Supports formats like "1h", "30m", "45s", "1h30m", "100ms" +func ParseDuration(s string) (time.Duration, error) { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return 0, errors.New("empty duration string") + } + + // First try Go's built-in parser + if d, err := time.ParseDuration(s); err == nil { + return d, nil + } + + // Handle simple numeric values (assume seconds) + if val, err := strconv.ParseFloat(s, 64); err == nil { + return time.Duration(val * float64(time.Second)), nil + } + + return 0, errors.Errorf("unable to parse duration: %s", s) +} + +// ParseGasLimit parses a gas limit string with optional suffixes. +// Examples: "50e9", "50000000000", "50G", "50Ggas" +func ParseGasLimit(s string) (uint64, error) { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return 0, errors.New("empty gas limit string") + } + + // Remove "gas" suffix if present + s = strings.TrimSuffix(s, "gas") + s = strings.TrimSpace(s) + + // Handle scientific notation (e.g., "50e9") + if strings.Contains(s, "e") { + val, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, errors.Wrap(err, "failed to parse scientific notation") + } + return uint64(val), nil + } + + // Handle suffixes (K, M, G, T) + multipliers := map[string]uint64{ + "k": 1000, + "m": 1000000, + "g": 1000000000, + "t": 1000000000000, + } + + for suffix, multiplier := range multipliers { + if strings.HasSuffix(s, suffix) { + numStr := strings.TrimSuffix(s, suffix) + val, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, errors.Wrapf(err, "failed to parse gas limit with suffix %s", suffix) + } + return uint64(val * float64(multiplier)), nil + } + } + + // Try parsing as plain number + val, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, errors.Wrap(err, "failed to parse gas limit") + } + + return val, nil +} + +// TruncateString truncates a string to the specified length, adding an ellipsis if truncated. +func TruncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// TruncateMiddle truncates a string in the middle, preserving the start and end. +// Useful for displaying long hashes or addresses. +func TruncateMiddle(s string, startChars, endChars int) string { + if len(s) <= startChars+endChars+3 { + return s + } + return s[:startChars] + "..." + s[len(s)-endChars:] +} + +// IsValidHexAddress checks if a string is a valid Ethereum address (0x + 40 hex chars). +func IsValidHexAddress(address string) bool { + if len(address) != 42 { + return false + } + if !strings.HasPrefix(address, "0x") { + return false + } + matched, _ := regexp.MatchString("^0x[0-9a-fA-F]{40}$", address) + return matched +} + +// IsValidHexHash checks if a string is a valid 32-byte hex hash (0x + 64 hex chars). +func IsValidHexHash(hash string) bool { + if len(hash) != 66 { + return false + } + if !strings.HasPrefix(hash, "0x") { + return false + } + matched, _ := regexp.MatchString("^0x[0-9a-fA-F]{64}$", hash) + return matched +} + +// CalculateStats calculates basic statistics for a slice of float64 values. +// Returns mean, standard deviation, min, max, and median. +func CalculateStats(values []float64) (mean, stdDev, min, max, median float64, err error) { + if len(values) == 0 { + return 0, 0, 0, 0, 0, errors.New("empty values slice") + } + + // Calculate mean + sum := 0.0 + min = values[0] + max = values[0] + for _, v := range values { + sum += v + if v < min { + min = v + } + if v > max { + max = v + } + } + mean = sum / float64(len(values)) + + // Calculate standard deviation + sumSquaredDiff := 0.0 + for _, v := range values { + diff := v - mean + sumSquaredDiff += diff * diff + } + stdDev = math.Sqrt(sumSquaredDiff / float64(len(values))) + + // Calculate median (copy and sort to avoid modifying original) + sorted := make([]float64, len(values)) + copy(sorted, values) + sortFloat64s(sorted) + + n := len(sorted) + if n%2 == 0 { + median = (sorted[n/2-1] + sorted[n/2]) / 2 + } else { + median = sorted[n/2] + } + + return mean, stdDev, min, max, median, nil +} + +// sortFloat64s sorts a slice of float64 in ascending order (simple insertion sort for small slices) +func sortFloat64s(values []float64) { + for i := 1; i < len(values); i++ { + key := values[i] + j := i - 1 + for j >= 0 && values[j] > key { + values[j+1] = values[j] + j-- + } + values[j+1] = key + } +} + +// FormatTimestamp formats a time.Time to a standardized string format. +func FormatTimestamp(t time.Time) string { + return t.Format(time.RFC3339) +} + +// ParseTimestamp parses a timestamp string in various common formats. +func ParseTimestamp(s string) (time.Time, error) { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02", + } + + for _, format := range formats { + if t, err := time.Parse(format, s); err == nil { + return t, nil + } + } + + return time.Time{}, errors.Errorf("unable to parse timestamp: %s", s) +} + +// SafeDivide performs division with zero-safety, returning 0 if divisor is 0. +func SafeDivide(numerator, denominator float64) float64 { + if denominator == 0 { + return 0 + } + return numerator / denominator +} + +// SafeDivideUint64 performs integer division with zero-safety. +func SafeDivideUint64(numerator, denominator uint64) uint64 { + if denominator == 0 { + return 0 + } + return numerator / denominator +} diff --git a/runner/utils/utils_test.go b/runner/utils/utils_test.go new file mode 100644 index 0000000..dbbefe7 --- /dev/null +++ b/runner/utils/utils_test.go @@ -0,0 +1,367 @@ +package utils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGenerateRandomID(t *testing.T) { + t.Run("generates ID of correct length", func(t *testing.T) { + id, err := GenerateRandomID(8) + require.NoError(t, err) + require.Len(t, id, 16) // 8 bytes = 16 hex chars + }) + + t.Run("generates unique IDs", func(t *testing.T) { + id1, err := GenerateRandomID(8) + require.NoError(t, err) + id2, err := GenerateRandomID(8) + require.NoError(t, err) + require.NotEqual(t, id1, id2) + }) + + t.Run("rejects zero bytes", func(t *testing.T) { + _, err := GenerateRandomID(0) + require.Error(t, err) + }) + + t.Run("rejects negative bytes", func(t *testing.T) { + _, err := GenerateRandomID(-1) + require.Error(t, err) + }) +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + {"milliseconds", 500 * time.Millisecond, "500ms"}, + {"seconds only", 45 * time.Second, "45s"}, + {"minutes and seconds", 5*time.Minute + 30*time.Second, "5m 30s"}, + {"hours minutes seconds", 1*time.Hour + 23*time.Minute + 45*time.Second, "1h 23m 45s"}, + {"hours only", 2 * time.Hour, "2h"}, + {"zero", 0, "0ms"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatDuration(tt.duration) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatBytes(t *testing.T) { + tests := []struct { + name string + bytes uint64 + expected string + }{ + {"bytes", 512, "512 B"}, + {"kilobytes", 1024, "1.00 KB"}, + {"megabytes", 1024 * 1024, "1.00 MB"}, + {"gigabytes", 1024 * 1024 * 1024, "1.00 GB"}, + {"terabytes", 1024 * 1024 * 1024 * 1024, "1.00 TB"}, + {"mixed", 1536 * 1024, "1.50 MB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatBytes(tt.bytes) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatGas(t *testing.T) { + tests := []struct { + name string + gas uint64 + expected string + }{ + {"small gas", 500, "500 gas"}, + {"kilogas", 50000, "50.00 Kgas"}, + {"megagas", 5000000, "5.00 Mgas"}, + {"gigagas", 50000000000, "50.00 Ggas"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatGas(tt.gas) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatPercentage(t *testing.T) { + tests := []struct { + name string + ratio float64 + precision int + expected string + }{ + {"zero", 0.0, 1, "0.0%"}, + {"half", 0.5, 1, "50.0%"}, + {"full", 1.0, 1, "100.0%"}, + {"with precision", 0.856, 2, "85.60%"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatPercentage(tt.ratio, tt.precision) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + input string + expected time.Duration + wantErr bool + }{ + {"go format", "1h30m", 1*time.Hour + 30*time.Minute, false}, + {"seconds", "45s", 45 * time.Second, false}, + {"milliseconds", "500ms", 500 * time.Millisecond, false}, + {"numeric seconds", "60", 60 * time.Second, false}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDuration(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, result) + } + }) + } +} + +func TestParseGasLimit(t *testing.T) { + tests := []struct { + name string + input string + expected uint64 + wantErr bool + }{ + {"plain number", "50000000000", 50000000000, false}, + {"scientific", "50e9", 50000000000, false}, + {"with G suffix", "50G", 50000000000, false}, + {"with Ggas suffix", "50Ggas", 50000000000, false}, + {"with M suffix", "100M", 100000000, false}, + {"with K suffix", "500K", 500000, false}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseGasLimit(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, result) + } + }) + } +} + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + expected string + }{ + {"no truncation", "short", 10, "short"}, + {"truncation", "this is a long string", 10, "this is..."}, + {"exact length", "exact", 5, "exact"}, + {"very short max", "hello", 2, "he"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TruncateString(tt.input, tt.maxLen) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestTruncateMiddle(t *testing.T) { + tests := []struct { + name string + input string + startChars int + endChars int + expected string + }{ + {"hash truncation", "0x1234567890abcdef1234567890abcdef", 6, 4, "0x1234...cdef"}, + {"short string", "0x1234", 6, 4, "0x1234"}, + {"address", "0x9855054731540A48b28990B63DcF4f33d8AE46A1", 10, 8, "0x98550547...d8AE46A1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TruncateMiddle(tt.input, tt.startChars, tt.endChars) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestIsValidHexAddress(t *testing.T) { + tests := []struct { + name string + address string + expected bool + }{ + {"valid lowercase", "0x9855054731540a48b28990b63dcf4f33d8ae46a1", true}, + {"valid mixed case", "0x9855054731540A48b28990B63DcF4f33d8AE46A1", true}, + {"too short", "0x1234", false}, + {"too long", "0x9855054731540a48b28990b63dcf4f33d8ae46a1aa", false}, + {"missing 0x", "9855054731540a48b28990b63dcf4f33d8ae46a1", false}, + {"invalid chars", "0x9855054731540a48b28990b63dcf4f33d8ae46g1", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidHexAddress(tt.address) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestIsValidHexHash(t *testing.T) { + tests := []struct { + name string + hash string + expected bool + }{ + {"valid", "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", true}, + {"too short", "0x1234", false}, + {"address length", "0x9855054731540a48b28990b63dcf4f33d8ae46a1", false}, + {"missing 0x", "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidHexHash(tt.hash) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestCalculateStats(t *testing.T) { + t.Run("basic stats", func(t *testing.T) { + values := []float64{1, 2, 3, 4, 5} + mean, stdDev, min, max, median, err := CalculateStats(values) + require.NoError(t, err) + require.Equal(t, 3.0, mean) + require.Equal(t, 1.0, min) + require.Equal(t, 5.0, max) + require.Equal(t, 3.0, median) + require.InDelta(t, 1.414, stdDev, 0.01) + }) + + t.Run("single value", func(t *testing.T) { + values := []float64{42} + mean, stdDev, min, max, median, err := CalculateStats(values) + require.NoError(t, err) + require.Equal(t, 42.0, mean) + require.Equal(t, 42.0, min) + require.Equal(t, 42.0, max) + require.Equal(t, 42.0, median) + require.Equal(t, 0.0, stdDev) + }) + + t.Run("empty slice", func(t *testing.T) { + _, _, _, _, _, err := CalculateStats([]float64{}) + require.Error(t, err) + }) + + t.Run("even count median", func(t *testing.T) { + values := []float64{1, 2, 3, 4} + _, _, _, _, median, err := CalculateStats(values) + require.NoError(t, err) + require.Equal(t, 2.5, median) + }) +} + +func TestSafeDivide(t *testing.T) { + tests := []struct { + name string + numerator float64 + denominator float64 + expected float64 + }{ + {"normal division", 10, 2, 5}, + {"zero divisor", 10, 0, 0}, + {"zero numerator", 0, 5, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SafeDivide(tt.numerator, tt.denominator) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestSafeDivideUint64(t *testing.T) { + tests := []struct { + name string + numerator uint64 + denominator uint64 + expected uint64 + }{ + {"normal division", 10, 2, 5}, + {"zero divisor", 10, 0, 0}, + {"zero numerator", 0, 5, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SafeDivideUint64(tt.numerator, tt.denominator) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatTimestamp(t *testing.T) { + ts := time.Date(2025, 1, 6, 12, 30, 45, 0, time.UTC) + result := FormatTimestamp(ts) + require.Equal(t, "2025-01-06T12:30:45Z", result) +} + +func TestParseTimestamp(t *testing.T) { + tests := []struct { + name string + input string + expected time.Time + wantErr bool + }{ + {"RFC3339", "2025-01-06T12:30:45Z", time.Date(2025, 1, 6, 12, 30, 45, 0, time.UTC), false}, + {"date only", "2025-01-06", time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC), false}, + {"space separator", "2025-01-06 12:30:45", time.Date(2025, 1, 6, 12, 30, 45, 0, time.UTC), false}, + {"invalid", "not-a-date", time.Time{}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseTimestamp(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.True(t, tt.expected.Equal(result)) + } + }) + } +}