package money import ( "errors" "math" "testing" ) // 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 { in string want Cents }{ {"149.95", Cents(14995)}, {"-149.95", Cents(-14995)}, {"0", Cents(0)}, {"0.00", Cents(0)}, {"0.05", Cents(5)}, {"0.5", Cents(50)}, {".5", Cents(50)}, {"-0.05", Cents(-5)}, {"149", Cents(14900)}, {"149.", Cents(14900)}, {"+12.34", Cents(1234)}, } for _, c := range cases { got, err := ParseCents(c.in) if err != nil { t.Errorf("ParseCents(%q): unexpected error %v", c.in, err) continue } if 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 // prevent. Acceptance of "." and other leading-dot forms is locked in // 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", "1,95", "1.234", "1.2.3", "1.2.34", "--1", "++1", "+-1", "-+1", ".-", "1.-0", } for _, in := range cases { _, err := ParseCents(in) if err == nil { t.Errorf("ParseCents(%q): expected error, got nil", in) continue } if !errors.Is(err, ErrParse) { t.Errorf("ParseCents(%q): error %v should wrap ErrParse", in, err) } } } // 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 { in float64 want Cents }{ {0.0, Cents(0)}, {0.05, Cents(5)}, {0.055, Cents(6)}, // half-up: 0.055 * 100 = 5.5 → 6 {-0.055, Cents(-6)}, {1.99, Cents(199)}, {-1.99, Cents(-199)}, } for _, c := range cases { got := FromFloatMajor(c.in) if got != c.want { t.Errorf("FromFloatMajor(%v) = %d; want %d", c.in, got, c.want) } } } // TestMul_QuantityIsInteger asserts that the explicit Mul(n) exists so // callers don't write `price * Cents(qty)` (which literally reads as // "money squared", a nonsense unit). This is a behaviour test on the // method's existence and correctness, not a type-level test. func TestMul_QuantityIsInteger(t *testing.T) { price := Cents(14995) // 149.95 got := price.Mul(3) if got != Cents(44985) { t.Errorf("14995 * 3 = %d; want 44985", got) } }