diff --git a/Sources/FixedPointDecimal/FixedPointDecimal+Arithmetic.swift b/Sources/FixedPointDecimal/FixedPointDecimal+Arithmetic.swift index 71c8223..24095f9 100644 --- a/Sources/FixedPointDecimal/FixedPointDecimal+Arithmetic.swift +++ b/Sources/FixedPointDecimal/FixedPointDecimal+Arithmetic.swift @@ -28,8 +28,8 @@ extension FixedPointDecimal { extension FixedPointDecimal { /// Returns the sum of two values. /// - /// If either operand is NaN, the result is NaN. - /// Traps on overflow, matching Swift `Int` behavior. + /// Traps if either operand is NaN or if the result overflows, + /// matching Swift `Int` behavior. /// /// ```swift /// let a: FixedPointDecimal = "10.5" @@ -41,10 +41,11 @@ extension FixedPointDecimal { /// - lhs: The first addend. /// - rhs: The second addend. /// - Returns: The sum of `lhs` and `rhs`. + /// - Precondition: Neither operand may be NaN. /// - Precondition: The result must fit in `Int64` after scaling. @inlinable public static func + (lhs: Self, rhs: Self) -> Self { - if lhs.isNaN || rhs.isNaN { return .nan } + precondition(!lhs.isNaN && !rhs.isNaN, "NaN in FixedPointDecimal addition") let (result, overflow) = lhs._storage.addingReportingOverflow(rhs._storage) precondition(!overflow, "FixedPointDecimal addition overflow") precondition(result != .min, "FixedPointDecimal addition produced NaN sentinel") @@ -53,7 +54,7 @@ extension FixedPointDecimal { /// Adds the right-hand value to the left-hand value in place. /// - /// Traps on overflow. Propagates NaN. + /// Traps on overflow or NaN. /// /// ```swift /// var total: FixedPointDecimal = "100.0" @@ -71,8 +72,8 @@ extension FixedPointDecimal { /// Returns the difference of two values. /// - /// If either operand is NaN, the result is NaN. - /// Traps on overflow, matching Swift `Int` behavior. + /// Traps if either operand is NaN or if the result overflows, + /// matching Swift `Int` behavior. /// /// ```swift /// let a: FixedPointDecimal = "10.5" @@ -84,10 +85,11 @@ extension FixedPointDecimal { /// - lhs: The minuend. /// - rhs: The subtrahend. /// - Returns: The difference of `lhs` and `rhs`. + /// - Precondition: Neither operand may be NaN. /// - Precondition: The result must fit in `Int64` after scaling. @inlinable public static func - (lhs: Self, rhs: Self) -> Self { - if lhs.isNaN || rhs.isNaN { return .nan } + precondition(!lhs.isNaN && !rhs.isNaN, "NaN in FixedPointDecimal subtraction") let (result, overflow) = lhs._storage.subtractingReportingOverflow(rhs._storage) precondition(!overflow, "FixedPointDecimal subtraction overflow") precondition(result != .min, "FixedPointDecimal subtraction produced NaN sentinel") @@ -96,7 +98,7 @@ extension FixedPointDecimal { /// Subtracts the right-hand value from the left-hand value in place. /// - /// Traps on overflow. Propagates NaN. + /// Traps on overflow or NaN. /// /// ```swift /// var balance: FixedPointDecimal = "100.0" @@ -117,7 +119,7 @@ extension FixedPointDecimal { /// Uses `Int128` intermediate arithmetic to prevent precision loss during /// the multiply-then-divide-by-scale-factor operation. The result is /// rounded using banker's rounding (round half to even). If either operand - /// is NaN, the result is NaN. Traps if the final result does not fit in + /// Traps if either operand is NaN or if the final result does not fit in /// `Int64`. /// /// ```swift @@ -130,10 +132,11 @@ extension FixedPointDecimal { /// - lhs: The first factor. /// - rhs: The second factor. /// - Returns: The product of `lhs` and `rhs`. + /// - Precondition: Neither operand may be NaN. /// - Precondition: The result must fit in `Int64` after scaling. @inlinable public static func * (lhs: Self, rhs: Self) -> Self { - if lhs.isNaN || rhs.isNaN { return .nan } + precondition(!lhs.isNaN && !rhs.isNaN, "NaN in FixedPointDecimal multiplication") let wide = Int128(lhs._storage) * Int128(rhs._storage) let scaled = _bankersDiv(wide, Int128(scaleFactor)) precondition(scaled > Int128(Int64.min) && scaled <= Int128(Int64.max), @@ -143,7 +146,7 @@ extension FixedPointDecimal { /// Multiplies the left-hand value by the right-hand value in place. /// - /// Traps on overflow. Propagates NaN. + /// Traps on overflow or NaN. /// /// - Parameters: /// - lhs: The value to modify. @@ -157,7 +160,7 @@ extension FixedPointDecimal { /// /// Uses `Int128` intermediate arithmetic for precision. The result is /// rounded using banker's rounding (round half to even). If either operand - /// is NaN, the result is NaN. Traps on division by zero or if the result + /// Traps if either operand is NaN, on division by zero, or if the result /// does not fit in `Int64`. /// /// ```swift @@ -170,11 +173,12 @@ extension FixedPointDecimal { /// - lhs: The dividend. /// - rhs: The divisor. /// - Returns: The quotient of `lhs` divided by `rhs`. + /// - Precondition: Neither operand may be NaN. /// - Precondition: `rhs` must not be zero. /// - Precondition: The result must fit in `Int64` after scaling. @inlinable public static func / (lhs: Self, rhs: Self) -> Self { - if lhs.isNaN || rhs.isNaN { return .nan } + precondition(!lhs.isNaN && !rhs.isNaN, "NaN in FixedPointDecimal division") precondition(rhs._storage != 0, "Division by zero") let wide = Int128(lhs._storage) * Int128(scaleFactor) let result = _bankersDiv(wide, Int128(rhs._storage)) @@ -185,7 +189,7 @@ extension FixedPointDecimal { /// Divides the left-hand value by the right-hand value in place. /// - /// Traps on division by zero or overflow. Propagates NaN. + /// Traps on division by zero, overflow, or NaN. /// /// - Parameters: /// - lhs: The value to modify. @@ -212,7 +216,7 @@ extension FixedPointDecimal { /// Returns the remainder of dividing the first value by the second. /// /// The sign of the result matches the sign of the dividend (`lhs`). - /// If either operand is NaN, the result is NaN. + /// Traps if either operand is NaN. /// /// ```swift /// let a: FixedPointDecimal = "10.0" @@ -224,17 +228,18 @@ extension FixedPointDecimal { /// - lhs: The dividend. /// - rhs: The divisor. /// - Returns: The remainder of `lhs` divided by `rhs`. + /// - Precondition: Neither operand may be NaN. /// - Precondition: `rhs` must not be zero. @inlinable public static func % (lhs: Self, rhs: Self) -> Self { - if lhs.isNaN || rhs.isNaN { return .nan } + precondition(!lhs.isNaN && !rhs.isNaN, "NaN in FixedPointDecimal remainder") precondition(rhs._storage != 0, "Division by zero in remainder") return Self(rawValue: lhs._storage % rhs._storage) } /// Divides the left-hand value by the right-hand value and stores the remainder in place. /// - /// Propagates NaN. + /// Traps on NaN. /// /// - Parameters: /// - lhs: The value to modify. diff --git a/Sources/FixedPointDecimal/FixedPointDecimal+Numeric.swift b/Sources/FixedPointDecimal/FixedPointDecimal+Numeric.swift index 81d7217..f89d787 100644 --- a/Sources/FixedPointDecimal/FixedPointDecimal+Numeric.swift +++ b/Sources/FixedPointDecimal/FixedPointDecimal+Numeric.swift @@ -25,16 +25,16 @@ extension FixedPointDecimal { /// The absolute value of this instance. /// - /// Returns NaN for NaN, matching `Double.nan.magnitude` behavior. + /// Traps on NaN. /// /// ```swift /// let v = FixedPointDecimal("-5.0")! /// v.magnitude // 5.0 - /// FixedPointDecimal.nan.magnitude.isNaN // true /// ``` + /// - Precondition: The value must not be NaN. @inlinable public var magnitude: Magnitude { - if isNaN { return .nan } + precondition(!isNaN, "magnitude called on NaN") return FixedPointDecimal(rawValue: abs(_storage)) } @@ -63,35 +63,35 @@ extension FixedPointDecimal { /// Returns the additive inverse of this value. /// - /// Returns NaN if the operand is NaN, matching the behavior of all - /// arithmetic operators on this type. + /// Traps if the operand is NaN. /// /// ```swift /// let price = FixedPointDecimal("42.5")! /// let neg = -price // -42.5 - /// (-FixedPointDecimal.nan).isNaN // true /// ``` /// /// - Parameter operand: The value to negate. - /// - Returns: The negated value, or NaN if the operand is NaN. + /// - Returns: The negated value. + /// - Precondition: The operand must not be NaN. @inlinable public prefix static func - (operand: Self) -> Self { - if operand.isNaN { return .nan } + precondition(!operand.isNaN, "NaN in FixedPointDecimal negation") return Self(rawValue: -operand._storage) } /// Replaces this value with its additive inverse. /// - /// If the value is NaN, this is a no-op (NaN is preserved). + /// Traps if the value is NaN. /// /// ```swift /// var price = FixedPointDecimal("42.5")! /// price.negate() /// // price is now -42.5 /// ``` + /// - Precondition: The value must not be NaN. @inlinable public mutating func negate() { - if isNaN { return } + precondition(!isNaN, "NaN in FixedPointDecimal negation") _storage = -_storage } } diff --git a/Sources/FixedPointDecimal/FixedPointDecimal+Overflow.swift b/Sources/FixedPointDecimal/FixedPointDecimal+Overflow.swift index 5effbfa..d963951 100644 --- a/Sources/FixedPointDecimal/FixedPointDecimal+Overflow.swift +++ b/Sources/FixedPointDecimal/FixedPointDecimal+Overflow.swift @@ -71,7 +71,7 @@ extension FixedPointDecimal { /// Returns the sum of this value and the given value, along with a Boolean /// indicating whether overflow occurred in the operation. /// - /// If either operand is NaN, the result is `(.nan, false)`. + /// Traps if either operand is NaN. /// /// ```swift /// let a: FixedPointDecimal = "50.0" @@ -82,9 +82,10 @@ extension FixedPointDecimal { /// /// - Parameter other: The value to add. /// - Returns: A tuple containing the partial sum and a Boolean overflow flag. + /// - Precondition: Neither operand may be NaN. @inlinable public func addingReportingOverflow(_ other: Self) -> (partialValue: Self, overflow: Bool) { - if isNaN || other.isNaN { return (.nan, false) } + precondition(!isNaN && !other.isNaN, "NaN in FixedPointDecimal addition") let (result, overflow) = _storage.addingReportingOverflow(other._storage) return (Self(rawValue: result), overflow || result == .min) } @@ -92,7 +93,7 @@ extension FixedPointDecimal { /// Returns the difference of this value and the given value, along with a /// Boolean indicating whether overflow occurred in the operation. /// - /// If either operand is NaN, the result is `(.nan, false)`. + /// Traps if either operand is NaN. /// /// ```swift /// let a: FixedPointDecimal = "50.0" @@ -103,9 +104,10 @@ extension FixedPointDecimal { /// /// - Parameter other: The value to subtract. /// - Returns: A tuple containing the partial difference and a Boolean overflow flag. + /// - Precondition: Neither operand may be NaN. @inlinable public func subtractingReportingOverflow(_ other: Self) -> (partialValue: Self, overflow: Bool) { - if isNaN || other.isNaN { return (.nan, false) } + precondition(!isNaN && !other.isNaN, "NaN in FixedPointDecimal subtraction") let (result, overflow) = _storage.subtractingReportingOverflow(other._storage) return (Self(rawValue: result), overflow || result == .min) } @@ -114,7 +116,7 @@ extension FixedPointDecimal { /// indicating whether overflow occurred in the operation. /// /// Uses `Int128` intermediate arithmetic. Overflow is reported when the scaled - /// result exceeds `Int64` range. If either operand is NaN, the result is `(.nan, false)`. + /// result exceeds `Int64` range. Traps if either operand is NaN. /// /// ```swift /// let a: FixedPointDecimal = "10.0" @@ -125,9 +127,10 @@ extension FixedPointDecimal { /// /// - Parameter other: The value to multiply by. /// - Returns: A tuple containing the partial product and a Boolean overflow flag. + /// - Precondition: Neither operand may be NaN. @inlinable public func multipliedReportingOverflow(by other: Self) -> (partialValue: Self, overflow: Bool) { - if isNaN || other.isNaN { return (.nan, false) } + precondition(!isNaN && !other.isNaN, "NaN in FixedPointDecimal multiplication") let wide = Int128(_storage) * Int128(other._storage) let scaled = Self._bankersDiv(wide, Int128(Self.scaleFactor)) let fits = scaled > Int128(Int64.min) && scaled <= Int128(Int64.max) @@ -139,7 +142,7 @@ extension FixedPointDecimal { /// a Boolean indicating whether overflow occurred in the operation. /// /// Overflow is reported for division by zero or when the result exceeds - /// `Int64` range. If either operand is NaN, the result is `(.nan, false)`. + /// `Int64` range. Traps if either operand is NaN. /// /// ```swift /// let a: FixedPointDecimal = "100.0" @@ -153,9 +156,10 @@ extension FixedPointDecimal { /// /// - Parameter other: The value to divide by. /// - Returns: A tuple containing the partial quotient and a Boolean overflow flag. + /// - Precondition: Neither operand may be NaN. @inlinable public func dividedReportingOverflow(by other: Self) -> (partialValue: Self, overflow: Bool) { - if isNaN || other.isNaN { return (.nan, false) } + precondition(!isNaN && !other.isNaN, "NaN in FixedPointDecimal division") guard other._storage != 0 else { return (.zero, true) } diff --git a/Sources/FixedPointDecimal/FixedPointDecimal+Rounding.swift b/Sources/FixedPointDecimal/FixedPointDecimal+Rounding.swift index 27876fb..f1806e2 100644 --- a/Sources/FixedPointDecimal/FixedPointDecimal+Rounding.swift +++ b/Sources/FixedPointDecimal/FixedPointDecimal+Rounding.swift @@ -28,7 +28,7 @@ extension FixedPointDecimal { /// Returns this value rounded to the specified number of fractional decimal digits. /// - /// Returns NaN unchanged. + /// Traps on NaN. /// /// ```swift /// let price = FixedPointDecimal("123.456789")! @@ -42,12 +42,13 @@ extension FixedPointDecimal { /// - scale: The number of fractional digits to keep (0...8). Default is 0. /// - mode: The rounding mode. Default is `.toNearestOrEven`. /// - Returns: The rounded value. + /// - Precondition: The value must not be NaN. /// - Precondition: `scale` must be in `0...8`. @inlinable public func rounded(scale: Int = 0, _ mode: RoundingMode = .toNearestOrEven) -> Self { precondition(scale >= 0 && scale <= Self.fractionalDigitCount, "Scale must be in 0...\(Self.fractionalDigitCount)") - if isNaN { return .nan } + precondition(!isNaN, "NaN in FixedPointDecimal rounding") guard scale < Self.fractionalDigitCount else { return self } let divisor = Self._powerOf10(Self.fractionalDigitCount - scale) @@ -174,7 +175,7 @@ extension FixedPointDecimal { /// Returns the absolute value of a `FixedPointDecimal`. /// -/// Returns NaN unchanged. +/// Traps on NaN. /// /// ```swift /// let v: FixedPointDecimal = "-42.5" @@ -183,8 +184,9 @@ extension FixedPointDecimal { /// /// - Parameter value: The value whose absolute value is returned. /// - Returns: The absolute value of `value`. +/// - Precondition: The value must not be NaN. @inlinable public func abs(_ value: FixedPointDecimal) -> FixedPointDecimal { - if value.isNaN { return .nan } + precondition(!value.isNaN, "abs called on NaN") return FixedPointDecimal(rawValue: abs(value._storage)) } diff --git a/Sources/FixedPointDecimal/FixedPointDecimal+SwiftUI.swift b/Sources/FixedPointDecimal/FixedPointDecimal+SwiftUI.swift index 2249c25..8f2af89 100644 --- a/Sources/FixedPointDecimal/FixedPointDecimal+SwiftUI.swift +++ b/Sources/FixedPointDecimal/FixedPointDecimal+SwiftUI.swift @@ -18,12 +18,13 @@ extension FixedPointDecimal: VectorArithmetic { /// Scales the raw value by a `Double` factor. Used by SwiftUI's animation system. /// /// The result is clamped to the valid range (`Int64.min + 1 ... Int64.max`) - /// to avoid producing the NaN sentinel. NaN values are left unchanged. + /// to avoid producing the NaN sentinel. Traps on NaN. /// /// - Parameter rhs: The scaling factor. + /// - Precondition: The value must not be NaN. @inlinable public mutating func scale(by rhs: Double) { - if isNaN { return } + precondition(!isNaN, "NaN in FixedPointDecimal scale") let scaled = Double(rawValue) * rhs if scaled >= Double(Int64.max) { self = Self(rawValue: .max) @@ -38,10 +39,10 @@ extension FixedPointDecimal: VectorArithmetic { /// The squared magnitude of the raw storage, used by SwiftUI for /// animation interpolation. /// - /// Returns `Double.nan` for NaN values. + /// - Precondition: The value must not be NaN. @inlinable public var magnitudeSquared: Double { - if isNaN { return .nan } + precondition(!isNaN, "magnitudeSquared called on NaN") let d = Double(rawValue) return d * d } diff --git a/Sources/FixedPointDecimal/FixedPointDecimal.swift b/Sources/FixedPointDecimal/FixedPointDecimal.swift index 5f54856..21db149 100644 --- a/Sources/FixedPointDecimal/FixedPointDecimal.swift +++ b/Sources/FixedPointDecimal/FixedPointDecimal.swift @@ -200,7 +200,6 @@ public struct FixedPointDecimal: Sendable, BitwiseCopyable { /// ```swift /// let missing: FixedPointDecimal = .nan /// missing.isNaN // true - /// (missing + FixedPointDecimal(1)).isNaN // true (NaN propagates) /// ``` @inlinable public static var nan: FixedPointDecimal { @@ -279,29 +278,31 @@ public struct FixedPointDecimal: Sendable, BitwiseCopyable { /// Unlike `Double`, where ULP varies across the range, `FixedPointDecimal` /// has uniform precision — the distance between any two adjacent /// representable values is always `0.00000001`. - /// Returns NaN for NaN. + /// - Precondition: The value must not be NaN. @inlinable public var ulp: FixedPointDecimal { - if isNaN { return .nan } + precondition(!isNaN, "ulp called on NaN") return .leastNonzeroMagnitude } /// The least representable value greater than this one. /// - /// Returns NaN for NaN. Traps on `.max` (no representable value above). + /// - Precondition: The value must not be NaN. + /// - Precondition: The value must be less than `.max`. @inlinable public var nextUp: FixedPointDecimal { - if isNaN { return .nan } + precondition(!isNaN, "nextUp called on NaN") precondition(self < .max, "nextUp called on .max") return FixedPointDecimal(rawValue: _storage + 1) } /// The greatest representable value less than this one. /// - /// Returns NaN for NaN. Traps on `.min` (no representable value below). + /// - Precondition: The value must not be NaN. + /// - Precondition: The value must be greater than `.min`. @inlinable public var nextDown: FixedPointDecimal { - if isNaN { return .nan } + precondition(!isNaN, "nextDown called on NaN") precondition(self > .min, "nextDown called on .min") return FixedPointDecimal(rawValue: _storage - 1) } @@ -410,7 +411,7 @@ public struct FixedPointDecimal: Sendable, BitwiseCopyable { /// - Returns: `x` raised to the power `n`. @inlinable public static func pow(_ x: FixedPointDecimal, _ n: Int) -> FixedPointDecimal { - guard !x.isNaN else { return .nan } + precondition(!x.isNaN, "NaN in FixedPointDecimal pow") if n == 0 { return FixedPointDecimal(rawValue: scaleFactor) } // 1 if n == 1 { return x } @@ -421,24 +422,20 @@ public struct FixedPointDecimal: Sendable, BitwiseCopyable { if shift >= 0, shift < _pow10Table.count { return FixedPointDecimal(rawValue: _pow10Table[shift]) } - return shift < 0 ? .zero : .nan + precondition(shift < 0, "FixedPointDecimal pow overflow") + return .zero } if n < 0 { let positive = pow(x, -n) - if positive.isNaN || positive == .zero { return .nan } - let (result, overflow) = FixedPointDecimal(rawValue: scaleFactor) - .dividedReportingOverflow(by: positive) - return overflow ? .nan : result + precondition(positive != .zero, "Division by zero in FixedPointDecimal pow") + return FixedPointDecimal(rawValue: scaleFactor) / positive } - // Repeated multiplication with overflow detection. - // Uses multipliedReportingOverflow to return .nan instead of trapping. + // Repeated multiplication — traps on overflow via `*`. var result = x for _ in 1 ..< n { - let (product, overflow) = result.multipliedReportingOverflow(by: x) - if overflow { return .nan } - result = product + result = result * x } return result } diff --git a/Tests/FixedPointDecimalTests/ArithmeticTests.swift b/Tests/FixedPointDecimalTests/ArithmeticTests.swift index 9635913..c117517 100644 --- a/Tests/FixedPointDecimalTests/ArithmeticTests.swift +++ b/Tests/FixedPointDecimalTests/ArithmeticTests.swift @@ -303,12 +303,11 @@ struct ArithmeticTests { #expect(result == FixedPointDecimal.min) } - @Test("NaN / Int64 returns NaN (guards against Int64.min / -1 overflow)") - func nanDivByInt64NegOne() { - // NaN sentinel is Int64.min; dividing Int64.min by -1 would overflow - // but the NaN check catches it first - let result = FixedPointDecimal.nan / (-1) - #expect(result.isNaN) + @Test("NaN / Int64 traps (guards against Int64.min / -1 overflow)") + func nanDivByInt64NegOne() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal.nan / (-1) + } } // MARK: - Remainder Edge Cases @@ -353,24 +352,21 @@ struct ArithmeticTests { #expect(a == 1.3 as FixedPointDecimal) } - // MARK: - NaN Propagation in Mixed-Type Arithmetic + // MARK: - NaN Trapping in Mixed-Type Arithmetic - @Test("NaN * Int64 propagates NaN") - func nanTimesInt64() { - let nan = FixedPointDecimal.nan - #expect((nan * 5).isNaN) + @Test("NaN * Int64 traps") + func nanTimesInt64() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan * 5 } } - @Test("Int64 * NaN propagates NaN") - func int64TimesNaN() { - let nan = FixedPointDecimal.nan - #expect((5 * nan).isNaN) + @Test("Int64 * NaN traps") + func int64TimesNaN() async { + await #expect(processExitsWith: .failure) { _ = 5 * FixedPointDecimal.nan } } - @Test("NaN / Int64 propagates NaN") - func nanDividedByInt64() { - let nan = FixedPointDecimal.nan - #expect((nan / 5).isNaN) + @Test("NaN / Int64 traps") + func nanDividedByInt64() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan / 5 } } // MARK: - Iterator Aggregation (Sum and Product) diff --git a/Tests/FixedPointDecimalTests/BasicTests.swift b/Tests/FixedPointDecimalTests/BasicTests.swift index 720b9be..e76ca91 100644 --- a/Tests/FixedPointDecimalTests/BasicTests.swift +++ b/Tests/FixedPointDecimalTests/BasicTests.swift @@ -89,6 +89,26 @@ struct BasicTests { #expect(FixedPointDecimal.min.rawValue == Int64.min + 1) #expect(FixedPointDecimal.leastNonzeroMagnitude.rawValue == 1) #expect(FixedPointDecimal.greatestFiniteMagnitude.rawValue == Int64.max) + #expect(FixedPointDecimal.leastFiniteMagnitude == .min) + } + + @Test("isFinite returns true for non-NaN, false for NaN") + func isFinite() { + #expect(FixedPointDecimal.zero.isFinite) + #expect(FixedPointDecimal(42).isFinite) + #expect(FixedPointDecimal(-1).isFinite) + #expect(FixedPointDecimal.max.isFinite) + #expect(FixedPointDecimal.min.isFinite) + #expect(!FixedPointDecimal.nan.isFinite) + } + + @Test("sign returns .plus for positive/zero/NaN, .minus for negative") + func sign() { + #expect(FixedPointDecimal(42).sign == .plus) + #expect(FixedPointDecimal.zero.sign == .plus) + #expect(FixedPointDecimal(-42).sign == .minus) + #expect(FixedPointDecimal.min.sign == .minus) + #expect(FixedPointDecimal.nan.sign == .plus) } @Test("Integer part and fractional part") diff --git a/Tests/FixedPointDecimalTests/ConversionTests.swift b/Tests/FixedPointDecimalTests/ConversionTests.swift index e81672a..88dd572 100644 --- a/Tests/FixedPointDecimalTests/ConversionTests.swift +++ b/Tests/FixedPointDecimalTests/ConversionTests.swift @@ -207,9 +207,9 @@ struct ConversionTests { #expect(value.magnitude == 42.5 as FixedPointDecimal) } - @Test("Numeric magnitude for NaN is NaN") - func magnitudeNaN() { - #expect(FixedPointDecimal.nan.magnitude.isNaN) + @Test("Numeric magnitude for NaN traps") + func magnitudeNaN() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan.magnitude } } @Test("Numeric magnitude for zero") @@ -537,6 +537,99 @@ struct ConversionTests { #expect(result == nil) } + // MARK: - Concrete Int/Int64/Int32 init?(exactly: FixedPointDecimal) + + @Test("Int(exactly:) succeeds for exact integers") + func intExactlySucceeds() { + #expect(Int(exactly: FixedPointDecimal(42)) == 42) + #expect(Int(exactly: FixedPointDecimal(0)) == 0) + #expect(Int(exactly: FixedPointDecimal(-99)) == -99) + } + + @Test("Int(exactly:) returns nil for fractional values") + func intExactlyFractional() { + #expect(Int(exactly: FixedPointDecimal(42.5)) == nil) + #expect(Int(exactly: FixedPointDecimal(0.00000001)) == nil) + } + + @Test("Int(exactly:) returns nil for NaN") + func intExactlyNaN() { + #expect(Int(exactly: FixedPointDecimal.nan) == nil) + } + + @Test("Int64(exactly:) succeeds for exact integers") + func int64ExactlySucceeds() { + #expect(Int64(exactly: FixedPointDecimal(42)) == 42) + #expect(Int64(exactly: FixedPointDecimal(0)) == 0) + #expect(Int64(exactly: FixedPointDecimal(-99)) == -99) + } + + @Test("Int64(exactly:) returns nil for fractional values") + func int64ExactlyFractional() { + #expect(Int64(exactly: FixedPointDecimal(42.5)) == nil) + #expect(Int64(exactly: FixedPointDecimal(0.00000001)) == nil) + } + + @Test("Int64(exactly:) returns nil for NaN") + func int64ExactlyNaN() { + #expect(Int64(exactly: FixedPointDecimal.nan) == nil) + } + + @Test("Int32(exactly:) succeeds for in-range integers") + func int32ExactlySucceeds() { + #expect(Int32(exactly: FixedPointDecimal(1000)) == 1000) + #expect(Int32(exactly: FixedPointDecimal(-1000)) == -1000) + } + + @Test("Int32(exactly:) returns nil for fractional values") + func int32ExactlyFractional() { + #expect(Int32(exactly: FixedPointDecimal(1.5)) == nil) + } + + @Test("Int32(exactly:) returns nil for out-of-range integers") + func int32ExactlyOutOfRange() { + #expect(Int32(exactly: FixedPointDecimal(Int64(Int32.max) + 1)) == nil) + } + + @Test("Int32(exactly:) returns nil for NaN") + func int32ExactlyNaN() { + #expect(Int32(exactly: FixedPointDecimal.nan) == nil) + } + + // MARK: - FixedWidthInteger generic init?(exactly: FixedPointDecimal) + + @Test("Int16(exactly:) returns nil for NaN") + func int16ExactlyNaN() { + #expect(Int16(exactly: FixedPointDecimal.nan) == nil) + } + + @Test("UInt64(exactly:) succeeds for non-negative integers") + func uint64ExactlySucceeds() { + #expect(UInt64(exactly: FixedPointDecimal(42)) == 42) + #expect(UInt64(exactly: FixedPointDecimal(0)) == 0) + } + + @Test("UInt64(exactly:) returns nil for negative values") + func uint64ExactlyNegative() { + #expect(UInt64(exactly: FixedPointDecimal(-1)) == nil) + } + + @Test("UInt64(exactly:) returns nil for fractional values") + func uint64ExactlyFractional() { + #expect(UInt64(exactly: FixedPointDecimal(1.5)) == nil) + } + + @Test("UInt64(exactly:) returns nil for NaN") + func uint64ExactlyNaN() { + #expect(UInt64(exactly: FixedPointDecimal.nan) == nil) + } + + @Test("UInt16(exactly:) returns nil for out-of-range values") + func uint16ExactlyOutOfRange() { + #expect(UInt16(exactly: FixedPointDecimal(Int64(UInt16.max) + 1)) == nil) + #expect(UInt16(exactly: FixedPointDecimal(-1)) == nil) + } + @Test("FPD negation matches Decimal negation") func negationParityWithDecimal() { let values = ["0", "1", "-1", "123.45", "-99.99", "0.00000001"] diff --git a/Tests/FixedPointDecimalTests/EdgeCaseTests.swift b/Tests/FixedPointDecimalTests/EdgeCaseTests.swift index 5b2db4b..83c13c5 100644 --- a/Tests/FixedPointDecimalTests/EdgeCaseTests.swift +++ b/Tests/FixedPointDecimalTests/EdgeCaseTests.swift @@ -25,18 +25,44 @@ struct EdgeCaseTests { #expect(!(nan < nan)) // nan == nan, so not less than } - @Test("NaN propagates through arithmetic") - func nanPropagation() { - let nan = FixedPointDecimal.nan - let value: FixedPointDecimal = 42 - #expect((nan + value).isNaN) - #expect((value + nan).isNaN) - #expect((nan - value).isNaN) - #expect((nan * value).isNaN) - #expect((nan / value).isNaN) - #expect((value / nan).isNaN) - #expect((nan % value).isNaN) - #expect((value % nan).isNaN) + @Test("NaN + value traps") + func nanAddTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan + 42 } + } + + @Test("value + NaN traps") + func valueAddNanTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal(42) + .nan } + } + + @Test("NaN - value traps") + func nanSubTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan - 42 } + } + + @Test("NaN * value traps") + func nanMulTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan * 42 } + } + + @Test("NaN / value traps") + func nanDivTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan / 42 } + } + + @Test("value / NaN traps") + func valueDivNanTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal(42) / .nan } + } + + @Test("NaN % value traps") + func nanModTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan % 42 } + } + + @Test("value % NaN traps") + func valueModNanTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal(42) % .nan } } @Test("NaN double value") @@ -190,43 +216,60 @@ struct EdgeCaseTests { // MARK: - NaN with Remainder - @Test("NaN % value = NaN") - func nanRemainder() { - let value: FixedPointDecimal = 3 - #expect((FixedPointDecimal.nan % value).isNaN) - #expect((value % FixedPointDecimal.nan).isNaN) + @Test("NaN % value traps (remainder)") + func nanRemainder() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan % 3 } + } + + @Test("value % NaN traps (remainder)") + func valueRemainderNan() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal(3) % .nan } } // MARK: - NaN Negation - @Test("Negation of NaN returns NaN") - func negateNaN() { - let result = -FixedPointDecimal.nan - #expect(result.isNaN) + @Test("Negation of NaN traps") + func negateNaN() async { + await #expect(processExitsWith: .failure) { _ = -FixedPointDecimal.nan } } - @Test("Mutating negate of NaN preserves NaN") - func mutatingNegateNaN() { - var value = FixedPointDecimal.nan - value.negate() - #expect(value.isNaN) + @Test("Mutating negate of NaN traps") + func mutatingNegateNaN() async { + await #expect(processExitsWith: .failure) { + var nan = FixedPointDecimal.nan + nan.negate() + } + } + + // MARK: - NaN Properties + + @Test("ulp on NaN traps") + func ulpNaN() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan.ulp } + } + + @Test("nextUp on NaN traps") + func nextUpNaN() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan.nextUp } + } + + @Test("nextDown on NaN traps") + func nextDownNaN() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan.nextDown } } // MARK: - NaN Rounding - @Test("NaN rounded returns NaN at each scale") - func nanRoundedAllScales() { - for scale in 0...8 { - #expect(FixedPointDecimal.nan.rounded(scale: scale).isNaN, - "NaN.rounded(scale: \(scale)) should be NaN") - } + @Test("NaN rounded traps") + func nanRoundedTraps() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan.rounded(scale: 0) } } // MARK: - NaN absoluteValue - @Test("NaN absoluteValue returns NaN") - func nanAbsoluteValue() { - #expect(abs(FixedPointDecimal.nan).isNaN) + @Test("NaN absoluteValue traps") + func nanAbsoluteValue() async { + await #expect(processExitsWith: .failure) { _ = abs(FixedPointDecimal.nan) } } // MARK: - Distance & Advance Edge Cases @@ -409,11 +452,12 @@ struct EdgeCaseTests { #expect(value == -42.5 as FixedPointDecimal) } - @Test("VectorArithmetic scale NaN is no-op") - func vectorScaleNaN() { - var value = FixedPointDecimal.nan - value.scale(by: 2.0) - #expect(value.isNaN) + @Test("VectorArithmetic scale NaN traps") + func vectorScaleNaN() async { + await #expect(processExitsWith: .failure) { + var nan = FixedPointDecimal.nan + nan.scale(by: 2.0) + } } @Test("VectorArithmetic scale clamps to max") @@ -438,9 +482,9 @@ struct EdgeCaseTests { #expect(value.magnitudeSquared == expected) } - @Test("VectorArithmetic magnitudeSquared of NaN") - func vectorMagnitudeSquaredNaN() { - #expect(FixedPointDecimal.nan.magnitudeSquared.isNaN) + @Test("VectorArithmetic magnitudeSquared of NaN traps") + func vectorMagnitudeSquaredNaN() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.nan.magnitudeSquared } } #endif diff --git a/Tests/FixedPointDecimalTests/OverflowTests.swift b/Tests/FixedPointDecimalTests/OverflowTests.swift index 4c8deb5..2976d80 100644 --- a/Tests/FixedPointDecimalTests/OverflowTests.swift +++ b/Tests/FixedPointDecimalTests/OverflowTests.swift @@ -102,44 +102,39 @@ struct OverflowTests { // MARK: - NaN with Overflow-Reporting Operators - @Test("NaN addingReportingOverflow returns NaN, no overflow") - func nanAddReporting() { - let nan = FixedPointDecimal.nan - let value: FixedPointDecimal = 42 - let (result1, overflow1) = nan.addingReportingOverflow(value) - #expect(result1.isNaN) - #expect(!overflow1) - - let (result2, overflow2) = value.addingReportingOverflow(nan) - #expect(result2.isNaN) - #expect(!overflow2) - } - - @Test("NaN subtractingReportingOverflow returns NaN, no overflow") - func nanSubReporting() { - let nan = FixedPointDecimal.nan - let value: FixedPointDecimal = 42 - let (result, overflow) = nan.subtractingReportingOverflow(value) - #expect(result.isNaN) - #expect(!overflow) + @Test("NaN addingReportingOverflow traps (lhs)") + func nanAddReportingLhs() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal.nan.addingReportingOverflow(FixedPointDecimal(42)) + } } - @Test("NaN multipliedReportingOverflow returns NaN, no overflow") - func nanMulReporting() { - let nan = FixedPointDecimal.nan - let value: FixedPointDecimal = 42 - let (result, overflow) = nan.multipliedReportingOverflow(by: value) - #expect(result.isNaN) - #expect(!overflow) + @Test("NaN addingReportingOverflow traps (rhs)") + func nanAddReportingRhs() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal(42).addingReportingOverflow(.nan) + } } - @Test("NaN dividedReportingOverflow returns NaN, no overflow") - func nanDivReporting() { - let nan = FixedPointDecimal.nan - let value: FixedPointDecimal = 42 - let (result, overflow) = nan.dividedReportingOverflow(by: value) - #expect(result.isNaN) - #expect(!overflow) + @Test("NaN subtractingReportingOverflow traps") + func nanSubReporting() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal.nan.subtractingReportingOverflow(FixedPointDecimal(42)) + } + } + + @Test("NaN multipliedReportingOverflow traps") + func nanMulReporting() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal.nan.multipliedReportingOverflow(by: FixedPointDecimal(42)) + } + } + + @Test("NaN dividedReportingOverflow traps") + func nanDivReporting() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal.nan.dividedReportingOverflow(by: FixedPointDecimal(42)) + } } // MARK: - Overflow near boundary diff --git a/Tests/FixedPointDecimalTests/PowTests.swift b/Tests/FixedPointDecimalTests/PowTests.swift index 3dfc9d3..6f0dc26 100644 --- a/Tests/FixedPointDecimalTests/PowTests.swift +++ b/Tests/FixedPointDecimalTests/PowTests.swift @@ -35,10 +35,10 @@ struct PowTests { #expect(FixedPointDecimal.pow(1, -100) == 1) } - @Test("pow NaN propagates") - func powNaN() { - #expect(FixedPointDecimal.pow(.nan, 2).isNaN) - #expect(FixedPointDecimal.pow(.nan, 0).isNaN) + @Test("pow NaN traps") + func powNaN() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(.nan, 2) } + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(.nan, 0) } } @Test("pow zero base") @@ -46,7 +46,11 @@ struct PowTests { #expect(FixedPointDecimal.pow(0, 1) == 0) #expect(FixedPointDecimal.pow(0, 5) == 0) #expect(FixedPointDecimal.pow(0, 0) == 1) // 0^0 = 1 by convention - #expect(FixedPointDecimal.pow(0, -1).isNaN) // 1/0 = NaN + } + + @Test("pow zero base negative exponent traps") + func powZeroBaseNegativeExponent() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(0, -1) } } @Test("pow type inference in context") @@ -75,25 +79,23 @@ struct PowTests { #expect(FixedPointDecimal.pow(FixedPointDecimal(-2.0), 3) == -8.0) } - @Test("pow overflow returns NaN") - func powOverflow() { + @Test("pow overflow traps") + func powOverflow() async { // Int64 max raw value is ~9.2×10^18, so 10^11 × 10^8 (scale) overflows - #expect(FixedPointDecimal.pow(10, 11).isNaN) + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(10, 11) } // Large base to large power - #expect(FixedPointDecimal.pow(FixedPointDecimal(1000000), 4).isNaN) + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(FixedPointDecimal(1000000), 4) } // Negative exponent where positive overflows - #expect(FixedPointDecimal.pow(FixedPointDecimal(1000000), -4).isNaN) + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(FixedPointDecimal(1000000), -4) } } @Test("pow overflow boundary — largest non-overflowing power of 10") func powOverflowBoundary() { // 10^10 = 10_000_000_000 — rawValue = 10^18, fits in Int64 let p10 = FixedPointDecimal.pow(10, 10) - #expect(!p10.isNaN) #expect(p10 == 10000000000 as FixedPointDecimal) - // 10^11 would be rawValue = 10^19, overflows Int64 - #expect(FixedPointDecimal.pow(10, 11).isNaN) + // 10^11 would be rawValue = 10^19, overflows Int64 — tested in powOverflow } // MARK: - numberOfFractionalDigits @@ -222,10 +224,9 @@ struct PowTests { #expect(FixedPointDecimal.pow(ten, -9) == .zero) } - @Test("pow(10, 11) overflows returns NaN") - func pow10Overflow() { - let ten: FixedPointDecimal = 10 - #expect(FixedPointDecimal.pow(ten, 11).isNaN) + @Test("pow(10, 11) overflows traps") + func pow10Overflow() async { + await #expect(processExitsWith: .failure) { _ = FixedPointDecimal.pow(10, 11) } } @Test("pow(10, n) fast path matches general path for all valid exponents") diff --git a/Tests/FixedPointDecimalTests/PreconditionTests.swift b/Tests/FixedPointDecimalTests/PreconditionTests.swift index 3370ecf..7dbcddc 100644 --- a/Tests/FixedPointDecimalTests/PreconditionTests.swift +++ b/Tests/FixedPointDecimalTests/PreconditionTests.swift @@ -35,6 +35,20 @@ struct PreconditionTests { } } + @Test("init(significand:exponent:) traps on out-of-range shift") + func initSignificandExponentOutOfRange() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal(significand: 1, exponent: 100) + } + } + + @Test("init(significand:exponent:) traps on overflow") + func initSignificandExponentOverflow() async { + await #expect(processExitsWith: .failure) { + _ = FixedPointDecimal(significand: Int(Int64.max), exponent: 1) + } + } + @Test("init(_ Double) traps on Double.nan") func initDoubleNaN() async { await #expect(processExitsWith: .failure) { diff --git a/Tests/FixedPointDecimalTests/PropertyTests.swift b/Tests/FixedPointDecimalTests/PropertyTests.swift index 079e8a6..9b79fd7 100644 --- a/Tests/FixedPointDecimalTests/PropertyTests.swift +++ b/Tests/FixedPointDecimalTests/PropertyTests.swift @@ -330,13 +330,11 @@ struct PropertyTests { // Test addition let (addResult, addOvf) = a.addingReportingOverflow(b) if !addOvf { - #expect(!addResult.isNaN || a.isNaN || b.isNaN, + #expect(!addResult.isNaN, "Non-overflow add produced NaN for \(a)+\(b)") - if !a.isNaN && !b.isNaN { - let decSum = Self.toDecimal(a) + Self.toDecimal(b) - #expect(Decimal(addResult) == decSum, - "Add overflow detection: \(a)+\(b)") - } + let decSum = Self.toDecimal(a) + Self.toDecimal(b) + #expect(Decimal(addResult) == decSum, + "Add overflow detection: \(a)+\(b)") } valid += 1 diff --git a/Tests/FixedPointDecimalTests/RoundingTests.swift b/Tests/FixedPointDecimalTests/RoundingTests.swift index 7269a18..5f1ec2d 100644 --- a/Tests/FixedPointDecimalTests/RoundingTests.swift +++ b/Tests/FixedPointDecimalTests/RoundingTests.swift @@ -114,9 +114,9 @@ struct RoundingTests { #expect(abs(FixedPointDecimal.zero) == .zero) } - @Test("Absolute value of NaN") - func absNaN() { - #expect(abs(FixedPointDecimal.nan).isNaN) + @Test("Absolute value of NaN traps") + func absNaN() async { + await #expect(processExitsWith: .failure) { _ = abs(FixedPointDecimal.nan) } } // MARK: - Rounding at Every Scale