mathlex 0.3.3

Mathematical expression parser for LaTeX and plain text notation, producing a language-agnostic AST
Documentation
import Foundation
import XCTest

@testable import MathLex

/// Tests for the `toJSON()` and `toJSONPretty()` methods on `MathExpression`.
///
/// Each test verifies one or more of the following properties:
/// - The call succeeds without throwing
/// - The returned string is valid JSON (parseable by `JSONSerialization`)
/// - Key structural fields are present in the JSON object graph
/// - Pretty-printed output contains newlines
///
/// JSON shape mirrors the Rust serde representation, e.g.:
/// - `{"Integer":42}`
/// - `{"Variable":"x"}`
/// - `{"Constant":"Pi"}`
/// - `{"Binary":{"op":"Add","left":...,"right":...}}`
final class JSONSerializationTests: XCTestCase {

  // MARK: - Helpers

  /// Decode a JSON string and return the top-level object, or fail the test.
  private func decode(_ json: String, file: StaticString = #file, line: UInt = #line) -> Any? {
    guard let data = json.data(using: .utf8) else {
      XCTFail("JSON string could not be encoded as UTF-8", file: file, line: line)
      return nil
    }
    do {
      return try JSONSerialization.jsonObject(with: data, options: [])
    } catch {
      XCTFail("Invalid JSON: \(error)  —  input: \(json)", file: file, line: line)
      return nil
    }
  }

  // MARK: - Basic Types

  func testIntegerJSON() throws {
    let expr = try MathExpression.parse("42")
    let json = try expr.toJSON()

    XCTAssertFalse(json.isEmpty, "JSON must not be empty")
    let obj = decode(json) as? [String: Any]
    XCTAssertNotNil(obj, "JSON top level must be a dictionary")
    XCTAssertNotNil(obj?["Integer"], "Integer node must have 'Integer' key")
    XCTAssertEqual(obj?["Integer"] as? Int, 42)
  }

  func testFloatJSON() throws {
    let expr = try MathExpression.parse("3.14")
    let json = try expr.toJSON()

    XCTAssertFalse(json.isEmpty)
    XCTAssertNotNil(decode(json), "Float must produce valid JSON")
    XCTAssertTrue(json.contains("Float"), "JSON must contain 'Float' key")
  }

  func testVariableJSON() throws {
    let expr = try MathExpression.parse("x")
    let json = try expr.toJSON()

    XCTAssertFalse(json.isEmpty)
    let obj = decode(json) as? [String: Any]
    XCTAssertNotNil(obj, "JSON top level must be a dictionary")
    XCTAssertEqual(obj?["Variable"] as? String, "x", "Variable name must be 'x'")
  }

  // MARK: - Operations

  func testBinaryAdditionJSON() throws {
    let expr = try MathExpression.parse("2 + 3")
    let json = try expr.toJSON()

    let obj = decode(json) as? [String: Any]
    XCTAssertNotNil(obj, "JSON top level must be a dictionary")
    XCTAssertTrue(json.contains("Binary"), "Binary operation must use 'Binary' key")
    XCTAssertTrue(json.contains("Add"), "Addition must encode op as 'Add'")
  }

  func testFunctionCallJSON() throws {
    let expr = try MathExpression.parse("sin(x)")
    let json = try expr.toJSON()

    XCTAssertNotNil(decode(json), "Function call must produce valid JSON")
    XCTAssertTrue(json.contains("Function"), "Function call must use 'Function' key")
    XCTAssertTrue(json.contains("sin"), "Function name 'sin' must appear in JSON")
  }

  // MARK: - Complex Expressions

  func testPythagoreanIdentityJSON() throws {
    let expr = try MathExpression.parse("sin(x)^2 + cos(x)^2")
    let json = try expr.toJSON()

    XCTAssertNotNil(decode(json), "Pythagorean identity must produce valid JSON")
    XCTAssertTrue(json.contains("sin"), "JSON must reference 'sin'")
    XCTAssertTrue(json.contains("cos"), "JSON must reference 'cos'")
    XCTAssertTrue(json.contains("Binary"), "JSON must contain Binary node")
  }

  func testLatexFractionJSON() throws {
    let expr = try MathExpression.parseLatex(#"\frac{1}{2}"#)
    let json = try expr.toJSON()

    XCTAssertNotNil(decode(json), "LaTeX fraction must produce valid JSON")
    // \frac{1}{2} is a division binary operation
    XCTAssertTrue(json.contains("Binary"), "Fraction must be encoded as Binary")
  }

  // MARK: - Pretty Print

  func testPrettyPrintHasNewlines() throws {
    let expr = try MathExpression.parse("x + 1")
    let pretty = try expr.toJSONPretty()

    XCTAssertTrue(
      pretty.contains("\n"),
      "Pretty-printed JSON must contain at least one newline"
    )
  }

  func testPrettyPrintIsValidJSON() throws {
    let expr = try MathExpression.parse("2 * x + 1")
    let pretty = try expr.toJSONPretty()

    XCTAssertNotNil(decode(pretty), "Pretty-printed JSON must be valid JSON")
  }

  func testCompactAndPrettyDecodeSameTopLevelKeys() throws {
    let expr = try MathExpression.parse("x + y")
    let compact = try expr.toJSON()
    let pretty = try expr.toJSONPretty()

    let compactObj = decode(compact) as? [String: Any]
    let prettyObj = decode(pretty) as? [String: Any]

    XCTAssertNotNil(compactObj, "Compact JSON top level must be a dictionary")
    XCTAssertNotNil(prettyObj, "Pretty JSON top level must be a dictionary")

    // Both must share the same top-level keys
    let compactKeys = Set(compactObj.map { Array($0.keys) } ?? [])
    let prettyKeys = Set(prettyObj.map { Array($0.keys) } ?? [])
    XCTAssertEqual(
      compactKeys, prettyKeys, "Compact and pretty JSON must have identical top-level keys")
  }

  // MARK: - Constants

  func testConstantPiJSON() throws {
    let expr = try MathExpression.parse("pi")
    let json = try expr.toJSON()

    let obj = decode(json) as? [String: Any]
    XCTAssertNotNil(obj, "Constant 'pi' must produce a JSON object")
    XCTAssertTrue(
      json.contains("Constant"),
      "pi must be encoded under 'Constant' key"
    )
    XCTAssertTrue(json.contains("Pi"), "pi must be encoded as 'Pi'")
  }

  func testConstantEJSON() throws {
    let expr = try MathExpression.parse("e")
    let json = try expr.toJSON()

    XCTAssertNotNil(decode(json), "Constant 'e' must produce valid JSON")
    XCTAssertTrue(json.contains("Constant"), "e must be encoded under 'Constant' key")
  }

  func testConstantNaNJSON() throws {
    let expr = try MathExpression.parse("nan")
    let json = try expr.toJSON()

    // NaN must be encoded as a JSON-safe string value, not a bare NaN literal
    XCTAssertNotNil(decode(json), "Constant 'nan' must produce valid JSON")
    XCTAssertTrue(json.contains("Constant"), "nan must be encoded under 'Constant' key")
    XCTAssertTrue(json.contains("NaN"), "nan must be encoded as 'NaN'")
  }

  // MARK: - Round-trip Batch

  func testBatchRoundTrip() throws {
    let inputs = [
      "x + y",
      "sin(x)",
      "cos(pi)",
      "2^10",
      "log(e)",
    ]

    for input in inputs {
      let expr = try MathExpression.parse(input)
      let json = try expr.toJSON()

      XCTAssertFalse(json.isEmpty, "Batch: '\(input)' produced empty JSON")
      XCTAssertNotNil(decode(json), "Batch: '\(input)' produced invalid JSON")
    }
  }
}