diff --git a/number.go b/number.go index 389a8585d..c91d3daaf 100644 --- a/number.go +++ b/number.go @@ -273,6 +273,217 @@ func (n *Number) NotInDelta(value, delta float64) *Number { return n } +// InDeltaRelative succeeds if two numbers are within relative delta of each other. +// +// The relative delta is expressed as a decimal. For example, to determine if a number +// and a value are within 1% of each other, use 0.01. +// +// A number and a value are within relative delta if +// Abs(number-value) / Abs(number) < relative delta. +// +// Please note that number, value, and delta can't be NaN, number and value can't +// be opposite Inf and delta cannot be Inf. +// +// Example: +// +// number := NewNumber(t, 123.0) +// number.InDeltaRelative(126.5, 0.03) +func (n *Number) InDeltaRelative(value, delta float64) *Number { + opChain := n.chain.enter("InDeltaRelative()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + anyNumIsNaN := math.IsNaN(n.value) || math.IsNaN(value) || math.IsNaN(delta) + + if anyNumIsNaN { + assertionErrors := numNaNCheck(n.value, value, delta) + + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: assertionErrors, + }) + return n + } + + if math.IsInf(delta, 0) { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected Inf delta argument"), + }, + }) + return n + } + + if delta < 0 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected negative delta argument"), + }, + }) + return n + } + + // Pass if number and value are +-Inf and equal, + // regardless if delta is 0 or positive number + sameInfNumCheck := math.IsInf(n.value, 0) && math.IsInf(value, 0) && value == n.value + if sameInfNumCheck { + return n + } + + // Fail if number and value are +=Inf and unequal with specific error message + diffInfNumCheck := math.IsInf(n.value, 0) && math.IsInf(value, 0) && value != n.value + if diffInfNumCheck { + var assertionErrors []error + assertionErrors = append( + assertionErrors, + errors.New("expected: can compare values with relative delta"), + errors.New("actual value and expected value are opposite Infs"), + ) + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: assertionErrors, + }) + return n + } + + deltaRelativeError := deltaRelativeErrorCheck(true, n.value, value, delta) + if deltaRelativeError { + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: []error{ + errors.New("expected: numbers lie within relative delta"), + }, + }) + return n + } + + return n +} + +// NotInDeltaRelative succeeds if two numbers aren't within relative delta of each other. +// +// The relative delta is expressed as a decimal. For example, to determine if a number +// and a value are within 1% of each other, use 0.01. +// +// A number and a value are within relative delta if +// Abs(number-value) / Abs(number) < relative delta. +// +// Please note that number, value, and delta can't be NaN, number and value can't +// be opposite Inf and delta cannot be Inf. +// +// Example: +// +// number := NewNumber(t, 123.0) +// number.NotInDeltaRelative(126.5, 0.01) +func (n *Number) NotInDeltaRelative(value, delta float64) *Number { + opChain := n.chain.enter("NotInDeltaRelative()") + defer opChain.leave() + + if opChain.failed() { + return n + } + + anyNumIsNaN := math.IsNaN(n.value) || math.IsNaN(value) || math.IsNaN(delta) + + if anyNumIsNaN { + assertionErrors := numNaNCheck(n.value, value, delta) + + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: assertionErrors, + }) + return n + } + + if math.IsInf(delta, 0) { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected Inf delta argument"), + }, + }) + return n + } + + if delta < 0 { + opChain.fail(AssertionFailure{ + Type: AssertUsage, + Errors: []error{ + errors.New("unexpected negative delta argument"), + }, + }) + return n + } + + // Fail if number and value are +-Inf and equal, + // regardless if delta is 0 or positive number + sameInfNumCheck := math.IsInf(n.value, 0) && math.IsInf(value, 0) && value == n.value + if sameInfNumCheck { + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: []error{ + errors.New("expected: numbers lie within relative delta"), + }, + }) + return n + } + + // Fail is number and value are +=Inf and unequal with specific error message + diffInfNumCheck := math.IsInf(n.value, 0) && math.IsInf(value, 0) && value != n.value + if diffInfNumCheck { + var assertionErrors []error + assertionErrors = append( + assertionErrors, + errors.New("expected: can compare values with relative delta"), + errors.New("actual value and expected value are opposite Infs"), + ) + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: assertionErrors, + }) + return n + } + + deltaRelativeError := deltaRelativeErrorCheck(false, n.value, value, delta) + if deltaRelativeError { + opChain.fail(AssertionFailure{ + Type: AssertEqual, + Actual: &AssertionValue{n.value}, + Expected: &AssertionValue{value}, + Delta: &AssertionValue{relativeDelta(delta)}, + Errors: []error{ + errors.New("expected: numbers lie within relative delta"), + }, + }) + return n + } + + return n +} + // Deprecated: use InDelta instead. func (n *Number) EqualDelta(value, delta float64) *Number { return n.InDelta(value, delta) @@ -1091,3 +1302,63 @@ func (b intBoundary) String() string { } return fmt.Sprintf("%s", b.val) } + +type relativeDelta float64 + +func (rd relativeDelta) String() string { + return fmt.Sprintf("%v (%.f%%)", float64(rd), rd*100) +} + +func appendError(errorSlice []error, errorMsg string) []error { + return append( + errorSlice, + errors.New(errorMsg), + ) +} + +func deltaRelativeErrorCheck(inDeltaRelative bool, number, value, delta float64) bool { + if (number == 0 || math.IsInf(number, 0)) && value != number { + return true + } + if math.Abs(number-value)/math.Abs(number) > delta { + if inDeltaRelative { + return true + } + } else { + if !(inDeltaRelative) { + return true + } + } + return false +} + +func numNaNCheck(number, value, delta float64) []error { + var assertionErrors []error + assertionErrors = appendError( + assertionErrors, + "expected: can compare values with relative delta", + ) + + if math.IsNaN(number) { + assertionErrors = appendError( + assertionErrors, + "actual value is NaN", + ) + } + + if math.IsNaN(value) { + assertionErrors = appendError( + assertionErrors, + "expected value is NaN", + ) + } + + if math.IsNaN(delta) { + assertionErrors = appendError( + assertionErrors, + "delta is NaN", + ) + } + + return assertionErrors +} diff --git a/number_test.go b/number_test.go index 8778f14b9..b09c057eb 100644 --- a/number_test.go +++ b/number_test.go @@ -24,6 +24,8 @@ func TestNumber_FailedChain(t *testing.T) { value.NotEqual(0) value.InDelta(0, 0) value.NotInDelta(0, 0) + value.InDeltaRelative(0, 0) + value.NotInDeltaRelative(0, 0) value.InRange(0, 0) value.NotInRange(0, 0) value.InList(0) @@ -317,6 +319,166 @@ func TestNumber_InDelta(t *testing.T) { } } +func TestNumber_InDeltaRelative(t *testing.T) { + cases := []struct { + name string + number float64 + value float64 + delta float64 + wantInDelta chainResult + wantNotInDelta chainResult + }{ + { + name: "larger value in delta range", + number: 1234.5, + value: 1271.5, + delta: 0.03, + wantInDelta: success, + wantNotInDelta: failure, + }, + { + name: "smaller value in delta range", + number: 1234.5, + value: 1221.1, + delta: 0.03, + wantInDelta: success, + wantNotInDelta: failure, + }, + { + name: "larger value not in delta range", + number: 1234.5, + value: 1259.1, + delta: 0.01, + wantInDelta: failure, + wantNotInDelta: success, + }, + { + name: "smaller value not in delta range", + number: 1234.5, + value: 1209.8, + delta: 0.01, + wantInDelta: failure, + wantNotInDelta: success, + }, + { + name: "delta is negative", + number: 1234.5, + value: 1234.0, + delta: -0.01, + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "target is NaN", + number: math.NaN(), + value: 1234.0, + delta: 0.01, + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "value is NaN", + number: 1234.5, + value: math.NaN(), + delta: 0.01, + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "delta is NaN", + number: 1234.5, + value: 1234.0, + delta: math.NaN(), + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "delta is +Inf", + number: 1234.5, + value: 1234.0, + delta: math.Inf(1), + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "+Inf target", + number: math.Inf(1), + value: 1234.0, + delta: 0, + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "-Inf value", + number: 1234.5, + value: math.Inf(-1), + delta: 0.01, + wantInDelta: failure, + wantNotInDelta: success, + }, + { + name: "+Inf number and target with 0 delta", + number: math.Inf(1), + value: math.Inf(1), + delta: 0, + wantInDelta: success, + wantNotInDelta: failure, + }, + { + name: "-Inf number and target with 0 delta", + number: math.Inf(-1), + value: math.Inf(-1), + delta: 0, + wantInDelta: success, + wantNotInDelta: failure, + }, + { + name: "+Inf number and -Inf target", + number: math.Inf(1), + value: math.Inf(-1), + delta: 0, + wantInDelta: failure, + wantNotInDelta: failure, + }, + { + name: "target is 0 in delta range", + number: 0, + value: 0, + delta: 0, + wantInDelta: success, + wantNotInDelta: failure, + }, + { + name: "value is 0 in delta range", + number: 0.05, + value: 0, + delta: 1.0, + wantInDelta: success, + wantNotInDelta: failure, + }, + { + name: "value is 0 not in delta range", + number: 0.01, + value: 0, + delta: 0.01, + wantInDelta: failure, + wantNotInDelta: success, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + reporter := newMockReporter(t) + + NewNumber(reporter, tc.number).InDeltaRelative(tc.value, tc.delta). + chain.assert(t, tc.wantInDelta) + + NewNumber(reporter, tc.number).NotInDeltaRelative(tc.value, tc.delta). + chain.assert(t, tc.wantNotInDelta) + }) + } +} + func TestNumber_InRange(t *testing.T) { t.Run("basic", func(t *testing.T) { cases := []struct {