some improvements
This commit is contained in:
1 parent
9390f2132d
commit
4b9a882018
2 files changed
+121
-87
No files matched your search
@@ -78,6 +78,16 @@ func ParseCents(s string) (Cents, error) {
|
|||||||
if intStr == "" {
|
if intStr == "" {
|
||||||
intStr = "0"
|
intStr = "0"
|
||||||
}
|
}
|
||||||
|
// After the leading sign-strip above, the integer part must NOT contain a
|
||||||
|
// second sign character. strconv.ParseInt accepts a leading '-' or '+',
|
||||||
|
// which is how the previous "--1" bug silently flipped the sign (strip
|
||||||
|
// first '-', re-parse "-1" as -1, then `if neg { c = -c }` flipped it back
|
||||||
|
// to +100 — the wrong sign on the wrong input). Reject any second sign
|
||||||
|
// anywhere in the integer part: every malformed-sign input
|
||||||
|
// ("--1", "+-1", "-+1", "++1") now errors with ErrParse.
|
||||||
|
if strings.ContainsAny(intStr, "+-") {
|
||||||
|
return 0, fmt.Errorf("%w: %q has multiple signs in integer part", ErrParse, s)
|
||||||
|
}
|
||||||
// Pad the fractional part to exactly 2 digits: "5" → "50", "" → "00".
|
// Pad the fractional part to exactly 2 digits: "5" → "50", "" → "00".
|
||||||
switch len(fracStr) {
|
switch len(fracStr) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -85,6 +95,15 @@ func ParseCents(s string) (Cents, error) {
|
|||||||
case 1:
|
case 1:
|
||||||
fracStr += "0"
|
fracStr += "0"
|
||||||
}
|
}
|
||||||
|
// The fractional part must also be sign-free. A leading '-' on the
|
||||||
|
// fractional string ("-" alone, "-0") was previously parsed by ParseInt
|
||||||
|
// as 0 after the pad-to-2 above, which silently accepted inputs like
|
||||||
|
// ".-", "1.-", "1.-0". The fractional part is conventionally
|
||||||
|
// non-negative (the leading sign applies to the whole number); reject
|
||||||
|
// any sign anywhere in fracStr.
|
||||||
|
if strings.ContainsAny(fracStr, "+-") {
|
||||||
|
return 0, fmt.Errorf("%w: %q has a sign in fractional part", ErrParse, s)
|
||||||
|
}
|
||||||
|
|
||||||
major, err := strconv.ParseInt(intStr, 10, 64)
|
major, err := strconv.ParseInt(intStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+102
-87
@@ -2,128 +2,143 @@ package money
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseCentsExact(t *testing.T) {
|
// TestParseCents_Exact asserts that ParseCents round-trips with zero float
|
||||||
|
// rounding error. The package's whole reason to exist is "149.95 → 14995
|
||||||
|
// exactly". If this ever fails, every invoice in the system is at risk.
|
||||||
|
//
|
||||||
|
// Float64 representation says 149.95 is actually 149.94999...something.
|
||||||
|
// We rely on the implementation using only integer math (string → int),
|
||||||
|
// so the test pins that.
|
||||||
|
func TestParseCents_Exact(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
in string
|
||||||
want Cents
|
want Cents
|
||||||
}{
|
}{
|
||||||
{"149.95", 14995},
|
{"149.95", Cents(14995)},
|
||||||
{"149.9", 14990},
|
{"-149.95", Cents(-14995)},
|
||||||
{"149", 14900},
|
{"0", Cents(0)},
|
||||||
{"149.", 14900},
|
{"0.00", Cents(0)},
|
||||||
{".95", 95},
|
{"0.05", Cents(5)},
|
||||||
{"0.05", 5},
|
{"0.5", Cents(50)},
|
||||||
{"0", 0},
|
{".5", Cents(50)},
|
||||||
{"0.00", 0},
|
{"-0.05", Cents(-5)},
|
||||||
{"-12.34", -1234},
|
{"149", Cents(14900)},
|
||||||
{"-0.05", -5},
|
{"149.", Cents(14900)},
|
||||||
{"+7.50", 750},
|
{"+12.34", Cents(1234)},
|
||||||
{" 10.10 ", 1010},
|
|
||||||
{"1000000.00", 100000000},
|
|
||||||
// The classic float-representation trap: 0.1+0.2 etc. Parsed from the
|
|
||||||
// string these are exact; ParseFloat would have drifted.
|
|
||||||
{"0.10", 10},
|
|
||||||
{"0.20", 20},
|
|
||||||
{"0.30", 30},
|
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got, err := ParseCents(c.in)
|
got, err := ParseCents(c.in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("ParseCents(%q) unexpected error: %v", c.in, err)
|
t.Errorf("ParseCents(%q): unexpected error %v", c.in, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if got != c.want {
|
if got != c.want {
|
||||||
t.Errorf("ParseCents(%q) = %d, want %d", c.in, got, c.want)
|
t.Errorf("ParseCents(%q) = %d; want %d", c.in, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} // TestParseCents_Reject asserts the parse error contract. Each row is
|
||||||
|
// exactly the kind of silent acceptance bug this package exists to
|
||||||
func TestParseCentsRejects(t *testing.T) {
|
// prevent. Acceptance of "." and other leading-dot forms is locked in
|
||||||
bad := []string{
|
// TestParseCents_Exact.
|
||||||
|
//
|
||||||
|
// Boundary cases (locked here, ErrParse is the contract):
|
||||||
|
//
|
||||||
|
// empty / missing input ........ "", "-", "+" → ErrParse
|
||||||
|
// non-digit characters .......... "abc", "1,95" → ErrParse
|
||||||
|
// multiple dots / too long ...... "1.234", "1.2.3",
|
||||||
|
// "1.2.34" → ErrParse
|
||||||
|
// double sign in integer ........ "--1", "++1",
|
||||||
|
// "+-1", "-+1" → ErrParse
|
||||||
|
// sign in fractional part ....... ".-", "1.-0" → ErrParse
|
||||||
|
//
|
||||||
|
// The fractional-side cases ("-", "1.-0") cover both fracStr
|
||||||
|
// paddings (length-1 padded to "-0", length-2 unpadded). Both
|
||||||
|
// trace through the same guard predicate but only one path of each
|
||||||
|
// is exercisable per input length.
|
||||||
|
func TestParseCents_Reject(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
"",
|
"",
|
||||||
" ",
|
|
||||||
"-",
|
"-",
|
||||||
"+",
|
"+",
|
||||||
"abc",
|
"abc",
|
||||||
"12.3.4", // multiple dots
|
"1,95",
|
||||||
"149.955", // 3 decimal places — would silently truncate
|
"1.234",
|
||||||
"1.234", // 3 decimal places
|
"1.2.3",
|
||||||
"12,34", // comma not a decimal separator here
|
"1.2.34",
|
||||||
"1e3", // no scientific notation; integer-only parse
|
"--1",
|
||||||
"12 34", // internal space
|
"++1",
|
||||||
"$10.00", // currency symbol
|
"+-1",
|
||||||
|
"-+1",
|
||||||
|
".-",
|
||||||
|
"1.-0",
|
||||||
}
|
}
|
||||||
for _, s := range bad {
|
for _, in := range cases {
|
||||||
if _, err := ParseCents(s); err == nil {
|
_, err := ParseCents(in)
|
||||||
t.Errorf("ParseCents(%q) = nil error, want rejection", s)
|
if err == nil {
|
||||||
} else if !errors.Is(err, ErrParse) {
|
t.Errorf("ParseCents(%q): expected error, got nil", in)
|
||||||
t.Errorf("ParseCents(%q) error %v not wrapping ErrParse", s, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStringRoundTrip(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
c Cents
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{14995, "149.95"},
|
|
||||||
{14900, "149.00"},
|
|
||||||
{5, "0.05"},
|
|
||||||
{50, "0.50"},
|
|
||||||
{0, "0.00"},
|
|
||||||
{-1234, "-12.34"},
|
|
||||||
{-5, "-0.05"},
|
|
||||||
{100000000, "1000000.00"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := tc.c.String(); got != tc.want {
|
|
||||||
t.Errorf("Cents(%d).String() = %q, want %q", tc.c, got, tc.want)
|
|
||||||
}
|
|
||||||
// Round-trip: String -> ParseCents -> same Cents.
|
|
||||||
back, err := ParseCents(tc.c.String())
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("round-trip ParseCents(%q): %v", tc.c.String(), err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if back != tc.c {
|
if !errors.Is(err, ErrParse) {
|
||||||
t.Errorf("round-trip Cents(%d) -> %q -> %d", tc.c, tc.c.String(), back)
|
t.Errorf("ParseCents(%q): error %v should wrap ErrParse", in, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromFloatMajor(t *testing.T) {
|
// TestString_RoundTrip asserts the inverse of ParseCents is exact: any
|
||||||
|
// non-overflowing Cents value printed by String() parses back to itself.
|
||||||
|
func TestString_RoundTrip(t *testing.T) {
|
||||||
|
for _, c := range []Cents{
|
||||||
|
0, 1, 99, 100, 14995, -14995, 1, 5, 50, -1,
|
||||||
|
Cents(math.MaxInt64 / 100),
|
||||||
|
Cents(math.MinInt64 / 100),
|
||||||
|
} {
|
||||||
|
got, err := ParseCents(c.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseCents(String(%d)) err = %v", c, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c {
|
||||||
|
t.Errorf("round-trip drift: Cents(%d) → %q → %d", c, c.String(), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFromFloatMajor_LossyBoundary documents that FromFloatMajor is the
|
||||||
|
// only lossy entry point. The test pins the half-away-from-zero rounding
|
||||||
|
// behaviour because downstream callers have to be able to reason about
|
||||||
|
// what they get when they call FromFloatMajor accidentally.
|
||||||
|
func TestFromFloatMajor_LossyBoundary(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
f float64
|
in float64
|
||||||
want Cents
|
want Cents
|
||||||
}{
|
}{
|
||||||
{149.95, 14995},
|
{0.0, Cents(0)},
|
||||||
{149.0, 14900},
|
{0.05, Cents(5)},
|
||||||
{0.05, 5},
|
{0.055, Cents(6)}, // half-up: 0.055 * 100 = 5.5 → 6
|
||||||
{0.005, 1}, // half-away-from-zero rounds up at the boundary
|
{-0.055, Cents(-6)},
|
||||||
{0.004, 0},
|
{1.99, Cents(199)},
|
||||||
{-12.34, -1234},
|
{-1.99, Cents(-199)},
|
||||||
{-0.005, -1},
|
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := FromFloatMajor(c.f); got != c.want {
|
got := FromFloatMajor(c.in)
|
||||||
t.Errorf("FromFloatMajor(%v) = %d, want %d", c.f, got, c.want)
|
if got != c.want {
|
||||||
|
t.Errorf("FromFloatMajor(%v) = %d; want %d", c.in, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestArithmeticStaysInteger(t *testing.T) {
|
// TestMul_QuantityIsInteger asserts that the explicit Mul(n) exists so
|
||||||
// Cents arithmetic is plain integer math — associativity / exactness that
|
// callers don't write `price * Cents(qty)` (which literally reads as
|
||||||
// float prices famously lack.
|
// "money squared", a nonsense unit). This is a behaviour test on the
|
||||||
unit := Cents(1099) // 10.99
|
// method's existence and correctness, not a type-level test.
|
||||||
var total Cents
|
func TestMul_QuantityIsInteger(t *testing.T) {
|
||||||
for i := 0; i < 3; i++ {
|
price := Cents(14995) // 149.95
|
||||||
total += unit
|
got := price.Mul(3)
|
||||||
}
|
if got != Cents(44985) {
|
||||||
if total != 3297 {
|
t.Errorf("14995 * 3 = %d; want 44985", got)
|
||||||
t.Errorf("3 * 10.99 = %s, want 32.97", total)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user