diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt index 96216a28e7..463d69806c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt @@ -188,14 +188,20 @@ constructor( /** * This function creates a property key from the string [value] and uses the key to retrieve the - * correct translation from the string.properties file. + * correct translation from the string.properties file. If no translation is found, it falls + * back to the original value. */ - fun translate(value: String): String = - configurationRegistry.localizationHelper.parseTemplate( - LocalizationHelper.STRINGS_BASE_BUNDLE_NAME, - Locale.getDefault(), - "{{${value.translationPropertyKey()}}}", - ) + fun translate(value: String): String { + val translationKey = value.translationPropertyKey() + val template = "{{$translationKey}}" + val translatedText = + configurationRegistry.localizationHelper.parseTemplate( + LocalizationHelper.STRINGS_BASE_BUNDLE_NAME, + Locale.getDefault(), + template, + ) + return if (translatedText == template) value else translatedText + } /** * This method retrieves a list of relatedResources for a given resource from the facts map It diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt index 568b97670f..379637305a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt @@ -81,12 +81,24 @@ fun String.messageFormat(locale: Locale?, vararg arguments: Any?): String? = MessageFormat(this, locale).format(arguments) /** - * Creates identifier from string text by doing clean up on the passed value + * Creates a translation property key from string text by normalizing the input. * - * @return string.properties key to be used in string look ups + * This function: + * 1. Trims leading/trailing whitespace + * 2. Converts to lowercase + * 3. Replaces all non-alphanumeric characters with dots + * 4. Removes leading/trailing dots + * + * Example: + * ``` + * "Discuss Confidentiality".translationPropertyKey() // Returns "discuss.confidentiality" + * " C-SSRS ".translationPropertyKey() // Returns "c.ssrs" + * ``` + * + * @return string.properties key to be used in translation lookups */ fun String.translationPropertyKey(): String { - return this.trim { it <= ' ' }.lowercase(Locale.ENGLISH).replace(" ".toRegex(), ".") + return this.trim().lowercase(Locale.ENGLISH).replace("[^a-z0-9]+".toRegex(), ".").trim('.') } /** diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt index 85c6238a0d..3b062102dc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/TaskExtension.kt @@ -16,8 +16,11 @@ package org.smartregister.fhircore.engine.util.extension +import java.util.Locale import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Task +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.util.helper.LocalizationHelper fun Task.hasPastEnd() = this.hasExecutionPeriod() && @@ -62,3 +65,29 @@ fun Task.isOverDue() = this.executionPeriod.end.before(today()) fun Task.isDue() = this.hasStatus() && this.status == Task.TaskStatus.READY + +/** + * Retrieves the localized description of the Task using the translation configuration. + * + * This function: + * 1. Takes the task description (typically from ActivityDefinition.productCodeableConcept.text) + * 2. Converts it to a translation property key (e.g., "Discuss Confidentiality" → + * "discuss.confidentiality") + * 3. Looks up the translation in the configured translation bundles for the current locale + * 4. Falls back to the original description if no translation is found + * + * @param configurationRegistry The ConfigurationRegistry instance to access the localization helper + * @return The localized task description, or the original description if no translation found + */ +fun Task.getLocalizedDescription(configurationRegistry: ConfigurationRegistry): String { + val description = this.description ?: return "" + val translationKey = description.translationPropertyKey() + val template = "{{$translationKey}}" + val translatedText = + configurationRegistry.localizationHelper.parseTemplate( + LocalizationHelper.STRINGS_BASE_BUNDLE_NAME, + Locale.getDefault(), + template, + ) + return if (translatedText == template) description else translatedText +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt index 2cb692af6a..589b2e4a27 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt @@ -105,6 +105,74 @@ class RulesEngineServiceTest : RobolectricTest() { Assert.assertEquals("Statut Vaccinal Traduit", result) } + @Test + fun testTranslate_WithValidTranslation_ReturnsTranslatedText() { + // Uses real translation from strings.properties + val result = rulesEngineService.translate("Discuss Confidentiality") + + Assert.assertEquals("Discuss Confidentiality (English)", result) + } + + @Test + fun testTranslate_WithUnknownText_FallsBackToOriginal() { + val originalText = "Some Unknown Task That Does Not Exist" + + val result = rulesEngineService.translate(originalText) + + // Should fallback to original text and never return template strings + Assert.assertEquals(originalText, result) + Assert.assertFalse(result.contains("{{")) + Assert.assertFalse(result.contains("}}")) + } + + @Test + fun testTranslate_WithEmptyString_ReturnsEmptyString() { + val result = rulesEngineService.translate("") + + Assert.assertEquals("", result) + } + + @Test + fun testTranslate_WithSpecialCharactersAndNumbers_HandlesCorrectly() { + // Tests dashes, numbers, and special characters + val result = rulesEngineService.translate("PHQ-9") + + Assert.assertEquals("PHQ-9 Screening", result) + } + + @Test + fun testTranslate_WithWhitespace_TrimsAndTranslates() { + // Tests whitespace trimming and consecutive spaces + val result = rulesEngineService.translate(" IPC Session 1 ") + + Assert.assertEquals("IPC Session 1 (English)", result) + } + + @Test + fun testTranslate_WithUppercaseInput_MatchesLowercaseKey() { + // Tests case insensitivity - uppercase converted to lowercase for key lookup + val result = rulesEngineService.translate("DISCUSS CONFIDENTIALITY") + + Assert.assertEquals("Discuss Confidentiality (English)", result) + } + + @Test + fun testTranslate_WithParenthesesInOriginal_HandlesCorrectly() { + // Tests parentheses and dashes in original text + val result = rulesEngineService.translate("Information Only (Low Risk)") + + Assert.assertEquals("Information Only - Low Risk Category", result) + } + + @Test + fun testTranslate_WithFrenchLocale_ReturnsCorrectTranslation() { + Locale.setDefault(Locale.FRENCH) + + val result = rulesEngineService.translate("Discuss Confidentiality") + + Assert.assertEquals("Discuter de la Confidentialité", result) + } + @Test fun testComputeTotalCountShouldReturnSumOfAllCounts() { val totalCount = diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/TaskExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/TaskExtensionTest.kt index 17094dfd4c..3a444c3113 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/TaskExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/TaskExtensionTest.kt @@ -16,13 +16,18 @@ package org.smartregister.fhircore.engine.util.extension +import io.mockk.every +import io.mockk.mockk import java.time.LocalDate import java.time.ZoneId import java.util.Date import org.hl7.fhir.r4.model.Period import org.hl7.fhir.r4.model.Task import org.junit.Assert +import org.junit.Before import org.junit.Test +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.util.helper.LocalizationHelper class TaskExtensionTest { @@ -227,4 +232,303 @@ class TaskExtensionTest { val expected = task.isDue() Assert.assertTrue(expected) } + + private lateinit var mockConfigurationRegistry: ConfigurationRegistry + private lateinit var mockLocalizationHelper: LocalizationHelper + + @Before + fun setUpLocalization() { + mockLocalizationHelper = mockk() + mockConfigurationRegistry = mockk { + every { localizationHelper } returns mockLocalizationHelper + } + } + + fun testGetLocalizedDescription_WithValidTranslation() { + val task = Task().apply { description = "Discuss Confidentiality" } + + val expectedTranslation = "Jadili Usiri" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithMultipleWordDescription() { + val task = Task().apply { description = "IPC - Interpersonal Counseling" } + + val expectedTranslation = "IPC - Ushauri wa Kupokea Watu Wengine" + setupMockTranslation("ipc.interpersonal.counseling", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithSessionNumber() { + val task = Task().apply { description = "IPC Session 1" } + + val expectedTranslation = "Kikao cha IPC 1" + setupMockTranslation("ipc.session.1", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithPdfDescription() { + val task = Task().apply { description = "PHQ-9 Scores PDF" } + + val expectedTranslation = "PDfa ya Alama za PHQ-9" + setupMockTranslation("phq.9.scores.pdf", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_FallbackToOriginal_WhenTranslationNotFound() { + val task = Task().apply { description = "Discuss Confidentiality" } + + // Mock returns the template unchanged, indicating translation not found + val template = "{{discuss.confidentiality}}" + every { mockLocalizationHelper.parseTemplate(any(), any(), any()) } returns template + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + // Should fallback to original description + Assert.assertEquals("Discuss Confidentiality", result) + } + + @Test + fun testGetLocalizedDescription_FallbackToOriginal_WhenNullDescription() { + val task = Task().apply { description = null } + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals("", result) + } + + @Test + fun testGetLocalizedDescription_FallbackToOriginal_WhenEmptyDescription() { + val task = Task().apply { description = "" } + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals("", result) + } + + @Test + fun testGetLocalizedDescription_FallbackToOriginal_UnknownDescription() { + val task = Task().apply { description = "Some Unknown Task Description" } + + // Mock returns the template unchanged + val template = "{{some.unknown.task.description}}" + every { mockLocalizationHelper.parseTemplate(any(), any(), any()) } returns template + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals("Some Unknown Task Description", result) + } + + @Test + fun testGetLocalizedDescription_WithSpecialCharacters() { + val task = Task().apply { description = "Information Only (Low Risk)" } + + val expectedTranslation = "Habari tu (Hatari Ndogo)" + setupMockTranslation("information.only.low.risk", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithDashesAndNumbers() { + val task = Task().apply { description = "Short-Form PCL-5-8" } + + val expectedTranslation = "Fomu Fupi ya PCL-5-8" + setupMockTranslation("short.form.pcl.5.8", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithLeadingTrailingWhitespace() { + val task = Task().apply { description = " Discuss Confidentiality " } + + // translationPropertyKey() trims the string first + val expectedTranslation = "Jadili Usiri" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithMixedCase() { + val task = Task().apply { description = "DISCUSS CONFIDENTIALITY" } + + // translationPropertyKey() converts to lowercase + val expectedTranslation = "Jadili Usiri" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithSingleCharacterString() { + val task = Task().apply { description = "A" } + + val expectedTranslation = "Alfa" + setupMockTranslation("a", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithVeryLongDescription() { + val task = Task().apply { description = "Mental Wellness Tool-IPC session 4 Provider PDF" } + + val expectedTranslation = "PDF ya Zana ya Afya ya Akili-Kikao cha IPC 4 Mtoaji" + setupMockTranslation("mental.wellness.tool.ipc.session.4.provider.pdf", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithSwahiliTranslation() { + val task = Task().apply { description = "Discuss Confidentiality" } + + val expectedTranslation = "Jadili Usiri" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithFrenchTranslation() { + val task = Task().apply { description = "Suicide Prevention" } + + val expectedTranslation = "Prévention du Suicide" + setupMockTranslation("suicide.prevention", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithArabicTranslation() { + val task = Task().apply { description = "Discuss Confidentiality" } + + val expectedTranslation = "مناقشة السرية" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithGermanTranslation() { + val task = Task().apply { description = "Discuss Confidentiality" } + + val expectedTranslation = "Vertraulichkeit besprechen" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithSpanishTranslation() { + val task = Task().apply { description = "Discuss Confidentiality" } + + val expectedTranslation = "Discutir Confidencialidad" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testGetLocalizedDescription_WithIndonesianTranslation() { + val task = Task().apply { description = "Discuss Confidentiality" } + + val expectedTranslation = "Diskusikan Kerahasiaan" + setupMockTranslation("discuss.confidentiality", expectedTranslation) + + val result = task.getLocalizedDescription(mockConfigurationRegistry) + + Assert.assertEquals(expectedTranslation, result) + } + + @Test + fun testTranslationPropertyKeyConversion_SimpleWord() { + val key = "Vaccine".translationPropertyKey() + Assert.assertEquals("vaccine", key) + } + + @Test + fun testTranslationPropertyKeyConversion_MultipleWords() { + val key = "Discuss Confidentiality".translationPropertyKey() + Assert.assertEquals("discuss.confidentiality", key) + } + + @Test + fun testTranslationPropertyKeyConversion_WithDashes() { + val key = "Short-Form PCL-5-8".translationPropertyKey() + Assert.assertEquals("short.form.pcl.5.8", key) + } + + @Test + fun testTranslationPropertyKeyConversion_WithParentheses() { + val key = "Information Only (Low Risk)".translationPropertyKey() + Assert.assertEquals("information.only.low.risk", key) + } + + @Test + fun testTranslationPropertyKeyConversion_UppercaseConversion() { + val key = "DISCUSS CONFIDENTIALITY".translationPropertyKey() + Assert.assertEquals("discuss.confidentiality", key) + } + + @Test + fun testTranslationPropertyKeyConversion_WithWhitespace() { + val key = " Discuss Confidentiality ".translationPropertyKey() + Assert.assertEquals("discuss.confidentiality", key) + } + + @Test + fun testTranslationPropertyKeyConversion_MixedCase() { + val key = "DiScUsS cOnFiDeNtIaLiTy".translationPropertyKey() + Assert.assertEquals("discuss.confidentiality", key) + } + + /** + * Helper method to set up mock translation. When parseTemplate is called with the expected + * template, it returns the translated text. + */ + private fun setupMockTranslation(propertyKey: String, translation: String) { + every { mockLocalizationHelper.parseTemplate(any(), any(), any()) } returns translation + } } diff --git a/android/engine/src/test/resources/strings.properties b/android/engine/src/test/resources/strings.properties index 9db030cd29..00cac7fdfe 100644 --- a/android/engine/src/test/resources/strings.properties +++ b/android/engine/src/test/resources/strings.properties @@ -3,4 +3,12 @@ person.address=Nairobi, Kenya person.profile.description=Age is {0} years, Height is {1}cm, Gender is {2} person.home.address.description=Home address is Nairobi Kenya 106 Park Drive Avenue vaccine.status=Translated Vaccine status -40.weeks=40 Weeks \ No newline at end of file +40.weeks=40 Weeks + +# Task description translations for testing +discuss.confidentiality=Discuss Confidentiality (English) +phq.9=PHQ-9 Screening +ipc.session.1=IPC Session 1 (English) +short.form.pcl.5.8=Short-Form PCL-5-8 Assessment +information.only.low.risk=Information Only - Low Risk Category +family.name=Family Name (English) \ No newline at end of file diff --git a/android/engine/src/test/resources/strings_fr.properties b/android/engine/src/test/resources/strings_fr.properties index e918dd1ff8..e255d9f93b 100644 --- a/android/engine/src/test/resources/strings_fr.properties +++ b/android/engine/src/test/resources/strings_fr.properties @@ -1,3 +1,11 @@ -person.gender=Mâle +person.gender=Mâle person.address=Paris, France -vaccine.status=Statut Vaccinal Traduit \ No newline at end of file +vaccine.status=Statut Vaccinal Traduit + +# Task description translations for testing (French) +discuss.confidentiality=Discuter de la Confidentialité +phq.9=Dépistage PHQ-9 +ipc.session.1=Session IPC 1 +short.form.pcl.5.8=Évaluation PCL-5-8 Forme Courte +information.only.low.risk=Information Seulement - Catégorie à Faible Risque +family.name=Nom de Famille