diff --git a/crhumanize/float.go b/crhumanize/float.go index 796753b..86675de 100644 --- a/crhumanize/float.go +++ b/crhumanize/float.go @@ -15,10 +15,13 @@ package crhumanize import ( + "math" "strconv" "strings" ) +// Float formats the given float with the specified number of decimal digits. +// Trailing 0 decimals are stripped. func Float(value float64, decimalDigits int) SafeString { s := strconv.FormatFloat(value, 'f', decimalDigits, 64) s = stripTrailingZeroDecimals(s) @@ -37,3 +40,29 @@ func stripTrailingZeroDecimals(s string) string { } return s } + +// Percent formats (numerator/denominator) as a percentage. At most one decimal +// digit is used (only when the integer part is a single digit). If denominator +// is 0, returns the empty string. +// +// Values very close to 0 are formatted as ~0% to indicate that the value is +// non-zero. +// +// Examples: "0.2%", "12%". +func Percent[T Numeric](numerator, denominator T) SafeString { + if denominator == 0 { + return "" + } + if numerator == 0 { + return "0%" + } + value := (float64(numerator) / float64(denominator)) * 100 + if math.Abs(value) < 0.05 { + return "~0%" + } + decimalDigits := 0 + if math.Abs(value) < 9.95 { + decimalDigits = 1 + } + return Float(value, decimalDigits) + "%" +} diff --git a/crhumanize/float_test.go b/crhumanize/float_test.go index 2a2b6ba..9c6be74 100644 --- a/crhumanize/float_test.go +++ b/crhumanize/float_test.go @@ -33,6 +33,7 @@ func TestFloat(t *testing.T) { {0.01, 1, "0"}, {0.01, 2, "0.01"}, {0.01, 4, "0.01"}, + {-1.23456789, 2, "-1.23"}, {1.23456789, 2, "1.23"}, {1.23456789, 3, "1.235"}, {1.23456789, 3, "1.235"}, @@ -40,7 +41,9 @@ func TestFloat(t *testing.T) { {123456.7777, 2, "123456.78"}, {123456.1010, 4, "123456.101"}, {123456.1010, 2, "123456.1"}, + {-123456.1010, 1, "-123456.1"}, {123456.1010, 1, "123456.1"}, + {-123456.1010, 0, "-123456"}, {123456.1010, 0, "123456"}, } @@ -52,6 +55,34 @@ func TestFloat(t *testing.T) { } } +func TestPercent(t *testing.T) { + tests := []struct { + a, b float64 + expected string + }{ + {a: 0, b: 0, expected: ""}, + {a: 0, b: 100, expected: "0%"}, + {a: 0.0001, b: 100.0, expected: "~0%"}, + {a: 0.044, b: 100, expected: "~0%"}, + {a: 0.05, b: 100, expected: "0.1%"}, + {a: 0.1234, b: 100.0, expected: "0.1%"}, + {a: -0.1234, b: 100.0, expected: "-0.1%"}, + {a: 0.05, b: 100.0, expected: "0.1%"}, + {a: 9.95, b: 100.0, expected: "10%"}, + {a: 9.94, b: 100.0, expected: "9.9%"}, + {a: -9.95, b: 100.0, expected: "-10%"}, + {a: -9.94, b: 100.0, expected: "-9.9%"}, + {a: 10.52345, b: 100.0, expected: "11%"}, + } + + for _, test := range tests { + result := string(Percent(test.a, test.b)) + if result != test.expected { + t.Errorf("Percent(%f,%f) = %s; expected %s", test.a, test.b, result, test.expected) + } + } +} + func ExampleFloat() { fmt.Println(Float(100.1234, 3)) fmt.Println(Float(100.12, 3)) @@ -63,3 +94,17 @@ func ExampleFloat() { // 100.1 // 100 } + +func ExamplePercent() { + fmt.Println(Percent(uint64(0), uint64(10000))) + fmt.Println(Percent(uint64(1), uint64(10000))) + fmt.Println(Percent(uint64(12), uint64(10000))) + fmt.Println(Percent(uint64(123), uint64(10000))) + fmt.Println(Percent(uint64(1234), uint64(10000))) + // Output: + // 0% + // ~0% + // 0.1% + // 1.2% + // 12% +} diff --git a/crhumanize/humanize.go b/crhumanize/humanize.go index 64d0df1..760b1e8 100644 --- a/crhumanize/humanize.go +++ b/crhumanize/humanize.go @@ -76,6 +76,11 @@ type Integer interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr } +// Numeric is a constraint that permits any integer or floating-point type. +type Numeric interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 +} + // SafeString represents a human readable representation of a value. It // implements a `SafeValue()` marker method (implementing the // github.com/cockroachdb/redact.SafeValue interface) to signal that it