Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.evomaster.core.search.gene.numeric.IntegerGene
import org.evomaster.core.search.gene.numeric.LongGene
import org.evomaster.core.search.gene.placeholder.CycleObjectGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene
import org.evomaster.core.search.gene.regex.RegexGene
import org.evomaster.core.search.gene.string.Base64StringGene
import org.evomaster.core.search.gene.string.StringGene
Expand Down Expand Up @@ -119,18 +120,47 @@ class DtoWriter(
gene is ObjectGene -> calculateDtoFromObject(gene, actionName)
gene is ArrayGene<*> -> calculateDtoFromArray(gene, actionName)
gene is FixedMapGene<*, *> -> calculateDtoFromFixedMapGene(gene, actionName)
// TODO: a JsonPatchDocumentGene is currently skipped from DTO collection. Once we decide
// how a JSON Patch document should be rendered when a test case is written (it is not a
// regular object/array DTO but an RFC 6902 array of operations), this should build and
// emit the corresponding DTO instead of returning.
gene is JsonPatchDocumentGene -> return
gene is JsonPatchDocumentGene -> calculateDtoFromJsonPatch(gene)
isPrimitiveGene(gene) -> return
else -> {
throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $actionName")
}
}
}

/**
* Collects the DTO needed to render a JSON Patch document (RFC 6902) as a typed payload.
*
* A single shared [GeneToDto.JSON_PATCH_OPERATION_DTO] class is used for all patch operations,
* holding every field used across the operation types: "op" and "path" (always present),
* "from" (move/copy) and "value" (add/replace/test). The "value" field is typed as the generic
* object type since a JSON Patch value can be any JSON value; fields not used by a given
* operation are left null and skipped on serialization (see @JsonInclude(NON_NULL)).
*
* When an operation carries an object or array value, the corresponding nested DTOs are also
* collected so the value can be rendered as a proper object instead of stringified JSON.
*/
private fun calculateDtoFromJsonPatch(gene: JsonPatchDocumentGene) {
val dtoName = GeneToDto.JSON_PATCH_OPERATION_DTO
val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(it) }
dtoClass.addField(GeneToDto.FIELD_OP, DtoField(GeneToDto.FIELD_OP, "String"))
dtoClass.addField(GeneToDto.FIELD_PATH, DtoField(GeneToDto.FIELD_PATH, "String"))
dtoClass.addField(GeneToDto.FIELD_FROM, DtoField(GeneToDto.FIELD_FROM, "String"))
dtoClass.addField(GeneToDto.FIELD_VALUE, DtoField(GeneToDto.FIELD_VALUE, anyType()))
dtoCollector[dtoName] = dtoClass

gene.operations.filterIsInstance<JsonPatchPathValueGene>().forEach { operation ->
when (val valueGene = operation.pathValueChoice.activeGene().second.getLeafGene()) {
is ObjectGene -> calculateDtoFromObject(valueGene, GeneToDto.FIELD_VALUE)
is ArrayGene<*> -> calculateDtoFromArray(valueGene, GeneToDto.FIELD_VALUE)
}
}
}

private fun anyType(): String {
return if (outputFormat.isJava()) "Object" else "Any"
}

private fun calculateDtoFromFixedMapGene(gene: FixedMapGene<*, *>, actionName: String) {
val dtoName = TestWriterUtils.safeVariableName(actionName)
val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(dtoName) }
Expand Down
99 changes: 99 additions & 0 deletions core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import org.evomaster.core.search.gene.collection.PairGene
import org.evomaster.core.search.gene.datetime.DateGene
import org.evomaster.core.search.gene.datetime.DateTimeGene
import org.evomaster.core.search.gene.datetime.TimeGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchFromPathGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchOperationGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathOnlyGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene
import org.evomaster.core.search.gene.numeric.DoubleGene
import org.evomaster.core.search.gene.numeric.FloatGene
import org.evomaster.core.search.gene.numeric.IntegerGene
Expand All @@ -36,6 +41,19 @@ class GeneToDto(
val outputFormat: OutputFormat
) {

companion object {
/**
* Shared DTO class name used to represent a single JSON Patch operation (RFC 6902).
* A single class is enough since all operations share the same set of possible fields;
* fields not used by a given operation are simply left null and skipped on serialization.
*/
const val JSON_PATCH_OPERATION_DTO = "JsonPatchOperation"
const val FIELD_OP = "op"
const val FIELD_PATH = "path"
const val FIELD_FROM = "from"
const val FIELD_VALUE = "value"
}

private val log: Logger = LoggerFactory.getLogger(GeneToDto::class.java)

private var dtoOutput: DtoOutput = if (outputFormat.isJava()) {
Expand Down Expand Up @@ -67,6 +85,7 @@ class GeneToDto(
}
is ChoiceGene<*> -> TestWriterUtils.safeVariableName(fallback)
is FixedMapGene<*,*> -> TestWriterUtils.safeVariableName(fallback)
is JsonPatchDocumentGene -> JSON_PATCH_OPERATION_DTO
else -> throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $fallback")
}
}
Expand All @@ -85,10 +104,90 @@ class GeneToDto(
is ArrayGene<*> -> getArrayDtoCall(gene, dtoName, counters, null, capitalize)
is ChoiceGene<*> -> getDtoCall(gene.activeGene(), dtoName, counters, capitalize)
is FixedMapGene<*,*> -> getFixedMapGeneDtoCall(gene, dtoName, counters)
is JsonPatchDocumentGene -> getJsonPatchDtoCall(gene, counters)
else -> throw RuntimeException("BUG: Gene $gene (with type ${this::class.java.simpleName}) should not be creating DTOs")
}
}

/**
* A JSON Patch document (RFC 6902) is rendered as a list of [JSON_PATCH_OPERATION_DTO] objects,
* one per active operation in the document. This mirrors the JSON array structure of the payload
* while keeping the generated test readable and type-safe.
*/
private fun getJsonPatchDtoCall(gene: JsonPatchDocumentGene, counters: MutableList<Int>): DtoCall {
val listVarName = "list_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}"
val result = mutableListOf<String>()
result.add(dtoOutput.getNewListStatement(JSON_PATCH_OPERATION_DTO, listVarName))

var operationCounter = 1
gene.operations.forEach { operation ->
val childCounter = mutableListOf<Int>().apply {
addAll(counters)
add(operationCounter++)
}
val operationCall = getJsonPatchOperationCall(operation, childCounter)
result.addAll(operationCall.objectCalls)
result.add(dtoOutput.getAddElementToListStatement(listVarName, operationCall.varName))
}

return DtoCall(listVarName, result)
}

private fun getJsonPatchOperationCall(operation: JsonPatchOperationGene, counters: MutableList<Int>): DtoCall {
val varName = "dto_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}"
val result = mutableListOf<String>()
result.add(dtoOutput.getNewObjectStatement(JSON_PATCH_OPERATION_DTO, varName))
result.add(dtoOutput.getSetterStatement(varName, FIELD_OP, "\"${operation.operationName}\""))

when (operation) {
is JsonPatchPathOnlyGene -> {
result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(operation.pathGene)))
}
is JsonPatchFromPathGene -> {
result.add(dtoOutput.getSetterStatement(varName, FIELD_FROM, renderLeafValue(operation.fromGene)))
result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(operation.pathGene)))
}
is JsonPatchPathValueGene -> {
val pair = operation.pathValueChoice.activeGene()
result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(pair.first)))
setJsonPatchValue(varName, pair.second.getLeafGene(), counters, result)
}
}

return DtoCall(varName, result)
}

/**
* Sets the "value" field of a JSON Patch operation. Primitive values are inlined as literals,
* while object and array values reuse the regular DTO/list generation so nested structures
* are rendered as proper objects rather than stringified JSON.
*/
private fun setJsonPatchValue(
varName: String,
valueGene: Gene,
counters: MutableList<Int>,
result: MutableList<String>
) {
when (valueGene) {
is ObjectGene -> {
val childCall = getDtoCall(valueGene, getDtoName(valueGene, FIELD_VALUE, true), counters, true)
result.addAll(childCall.objectCalls)
result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, childCall.varName))
}
is ArrayGene<*> -> {
val childCall = getArrayDtoCall(valueGene, getDtoName(valueGene, FIELD_VALUE, true), counters, FIELD_VALUE, true)
result.addAll(childCall.objectCalls)
result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, childCall.varName))
}
else -> result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, renderLeafValue(valueGene)))
}
}

private fun renderLeafValue(gene: Gene): String {
val leafGene = gene.getLeafGene()
return "${leafGene.getValueAsPrintableString(targetFormat = outputFormat)}${getValueSuffix(leafGene)}"
}

private fun getObjectDtoCall(gene: ObjectGene, dtoName: String, counters: MutableList<Int>): DtoCall {
val dtoVarName = "dto_${dtoName}_${counters.joinToString("_")}"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,13 @@ abstract class HttpWsTestCaseWriter : ApiTestCaseWriter() {
val bodyParam = call.parameters.find { p -> p is BodyParam } as BodyParam?
if (bodyParam != null && bodyParam.isJson() && payloadIsValidJson(bodyParam)) {
val primaryGene = bodyParam.primaryGene()
if (primaryGene.getWrappedGene(JsonPatchDocumentGene::class.java) != null) {
return ""
val actionName = call.getName()
val jsonPatchGene = primaryGene.getWrappedGene(JsonPatchDocumentGene::class.java)
if (jsonPatchGene != null) {
// A JSON Patch document is rendered as a List<JsonPatchOperation> DTO (RFC 6902).
return generateDtoCall(jsonPatchGene, actionName, lines).varName
}
val choiceGene = primaryGene.getWrappedGene(ChoiceGene::class.java)
val actionName = call.getName()
if (choiceGene != null) {
// We only generate DTOs for ChoiceGene objects that contain either an ObjectGene or ArrayGene in their
// genes. This check is necessary since when using `example` and `default` entries,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.evomaster.core.output.dto

import org.evomaster.core.output.OutputFormat
import org.evomaster.core.output.Termination
import org.evomaster.core.output.naming.RestActionTestCaseUtils.getEvaluatedIndividualWith
import org.evomaster.core.output.naming.RestActionTestCaseUtils.getRestCallAction
import org.evomaster.core.problem.api.param.Param
import org.evomaster.core.problem.rest.data.HttpVerb
import org.evomaster.core.problem.rest.param.BodyParam
import org.evomaster.core.search.Solution
import org.evomaster.core.search.gene.ObjectGene
import org.evomaster.core.search.gene.collection.EnumGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.numeric.IntegerGene
import org.evomaster.core.search.gene.string.StringGene
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import java.nio.file.Paths
import java.util.Collections.singletonList

/**
* Verifies that [DtoWriter] collects the shared JsonPatchOperation DTO (RFC 6902) with every
* field used across operation types, so a JSON Patch payload can be written as a DTO.
*/
class DtoWriterJsonPatchTest {

private val outputTestSuitePath = Paths.get("./target/dto-writer-json-patch-test")
private val testPackage = "test.package"

private fun jsonPatchBodyParam(): Param {
val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age")))
val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 }
return BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene)
}

private fun jsonPatchSolution(): Solution<*> {
val action = getRestCallAction("/items/{id}", HttpVerb.PATCH, mutableListOf(jsonPatchBodyParam()))
val eIndividual = getEvaluatedIndividualWith(action)
return Solution(singletonList(eIndividual), "", "", Termination.NONE, emptyList(), emptyList())
}

@Test
fun collectsJsonPatchOperationDtoWithAllFields() {
val dtoWriter = DtoWriter(OutputFormat.KOTLIN_JUNIT_5)
dtoWriter.write(outputTestSuitePath, testPackage, jsonPatchSolution())

val dtos = dtoWriter.getCollectedDtos()
val operationDto = dtos[GeneToDto.JSON_PATCH_OPERATION_DTO]
assertNotNull(operationDto, "Expected a ${GeneToDto.JSON_PATCH_OPERATION_DTO} DTO to be collected")

val fields = operationDto!!.fieldsMap
assertEquals(DtoField(GeneToDto.FIELD_OP, "String"), fields[GeneToDto.FIELD_OP])
assertEquals(DtoField(GeneToDto.FIELD_PATH, "String"), fields[GeneToDto.FIELD_PATH])
assertEquals(DtoField(GeneToDto.FIELD_FROM, "String"), fields[GeneToDto.FIELD_FROM])
// value is the generic object type since a JSON Patch value can be any JSON value
assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Any"), fields[GeneToDto.FIELD_VALUE])
}

@Test
fun valueFieldIsObjectForJavaOutput() {
val dtoWriter = DtoWriter(OutputFormat.JAVA_JUNIT_5)
dtoWriter.write(outputTestSuitePath, testPackage, jsonPatchSolution())

val operationDto = dtoWriter.getCollectedDtos()[GeneToDto.JSON_PATCH_OPERATION_DTO]
assertNotNull(operationDto)
assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Object"), operationDto!!.fieldsMap[GeneToDto.FIELD_VALUE])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package org.evomaster.core.output.dto

import org.evomaster.core.output.OutputFormat
import org.evomaster.core.search.gene.ObjectGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchFromPathGene
import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene
import org.evomaster.core.search.gene.numeric.IntegerGene
import org.evomaster.core.search.gene.string.StringGene
import org.evomaster.core.search.service.Randomness
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

/**
* Verifies that a [JsonPatchDocumentGene] (RFC 6902) is rendered by [GeneToDto] as a
* List<JsonPatchOperation> instead of stringified JSON.
*/
class GeneToDtoJsonPatchTest {

private val dtoName = GeneToDto.JSON_PATCH_OPERATION_DTO

private fun patchDoc(seed: Long = 42L): JsonPatchDocumentGene {
val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age")))
val doc = JsonPatchDocumentGene("patch", schema)
doc.doInitialize(Randomness().apply { updateSeed(seed) })
return doc
}

private fun setOpValue(line: String): String =
Regex("""\.setOp\("(.*?)"\)""").find(line)!!.groupValues[1]

@Test
fun rendersAsListOfJsonPatchOperationDtosKotlin() {
val doc = patchDoc()
val dtoCall = GeneToDto(OutputFormat.KOTLIN_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false)
val calls = dtoCall.objectCalls

assertEquals("list_${dtoName}_0", dtoCall.varName)
assertEquals("val list_${dtoName}_0 = mutableListOf<$dtoName>()", calls.first())

val ops = doc.operations
// One object instantiation and one add-to-list per operation
assertEquals(ops.size, calls.count { it.contains("= $dtoName()") })
assertEquals(ops.size, calls.count { it.startsWith("list_${dtoName}_0.add(dto_${dtoName}_") })

// Every operation sets its "op", and the multiset of op names matches the document
val opNamesInCode = calls.filter { it.contains(".setOp(") }.map { setOpValue(it) }
assertEquals(ops.map { it.operationName }.sorted(), opNamesInCode.sorted())
}

@Test
fun setsOnlyTheFieldsRelevantToEachOperation() {
val doc = patchDoc()
val calls = GeneToDto(OutputFormat.KOTLIN_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false).objectCalls

val ops = doc.operations
// "from" only for move/copy, "value" only for add/replace/test
assertEquals(ops.count { it is JsonPatchFromPathGene }, calls.count { it.contains(".setFrom(") })
assertEquals(ops.count { it is JsonPatchPathValueGene }, calls.count { it.contains(".setValue(") })
// "op" and "path" are present in every operation
assertEquals(ops.size, calls.count { it.contains(".setOp(") })
assertEquals(ops.size, calls.count { it.contains(".setPath(") })
}

@Test
fun primitiveValuesAreInlinedAsLiterals() {
// Force every operation type to render; assert string values are quoted and int values are bare
val doc = patchDoc(seed = 7L)
val calls = GeneToDto(OutputFormat.KOTLIN_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false).objectCalls

calls.filter { it.contains(".setValue(") }.forEach { line ->
val value = Regex("""\.setValue\((.*)\)""").find(line)!!.groupValues[1]
val isQuotedString = value.startsWith("\"") && value.endsWith("\"")
val isInt = value.toIntOrNull() != null
assertTrue(isQuotedString || isInt, "Unexpected non-literal value rendering: $line")
}
}

@Test
fun rendersWithJavaSyntax() {
val doc = patchDoc()
val calls = GeneToDto(OutputFormat.JAVA_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false).objectCalls

assertEquals("List<$dtoName> list_${dtoName}_0 = new ArrayList<$dtoName>();", calls.first())
assertTrue(calls.any { it.contains("$dtoName dto_${dtoName}_0_1 = new $dtoName();") })
}
}
Loading