diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8efeb55..ae91a60 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,8 +1,8 @@ name: CI on: - pull_request: { } - workflow_dispatch: { } + pull_request: {} + workflow_dispatch: {} push: branches: - master @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: true jobs: - build: + build-and-test: runs-on: ubuntu-latest steps: @@ -23,11 +23,21 @@ jobs: - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: '17' - distribution: 'corretto' + java-version: "17" + distribution: "corretto" - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle run: ./gradlew build + + - name: Run tests + run: ./gradlew test + + - name: Upload test results + uses: actions/upload-artifact@v2 + if: always() + with: + name: test-results + path: "**/build/test-results/test" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a7e208..f12b5a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ appcompat = "1.7.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +robolectric = "4.8" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -18,6 +19,7 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a junit = { module = "junit:junit", version.ref = "junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } ktlint = "com.pinterest.ktlint:ktlint-cli:1.3.1" diff --git a/library/build.gradle.kts b/library/build.gradle.kts index b97b5f7..8aaf440 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { compileOnly(libs.androidx.appcompat) // Testing dependencies testImplementation(libs.junit) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/library/src/main/java/com/ding1ding/jsbridge/JsonUtils.kt b/library/src/main/java/com/ding1ding/jsbridge/JsonUtils.kt index 5f63070..f36d261 100644 --- a/library/src/main/java/com/ding1ding/jsbridge/JsonUtils.kt +++ b/library/src/main/java/com/ding1ding/jsbridge/JsonUtils.kt @@ -1,3 +1,5 @@ +@file:Suppress("ktlint:standard:max-line-length") + package com.ding1ding.jsbridge import java.math.BigInteger @@ -16,61 +18,85 @@ object JsonUtils { fun toJson(any: Any?): String = when (any) { null -> "null" is JSONObject, is JSONArray -> any.toString() - is String -> JSONObject.quote(any) + is String -> JSONObject.quote(any) // Use JSONObject.quote to handle special characters correctly is Boolean, is Number -> any.toString() is Date -> JSONObject.quote(isoDateFormat.format(any)) - is Map<*, *> -> any.toJsonObject().toString() - is Collection<*> -> any.toJsonArray().toString() + is Map<*, *> -> mapToJson(any) + is Collection<*> -> collectionToJson(any) is Enum<*> -> JSONObject.quote(any.name) else -> try { - any::class.java.declaredFields - .filter { !it.isSynthetic } - .associate { field -> - field.isAccessible = true - field.name to field.get(any) - }.toJsonObject().toString() + objectToJson(any) } catch (e: Exception) { Logger.e(e) { "Failed to serialize object of type ${any::class.java.simpleName}" } JSONObject.quote(any.toString()) } } - private fun Map<*, *>.toJsonObject() = JSONObject().apply { - forEach { (key, value) -> - put(key.toString(), value?.let { toJsonValue(it) } ?: JSONObject.NULL) + private fun mapToJson(map: Map<*, *>): String { + val jsonObject = JSONObject() + for ((key, value) in map) { + jsonObject.put(key.toString(), toJsonValue(value)) } + return jsonObject.toString() } - private fun Collection<*>.toJsonArray() = JSONArray( - map { - it?.let { toJsonValue(it) } - ?: JSONObject.NULL - }, - ) + private fun collectionToJson(collection: Collection<*>): String { + val jsonArray = JSONArray() + for (item in collection) { + jsonArray.put(toJsonValue(item)) + } + return jsonArray.toString() + } - private fun toJsonValue(value: Any): Any = when (value) { - is JSONObject, is JSONArray, is String, is Boolean, is Number -> value + private fun objectToJson(obj: Any): String { + val jsonObject = JSONObject() + obj::class.java.declaredFields + .filter { !it.isSynthetic } + .forEach { field -> + field.isAccessible = true + jsonObject.put(field.name, toJsonValue(field.get(obj))) + } + return jsonObject.toString() + } + + private fun toJsonValue(value: Any?): Any? = when (value) { + null -> JSONObject.NULL + is JSONObject, is JSONArray, is String, is Boolean, is Number, is Char -> value + is Map<*, *> -> JSONObject(mapToJson(value)) + is Collection<*> -> JSONArray(collectionToJson(value)) + is Date -> isoDateFormat.format(value) + is Enum<*> -> value.name else -> toJson(value) } fun fromJson(json: String): Any? = try { when { json == "null" -> null - json.startsWith("{") && json.endsWith("}") -> JSONObject(json).toMap() - json.startsWith("[") && json.endsWith("]") -> JSONArray(json).toList() - json.startsWith("\"") && json.endsWith("\"") -> json.unquote().let { unquoted -> - tryParseDate(unquoted) ?: unquoted + json.startsWith("{") && json.endsWith("}") -> parseJsonObject(json) + json.startsWith("[") && json.endsWith("]") -> parseJsonArray(json) + json.startsWith("\"") && json.endsWith("\"") -> { + val unquoted = json.substring(1, json.length - 1) + tryParseDate(unquoted) ?: unescapeString(unquoted) } json == "true" -> true json == "false" -> false - else -> parseNumber(json) + else -> parseNumber(json) ?: json } } catch (e: Exception) { Logger.e(e) { "Error parsing JSON: $json" } json // Return the original string if parsing fails } + private fun parseJsonObject(json: String): Map = + JSONObject(json).keys().asSequence().associateWith { key -> + fromJson(JSONObject(json).get(key).toString()) + } + + private fun parseJsonArray(json: String): List = JSONArray(json).let { array -> + (0 until array.length()).map { fromJson(array.get(it).toString()) } + } + private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { key -> when (val value = get(key)) { is JSONObject -> value.toMap() @@ -89,7 +115,7 @@ object JsonUtils { } } - private fun parseNumber(value: String): Any = when { + private fun parseNumber(value: String): Any? = when { value.contains(".") || value.lowercase(Locale.US).contains("e") -> { try { val doubleValue = value.toDouble() @@ -99,7 +125,7 @@ object JsonUtils { else -> doubleValue } } catch (e: NumberFormatException) { - value + null } } @@ -114,7 +140,7 @@ object JsonUtils { try { BigInteger(value) } catch (e: NumberFormatException) { - value + null } } } @@ -126,9 +152,34 @@ object JsonUtils { null } - private fun String.unquote(): String = if (length >= 2 && startsWith('"') && endsWith('"')) { - substring(1, length - 1).replace("\\\"", "\"").replace("\\\\", "\\") - } else { - this + private fun unescapeString(s: String): String { + val sb = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + var ch = s[i] + if (ch == '\\' && i + 1 < s.length) { + ch = s[++i] + when (ch) { + 'b' -> sb.append('\b') + 'f' -> sb.append('\u000C') + 'n' -> sb.append('\n') + 'r' -> sb.append('\r') + 't' -> sb.append('\t') + 'u' -> { + if (i + 4 < s.length) { + val hex = s.substring(i + 1, i + 5) + sb.append(hex.toInt(16).toChar()) + i += 4 + } + } + + else -> sb.append(ch) + } + } else { + sb.append(ch) + } + i++ + } + return sb.toString() } } diff --git a/library/src/test/java/com/ding1ding/jsbridge/JsonUtilsTest.kt b/library/src/test/java/com/ding1ding/jsbridge/JsonUtilsTest.kt new file mode 100644 index 0000000..32f6ad3 --- /dev/null +++ b/library/src/test/java/com/ding1ding/jsbridge/JsonUtilsTest.kt @@ -0,0 +1,309 @@ +package com.ding1ding.jsbridge + +import java.math.BigInteger +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class JsonUtilsTest { + + @Before + fun setup() { + } + + // 测试基本数据类型的序列化 + @Test + fun testToJsonPrimitives() { + assertEquals("null", JsonUtils.toJson(null)) + assertEquals("\"hello\"", JsonUtils.toJson("hello")) + assertEquals("true", JsonUtils.toJson(true)) + assertEquals("false", JsonUtils.toJson(false)) + assertEquals("42", JsonUtils.toJson(42)) + assertEquals("3.14", JsonUtils.toJson(3.14)) + } + + // 测试日期的序列化 + @Test + fun testToJsonDate() { + val date = Date(1609459200000) // 2021-01-01T00:00:00.000Z + assertEquals("\"2021-01-01T00:00:00.000Z\"", JsonUtils.toJson(date)) + } + + // 测试复杂 Map 的序列化和反序列化 + @Test + fun testToJsonMap() { + val original = mapOf( + "key" to "value", + "number" to 42, + "boolean" to true, + "null" to null, + "nested" to mapOf("inner" to "value"), + "array" to listOf(1, 2, 3), + ) + val json = JsonUtils.toJson(original) + println("JSON: $json") + + // 将 JSON 字符串解析回对象 + val result = JsonUtils.fromJson(json) as? Map<*, *> + assertNotNull("Parsed result should not be null", result) + + // 比较原始 Map 和解析后的 Map + assertEquals(original.size, result?.size) + assertEquals(original["key"], result?.get("key")) + assertEquals(original["number"], result?.get("number")) + assertEquals(original["boolean"], result?.get("boolean")) + assertNull(result?.get("null")) + + val originalNested = original["nested"] as Map<*, *> + val resultNested = result?.get("nested") as? Map<*, *> + assertNotNull("Nested object should not be null", resultNested) + assertEquals(originalNested["inner"], resultNested?.get("inner")) + + val originalArray = original["array"] as List<*> + val resultArray = result?.get("array") as? List<*> + assertNotNull("Array should not be null", resultArray) + assertEquals(originalArray, resultArray) + } + + // 测试集合的序列化 + @Test + fun testToJsonCollection() { + val list = listOf("a", 1, true) + assertEquals("[\"a\",1,true]", JsonUtils.toJson(list)) + } + + enum class TestEnum { VALUE } + + // 测试枚举的序列化 + @Test + fun testToJsonEnum() { + assertEquals("\"VALUE\"", JsonUtils.toJson(TestEnum.VALUE)) + } + + // 测试自定义对象的序列化 + @Test + fun testToJsonCustomObject() { + data class TestObject(val name: String, val age: Int) + + val obj = TestObject("John", 30) + assertEquals("{\"name\":\"John\",\"age\":30}", JsonUtils.toJson(obj)) + } + + // 测试 null 值的反序列化 + @Test + fun testFromJsonNull() { + assertNull(JsonUtils.fromJson("null")) + } + + // 测试 JSON 对象的反序列化 + @Test + fun testFromJsonObject() { + val json = "{\"key\":\"value\",\"number\":42}" + val result = JsonUtils.fromJson(json) as Map<*, *> + assertEquals("value", result["key"]) + assertEquals(42, result["number"]) + } + + // 测试 JSON 数组的反序列化 + @Test + fun testFromJsonArray() { + val json = "[\"a\",1,true]" + val result = JsonUtils.fromJson(json) as List<*> + assertEquals("a", result[0]) + assertEquals(1, result[1]) + assertEquals(true, result[2]) + } + + // 测试字符串的反序列化 + @Test + fun testFromJsonString() { + assertEquals("hello", JsonUtils.fromJson("\"hello\"")) + } + + // 测试布尔值的反序列化 + @Test + fun testFromJsonBoolean() { + assertEquals(true, JsonUtils.fromJson("true")) + assertEquals(false, JsonUtils.fromJson("false")) + } + + // 测试数字的反序列化 + @Test + fun testFromJsonNumber() { + assertEquals(42, JsonUtils.fromJson("42")) + assertEquals(3.14, JsonUtils.fromJson("3.14")) + assertEquals(Long.MAX_VALUE, JsonUtils.fromJson(Long.MAX_VALUE.toString())) + assertEquals(BigInteger("9223372036854775808"), JsonUtils.fromJson("9223372036854775808")) + } + + // 测试日期的反序列化 + @Test + fun testFromJsonDate() { + val dateString = "\"2021-01-01T00:00:00.000Z\"" + val result = JsonUtils.fromJson(dateString) as Date + assertEquals(1609459200000, result.time) + } + + // 测试复杂对象的序列化和反序列化一致性 + @Test + fun testRoundTripConversion() { + val original = mapOf( + "string" to "value", + "number" to 42, + "boolean" to true, + "null" to null, + "array" to listOf(1, 2, 3), + "object" to mapOf("nested" to "value"), + ) + val json = JsonUtils.toJson(original) + println("JSON: $json") + + val result = JsonUtils.fromJson(json) + println("Result: $result") + + // 检查 result 的类型 + assertTrue("Result should be a Map, but was ${result?.javaClass}", result is Map<*, *>) + + if (result is Map<*, *>) { + assertEquals(original["string"], result["string"]) + assertEquals(original["number"], result["number"]) + assertEquals(original["boolean"], result["boolean"]) + assertNull(result["null"]) + + val array = result["array"] + assertTrue("Array should be a List, but was ${array?.javaClass}", array is List<*>) + if (array is List<*>) { + assertEquals(original["array"], array) + } + + val nestedObject = result["object"] + assertTrue( + "Nested object should be a Map, but was ${nestedObject?.javaClass}", + nestedObject is Map<*, *>, + ) + if (nestedObject is Map<*, *>) { + assertEquals((original["object"] as Map<*, *>)["nested"], nestedObject["nested"]) + } + } + } + + // 测试无效 JSON 的处理 + @Test + fun testParseInvalidJson() { + val invalidJson = "invalid json" + assertEquals(invalidJson, JsonUtils.fromJson(invalidJson)) + } + + // 测试复杂嵌套对象的序列化和反序列化 + @Test + fun testComplexNestedObject() { + data class Inner(val value: String) + data class Outer(val inner: Inner, val list: List) + + val complex = Outer(Inner("nested"), listOf(1, 2, 3)) + val json = JsonUtils.toJson(complex) + val result = JsonUtils.fromJson(json) as? Map<*, *> + + assertNotNull(result) + val innerMap = result?.get("inner") as? Map<*, *> + assertNotNull(innerMap) + assertEquals("nested", innerMap?.get("value")) + assertEquals(listOf(1, 2, 3), result?.get("list")) + } + + // 测试空集合和空 Map 的处理 + @Test + fun testEmptyCollectionsAndMaps() { + val empty = mapOf( + "emptyList" to emptyList(), + "emptyMap" to emptyMap(), + ) + val json = JsonUtils.toJson(empty) + val result = JsonUtils.fromJson(json) as? Map<*, *> + + assertNotNull(result) + assertTrue((result?.get("emptyList") as? List<*>)?.isEmpty() == true) + assertTrue((result?.get("emptyMap") as? Map<*, *>)?.isEmpty() == true) + } + + // 测试特殊字符的处理 + @Test + fun testSpecialCharacters() { + val special = "Hello\n\t\r\b\u000c\u0001World" + val json = JsonUtils.toJson(special) + println("JSON: $json") + val result = JsonUtils.fromJson(json) + + assertEquals(special, result) + assertEquals("\"Hello\\n\\t\\r\\b\\f\\u0001World\"", json) + } + + // 测试超大数字的处理 + @Test + fun testVeryLargeNumbers() { + val veryLarge = "9223372036854775808" // Long.MAX_VALUE + 1 + val json = JsonUtils.toJson(BigInteger(veryLarge)) + val result = JsonUtils.fromJson(json) + + assertTrue(result is BigInteger) + assertEquals(BigInteger(veryLarge), result) + } + + // 测试浮点数精度 + @Test + fun testFloatingPointPrecision() { + val precise = 1.23456789012345 + val json = JsonUtils.toJson(precise) + val result = JsonUtils.fromJson(json) + + assertTrue(result is Double) + assertEquals(precise, result as Double, 1e-15) + } + + // 测试 Unicode 字符的处理 + @Test + fun testUnicodeCharacters() { + val unicode = "Hello, 世界 ! 🌍 " + val json = JsonUtils.toJson(unicode) + val result = JsonUtils.fromJson(json) + + assertEquals(unicode, result) + } + + // 测试集合中 null 值的处理 + @Test + fun testNullValuesInCollections() { + val withNulls = listOf(1, null, "three", null) + val json = JsonUtils.toJson(withNulls) + val result = JsonUtils.fromJson(json) as? List<*> + + assertNotNull(result) + assertEquals(withNulls, result) + } + + // 测试非字符串键的 Map 处理 + @Test + fun testMapWithNonStringKeys() { + val map = mapOf( + 1 to "one", + 2.5 to "two point five", + true to "boolean", + ) + val json = JsonUtils.toJson(map) + val result = JsonUtils.fromJson(json) as? Map<*, *> + + assertNotNull(result) + assertEquals("one", result?.get("1")) + assertEquals("two point five", result?.get("2.5")) + assertEquals("boolean", result?.get("true")) + } +}