Repository: theopenconversationkit/tock
Issue: [Test context] Allow empty sentences in Xray attachments

Bot answers stored in Xray attachments created within test context may have empty sentences like this :
`{sentence:}`.
When a test execution is ordered, message parsing fails because of those empty sentences.

Changed files:
bot/xray/src/main/kotlin/XrayService.kt, bot/engine/src/main/kotlin/engine/message/parser/MessageParser.kt, bot/engine/src/test/kotlin/engine/message/parser/MessageParserTest.kt

diff --git a/bot/engine/src/main/kotlin/engine/message/parser/MessageParser.kt b/bot/engine/src/main/kotlin/engine/message/parser/MessageParser.kt
index 1ec8ae65d..3eca8225a 100644
--- a/bot/engine/src/main/kotlin/engine/message/parser/MessageParser.kt
+++ b/bot/engine/src/main/kotlin/engine/message/parser/MessageParser.kt
@@ -54,21 +54,21 @@ object MessageParser {
 
     //duplicate mapper to avoid ALLOW_UNQUOTED_FIELD_NAMES default
     private val mapper: ObjectMapper =
-            jacksonObjectMapper()
-                    .findAndRegisterModules()
-                    //force java time module
-                    .registerModule(JavaTimeModule())
-                    .configure(ALLOW_UNQUOTED_FIELD_NAMES, true)
-                    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
-                    .setSerializationInclusion(JsonInclude.Include.NON_NULL)
-                    .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
-                    .configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true)
-                    .configure(MapperFeature.PROPAGATE_TRANSIENT_MARKER, true)
+        jacksonObjectMapper()
+            .findAndRegisterModules()
+            //force java time module
+            .registerModule(JavaTimeModule())
+            .configure(ALLOW_UNQUOTED_FIELD_NAMES, true)
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+            .configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true)
+            .configure(MapperFeature.PROPAGATE_TRANSIENT_MARKER, true)
 
     fun parse(content: String): List<Message> {
         return content.trim()
-                .split(multiMessagesSeparator)
-                .map { toMessage(it) }
+            .split(multiMessagesSeparator)
+            .map { toMessage(it) }
     }
 
     internal fun mapToString(map: Map<String, String>): String {
@@ -82,13 +82,17 @@ object MessageParser {
     private fun elementToString(element: GenericMessage): String {
         return with(element) {
             val content = listOfNotNull(
-                    if (connectorType == ConnectorType.none) null else "connectorType:$connectorType",
-                    if (attachments.isEmpty()) null else "attachments:[${attachments.joinToString(fieldSeparator) { it.toPrettyString() }}]",
-                    if (choices.isEmpty()) null else "choices:[${choices.joinToString(fieldSeparator) { it.toPrettyString() }}]",
-                    if (locations.isEmpty()) null else "locations:[${locations.joinToString(fieldSeparator) { it.toPrettyString() }}]",
-                    if (texts.isEmpty()) null else "texts:${mapper.writeValueAsString(texts)}",
-                    if (metadata.isEmpty()) null else "metadata:${mapper.writeValueAsString(metadata)}",
-                    if (subElements.isEmpty()) null else "subElements:[${subElements.joinToString(subElementsArraySeparator) { subElementToString(it) }}]"
+                if (connectorType == ConnectorType.none) null else "connectorType:$connectorType",
+                if (attachments.isEmpty()) null else "attachments:[${attachments.joinToString(fieldSeparator) { it.toPrettyString() }}]",
+                if (choices.isEmpty()) null else "choices:[${choices.joinToString(fieldSeparator) { it.toPrettyString() }}]",
+                if (locations.isEmpty()) null else "locations:[${locations.joinToString(fieldSeparator) { it.toPrettyString() }}]",
+                if (texts.isEmpty()) null else "texts:${mapper.writeValueAsString(texts)}",
+                if (metadata.isEmpty()) null else "metadata:${mapper.writeValueAsString(metadata)}",
+                if (subElements.isEmpty()) null else "subElements:[${subElements.joinToString(subElementsArraySeparator) {
+                    subElementToString(
+                        it
+                    )
+                }}]"
             ).joinToString(prefix = elementsSeparator, separator = elementsSeparator)
             "{$content}"
         }
@@ -97,11 +101,11 @@ object MessageParser {
     private fun subElementToString(element: GenericElement): String {
         return with(element) {
             val content = listOfNotNull(
-                    if (attachments.isEmpty()) null else "attachments:[${attachments.joinToString(fieldSeparator) { it.toPrettyString() }}]",
-                    if (choices.isEmpty()) null else "choices:[${choices.joinToString(fieldSeparator) { it.toPrettyString() }}]",
-                    if (locations.isEmpty()) null else "locations:[${locations.joinToString(fieldSeparator) { it.toPrettyString() }}]",
-                    if (texts.isEmpty()) null else "texts:${mapper.writeValueAsString(texts)}",
-                    if (metadata.isEmpty()) null else "metadata:${mapper.writeValueAsString(metadata)}"
+                if (attachments.isEmpty()) null else "attachments:[${attachments.joinToString(fieldSeparator) { it.toPrettyString() }}]",
+                if (choices.isEmpty()) null else "choices:[${choices.joinToString(fieldSeparator) { it.toPrettyString() }}]",
+                if (locations.isEmpty()) null else "locations:[${locations.joinToString(fieldSeparator) { it.toPrettyString() }}]",
+                if (texts.isEmpty()) null else "texts:${mapper.writeValueAsString(texts)}",
+                if (metadata.isEmpty()) null else "metadata:${mapper.writeValueAsString(metadata)}"
             ).joinToString(prefix = subElementsSeparator, separator = subElementsSeparator)
             "{$content}"
         }
@@ -109,38 +113,34 @@ object MessageParser {
 
     private fun toMessage(content: String): Message {
         return content.trim().let {
-            if (it.contains("{$sentence")) {
-                parseSentence(it)
-            } else if (it.contains("{$choice")) {
-                parseChoice(it)
-            } else if (it.contains("{$attachment")) {
-                parseAttachment(it)
-            } else if (it.contains("{$location")) {
-                parseLocation(it)
-            } else {
-                Sentence(it)
+            when {
+                it.contains("{$sentence") -> parseSentence(it)
+                it.contains("{$choice") -> parseChoice(it)
+                it.contains("{$attachment") -> parseAttachment(it)
+                it.contains("{$location") -> parseLocation(it)
+                else -> Sentence(it)
             }
         }
     }
 
     private fun parseSentence(content: String): Sentence {
         return content
-                .removePrefix("{")
-                .let {
-                    it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
-                            .let {
-                                Sentence(
-                                        null,
-                                        //only one element supported
-                                        mutableListOf(parseSentenceElement(it))
-                                )
-                            }
-                }
+            .removePrefix("{")
+            .let {
+                it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
+                    .let {
+                        Sentence(
+                            null,
+                            //only one element supported
+                            mutableListOf(parseSentenceElement(it))
+                        )
+                    }
+            }
     }
 
     private fun parseSentenceElement(content: String): GenericMessage {
-        return content
-                .substring(content.indexOf("{") + 1, content.lastIndexOf("}"))
+        return content.takeIf { it.isNotEmpty() }.let {
+            it?.substring(content.indexOf("{") + 1, content.lastIndexOf("}"))
                 .let {
                     var attachments: List<Attachment> = emptyList()
                     var choices: List<Choice> = emptyList()
@@ -149,135 +149,146 @@ object MessageParser {
                     var metadata: Map<String, String> = emptyMap()
                     var elements: List<GenericElement> = emptyList()
 
-                    it.split(elementsSeparator).forEach { s ->
-                        if (s.startsWith("attachments")) {
-                            attachments = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                    it?.split(elementsSeparator)?.forEach { s ->
+                        when {
+                            s.startsWith("attachments") -> {
+                                attachments = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
                                     .split(fieldSeparator)
                                     .map { parseAttachment(it.trim()) }
-                        } else if (s.startsWith("choices")) {
-                            choices = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                            }
+                            s.startsWith("choices") -> {
+                                choices = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
                                     .split(fieldSeparator)
                                     .map { parseChoice(it.trim()) }
-                        } else if (s.startsWith("locations")) {
-                            locations = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                            }
+                            s.startsWith("locations") -> {
+                                locations = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
                                     .split(fieldSeparator)
                                     .map { parseLocation(it.trim()) }
-                        } else if (s.startsWith("subElements")) {
-                            elements = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                            }
+                            s.startsWith("subElements") -> {
+                                elements = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
                                     .split(subElementsArraySeparator)
                                     .map { parseSentenceSubElement(it.trim()) }
-                        } else if (s.startsWith("texts")) {
-                            texts = s.substring(s.indexOf(":") + 1)
+                            }
+                            s.startsWith("texts") -> {
+                                texts = s.substring(s.indexOf(":") + 1)
                                     .let { mapper.readValue(it) }
-                        } else if (s.startsWith("metadata")) {
-                            metadata = s.substring(s.indexOf(":") + 1)
+                            }
+                            s.startsWith("metadata") -> {
+                                metadata = s.substring(s.indexOf(":") + 1)
                                     .let { mapper.readValue(it) }
+                            }
                         }
                     }
 
                     GenericMessage(
-                            ConnectorType.none,
-                            attachments,
-                            choices,
-                            texts,
-                            locations,
-                            metadata,
-                            elements)
+                        ConnectorType.none,
+                        attachments,
+                        choices,
+                        texts,
+                        locations,
+                        metadata,
+                        elements
+                    )
                 }
+        }
     }
 
     private fun parseSentenceSubElement(content: String): GenericElement {
         return content
-                .substring(content.indexOf("{") + 1, content.lastIndexOf("}"))
-                .let {
-                    var attachments: List<Attachment> = emptyList()
-                    var choices: List<Choice> = emptyList()
-                    var texts: Map<String, String> = emptyMap()
-                    var locations: List<Location> = emptyList()
-                    var metadata: Map<String, String> = emptyMap()
+            .substring(content.indexOf("{") + 1, content.lastIndexOf("}"))
+            .let {
+                var attachments: List<Attachment> = emptyList()
+                var choices: List<Choice> = emptyList()
+                var texts: Map<String, String> = emptyMap()
+                var locations: List<Location> = emptyList()
+                var metadata: Map<String, String> = emptyMap()
 
-                    it.split(subElementsSeparator).forEach { s ->
-                        if (s.startsWith("attachments")) {
-                            attachments = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
-                                    .split(fieldSeparator)
-                                    .map { parseAttachment(it.trim()) }
-                        } else if (s.startsWith("choices")) {
-                            choices = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
-                                    .split(fieldSeparator)
-                                    .map { parseChoice(it.trim()) }
-                        } else if (s.startsWith("locations")) {
-                            locations = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
-                                    .split(fieldSeparator)
-                                    .map { parseLocation(it.trim()) }
-                        } else if (s.startsWith("texts")) {
-                            texts = s.substring(s.indexOf(":") + 1)
-                                    .let { mapper.readValue(it) }
-                        } else if (s.startsWith("metadata")) {
-                            metadata = s.substring(s.indexOf(":") + 1)
-                                    .let { mapper.readValue(it) }
-                        }
+                it.split(subElementsSeparator).forEach { s ->
+                    if (s.startsWith("attachments")) {
+                        attachments = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                            .split(fieldSeparator)
+                            .map { parseAttachment(it.trim()) }
+                    } else if (s.startsWith("choices")) {
+                        choices = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                            .split(fieldSeparator)
+                            .map { parseChoice(it.trim()) }
+                    } else if (s.startsWith("locations")) {
+                        locations = s.substring(s.indexOf("[") + 1, s.lastIndexOf("]"))
+                            .split(fieldSeparator)
+                            .map { parseLocation(it.trim()) }
+                    } else if (s.startsWith("texts")) {
+                        texts = s.substring(s.indexOf(":") + 1)
+                            .let { mapper.readValue(it) }
+                    } else if (s.startsWith("metadata")) {
+                        metadata = s.substring(s.indexOf(":") + 1)
+                            .let { mapper.readValue(it) }
                     }
-
-                    GenericElement(
-                            attachments,
-                            choices,
-                            texts,
-                            locations,
-                            metadata)
                 }
+
+                GenericElement(
+                    attachments,
+                    choices,
+                    texts,
+                    locations,
+                    metadata
+                )
+            }
     }
 
     private fun parseChoice(content: String): Choice {
         return content
-                .removePrefix("{")
-                .let {
-                    it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
-                            .let {
-                                val index = it.indexOf(",")
-                                if (index != -1) {
-                                    Choice(
-                                            it.substring(0, index).trim(),
-                                            mapper.readValue<Map<String, String>>(it.substring(index + 1))
-                                    )
-                                } else {
-                                    Choice(it.trim())
-                                }
-                            }
-                }
+            .removePrefix("{")
+            .let {
+                it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
+                    .let {
+                        val index = it.indexOf(",")
+                        if (index != -1) {
+                            Choice(
+                                it.substring(0, index).trim(),
+                                mapper.readValue<Map<String, String>>(it.substring(index + 1))
+                            )
+                        } else {
+                            Choice(it.trim())
+                        }
+                    }
+            }
     }
 
     private fun parseAttachment(content: String): Attachment {
         return content
-                .removePrefix("{")
-                .let {
-                    it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
-                            .let {
-                                val index = it.lastIndexOf(",")
-                                if (index != -1) {
-                                    Attachment(
-                                            it.substring(0, index).trim(),
-                                            enumValueOf(it.substring(index + 1).trim())
-                                    )
-                                } else {
-                                    Attachment(it.trim(), image)
-                                }
-                            }
-                }
+            .removePrefix("{")
+            .let {
+                it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
+                    .let {
+                        val index = it.lastIndexOf(",")
+                        if (index != -1) {
+                            Attachment(
+                                it.substring(0, index).trim(),
+                                enumValueOf(it.substring(index + 1).trim())
+                            )
+                        } else {
+                            Attachment(it.trim(), image)
+                        }
+                    }
+            }
     }
 
     private fun parseLocation(content: String): Location {
         return content
-                .removePrefix("{")
-                .let {
-                    it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
-                            .let {
-                                val index = it.indexOf(",")
-                                Location(
-                                        UserLocation(
-                                                it.substring(0, index).trim().toDouble(),
-                                                it.substring(index + 1).trim().toDouble())
-                                )
-                            }
-                }
+            .removePrefix("{")
+            .let {
+                it.substring(it.indexOf(":") + 1, it.lastIndexOf("}"))
+                    .let {
+                        val index = it.indexOf(",")
+                        Location(
+                            UserLocation(
+                                it.substring(0, index).trim().toDouble(),
+                                it.substring(index + 1).trim().toDouble()
+                            )
+                        )
+                    }
+            }
     }
 }
\\ No newline at end of file
diff --git a/bot/engine/src/test/kotlin/engine/message/parser/MessageParserTest.kt b/bot/engine/src/test/kotlin/engine/message/parser/MessageParserTest.kt
index af0fe4326..8f7e7e279 100644
--- a/bot/engine/src/test/kotlin/engine/message/parser/MessageParserTest.kt
+++ b/bot/engine/src/test/kotlin/engine/message/parser/MessageParserTest.kt
@@ -16,6 +16,7 @@
 
 package ai.tock.bot.engine.message.parser
 
+import ai.tock.bot.connector.ConnectorMessage
 import ai.tock.bot.engine.action.SendAttachment.AttachmentType.file
 import ai.tock.bot.engine.action.SendAttachment.AttachmentType.image
 import ai.tock.bot.engine.message.Attachment
@@ -26,6 +27,7 @@ import ai.tock.bot.engine.message.GenericMessage
 import ai.tock.bot.engine.message.GenericElement
 import ai.tock.bot.engine.user.UserLocation
 import org.junit.jupiter.api.Test
+import java.awt.font.TextMeasurer
 import kotlin.test.assertEquals
 
 /**
@@ -139,4 +141,12 @@ class MessageParserTest {
         assertEquals(listOf(s, c), MessageParser.parse("${s.toPrettyString()} |_| ${c.toPrettyString()}"))
     }
 
+
+    @Test
+    fun parse_shouldWork_forSentenceWithEmptyContent() {
+        val s = Sentence(text = null, messages = mutableListOf(GenericMessage()))
+        MessageParser.parse("{sentence:}")
+
+        assertEquals(s, MessageParser.parse(s.toPrettyString()).first())
+    }
 }
\\ No newline at end of file
diff --git a/bot/xray/src/main/kotlin/XrayService.kt b/bot/xray/src/main/kotlin/XrayService.kt
index a35383090..c2d81ba53 100644
--- a/bot/xray/src/main/kotlin/XrayService.kt
+++ b/bot/xray/src/main/kotlin/XrayService.kt
@@ -71,6 +71,7 @@ import java.time.OffsetDateTime.ofInstant
 import java.time.temporal.ChronoUnit
 import java.util.Locale
 import java.util.Base64
+import kotlin.math.log
 
 /**
  *
@@ -112,12 +113,12 @@ class XrayService(
 
     fun execute(namespace: String): XrayPlanExecutionResult {
         return when {
-            testKeys.isNotEmpty() && testPlanKeys.isNotEmpty() -> throw AssertionError("Impossible d'exécuter un test plan et un test à la fois.")
+            testKeys.isNotEmpty() && testPlanKeys.isNotEmpty() -> throw AssertionError("Cannot execute test plan and test at the same time.")
             testKeys.isNotEmpty() -> executeTests(namespace)
             testPlanKeys.isNotEmpty() -> executeTestPlans(namespace)
             else -> {
-                logger.error { "Veuillez spécifier le \\"testPlanKey\\" OU (xor) le \\"testKey\\"." }
-                XrayPlanExecutionResult(success = 0, total = 0, errorMessage = "ERREUR : Veuillez spécifier le \\"testPlanKey\\" OU (xor) le \\"testKey\\".")
+                logger.warn { "Specify \\"testPlanKey\\" OR (xor) \\"testKey\\"." }
+                XrayPlanExecutionResult(success = 0, total = 0, errorMessage = "ERROR : Specify \\"testPlanKey\\" OR (xor) \\"testKey\\".")
             }
         }
     }
@@ -162,11 +163,11 @@ class XrayService(
      */
     fun executeTests(namespace: String): XrayPlanExecutionResult {
         val dummyTestPlanKey = "AUTO_${testKeys[0]}"
-        val dummyTestPlan = listOf("MOCK")
         val jiraProject = getProjectFromIssue(testKeys[0])
         val executionId = Dice.newId()
 
         logger.info { "Execute tests with namespace $namespace" }
+        logger.debug { "Execution with id : $executionId started for test plan $dummyTestPlanKey" }
         return try {
             // getBotConfiguration retrieves all configuration for the selected namespace
             // getBotConfiguration will reach information stored in tab Configuration on BotAdmin site
@@ -182,6 +183,7 @@ class XrayService(
                         execTestsOnly(XrayExecutionConfiguration(it, listOf(dummyTestPlanKey), jiraProject), dummyTestPlanKey, testKeys, executionId.toId())
                     }
                     .let {
+                        logger.debug { "Execution with id : $executionId ended for test plan $dummyTestPlanKey" }
                         sendToXray(it)
                     }
         } catch (t: Throwable) {
@@ -400,19 +402,20 @@ class XrayService(
         return configuration
                 .xrayTestPlanKeys
                 .mapNotNull { xrayPlanKey ->
-                    logger.info { "Start plan $xrayPlanKey execution" }
+                    logger.info { "Start plan $xrayPlanKey execution for test plan $planKey" }
                     try {
                         // create a test plan with all given tests
                         val testPlan = createTestPlanWithTests(configuration, planKey, testKeys)
+                        logger.debug { "Common Tock test plan created : ${testPlan.hashCode()} for test plan $planKey" }
                         // if the current test plan has dialogs to send, then execute it, otherwise skip it and jump to the next one
                         if (testPlan.dialogs.isNotEmpty()) {
                             executePlan(configuration, testPlan.name, testPlan, executionId)
                         } else {
-                            logger.info { "Empty test plan for $configuration - skipped" }
+                            logger.debug { "Empty test plan for $configuration - skipped" }
                             null
                         }
                     } finally {
-                        logger.info { "Plan $xrayPlanKey executed" }
+                        logger.info { "Plan $xrayPlanKey executed for test plan $planKey" }
                     }
                 }
     }
@@ -423,7 +426,7 @@ class XrayService(
      * @param configuration is the configuration to execute the test plan.
      * @param xrayTestPlanKey is the Xray identifier of the test plan to execute.
      * @param testPlan is the test plan to execute formatted in the common Test Plan manageable by Tock test framework.
-     * @return a TestPlanExecutionReport which contains.... TODO
+     * @return a TestPlanExecutionReport which contains test plan execution results
      */
     private fun executePlan(
             configuration: XrayExecutionConfiguration,
@@ -431,9 +434,9 @@ class XrayService(
             testPlan: TestPlan,
             executionId: Id<TestPlanExecution>
     ): TestPlanExecutionReport {
-        logger.debug { "Execute test plan $testPlan" }
+        logger.debug { "Execute test plan ${testPlan.name} with execution id $executionId" }
         val execution = findTestClient().saveAndExecuteTestPlan(testPlan, executionId)
-        logger.debug { "Test plan execution $execution" }
+        logger.debug { "Test plan execution ${execution.testPlanId}" }
         return TestPlanExecutionReport(
                 configuration,
                 xrayTestPlanKey,
@@ -516,29 +519,27 @@ class XrayService(
      */
     private fun getDialogReport(configuration: XrayExecutionConfiguration, xrayTest: XrayTest): TestDialogReport {
         val userInterface = xrayTest.findUserInterface()
-        // store all steps of the given xrayTest
         val xraySteps = XrayClient.getTestSteps(xrayTest.key)
-        // return the TestDialogReport with parsed steps
         return TestDialogReport(
-                xraySteps.flatMap {
+                xraySteps.flatMap { xrayTestStep ->
                     listOfNotNull(
                             parseStepData(
                                     configuration,
                                     userInterface,
-                                    it.id,
+                                    xrayTestStep.id,
                                     userId,
-                                    it.data.raw,
-                                    it.attachments.firstOrNull { it.fileName == "user.message" }),
+                                    xrayTestStep.data.raw,
+                                    xrayTestStep.attachments.firstOrNull { it.fileName == "user.message" }),
                             parseStepData(
                                     configuration,
                                     userInterface,
-                                    it.id,
+                                    xrayTestStep.id,
                                     botId,
-                                    it.result.raw,
-                                    it.attachments.firstOrNull { it.fileName == "bot.message" })
+                                    xrayTestStep.result.raw,
+                                    xrayTestStep.attachments.firstOrNull { it.fileName == "bot.message" })
                     )
                 },
-                xrayTest.findUserInterface(),
+                userInterface,
                 xrayTest.key.toId()
         )
     }