145 lines
4.1 KiB
Go
145 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|