Files
purchases-ios/Tests/RulesEngineInternalTests/ArithmeticOperatorsTests.swift
T
Antonio PallaresandCursor e32933780b Arithmetic: drop divide-/modulo-by-zero short-circuit
The previous implementation intercepted `n / 0` and `n % 0` and returned
`.null`, on the rationale that `null` is friendlier for rule authors and
matches the engine's "missing value" convention. That's the last
remaining intentional deviation from `json-logic-js` in this file —
remove it for full spec parity.

`json-logic-js` delegates `/` and `%` to native JS arithmetic, which
follows IEEE 754: `n / 0` is `±Infinity` (sign matches the dividend),
`0 / 0` is `NaN`, and any `n % 0` is `NaN`. Swift's `Double` operators
and `truncatingRemainder(dividingBy:)` already produce these IEEE
values, so the fix is just dropping the divisor==0 short-circuit and
wrapping the result in `.float`.

Tests:
- Replace `testDivByZeroReturnsNull` with `testDivByZeroFollowsIeee754`
  pinning all three cases (`+Infinity`, `-Infinity`, `NaN`).
- Replace `testModByZeroReturnsNull` with `testModByZeroIsNan` covering
  `7 % 0` and `0 % 0`.
- Update the `1 / null` / `1 % null` cases inside
  `testSubDivAndModUseToNumberPerSpec` (divisor coerces to 0 →
  `+Infinity` / `NaN` instead of `.null`).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 10:27:32 +02:00

378 lines
13 KiB
Swift

//
// ArithmeticOperatorsTests.swift
//
// Created by Antonio Pallares.
//
import XCTest
@testable import RulesEngineInternal
// swiftlint:disable:next type_body_length
final class ArithmeticOperatorsTests: XCTestCase {
// MARK: - +
func testAddSumsTwoInts() throws {
XCTAssertEqual(
try run(ArithmeticOperators.opAdd, args: arr(.int(1), .int(2))),
.float(3.0)
)
}
func testAddIsVariadic() throws {
XCTAssertEqual(
try run(
ArithmeticOperators.opAdd,
args: arr(.int(1), .int(2), .int(3), .int(4))
),
.float(10.0)
)
}
func testAddOneArgActsAsNumericCast() throws {
// {"+": ["2.5"]} → 2.5
XCTAssertEqual(
try run(ArithmeticOperators.opAdd, args: arr(.string("2.5"))),
.float(2.5)
)
// `{"+": [true]}` → NaN. JS uses `parseFloat(value)`, and
// `parseFloat("true")` is `NaN` — bool coercion through arithmetic
// is *not* the same as `Number(true) === 1`.
let boolResult = try run(ArithmeticOperators.opAdd, args: arr(.bool(true)))
XCTAssertTrue(unwrapFloat(boolResult).isNaN)
}
func testAddCoercesNumericStrings() throws {
// "1" + 1 → 2 — `parseFloat("1")` is 1.
XCTAssertEqual(
try run(ArithmeticOperators.opAdd, args: arr(.string("1"), .int(1))),
.float(2.0)
)
// "3.14abc" + 0 → 3.14 — `parseFloat` parses the longest numeric
// prefix, so trailing junk doesn't poison the result. This is one
// of the visible side-effects of `+` using `parseFloat` instead
// of `Number()`.
XCTAssertEqual(
try run(ArithmeticOperators.opAdd, args: arr(.string("3.14abc"), .int(0))),
.float(3.14)
)
}
func testAddNonNumericPropagatesNan() throws {
// Object + 1 → NaN (object can't coerce, propagates)
let result = try run(
ArithmeticOperators.opAdd,
args: arr(.object([:]), .int(1))
)
XCTAssertTrue(unwrapFloat(result).isNaN)
}
func testAddZeroArgsIsTypeError() {
XCTAssertThrowsError(try run(ArithmeticOperators.opAdd, args: arr())) { error in
guard case RuleError.typeMismatch = error else {
return XCTFail("expected typeMismatch, got \(error)")
}
}
}
// MARK: - *
func testMulMultipliesArgs() throws {
XCTAssertEqual(
try run(
ArithmeticOperators.opMul,
args: arr(.int(2), .int(3), .int(4))
),
.float(24.0)
)
}
func testMulOneArgReturnsValueAsFloat() throws {
XCTAssertEqual(
try run(ArithmeticOperators.opMul, args: arr(.int(5))),
.float(5.0)
)
}
func testMulCoercesOperands() throws {
// "2" * "3" → 6
XCTAssertEqual(
try run(
ArithmeticOperators.opMul,
args: arr(.string("2"), .string("3"))
),
.float(6.0)
)
}
func testMulZeroArgsIsTypeError() {
XCTAssertThrowsError(try run(ArithmeticOperators.opMul, args: arr())) { error in
guard case RuleError.typeMismatch = error else {
return XCTFail("expected typeMismatch, got \(error)")
}
}
}
// MARK: - -
func testSubUnaryNegates() throws {
// {"-": [3]} → -3
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(.int(3))),
.float(-3.0)
)
// Negating a string-encoded number still works
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(.string("2.5"))),
.float(-2.5)
)
}
func testSubBinarySubtracts() throws {
XCTAssertEqual(
try run(
ArithmeticOperators.opSub,
args: arr(.int(10), .int(3))
),
.float(7.0)
)
}
func testSubThreeOrMoreArgsIsTypeError() {
XCTAssertThrowsError(
try run(ArithmeticOperators.opSub, args: arr(.int(1), .int(2), .int(3)))
) { error in
guard case RuleError.typeMismatch = error else {
return XCTFail("expected typeMismatch, got \(error)")
}
}
}
func testSubZeroArgsIsTypeError() {
XCTAssertThrowsError(try run(ArithmeticOperators.opSub, args: arr())) { error in
guard case RuleError.typeMismatch = error else {
return XCTFail("expected typeMismatch, got \(error)")
}
}
}
// MARK: - /
func testDivBasic() throws {
XCTAssertEqual(
try run(
ArithmeticOperators.opDiv,
args: arr(.int(10), .int(2))
),
.float(5.0)
)
}
func testDivCoercesOperands() throws {
XCTAssertEqual(
try run(
ArithmeticOperators.opDiv,
args: arr(.string("9"), .string("3"))
),
.float(3.0)
)
}
/// `n / 0` follows IEEE 754: positive dividend → `+Infinity`, negative
/// dividend → `-Infinity`, `0 / 0` → `NaN`. Matches `json-logic-js`,
/// which delegates to native JS `/` (no short-circuit).
func testDivByZeroFollowsIeee754() throws {
// 1 / 0 → +Infinity (covers both .int(0) and .float(0.0) divisors).
let posIntZero = unwrapFloat(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .int(0)))
)
XCTAssertEqual(posIntZero, .infinity)
let posFloatZero = unwrapFloat(
try run(ArithmeticOperators.opDiv, args: arr(.int(1), .float(0.0)))
)
XCTAssertEqual(posFloatZero, .infinity)
// -1 / 0 → -Infinity.
let neg = unwrapFloat(
try run(ArithmeticOperators.opDiv, args: arr(.int(-1), .int(0)))
)
XCTAssertEqual(neg, -.infinity)
// 0 / 0 → NaN.
assertNaN(try run(ArithmeticOperators.opDiv, args: arr(.int(0), .int(0))))
}
func testDivWrongArityIsTypeError() {
XCTAssertThrowsError(try run(ArithmeticOperators.opDiv, args: arr(.int(1)))) { error in
guard case RuleError.typeMismatch = error else {
return XCTFail("expected typeMismatch, got \(error)")
}
}
}
// MARK: - %
func testModBasic() throws {
XCTAssertEqual(
try run(
ArithmeticOperators.opMod,
args: arr(.int(10), .int(3))
),
.float(1.0)
)
}
/// `n % 0` is always `NaN` per IEEE 754. Matches `json-logic-js`,
/// which delegates to native JS `%` (no short-circuit).
func testModByZeroIsNan() throws {
assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(7), .int(0))))
assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(0), .int(0))))
}
func testModWrongArityIsTypeError() {
XCTAssertThrowsError(
try run(ArithmeticOperators.opMod, args: arr(.int(1), .int(2), .int(3)))
) { error in
guard case RuleError.typeMismatch = error else {
return XCTFail("expected typeMismatch, got \(error)")
}
}
}
// MARK: - Coercion semantics (`+`/`*` use parseFloat, others use Number)
/// `+` and `*` coerce every operand through JS `parseFloat(value)`.
/// `parseFloat` first calls `String(value)`, then parses the longest
/// numeric prefix — so `null` becomes the string `"null"` (→ NaN),
/// bools become `"true"` / `"false"` (→ NaN), the empty string
/// becomes the empty string (→ NaN), and `[1,2]` becomes `"1,2"`
/// (parses as `1`). This is asymmetric with `-` / `/` / `%`, which
/// use `Number(value)` (see `testSubDivAndModUseToNumberPerSpec`).
func testAddAndMulUseParseFloatPerSpec() throws {
// null + 1 → NaN (parseFloat("null") is NaN)
assertNaN(try run(ArithmeticOperators.opAdd, args: arr(.null, .int(1))))
// null * 1 → NaN
assertNaN(try run(ArithmeticOperators.opMul, args: arr(.null, .int(1))))
// true + 1 → NaN, false + 1 → NaN. Bools never bridge through
// `+` / `*` even though `Number(true) === 1`.
assertNaN(try run(ArithmeticOperators.opAdd, args: arr(.bool(true), .int(1))))
assertNaN(try run(ArithmeticOperators.opAdd, args: arr(.bool(false), .int(1))))
// "" + 1 → NaN (parseFloat("") is NaN, unlike Number("") === 0).
assertNaN(try run(ArithmeticOperators.opAdd, args: arr(.string(""), .int(1))))
// [1] + 1 → 2 — array stringifies to "1", parseFloat → 1.
XCTAssertEqual(
try run(ArithmeticOperators.opAdd, args: arr(arr(.int(1)), .int(1))),
.float(2.0)
)
// [1, 2] + 0 → 1 — array stringifies to "1,2", parseFloat parses
// the leading "1" prefix and stops at the comma.
XCTAssertEqual(
try run(ArithmeticOperators.opAdd, args: arr(arr(.int(1), .int(2)), .int(0))),
.float(1.0)
)
// {} + 1 → NaN — objects stringify to "[object Object]".
assertNaN(try run(ArithmeticOperators.opAdd, args: arr(.object([:]), .int(1))))
}
/// `-`, `/`, `%` delegate to native JS arithmetic, which calls
/// `Number(value)` on each operand. `Number()` is stricter about
/// numeric strings (`"3.14abc"` → NaN) but more permissive about
/// `null` / bools / empty strings (all → 0 or 1) than `parseFloat`.
/// Arrays / objects coerce via `ToPrimitive("number")` → `toString`
/// → recurse, so `[]` → 0, `[1]` → 1, `[1,2]` → NaN.
func testSubDivAndModUseToNumberPerSpec() throws {
// null is 0 across all three ops.
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(.null, .int(1))),
.float(-1.0)
)
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(.null)),
.float(0.0) // unary; -0.0 == 0.0
)
XCTAssertEqual(
try run(ArithmeticOperators.opDiv, args: arr(.null, .int(1))),
.float(0.0)
)
XCTAssertEqual(
try run(ArithmeticOperators.opMod, args: arr(.null, .int(1))),
.float(0.0)
)
// 1 / null → divisor coerces to 0 → +Infinity (IEEE 754, see
// testDivByZeroFollowsIeee754 for the broader pinning).
XCTAssertEqual(
unwrapFloat(try run(ArithmeticOperators.opDiv, args: arr(.int(1), .null))),
.infinity
)
// 1 % null → divisor coerces to 0 → NaN.
assertNaN(try run(ArithmeticOperators.opMod, args: arr(.int(1), .null)))
// Bools coerce to 0 / 1 (Number(true) === 1, Number(false) === 0).
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(.bool(true), .bool(false))),
.float(1.0)
)
// Empty string coerces to 0.
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(.string(""), .int(1))),
.float(-1.0)
)
// [] - 1 → -1 (toString → "" → 0).
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(arr(), .int(1))),
.float(-1.0)
)
// [1] - 1 → 0 (toString → "1" → 1).
XCTAssertEqual(
try run(ArithmeticOperators.opSub, args: arr(arr(.int(1)), .int(1))),
.float(0.0)
)
// [1, 2] - 0 → NaN (toString → "1,2" → NaN: whole-string parse
// fails because of the comma).
assertNaN(try run(ArithmeticOperators.opSub, args: arr(arr(.int(1), .int(2)), .int(0))))
// {} - 0 → NaN (toString → "[object Object]" → NaN).
assertNaN(try run(ArithmeticOperators.opSub, args: arr(.object([:]), .int(0))))
}
// MARK: - Helpers
private typealias Operation = (Value, Value) throws -> Value
private func run(_ operation: Operation, args: Value) throws -> Value {
try operation(args, .null)
}
private func arr(_ items: Value...) -> Value {
.array(items)
}
private func unwrapFloat(_ value: Value) -> Double {
guard case .float(let double) = value else {
XCTFail("expected .float, got \(value)")
return .nan
}
return double
}
private func assertNaN(
_ expression: @autoclosure () throws -> Value,
file: StaticString = #file,
line: UInt = #line
) {
do {
let value = try expression()
guard case .float(let double) = value else {
return XCTFail("expected .float(NaN), got \(value)", file: file, line: line)
}
XCTAssertTrue(double.isNaN, "expected NaN, got \(double)", file: file, line: line)
} catch {
XCTFail("threw \(error)", file: file, line: line)
}
}
}