JSONCompare.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import Foundation
  2. /// After the port from Javascript to Swift is complete, we should remove the logging module:
  3. /// https://github.com/nightscout/Trio-dev/issues/293
  4. enum JSONValue: Codable, Equatable {
  5. case string(String)
  6. case number(Double)
  7. case boolean(Bool)
  8. case array([JSONValue])
  9. case object([String: JSONValue])
  10. case null
  11. init(from decoder: Decoder) throws {
  12. let container = try decoder.singleValueContainer()
  13. if container.decodeNil() {
  14. self = .null
  15. return
  16. }
  17. if let string = try? container.decode(String.self) {
  18. self = .string(string)
  19. } else if let number = try? container.decode(Double.self) {
  20. self = .number(number)
  21. } else if let boolean = try? container.decode(Bool.self) {
  22. self = .boolean(boolean)
  23. } else if let array = try? container.decode([JSONValue].self) {
  24. self = .array(array)
  25. } else if let object = try? container.decode([String: JSONValue].self) {
  26. self = .object(object)
  27. } else {
  28. throw DecodingError.typeMismatch(JSONValue.self, DecodingError.Context(
  29. codingPath: decoder.codingPath,
  30. debugDescription: "Invalid JSON value"
  31. ))
  32. }
  33. }
  34. func encode(to encoder: Encoder) throws {
  35. var container = encoder.singleValueContainer()
  36. switch self {
  37. case let .string(value): try container.encode(value)
  38. case let .number(value): try container.encode(value)
  39. case let .boolean(value): try container.encode(value)
  40. case let .array(value): try container.encode(value)
  41. case let .object(value): try container.encode(value)
  42. case .null: try container.encodeNil()
  43. }
  44. }
  45. static func == (lhs: JSONValue, rhs: JSONValue) -> Bool {
  46. switch (lhs, rhs) {
  47. case (.null, .null):
  48. return true
  49. case let (.string(lhs), .string(rhs)):
  50. return lhs == rhs
  51. case let (.number(lhs), .number(rhs)):
  52. return lhs == rhs
  53. case let (.boolean(lhs), .boolean(rhs)):
  54. return lhs == rhs
  55. case let (.array(lhs), .array(rhs)):
  56. return lhs == rhs
  57. case let (.object(lhs), .object(rhs)):
  58. return lhs == rhs
  59. default:
  60. return false
  61. }
  62. }
  63. }
  64. struct ValueDifference: Codable {
  65. let js: JSONValue
  66. let swift: JSONValue
  67. let jsKeyMissing: Bool
  68. let nativeKeyMissing: Bool
  69. }
  70. enum JSONCompare {
  71. static let log = try? JsSwiftOrefComparisonLogger()
  72. static func logDifferences(
  73. function: OrefFunction,
  74. swift: OrefFunctionResult,
  75. swiftDuration: TimeInterval,
  76. javascript: OrefFunctionResult,
  77. javascriptDuration: TimeInterval,
  78. iobInputs: IobInputs? = nil,
  79. mealInputs: MealInputs? = nil
  80. ) {
  81. let comparison = createComparison(
  82. function: function,
  83. swift: swift,
  84. swiftDuration: swiftDuration,
  85. javascript: javascript,
  86. javascriptDuration: javascriptDuration,
  87. iobInputs: iobInputs,
  88. mealInputs: mealInputs
  89. )
  90. Task {
  91. do {
  92. try await log?.logComparison(comparison: comparison)
  93. } catch {
  94. warning(.openAPS, "logComparison exception: \(error)", error: error)
  95. }
  96. }
  97. }
  98. static func createComparison(
  99. function: OrefFunction,
  100. swift: OrefFunctionResult,
  101. swiftDuration: TimeInterval,
  102. javascript: OrefFunctionResult,
  103. javascriptDuration: TimeInterval,
  104. iobInputs: IobInputs?,
  105. mealInputs: MealInputs?
  106. ) -> AlgorithmComparison {
  107. switch (swift, javascript) {
  108. case let (.success(swiftJson), .success(javascriptJson)):
  109. do {
  110. let differences = try differences(function: function, swift: swiftJson, javascript: javascriptJson)
  111. let resultType: ComparisonResultType = differences.isEmpty ? .matching : .valueDifference
  112. return AlgorithmComparison(
  113. function: function,
  114. resultType: resultType,
  115. jsDuration: javascriptDuration,
  116. swiftDuration: swiftDuration,
  117. differences: differences.isEmpty ? nil : differences,
  118. iobInputs: differences.isEmpty ? nil : iobInputs,
  119. mealInputs: differences.isEmpty ? nil : mealInputs
  120. )
  121. } catch {
  122. return AlgorithmComparison(
  123. function: function,
  124. resultType: .comparisonError,
  125. jsDuration: javascriptDuration,
  126. swiftDuration: swiftDuration,
  127. comparisonError: AlgorithmException(error: error)
  128. )
  129. }
  130. case let (.failure(swiftError), .failure(jsError)):
  131. return AlgorithmComparison(
  132. function: function,
  133. resultType: .matchingExceptions,
  134. jsException: AlgorithmException(error: jsError),
  135. swiftException: AlgorithmException(error: swiftError)
  136. )
  137. case let (.failure(swiftError), .success):
  138. return AlgorithmComparison(
  139. function: function,
  140. resultType: .swiftOnlyException,
  141. jsDuration: javascriptDuration,
  142. swiftException: AlgorithmException(error: swiftError),
  143. iobInputs: iobInputs,
  144. mealInputs: mealInputs
  145. )
  146. case let (.success, .failure(jsError)):
  147. return AlgorithmComparison(
  148. function: function,
  149. resultType: .jsOnlyException,
  150. swiftDuration: swiftDuration,
  151. jsException: AlgorithmException(error: jsError),
  152. iobInputs: iobInputs,
  153. mealInputs: mealInputs
  154. )
  155. }
  156. }
  157. static func prettyPrint(_ differences: [String: ValueDifference]) {
  158. let encoder = JSONEncoder()
  159. encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
  160. if let data = try? encoder.encode(differences),
  161. let prettyString = String(data: data, encoding: .utf8)
  162. {
  163. debug(.openAPS, prettyString)
  164. }
  165. }
  166. static func differences(function: OrefFunction, swift: String, javascript: String) throws -> [String: ValueDifference] {
  167. let differences = try {
  168. switch function.returnType() {
  169. case .array:
  170. return try differencesArray(function: function, swift: swift, javascript: javascript)
  171. case .dictionary:
  172. return try differencesDictionary(function: function, swift: swift, javascript: javascript)
  173. }
  174. }()
  175. let keysToIgnore = function.keysToIgnore()
  176. return differences.filter { !keysToIgnore.contains($0.key) }
  177. }
  178. private static func differencesArray(
  179. function: OrefFunction,
  180. swift: String,
  181. javascript: String
  182. ) throws -> [String: ValueDifference] {
  183. guard let jsData = javascript.data(using: .utf8),
  184. let swiftData = swift.data(using: .utf8),
  185. let jsArray = try JSONSerialization.jsonObject(with: jsData) as? [Any],
  186. let swiftArray = try JSONSerialization.jsonObject(with: swiftData) as? [Any]
  187. else {
  188. throw NSError(domain: "JSONBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])
  189. }
  190. // Converting arrays into dictionaries for comparison
  191. let jsDict = Dictionary(uniqueKeysWithValues: jsArray.enumerated().map { index, value in
  192. ("[\(index)]", value)
  193. })
  194. let swiftDict = Dictionary(uniqueKeysWithValues: swiftArray.enumerated().map { index, value in
  195. ("[\(index)]", value)
  196. })
  197. return compareDict(function: function, swiftDict: swiftDict, jsDict: jsDict)
  198. }
  199. private static func differencesDictionary(
  200. function: OrefFunction,
  201. swift: String,
  202. javascript: String
  203. ) throws -> [String: ValueDifference] {
  204. guard let jsData = javascript.data(using: .utf8),
  205. let swiftData = swift.data(using: .utf8),
  206. let jsDict = try JSONSerialization.jsonObject(with: jsData) as? [String: Any],
  207. let swiftDict = try JSONSerialization.jsonObject(with: swiftData) as? [String: Any]
  208. else {
  209. throw NSError(domain: "JSONBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])
  210. }
  211. return compareDict(function: function, swiftDict: swiftDict, jsDict: jsDict)
  212. }
  213. private static func compareDict(
  214. function: OrefFunction,
  215. swiftDict: [String: Any],
  216. jsDict: [String: Any]
  217. ) -> [String: ValueDifference] {
  218. var differences: [String: ValueDifference] = [:]
  219. let approximateKeys = function.approximateMatchingNumbers()
  220. // Check all keys present in either dictionary
  221. Set(jsDict.keys).union(swiftDict.keys).forEach { key in
  222. let jsValue = jsDict[key].map(convertToJSONValue) ?? .null
  223. let swiftValue = swiftDict[key].map(convertToJSONValue) ?? .null
  224. if !valuesAreEqual(jsValue, swiftValue, approximately: approximateKeys[key], approximateKeys: approximateKeys) {
  225. differences[key] = ValueDifference(
  226. js: jsValue,
  227. swift: swiftValue,
  228. jsKeyMissing: !jsDict.keys.contains(key),
  229. nativeKeyMissing: !swiftDict.keys.contains(key)
  230. )
  231. }
  232. }
  233. return differences
  234. }
  235. private static func convertToJSONValue(_ value: Any) -> JSONValue {
  236. switch value {
  237. case let string as String:
  238. return .string(string)
  239. case let number as NSNumber:
  240. // NSNumber can represent both booleans and numbers
  241. // Check if it's a boolean using the objCType
  242. let objCType = String(cString: number.objCType)
  243. if objCType == "c" || objCType == "B" { // These represent BOOLs in ObjC
  244. return .boolean(number.boolValue)
  245. } else {
  246. return .number(number.doubleValue)
  247. }
  248. case let bool as Bool:
  249. return .boolean(bool)
  250. case let array as [Any]:
  251. return .array(array.map(convertToJSONValue))
  252. case let dict as [String: Any]:
  253. return .object(dict.mapValues(convertToJSONValue))
  254. case is NSNull:
  255. return .null
  256. default:
  257. return .null
  258. }
  259. }
  260. private static func valuesAreEqual(
  261. _ value1: JSONValue,
  262. _ value2: JSONValue,
  263. approximately: Double?,
  264. approximateKeys: [String: Double]
  265. ) -> Bool {
  266. switch (value1, value2) {
  267. case (.null, .null):
  268. return true
  269. case let (.string(s1), .string(s2)):
  270. return s1 == s2
  271. case let (.number(n1), .number(n2)):
  272. let match = n1.isApproximatelyEqual(to: n2, epsilon: approximately)
  273. return match
  274. case let (.boolean(b1), .boolean(b2)):
  275. return b1 == b2
  276. case let (.array(a1), .array(a2)):
  277. return a1.count == a2.count && zip(a1, a2).allSatisfy { v1, v2 in
  278. valuesAreEqual(v1, v2, approximately: approximately, approximateKeys: approximateKeys)
  279. }
  280. case let (.object(o1), .object(o2)):
  281. return o1.keys == o2.keys && o1.keys.allSatisfy { key in
  282. guard let v1 = o1[key], let v2 = o2[key] else { return false }
  283. return valuesAreEqual(v1, v2, approximately: approximateKeys[key], approximateKeys: approximateKeys)
  284. }
  285. default:
  286. return false
  287. }
  288. }
  289. }