Skip to content

Commit 95f3b51

Browse files
Test Coverage Added
1 parent 5174d8c commit 95f3b51

File tree

5 files changed

+185
-53
lines changed

5 files changed

+185
-53
lines changed

client/src/test/kotlin/io/github/hosseinkarami_dev/near/rpc/client/ChangesTest.kt

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class ChangesTest {
4545

4646
@Test
4747
fun testStatus() = runTest {
48-
var response = nearClient.changes(
48+
val response = nearClient.changes(
4949
RpcStateChangesInBlockByTypeRequest.AccountChangesByBlockId(
5050
blockId = BlockId.BlockHeight(167697415U),
5151
accountIds = listOf(AccountId("relay.tg")),
@@ -55,20 +55,6 @@ class ChangesTest {
5555
val result = response.getResultOrNull<RpcStateChangesInBlockResponse>()
5656
println("Changes Response: $result")
5757

58-
if (response !is RpcResponse.Success)
59-
assertTrue { false }
60-
61-
62-
response = nearClient.changes(
63-
RpcStateChangesInBlockByTypeRequest.AccountChangesByFinality(
64-
accountIds = listOf(AccountId("relay.tg")),
65-
changesType = RpcStateChangesInBlockByTypeRequest.AccountChangesByFinality.ChangesType.ACCOUNT_CHANGES,
66-
finality = Finality.FINAL
67-
)
68-
)
69-
70-
println("Changes Response: $result")
71-
7258
if (response !is RpcResponse.Success)
7359
assertTrue { false }
7460

client/src/test/kotlin/io/github/hosseinkarami_dev/near/rpc/client/ExperimentalChangesInBlockTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ class ExperimentalChangesInBlockTest {
4242
fun testStatus() = runTest {
4343
val response = nearClient.experimentalChangesInBlock(RpcStateChangesInBlockRequest.Finality(
4444
Finality.FINAL))
45+
4546
val result = response.getResultOrNull<RpcStateChangesInBlockByTypeResponse>()
46-
println("Experimental Changes In Block Response: $result")
47+
println("Experimental Changes In Block Response: $response")
4748
assertTrue { response is RpcResponse.Success }
4849
}
4950
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package io.github.hosseinkarami_dev.near.rpc.client
2+
3+
import io.github.hosseinkarami_dev.near.rpc.client.Utils.getResultOrNull
4+
import io.github.hosseinkarami_dev.near.rpc.models.AccountId
5+
import io.github.hosseinkarami_dev.near.rpc.models.Finality
6+
import io.github.hosseinkarami_dev.near.rpc.models.RpcQueryRequest
7+
import io.github.hosseinkarami_dev.near.rpc.models.RpcQueryResponse
8+
import io.ktor.client.HttpClient
9+
import io.ktor.client.engine.cio.CIO
10+
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11+
import io.ktor.serialization.kotlinx.json.json
12+
import kotlinx.coroutines.test.runTest
13+
import org.junit.jupiter.api.Test
14+
import kotlin.test.AfterTest
15+
import kotlin.test.BeforeTest
16+
import kotlin.test.assertNotNull
17+
18+
class QueryTest {
19+
20+
private lateinit var httpClient: HttpClient
21+
private lateinit var nearClient: NearClient
22+
23+
@BeforeTest
24+
fun setup() {
25+
httpClient = HttpClient(CIO) {
26+
install(ContentNegotiation) {
27+
json()
28+
}
29+
}
30+
31+
nearClient = NearClient(
32+
httpClient = httpClient,
33+
baseUrl = "https://rpc.mainnet.near.org"
34+
)
35+
}
36+
37+
@AfterTest
38+
fun teardown() {
39+
httpClient.close()
40+
}
41+
42+
@Test
43+
fun testStatus() = runTest {
44+
val response = nearClient.query(
45+
RpcQueryRequest.ViewAccountByFinality(
46+
finality = Finality.FINAL,
47+
accountId = AccountId("neardome2340.near"),
48+
requestType = RpcQueryRequest.ViewAccountByFinality.RequestType.VIEW_ACCOUNT
49+
)
50+
)
51+
val result = response.getResultOrNull<RpcQueryResponse>()
52+
println("ViewAccountByFinality Response: $result")
53+
54+
assertNotNull(response !is RpcResponse.Failure)
55+
}
56+
}

client/src/test/kotlin/io/github/hosseinkarami_dev/near/rpc/client/TxStatusTest.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,9 @@ class TxStatusTest {
4545
val response = nearClient.experimentalTxStatus(RpcTransactionStatusRequest.SenderAccountId(
4646
senderAccountId = AccountId("relay.tg"),
4747
txHash = CryptoHash("5FfisT8c27W2vg3AqFTX5EVKve2xV5ZUHuzr2vxYM6c2"),
48-
waitUntil = TxExecutionStatus.ExecutedOptimistic
4948
))
5049
val result = response.getResultOrNull<RpcTransactionResponse>()
51-
println("Experimental Tx Status Response: $result")
52-
assertTrue { response is RpcResponse.Failure }
50+
println("Experimental Tx Status Response: $response")
51+
assertTrue { response is RpcResponse.Success }
5352
}
5453
}

generator/src/main/kotlin/io/github/hosseinkarami_dev/near/rpc/generator/SerializerGenerator.kt

Lines changed: 124 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
package io.github.hosseinkarami_dev.near.rpc.generator
2+
13
import com.squareup.kotlinpoet.*
24
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
35
import io.github.hosseinkarami_dev.near.rpc.generator.SealedInfo
@@ -11,15 +13,14 @@ object SerializerGenerator {
1113
fun generateFromSealedInfos(
1214
sealedInfos: List<SealedInfo>,
1315
serializerPackage: String,
14-
output: File,
15-
discriminatorFields: List<String> = listOf("type", "name")
16+
output: File
1617
) {
1718
if (!output.exists()) output.mkdirs()
1819
if (sealedInfos.isEmpty()) return
1920

2021
for (info in sealedInfos) {
2122
try {
22-
val fileSpec = generateSealedClassSerializer(info, serializerPackage, discriminatorFields)
23+
val fileSpec = generateSealedClassSerializer(info, serializerPackage)
2324
fileSpec.writeTo(output)
2425
} catch (ex: Exception) {
2526
System.err.println("Failed generating serializer for ${info.className}: ${ex.message}")
@@ -32,13 +33,33 @@ object SerializerGenerator {
3233

3334
fun generateSealedClassSerializer(
3435
info: SealedInfo,
35-
serializerPackage: String,
36-
discriminatorFields: List<String> = listOf("type", "name")
36+
serializerPackage: String
3737
): FileSpec {
3838
val modelsPkg = info.packageName
3939
val clsName = info.className
4040
val serializerName = "${clsName}Serializer"
4141

42+
// derive discriminator candidate field names from the sealed-info itself
43+
val discCandidates: List<String> = run {
44+
val freq = mutableMapOf<String, Int>()
45+
val nVariants = info.variants.size
46+
for (v in info.variants) {
47+
for (p in v.props) {
48+
val t = sanitizeType(p.type)
49+
// consider only string-like properties as possible discriminators
50+
if (t.equals("String", ignoreCase = true) || t.equals("kotlin.String", ignoreCase = true)) {
51+
freq[p.serialName] = (freq[p.serialName] ?: 0) + 1
52+
}
53+
}
54+
}
55+
if (freq.isEmpty()) emptyList()
56+
else {
57+
// threshold: appear in at least half of variants
58+
val threshold = (nVariants + 1) / 2
59+
freq.filter { it.value >= threshold }.keys.toList()
60+
}
61+
}
62+
4263
val modelClass = ClassName(modelsPkg, clsName)
4364
val kSerializerOfModel =
4465
ClassName("kotlinx.serialization", "KSerializer").parameterizedBy(modelClass)
@@ -153,9 +174,26 @@ object SerializerGenerator {
153174
val objBuilder = TypeSpec.objectBuilder(serializerName)
154175
.addSuperinterface(kSerializerOfModel)
155176

177+
// --- build a descriptor with one element per variant (names = serialName)
178+
val descriptorInitializer = CodeBlock.builder()
179+
descriptorInitializer.add(
180+
"%M(%S) {\n",
181+
MemberName("kotlinx.serialization.descriptors", "buildClassSerialDescriptor"),
182+
"$modelsPkg.$clsName"
183+
)
184+
for (v in info.variants) {
185+
val variantClass = ClassName(modelsPkg, clsName, v.name)
186+
// produce: element("VariantSerialName", serializer<modelsPkg.ClsName.Variant>().descriptor)
187+
descriptorInitializer.add(
188+
" element(%S, %L)\n",
189+
v.serialName,
190+
CodeBlock.of("serializer<%T>().descriptor", ClassName("kotlinx.serialization.json","JsonElement"))
191+
)
192+
}
193+
descriptorInitializer.add("}")
156194
val descriptorProp = PropertySpec.builder("descriptor", serialDescriptor)
157195
.addModifiers(KModifier.OVERRIDE)
158-
.initializer("%M(%S)", MemberName("kotlinx.serialization.descriptors", "buildClassSerialDescriptor"), "$modelsPkg.$clsName")
196+
.initializer(descriptorInitializer.build())
159197
.build()
160198
objBuilder.addProperty(descriptorProp)
161199

@@ -231,23 +269,14 @@ object SerializerGenerator {
231269
scb.addStatement("return")
232270
scb.endControlFlow()
233271

272+
// non-JSON: encode full variant serializer for each variant (descriptor elements align with variant order)
234273
scb.addStatement("val out = encoder.beginStructure(descriptor)")
235274
scb.beginControlFlow("when (value)")
236275
var idx = 0
237276
for (v in info.variants) {
238-
if (v.kind == VariantInfo.Kind.OBJECT) {
239-
scb.addStatement("%T.%L -> out.encodeStringElement(descriptor, %L, %S)", modelClass, v.name, idx, v.serialName)
240-
} else {
241-
val variantClass = ClassName(modelsPkg, clsName, v.name)
242-
val p = v.props.firstOrNull()
243-
if (p != null) {
244-
val ser = serializerExpressionFor(p.type, v.name)
245-
scb.addStatement("is %T -> out.encodeSerializableElement(descriptor, %L, %L, value.%L)", variantClass, idx, ser, p.name)
246-
} else {
247-
val varSer = CodeBlock.of("serializer<%T>()", variantClass)
248-
scb.addStatement("is %T -> out.encodeSerializableElement(descriptor, %L, %L, value)", variantClass, idx, varSer)
249-
}
250-
}
277+
val variantClass = ClassName(modelsPkg, clsName, v.name)
278+
val varSer = CodeBlock.of("serializer<%T>()", variantClass)
279+
scb.addStatement("is %T -> out.encodeSerializableElement(descriptor, %L, %L, value)", variantClass, idx, varSer)
251280
idx++
252281
}
253282
scb.endControlFlow()
@@ -302,7 +331,7 @@ object SerializerGenerator {
302331
dcb.beginControlFlow("is %T ->", ClassName("kotlinx.serialization.json", "JsonObject"))
303332
dcb.addStatement("val jobj = element")
304333

305-
// ---------- new: field-based detection ----------
334+
// ---------- new: field-based detection with grouping to avoid duplicate checks ----------
306335
if (fieldBased) {
307336
dcb.addStatement("// fieldBased union: detect variant by unique field presence")
308337
for (v in dataVariants) {
@@ -333,6 +362,64 @@ object SerializerGenerator {
333362
}
334363
}
335364

365+
// --- Group variants by their required (non-nullable) keys to avoid emitting duplicated identical checks ---
366+
run {
367+
// build groups: Map(sortedRequiredKeysList -> List<VariantInfo>)
368+
val reqGroups = mutableMapOf<List<String>, MutableList<VariantInfo>>()
369+
for (v in dataVariants) {
370+
val reqKeys = v.props.filter { !it.type.trim().endsWith("?") }.map { it.serialName }
371+
if (reqKeys.isNotEmpty()) {
372+
val sortedKey = reqKeys.sorted()
373+
reqGroups.computeIfAbsent(sortedKey) { mutableListOf() }.add(v)
374+
}
375+
}
376+
377+
for ((reqKeys, variantsWithSameReq) in reqGroups) {
378+
if (reqKeys.isEmpty()) continue
379+
val reqListLiteral = reqKeys.joinToString(", ") { "\"$it\"" }
380+
if (variantsWithSameReq.size == 1) {
381+
val v = variantsWithSameReq[0]
382+
dcb.beginControlFlow("if (listOf($reqListLiteral).all { jobj[it] != null })")
383+
if (v.props.size == 1 && v.props[0].name == "value") {
384+
val ser = serializerExpressionFor(v.props[0].type, v.name)
385+
dcb.addStatement("return %T(decoder.json.decodeFromJsonElement(%L, jobj[%S]!!))", ClassName(modelsPkg, clsName, v.name), ser, v.props[0].serialName)
386+
} else {
387+
val variantSerializerCb = CodeBlock.of("serializer<%T>()", ClassName(modelsPkg, clsName, v.name))
388+
dcb.addStatement("return decoder.json.decodeFromJsonElement(%L, jobj)", variantSerializerCb)
389+
}
390+
dcb.endControlFlow()
391+
} else {
392+
// ambiguous group: try to disambiguate by 'type' field if present in all variants of the group
393+
val allHaveTypeField = variantsWithSameReq.all { vv -> vv.props.any { p -> p.serialName == "type" } }
394+
dcb.beginControlFlow("if (listOf($reqListLiteral).all { jobj[it] != null })")
395+
if (allHaveTypeField) {
396+
dcb.addStatement("val tfElem = jobj[%S]", "type")
397+
dcb.beginControlFlow("if (tfElem is %T)", ClassName("kotlinx.serialization.json", "JsonPrimitive"))
398+
dcb.addStatement("val tfVal = tfElem.content")
399+
dcb.beginControlFlow("when (tfVal)")
400+
for (v in variantsWithSameReq) {
401+
dcb.beginControlFlow("%S ->", v.serialName)
402+
if (v.props.size == 1 && v.props[0].name == "value") {
403+
val ser = serializerExpressionFor(v.props[0].type, v.name)
404+
dcb.addStatement("return %T(decoder.json.decodeFromJsonElement(%L, jobj[%S]!!))", ClassName(modelsPkg, clsName, v.name), ser, v.props[0].serialName)
405+
} else {
406+
val variantSerializerCb = CodeBlock.of("serializer<%T>()", ClassName(modelsPkg, clsName, v.name))
407+
dcb.addStatement("return decoder.json.decodeFromJsonElement(%L, jobj)", variantSerializerCb)
408+
}
409+
dcb.endControlFlow()
410+
}
411+
dcb.addStatement("else -> { /* not recognized by type field, fallthrough */ }")
412+
dcb.endControlFlow() // end when(tfVal)
413+
dcb.endControlFlow() // end if (tfElem is JsonPrimitive)
414+
} else {
415+
// can't disambiguate here; allow later heuristics (wrapper/flat/heuristic) to handle these cases.
416+
dcb.addStatement("// ambiguous required-keys group; skipping disambiguation here to avoid wrong decode")
417+
}
418+
dcb.endControlFlow() // end if listOf(...).all
419+
}
420+
}
421+
}
422+
336423
// wrapper-style with single-key
337424
dcb.beginControlFlow("if (jobj.size == 1)")
338425
dcb.addStatement("val entry = jobj.entries.first()")
@@ -377,34 +464,37 @@ object SerializerGenerator {
377464
dcb.endControlFlow() // end when(key)
378465
dcb.endControlFlow() // end if (jobj.size == 1)
379466

380-
// flat-style: try configured discriminators first, then heuristic fallback
467+
// flat-style: try configured discriminators first (derived from sealed info), then heuristic fallback
381468
dcb.beginControlFlow("else")
382469

383-
// inject discriminator candidates (from generator param)
384-
val discListLiteral = discriminatorFields.joinToString(", ") { "\"$it\"" }
385-
dcb.addStatement("val discriminatorCandidates = listOf($discListLiteral)")
386-
387470
dcb.addStatement("var typeField: String? = null")
388-
// try configured candidates
389-
dcb.beginControlFlow("for (cand in discriminatorCandidates)")
390-
dcb.addStatement("typeField = jobj[cand]?.jsonPrimitive?.contentOrNull")
391-
dcb.addStatement("if (typeField != null) break")
392-
dcb.endControlFlow()
471+
if (discCandidates.isNotEmpty()) {
472+
val discListLiteral = discCandidates.joinToString(", ") { "\"$it\"" }
473+
dcb.addStatement("val discriminatorCandidates = listOf($discListLiteral)")
474+
dcb.beginControlFlow("for (cand in discriminatorCandidates)")
475+
dcb.addStatement("val candElem = jobj[cand]")
476+
dcb.beginControlFlow("if (candElem is %T)", ClassName("kotlinx.serialization.json", "JsonPrimitive"))
477+
dcb.addStatement("typeField = candElem.contentOrNull")
478+
dcb.addStatement("if (typeField != null) break")
479+
dcb.endControlFlow()
480+
dcb.endControlFlow()
481+
}
393482

394483
// heuristic: if still null, look for any string value matching a known variant serialName
395484
val variantNamesList = info.variants.joinToString(", ") { "\"${it.serialName}\"" }
396485
dcb.addStatement("if (typeField == null) {")
397486
dcb.addStatement(" val knownVariantNames = setOf($variantNamesList)")
398487
dcb.addStatement(" for ((k, v) in jobj.entries) {")
399-
dcb.beginControlFlow(" if (v is %T && v.jsonPrimitive.isString)", ClassName("kotlinx.serialization.json", "JsonElement"))
400-
dcb.addStatement(" val s = (v as %T).jsonPrimitive.content", ClassName("kotlinx.serialization.json", "JsonElement"))
488+
dcb.beginControlFlow(" if (v is %T && v.isString)", ClassName("kotlinx.serialization.json", "JsonPrimitive"))
489+
dcb.addStatement(" val s = v.content")
401490
dcb.addStatement(" if (knownVariantNames.any { it.equals(s, ignoreCase = true) }) { typeField = s; break }")
402491
dcb.endControlFlow()
403492
dcb.addStatement(" }")
404493
dcb.addStatement("}")
405494

406495
// still null -> error
407-
dcb.addStatement("if (typeField == null) throw %T(%S)", SerializationException::class, "Missing discriminator (one of ${discriminatorFields.joinToString("/")}) or recognizable variant in $clsName")
496+
val discMsg = if (discCandidates.isNotEmpty()) "Missing discriminator (one of ${discCandidates.joinToString("/")}) or recognizable variant in $clsName" else "Missing discriminator or recognizable variant in $clsName"
497+
dcb.addStatement("if (typeField == null) throw %T(%S)", SerializationException::class, discMsg)
408498

409499
// normalize typeField for safe matching
410500
dcb.addStatement("val tf = typeField.trim()")
@@ -459,4 +549,4 @@ object SerializerGenerator {
459549
if (s.startsWith("`") && s.endsWith("`") && s.length > 1) s = s.substring(1, s.length - 1)
460550
return s.trim()
461551
}
462-
}
552+
}

0 commit comments

Comments
 (0)