Support parsing multiplatform tests

commit_hash:30c0f59f613adf7914f390c20f751136e7b34b75
This commit is contained in:
grechka62
2026-04-29 15:32:34 +03:00
parent 1d14f691f1
commit cf1e5515ff
28 changed files with 272 additions and 23 deletions
+2
View File
@@ -845,6 +845,8 @@
"client/android/div-data/src/test/java/com/yandex/div/internal/parser/JsonParserTest.kt":"divkit/public/client/android/div-data/src/test/java/com/yandex/div/internal/parser/JsonParserTest.kt",
"client/android/div-data/src/test/java/com/yandex/div/internal/parser/JsonPrinterTest.kt":"divkit/public/client/android/div-data/src/test/java/com/yandex/div/internal/parser/JsonPrinterTest.kt",
"client/android/div-data/src/test/java/com/yandex/div/internal/parser/JsonTopologicalSortingTest.kt":"divkit/public/client/android/div-data/src/test/java/com/yandex/div/internal/parser/JsonTopologicalSortingTest.kt",
"client/android/div-data/src/test/java/com/yandex/div/internal/parser/ParsingMultiplatformTest.kt":"divkit/public/client/android/div-data/src/test/java/com/yandex/div/internal/parser/ParsingMultiplatformTest.kt",
"client/android/div-data/src/test/java/com/yandex/div/internal/parser/ParsingTestCase.kt":"divkit/public/client/android/div-data/src/test/java/com/yandex/div/internal/parser/ParsingTestCase.kt",
"client/android/div-data/src/test/java/com/yandex/div/internal/util/JsonUtilsTest.kt":"divkit/public/client/android/div-data/src/test/java/com/yandex/div/internal/util/JsonUtilsTest.kt",
"client/android/div-data/src/test/resources/com/yandex/div/core/json/cyclic-dependency-composition.json":"divkit/public/client/android/div-data/src/test/resources/com/yandex/div/core/json/cyclic-dependency-composition.json",
"client/android/div-data/src/test/resources/com/yandex/div/core/json/cyclic-dependency-inheritance.json":"divkit/public/client/android/div-data/src/test/resources/com/yandex/div/core/json/cyclic-dependency-inheritance.json",
+3
View File
@@ -22,6 +22,9 @@ dependencies {
api(libs.androidx.core)
testImplementation(project(":test-utils"))
testImplementation(libs.json)
testImplementation(libs.kotlin.reflect)
}
@@ -774,7 +774,7 @@ public class JsonPropertyParser {
@Nullable final List<V> list,
@NonNull final Function1<V, R> converter
) {
if (list != null && !list.isEmpty()) {
if (list != null) {
int length = list.size();
JSONArray array = new JSONArray();
for (int i = 0; i < length; i++) {
@@ -796,7 +796,7 @@ public class JsonPropertyParser {
@Nullable final List<V> list,
@NonNull final Lazy<Serializer<JSONObject, V>> serializer
) {
if (list != null && !list.isEmpty()) {
if (list != null) {
int length = list.size();
JSONArray array = new JSONArray();
@@ -0,0 +1,103 @@
package com.yandex.div.internal.parser
import com.yandex.div.data.DivParsingEnvironment
import com.yandex.div.internal.util.forEach
import com.yandex.div.json.ParsingErrorLogger
import com.yandex.div.test.crossplatform.ParsingResult
import com.yandex.div.test.crossplatform.ParsingUtils.parseFiles
import com.yandex.div.test.crossplatform.isForAndroid
import com.yandex.div2.DivData
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class ParsingMultiplatformTest(testCaseParsingResult: ParsingResult<ParsingTestCase>) {
private val testCase = testCaseParsingResult.getOrThrow()
private val errors = mutableListOf<Throwable>()
@Test
fun run() {
val logger = ParsingErrorLogger { errors += it }
val env = DivParsingEnvironment(logger)
val divData = runCatching {
testCase.templates?.let { env.parseTemplates(it) }
DivData(env, testCase.card)
}.getOrNull()
assertCardEquals(testCase.expectedResult.card, divData?.writeToJSON())
Assert.assertEquals("Unexpected error count:", testCase.expectedResult.errorCount, errors.size)
}
private fun assertCardEquals(expected: JSONObject?, actual: JSONObject?) {
expected ?: return Assert.assertNull(actual)
actual ?: return Assert.fail(
"Failed to parse DivData. Errors: ${errors.joinToString(", ") { it.message ?: "" }}"
)
runCatching {
expected.compareValues("", actual)
}.onFailure {
Assert.fail(it.message)
}
}
private fun JSONObject.compareValues(parentPath: String, actual: JSONObject): Boolean {
forEach { key, expectedValue: Any? ->
val actualValue = actual.opt(key)
val path = parentPath.takeIf { it.isNotEmpty() }?.let { "$it/$key" } ?: key
if (!expectedValue.compare(path, actualValue)) {
throwError(path, expectedValue, actualValue)
}
}
return true
}
private fun Any?.compare(path: String, actual: Any?): Boolean {
return when (this) {
JSONObject.NULL -> actual == null || actual == this
is JSONObject -> (actual as? JSONObject)?.let { compareValues(path, it) } ?: false
is JSONArray -> (actual as? JSONArray)?.let { compareValues(path, it)} ?: false
is Int -> actual == this || (actual as? Long)?.toInt() == this
else -> actual == this
}
}
private fun JSONArray.compareValues(parentPath: String, actual: JSONArray): Boolean {
forEach { i, expectedValue: Any? ->
val actualValue = actual.opt(i)
val path = parentPath.takeIf { it.isNotEmpty() }?.let { "$it/$i" } ?: "$i"
if (!expectedValue.compare(path, actualValue)) {
throwError(path, expectedValue, actualValue)
}
}
return true
}
private fun throwError(path: String, expected: Any?, actual: Any?): Nothing =
throw Exception("DivData comparison failed for path '$path', expected='$expected', actual='$actual'")
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun cases(): List<ParsingResult<ParsingTestCase>> {
return parseFiles("parsing_test_data") { file, jsonString ->
val json = JSONObject(jsonString)
if (!json.isForAndroid) return@parseFiles emptyList()
val result = try {
val testCase = ParsingTestCase.from(file.name, json)
ParsingResult.Success(testCase)
} catch (e: Exception) {
ParsingResult.Error(fileName = file.name, error = e)
}
listOf(result)
}
}
}
}
@@ -0,0 +1,35 @@
package com.yandex.div.internal.parser
import org.json.JSONException
import org.json.JSONObject
class ParsingTestCase(
val name: String,
val templates: JSONObject?,
val card: JSONObject,
val expectedResult: ExpectedResult,
) {
class ExpectedResult(
val errorCount: Int?,
val card: JSONObject?,
)
override fun toString() = name
companion object {
@Throws(JSONException::class)
fun from(name: String, json: JSONObject): ParsingTestCase {
val expectedResult = json.getJSONObject("expected")
return ParsingTestCase(
name = name,
templates = json.optJSONObject("templates"),
card = json.getJSONObject("card"),
expectedResult = ExpectedResult(
errorCount = expectedResult.optInt("error_count"),
card = expectedResult.optJSONObject("card"),
),
)
}
}
}
@@ -8,7 +8,6 @@ data class ExpressionTestCase(
val expression: String,
val variables: List<JSONObject>,
val functions: List<JSONObject>,
val platform: List<String>,
val expectedType: String,
val expectedValue: Any,
val expectedWarnings: List<String>,
@@ -10,7 +10,6 @@ import com.yandex.div.json.ParsingErrorLogger
import com.yandex.div.test.crossplatform.ParsingResult
import com.yandex.div.test.crossplatform.isForAndroid
import com.yandex.div.test.crossplatform.parseDateTime
import com.yandex.div.test.crossplatform.platforms
import com.yandex.div.test.crossplatform.toObjectList
import com.yandex.div2.DivData
import com.yandex.div2.DivEvaluableType
@@ -64,7 +63,6 @@ object ExpressionTestCaseUtils {
json.getString(CASE_EXPRESSION_VALUE_FIELD),
json.optJSONArray(CASE_VARIABLES_FIELD).toObjectList(),
json.optJSONArray(CASE_FUNCTIONS_FIELD).toObjectList(),
json.platforms,
json.getJSONObject(CASE_EXPECTED_VALUE_FIELD).type,
json.getJSONObject(CASE_EXPECTED_VALUE_FIELD).getValue(),
json.optJSONArray(CASE_EXPECTED_WARNINGS_FIELD)?.map { it as String } ?: emptyList(),
@@ -55,11 +55,11 @@ object ParsingUtils {
}
}
val JSONObject.platforms: List<String>
get() = getJSONArray("platforms").map { it as String }
val JSONObject.platforms: List<String>?
get() = optJSONArray("platforms")?.map { it as String }
val JSONObject.isForAndroid: Boolean
get() = platforms.contains("android")
get() = platforms?.contains("android") ?: true
fun JSONArray?.toObjectList(): List<JSONObject> {
if (this == null) {
@@ -1,5 +1,11 @@
{
"description": "Invalid item in array (transition_triggers) is ignored",
"platforms": [
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,12 @@
{
"description": "Integer value in boolean_int property (div-text.auto_ellipsize)",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,12 @@
{
"description": "Integer value in string property is not valid (div-text.text)",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,11 @@
{
"description": "Invalid integer value in boolean_int property (div-text.auto_ellipsize)",
"platforms": [
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,11 @@
{
"description": "Invalid number value in boolean_int property (div-text.auto_ellipsize)",
"platforms": [
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,12 @@
{
"description": "Number value in boolean_int property (div-text.auto_ellipsize)",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,12 @@
{
"description": "Number value in integer property (div-text.font_size)",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,12 @@
{
"description": "Number value in string property is not valid (div-text.text)",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,11 @@
{
"description": "String value in boolean_int property (div-text.auto_ellipsize)",
"platforms": [
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
+9 -4
View File
@@ -1,5 +1,12 @@
{
"description": "All properties with expressions are parsed correctly",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -14,8 +21,7 @@
"text_color": "@{text_color}",
"actions": [
{
"log_id": "test_action",
"params": "@{action_params}"
"log_id": "@{action_log_id}"
}
]
}
@@ -37,8 +43,7 @@
"text_color": "@{text_color}",
"actions": [
{
"log_id": "test_action",
"params": "@{action_params}"
"log_id": "@{action_log_id}"
}
]
}
@@ -1,5 +1,12 @@
{
"description": "Invalid value replaced with default value",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,12 @@
{
"description": "Object with missing required property (div-action.log_id) is ignored",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
+14 -7
View File
@@ -1,5 +1,12 @@
{
"description": "All values are parsed corectly",
"description": "All values are parsed correctly",
"platforms": [
"android",
"ios"
],
"unsupported_platforms": {
"web": "There is no \"parsing\" process on the web"
},
"card": {
"log_id": "test",
"states": [
@@ -12,15 +19,15 @@
{
"type": "gradient",
"colors": [
"#112233",
"#332211"
"#FF112233",
"#FF332211"
]
}
],
"font_weight": "bold",
"font_size": 20,
"text": "Hello!",
"text_color": "#AABBCC",
"text_color": "#FFAABBCC",
"transition_triggers": [
"data_change",
"state_change"
@@ -42,15 +49,15 @@
{
"type": "gradient",
"colors": [
"#112233",
"#332211"
"#FF112233",
"#FF332211"
]
}
],
"font_weight": "bold",
"font_size": 20,
"text": "Hello!",
"text_color": "#AABBCC",
"text_color": "#FFAABBCC",
"transition_triggers": [
"data_change",
"state_change"
@@ -1,5 +1,9 @@
{
"description": "A derived template inherits a parent template whose body contains reference definitions inside nested structures. Verifies that inherited references from the parent and the derived template's own references all resolve against the usage's reference values.",
"platforms": [
"ios",
"web"
],
"templates": {
"base_text": {
"type": "text",
@@ -27,14 +27,14 @@
"state_id": 0,
"div": {
"type": "root",
"background_color": "#F00",
"background_color": "#FFFF0000",
"items": [
{
"type": "container",
"items": [
{
"type": "button",
"background_color": "#0f0",
"background_color": "#FF00FF00",
"text": "SAMPLE"
}
]
@@ -55,7 +55,7 @@
"background": [
{
"type": "solid",
"color": "#F00"
"color": "#FFFF0000"
}
],
"items": [
@@ -67,7 +67,7 @@
"background": [
{
"type": "solid",
"color": "#0f0"
"color": "#FF00FF00"
}
],
"text": "SAMPLE"
@@ -1,5 +1,9 @@
{
"description": "A template usage inside another template's body declares a reference definition whose source name matches a field on the card instance. Verifies that the reference value on the outer instance cascades through two levels of template usage to drive the inner template's field.",
"platforms": [
"ios",
"web"
],
"templates": {
"inner_template": {
"type": "image",
@@ -1,5 +1,9 @@
{
"description": "A reference definition lives inside a plain (non-template) nested dict embedded in a template's body — an element of an 'images' array. Verifies the reference still resolves against the outer instance despite the intermediate plain-dict layers.",
"platforms": [
"ios",
"web"
],
"card": {
"log_id": "test",
"states": [
@@ -1,5 +1,9 @@
{
"description": "A single template definition reuses the same reference source name ('content') at two depths: a top-level $text and a nested $log_id inside actions[]. Verifies both sites receive the same value from the usage's reference value.",
"platforms": [
"ios",
"web"
],
"templates": {
"action_text": {
"type": "text",
@@ -1,5 +1,9 @@
{
"description": "A reference definition points to a source name the usage does not provide, for an optional field. Verifies an unresolved reference on an optional field is silently ignored.",
"platforms": [
"ios",
"web"
],
"templates": {
"styled_text": {
"type": "text",
@@ -1,5 +1,9 @@
{
"description": "A template definition declares reference definitions at multiple depths: top-level ($text), inside a nested object ($top in paddings), and inside an array element ($url in actions[]). Verifies all references resolve against the usage's reference values.",
"platforms": [
"ios",
"web"
],
"templates": {
"title": {
"type": "text",