All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.io.kotest.assertions.json.compare.kt Maven / Gradle / Ivy

@file:Suppress("unused")

package io.kotest.assertions.json

@Deprecated("Json comparison options is now specified with `CompareJsonOptions`", ReplaceWith("TypeCoercion"))
enum class CompareMode {
   /**
    * Types must be identical and compare by value.
    *
    * For example, `"true"` and `true` will not match because one is a string, and the other is a boolean.
    * But `2.99E9` and `299000000` are considered equal as they are the same number, just in a different format.
    * `"100"` and `100` would not match as they are different types (string vs number).
    */
   @Deprecated(
      "Json comparison options is now specified with `CompareJsonOptions`",
      ReplaceWith("TypeCoercion.Disabled")
   )
   Strict,

   /**
    * Compare by value, coercing if possible.
    *
    * For example, "true" and true will match because the string value can be coerced into a valid boolean.
    * Similarly, "100" and 100 will match as the former can be coerced into an int.
    */
   @Deprecated(
      "Json comparison options is now specified with `CompareJsonOptions`",
      ReplaceWith("TypeCoercion.Enabled")
   )
   Lenient,
}

@Deprecated("Json comparison options is now specified with `CompareJsonOptions`", ReplaceWith("PropertyOrder"))
enum class CompareOrder {
   /**
    * All object properties must be in same order as expected.
    *
    * For example, { "x": 14.2, "y": 13.0 }` and `{ "y": 13.0, "x: 14.2 }` will NOT be considered equal.
    */
   @Deprecated(
      "Json comparison options is now specified with `CompareJsonOptions`",
      ReplaceWith("PropertyOrder.Strict")
   )
   Strict,

   @Deprecated(
      "Json comparison options is now specified with `CompareJsonOptions`",
      ReplaceWith("PropertyOrder.Lenient")
   )
   Lenient,
}

/**
 * helper method for bridging old compare options into new
 */
internal fun legacyOptions(mode: CompareMode, order: CompareOrder) =
   compareJsonOptions {
      typeCoercion = when (mode) {
         CompareMode.Strict -> TypeCoercion.Disabled
         CompareMode.Lenient -> TypeCoercion.Enabled
      }

      propertyOrder = when (order) {
         CompareOrder.Strict -> PropertyOrder.Strict
         CompareOrder.Lenient -> PropertyOrder.Lenient
      }
   }

internal val defaultCompareJsonOptions = CompareJsonOptions()

class CompareJsonOptions(

   /**
    * Controls whether property order must be identical
    */
   var propertyOrder: PropertyOrder = PropertyOrder.Lenient,

   /**
    * Controls whether array ordering must be identical.
    */
   var arrayOrder: ArrayOrder = ArrayOrder.Strict,

   /**
    * Controls whether the actual document may contain extra fields or not.
    */
   var fieldComparison: FieldComparison = FieldComparison.Strict,

   /**
    * Controls whether number formatting should be taken into consideration. For instance, comparing 1.00 to 1.0, or
    * 1E2 to 100
    */
   var numberFormat: NumberFormat = NumberFormat.Lenient,

   /**
    *  Controls whether types should be coerced when possible. For instance, when strings contain bool or numeric values.
    */
   var typeCoercion: TypeCoercion = TypeCoercion.Disabled
)

enum class PropertyOrder {
   /**
    * Default. Property order in objects does not matter.
    *
    * Example: `"""{ "a": 0, "b": 2 }""".shouldEqualJson("""{ "b": 2, "a": 1 }""", compareJsonOptions { propertyOrder = Lenient })` will pass
    */
   Lenient,

   /**
    * Properties must be in same order. E.g. `{ "a": 0, "b": 2 }` is not considered equal to `{ "b": 2, "a": 1 }`
    */
   Strict
}

enum class ArrayOrder {
   /**
    * Default. Arrays must contain the same elements in the same order.
    */
   Strict,

   /**
    * Arrays are allowed to be shuffled, but must still contain same items.
    */
   Lenient,
}

enum class FieldComparison {

   /**
    * Default. Objects in [expected] and [actual] must contain the same fields.
    */
   Strict,

   /**
    * Objects in the actual document may contain extraneous fields without causing comparison to fail.
    */
   Lenient,
}

enum class NumberFormat {
   /**
    * Default. Numbers will be interpreted before being compared. Meaning we can compare 0E3 to 1000 without fail
    */
   Lenient,

   /**
    * Numbers must also be formatted the same way to be considered equal.
    */
   Strict
}

enum class TypeCoercion {
   /**
    * Default. Types will not be converted. Meaning `"true"` and `true` are considered unequal.
    */
   Disabled,

   /**
    * Types may be coerced. Strings containing numbers will be considered equal to their numbers, and booleans in
    * strings will also be compared.
    *
    * For example: `"\"11\"".shouldEqualJson("12", compareJsonOptions { typeCoercion = TypeCoercion.Enabled })` will
    * succeed.
    */
   Enabled;

   internal fun isEnabled(): Boolean =
      this == Enabled
}

fun compareJsonOptions(builder: CompareJsonOptions.() -> Unit): CompareJsonOptions =
   CompareJsonOptions().apply(builder)

/**
 * Compares two json trees, returning a detailed error message if they differ.
 */
internal fun compare(
   path: List,
   expected: JsonNode,
   actual: JsonNode,
   options: CompareJsonOptions
): JsonError? {
   return when (expected) {
      is JsonNode.ObjectNode -> when (actual) {
         is JsonNode.ObjectNode -> compareObjects(path, expected, actual, options)
         else -> JsonError.ExpectedObject(path, actual)
      }
      is JsonNode.ArrayNode -> when (actual) {
         is JsonNode.ArrayNode -> compareArrays(path, expected, actual, options)
         else -> JsonError.ExpectedArray(path, actual)
      }
      is JsonNode.BooleanNode -> compareBoolean(path, expected, actual, options)
      is JsonNode.StringNode -> compareString(path, expected, actual, options)
      is JsonNode.NumberNode -> compareNumbers(path, expected, actual, options)
      JsonNode.NullNode -> compareNull(path, actual)
   }
}

internal fun compareObjects(
   path: List,
   expected: JsonNode.ObjectNode,
   actual: JsonNode.ObjectNode,
   options: CompareJsonOptions,
): JsonError? {

   if (FieldComparison.Strict == options.fieldComparison) {
      val expectedKeys = expected.elements.keys
      val actualKeys = actual.elements.keys

      if (actualKeys.size > expectedKeys.size) {
         val extra = actualKeys - expectedKeys
         return JsonError.ObjectExtraKeys(path, extra)
      }

      if (actualKeys.size < expectedKeys.size) {
         val missing = expectedKeys - actualKeys
         return JsonError.ObjectMissingKeys(path, missing)
      }
   }

   // when using strict order mode, the order of elements in json matters, normally, we don't care
   when (options.propertyOrder) {
      PropertyOrder.Strict ->
         expected.elements.entries.withIndex().zip(actual.elements.entries).forEach { (e, a) ->
            if (a.key != e.value.key) return JsonError.NameOrderDiff(path, e.index, e.value.key, a.key)
            val error = compare(path + a.key, e.value.value, a.value, options)
            if (error != null) return error
         }
      PropertyOrder.Lenient ->
         expected.elements.entries.forEach { (name, e) ->
            val a = actual.elements[name] ?: return JsonError.ObjectMissingKeys(path, setOf(name))
            val error = compare(path + name, e, a, options)
            if (error != null) return error
         }
   }

   return null
}

internal fun compareArrays(
   path: List,
   expected: JsonNode.ArrayNode,
   actual: JsonNode.ArrayNode,
   options: CompareJsonOptions,
): JsonError? {

   if (expected.elements.size != actual.elements.size)
      return JsonError.UnequalArrayLength(path, expected.elements.size, actual.elements.size)

   when (options.arrayOrder) {
      ArrayOrder.Strict -> {
         expected.elements.withIndex().zip(actual.elements.withIndex()).forEach { (a, b) ->
            val error = compare(path + "[${a.index}]", a.value, b.value, options)
            if (error != null) return error
         }
      }

      /**
       * In [ArrayOrder.Lenient], we try to allow array contents to be out-of-order.
       * We do this by searching for a match for each element in [actual], in the [expected] array,
       * flagging used matches so they can't be used twice. This will probably be slow for very big arrays.
       */
      ArrayOrder.Lenient -> {

         val consumedIndexes = BooleanArray(expected.elements.size) { false }

         fun availableIndexes() = consumedIndexes
            .mapIndexed { index, isConsumed -> if (!isConsumed) index else null }
            .filterNotNull()

         fun findMatchingIndex(element: JsonNode): Int? {
            for (i in availableIndexes()) {
               // Comparison with no error -> matching element
               val isMatch = compare(path + "[$i]", expected.elements[i], element, options) == null

               if (isMatch) {
                  return i
               }
            }

            return null
         }

         for ((i, element) in actual.elements.withIndex()) {
            val match = findMatchingIndex(element)
               ?: return JsonError.UnequalArrayContent(path + "[$i]", expected, element)

            consumedIndexes[match] = true
         }
      }
   }

   return null
}

/**
 * When comparing a string, if the [mode] is [CompareMode.Lenient] we can convert the actual node to a string.
 */
internal fun compareString(
   path: List,
   expected: JsonNode.StringNode,
   actual: JsonNode,
   options: CompareJsonOptions
): JsonError? {
   return when {
      actual is JsonNode.StringNode -> compareStrings(path, expected.value, actual.value)
      options.typeCoercion.isEnabled() -> when {
         actual is JsonNode.BooleanNode -> compareStrings(path, expected.value, actual.value.toString())
         actual is JsonNode.NumberNode && expected.contentIsNumber() -> compareNumberNodes(
            path,
            expected.toNumberNode(),
            actual
         )
         else -> JsonError.IncompatibleTypes(path, expected, actual)
      }
      else -> JsonError.IncompatibleTypes(path, expected, actual)
   }
}

internal fun compareStrings(path: List, expected: String, actual: String): JsonError? {
   return when (expected) {
      actual -> null
      else -> JsonError.UnequalStrings(path, expected, actual)
   }
}

/**
 * When comparing a boolean, if the [mode] is [CompareMode.Lenient] and the actual node is a text
 * node with "true" or "false", then we convert.
 */
internal fun compareBoolean(
   path: List,
   expected: JsonNode.BooleanNode,
   actual: JsonNode,
   options: CompareJsonOptions
): JsonError? {
   return when {
      actual is JsonNode.BooleanNode -> compareBooleans(path, expected.value, actual.value)
      options.typeCoercion.isEnabled() && actual is JsonNode.StringNode -> when (actual.value) {
         "true" -> compareBooleans(path, expected.value, true)
         "false" -> compareBooleans(path, expected.value, false)
         else -> JsonError.UnequalValues(path, expected, actual)
      }
      else -> JsonError.IncompatibleTypes(path, expected, actual)
   }
}

internal fun compareBooleans(path: List, expected: Boolean, actual: Boolean): JsonError? {
   return when (expected) {
      actual -> null
      else -> JsonError.UnequalBooleans(path, expected, actual)
   }
}

private fun compareNumbers(
   path: List,
   expected: JsonNode.NumberNode,
   actual: JsonNode,
   options: CompareJsonOptions
): JsonError? {
   return when (actual) {
      is JsonNode.NumberNode -> {
         when (options.numberFormat) {
            NumberFormat.Strict -> {
               if (expected.content != actual.content) JsonError.UnequalValues(path, expected.content, actual.content)
               else null
            }
            NumberFormat.Lenient -> compareNumberNodes(path, expected, actual)
         }
      }
      is JsonNode.StringNode -> {
         if (options.typeCoercion.isEnabled() && actual.contentIsNumber()) compareNumberNodes(
            path,
            expected,
            actual.toNumberNode()
         )
         else JsonError.IncompatibleTypes(path, expected, actual)
      }
      else -> JsonError.IncompatibleTypes(path, expected, actual)
   }
}


private fun compareNumberNodes(
   path: List,
   expected: JsonNode.NumberNode,
   actual: JsonNode.NumberNode
): JsonError? {

   return when {
      expected.lenientEquals(actual) -> null
      else -> JsonError.UnequalValues(path, expected.content, actual.content)
   }
}

internal fun compareNull(path: List, b: JsonNode): JsonError? {
   return when (b) {
      is JsonNode.NullNode -> null
      else -> JsonError.ExpectedNull(path, b)
   }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy