diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt index 760ec6653c..7aba062e05 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt @@ -16,8 +16,6 @@ import com.bitwarden.ui.platform.base.util.nullableTestTag import com.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors import com.bitwarden.ui.platform.components.field.toolbar.BitwardenEmptyTextToolbar import com.bitwarden.ui.platform.components.model.CardStyle -import com.bitwarden.ui.platform.components.util.compoundVisualTransformation -import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation import com.bitwarden.ui.platform.theme.BitwardenTheme /** @@ -46,10 +44,7 @@ fun BitwardenHiddenPasswordField( label = label?.let { { Text(text = it) } }, value = value, onValueChange = { }, - visualTransformation = compoundVisualTransformation( - PasswordVisualTransformation(), - forceLtrVisualTransformation(), - ), + visualTransformation = PasswordVisualTransformation(), singleLine = true, enabled = false, readOnly = true, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt index 35c0b25e53..e8a6c66dd6 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt @@ -143,10 +143,7 @@ fun BitwardenPasswordField( var lastTextValue by remember(value) { mutableStateOf(value = value) } val visualTransformation = when { - !showPassword -> compoundVisualTransformation( - PasswordVisualTransformation(), - forceLtrVisualTransformation(), - ) + !showPassword -> PasswordVisualTransformation() readOnly -> compoundVisualTransformation( nonLetterColorVisualTransformation(), diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt index b8d565f810..9b2eb9e116 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt @@ -10,10 +10,43 @@ import androidx.compose.ui.text.input.VisualTransformation /** * A [VisualTransformation] that chains multiple other [VisualTransformation]s. * - * This is useful for applying multiple transformations to a text field. The transformations - * are applied in the order they are provided. + * This is useful for applying multiple transformations to a text field. The + * transformations are applied in the order they are provided, with each transformation's + * output becoming the input for the next transformation. + * + * ## Example Usage + * + * Combining password masking with LTR direction enforcement: + * ```kotlin + * val visualTransformation = compoundVisualTransformation( + * PasswordVisualTransformation(), + * forceLtrVisualTransformation() + * ) + * TextField( + * value = password, + * visualTransformation = visualTransformation + * ) + * ``` + * + * Combining color transformation with LTR for readonly fields: + * ```kotlin + * val visualTransformation = compoundVisualTransformation( + * nonLetterColorVisualTransformation(), + * forceLtrVisualTransformation() + * ) + * ``` + * + * ## Important Notes + * + * - Offset mapping is correctly composed through all transformations + * - The order of transformations matters (first applied is first in the list) + * - Use [compoundVisualTransformation] function for proper `remember` optimization + * + * @param transformations The visual transformations to apply in order + * @see compoundVisualTransformation + * @see forceLtrVisualTransformation */ -private class CompoundVisualTransformation( +internal class CompoundVisualTransformation( vararg val transformations: VisualTransformation, ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt index 0c638c627f..3d954c1f88 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt @@ -9,16 +9,48 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation // Unicode characters for forcing LTR direction -private const val LRO = "\u202A" -private const val PDF = "\u202C" +internal const val LRO = "\u202A" +internal const val PDF = "\u202C" /** * A [VisualTransformation] that forces the output to have an LTR text direction. * - * This is useful for password fields where the input should always be LTR, even when the rest of - * the UI is RTL. + * This transformation wraps text with Unicode directional control characters (LRO/PDF) + * to ensure left-to-right rendering regardless of the UI's locale or text direction. + * + * ## When to Use + * + * Apply this transformation to fields containing **standardized, technical data** that is + * always interpreted from left-to-right, regardless of locale: + * - Passwords and sensitive authentication data + * - Social Security Numbers (SSN) + * - Driver's license numbers + * - Passport numbers + * - Payment card numbers + * - Email addresses (technical format) + * - Phone numbers (standardized format) + * - URIs and technical identifiers + * + * ## When NOT to Use + * + * Do NOT apply this transformation to **locale-dependent text** that may legitimately + * use RTL scripts: + * - Personal names (may use Arabic, Hebrew, etc.) + * - Company names + * - Addresses + * - Usernames (user choice) + * - Notes and other free-form text + * + * ## Implementation Notes + * + * - Only applies LTR transformation when text is **visible** + * - Do NOT use with obscured text (e.g., password bullets) as masked characters + * are directionally neutral + * - Can be composed with other transformations using [compoundVisualTransformation] + * + * @see compoundVisualTransformation */ -private object ForceLtrVisualTransformation : VisualTransformation { +internal object ForceLtrVisualTransformation : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val forcedLtrText = buildAnnotatedString { append(LRO) @@ -38,7 +70,7 @@ private object ForceLtrVisualTransformation : VisualTransformation { } /** - * Remembers a [ForceLtrVisualTransformation] for the given [transformations]. + * Remembers a [ForceLtrVisualTransformation] transformation. * * This is an optimization to avoid creating a new [ForceLtrVisualTransformation] on every * recomposition. diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformationTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformationTest.kt new file mode 100644 index 0000000000..e6c1644b88 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformationTest.kt @@ -0,0 +1,348 @@ +package com.bitwarden.ui.platform.components.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CompoundVisualTransformationTest { + + @Test + fun `compoundVisualTransformation with no transformations returns identity`() { + val text = AnnotatedString("test") + val transformation = CompoundVisualTransformation() + + val result = transformation.filter(text) + + assertEquals(text, result.text) + assertEquals(0, result.offsetMapping.originalToTransformed(0)) + assertEquals(4, result.offsetMapping.originalToTransformed(4)) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + assertEquals(4, result.offsetMapping.transformedToOriginal(4)) + } + + @Suppress("MaxLineLength") + @Test + fun `compoundVisualTransformation with single transformation behaves identically to that transformation`() { + val text = AnnotatedString("password") + val passwordTransformation = PasswordVisualTransformation() + + val singleResult = passwordTransformation.filter(text) + val compoundResult = CompoundVisualTransformation(passwordTransformation).filter(text) + + assertEquals(singleResult.text, compoundResult.text) + + // Test offset mapping equivalence for various offsets + for (offset in 0..text.length) { + assertEquals( + singleResult.offsetMapping.originalToTransformed(offset), + compoundResult.offsetMapping.originalToTransformed(offset), + "originalToTransformed($offset) should match", + ) + } + + for (offset in 0..singleResult.text.length) { + assertEquals( + singleResult.offsetMapping.transformedToOriginal(offset), + compoundResult.offsetMapping.transformedToOriginal(offset), + "transformedToOriginal($offset) should match", + ) + } + } + + @Test + fun `compoundVisualTransformation applies transformations in order`() { + val text = AnnotatedString("abc") + + // First transformation: prepend "X" + val prependX = VisualTransformation { text -> + TransformedText( + AnnotatedString("X${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = (offset - 1).coerceAtLeast(0) + }, + ) + } + + // Second transformation: append "Y" + val appendY = VisualTransformation { text -> + TransformedText( + AnnotatedString("${text.text}Y"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = + offset.coerceAtMost(text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(prependX, appendY) + val result = compound.filter(text) + + // Expected: "XabcY" + assertEquals("XabcY", result.text.text) + } + + @Test + fun `compoundVisualTransformation offset mapping handles composition correctly`() { + val text = AnnotatedString("test") + + // Create a simple transformation that adds one character at start + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString(">${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = + (offset - 1).coerceIn(0, text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix, addPrefix) + val result = compound.filter(text) + + // After two applications: ">>test" + assertEquals(">>test", result.text.text) + + // Test originalToTransformed mapping + assertEquals( + 2, + result.offsetMapping.originalToTransformed(0), + "Original 0 -> Transformed 2", + ) + assertEquals( + 3, + result.offsetMapping.originalToTransformed(1), + "Original 1 -> Transformed 3", + ) + assertEquals( + 6, + result.offsetMapping.originalToTransformed(4), + "Original 4 -> Transformed 6", + ) + + // Test transformedToOriginal mapping + assertEquals( + 0, + result.offsetMapping.transformedToOriginal(0), + "Transformed 0 -> Original 0", + ) + assertEquals( + 0, + result.offsetMapping.transformedToOriginal(1), + "Transformed 1 -> Original 0", + ) + assertEquals( + 0, + result.offsetMapping.transformedToOriginal(2), + "Transformed 2 -> Original 0", + ) + assertEquals( + 1, + result.offsetMapping.transformedToOriginal(3), + "Transformed 3 -> Original 1", + ) + assertEquals( + 4, + result.offsetMapping.transformedToOriginal(6), + "Transformed 6 -> Original 4", + ) + } + + @Test + fun `compoundVisualTransformation transformedToOriginal handles edge case at start`() { + val text = AnnotatedString("abc") + + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString("X${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = (offset - 1).coerceAtLeast(0) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix, addPrefix) + val result = compound.filter(text) + + // Test offset 0 (should map back to original 0) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + } + + @Test + fun `compoundVisualTransformation transformedToOriginal handles edge case at end`() { + val text = AnnotatedString("abc") + + val addSuffix = VisualTransformation { text -> + TransformedText( + AnnotatedString("${text.text}X"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = + offset.coerceAtMost(text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(addSuffix, addSuffix) + val result = compound.filter(text) + + // Result: "abcXX" (length 5) + // Testing beyond the original text length + assertEquals(3, result.offsetMapping.transformedToOriginal(3)) + assertEquals(3, result.offsetMapping.transformedToOriginal(4)) + assertEquals(3, result.offsetMapping.transformedToOriginal(5)) + } + + @Test + fun `compoundVisualTransformation with Password and ForceLtr transformations`() { + val text = AnnotatedString("password123") + + val passwordTransform = PasswordVisualTransformation() + val ltrTransform = ForceLtrVisualTransformation + + val compound = CompoundVisualTransformation(passwordTransform, ltrTransform) + val result = compound.filter(text) + + // Password transformation converts to bullets, then LTR adds control chars + // LTR adds LRO at start and PDF at end + val expectedLength = text.length + 2 // Original bullets + LRO + PDF + assertEquals(expectedLength, result.text.length) + + // Test offset mappings at various points + val mappings = listOf( + 0 to 1, // Original 0 should map to transformed 1 (after LRO) + 5 to 6, // Original 5 should map to transformed 6 + 11 to 12, // Original 11 (end) should map to transformed 12 + ) + + mappings.forEach { (original, transformed) -> + assertEquals( + transformed, + result.offsetMapping.originalToTransformed(original), + "Original $original should map to transformed $transformed", + ) + } + } + + @Test + fun `compoundVisualTransformation transformedToOriginal with out-of-bounds offset`() { + val text = AnnotatedString("test") + + // Transformation that adds characters at both ends + val wrapText = VisualTransformation { text -> + TransformedText( + AnnotatedString("[${text.text}]"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = + (offset - 1).coerceIn(0, text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(wrapText, wrapText) + val result = compound.filter(text) + + // Result should be "[[test]]" (length 8) + assertEquals("[[test]]", result.text.text) + + // Test with offsets beyond the transformed text length + // This tests the critical edge case mentioned in the review + val beyondEndOffset = result.text.length + 5 + val mappedOffset = result.offsetMapping.transformedToOriginal(beyondEndOffset) + + // Should be coerced to the original text length + assertEquals( + text.length, + mappedOffset, + "Out-of-bounds offset should be coerced to original text length", + ) + } + + @Test + fun `compoundVisualTransformation with empty string`() { + val text = AnnotatedString("") + + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString(">${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = (offset - 1).coerceAtLeast(0) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix) + val result = compound.filter(text) + + assertEquals(">", result.text.text) + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + assertEquals(0, result.offsetMapping.transformedToOriginal(1)) + } + + @Test + fun `compoundVisualTransformation preserves AnnotatedString spans`() { + val text = AnnotatedString.Builder().apply { + append("test") + }.toAnnotatedString() + + val identityTransform = VisualTransformation.None + val compound = CompoundVisualTransformation(identityTransform) + val result = compound.filter(text) + + assertEquals(text.text, result.text.text) + } + + @Test + fun `compoundVisualTransformation offset mapping is symmetric for identity`() { + val text = AnnotatedString("symmetric") + + val compound = CompoundVisualTransformation() + val result = compound.filter(text) + + // For identity transformation, offset mapping should be symmetric + for (offset in 0..text.length) { + val transformed = result.offsetMapping.originalToTransformed(offset) + val backToOriginal = result.offsetMapping.transformedToOriginal(transformed) + assertEquals( + offset, + backToOriginal, + "Round trip for offset $offset should return to original", + ) + } + } + + @Test + fun `compoundVisualTransformation with very long text`() { + val longText = "a".repeat(10000) + val text = AnnotatedString(longText) + + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString(">${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = + (offset - 1).coerceIn(0, text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix) + val result = compound.filter(text) + + assertEquals(10001, result.text.length) + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(10001, result.offsetMapping.originalToTransformed(10000)) + assertEquals(10000, result.offsetMapping.transformedToOriginal(10001)) + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformationTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformationTest.kt new file mode 100644 index 0000000000..d330737efb --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformationTest.kt @@ -0,0 +1,256 @@ +package com.bitwarden.ui.platform.components.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ForceLtrVisualTransformationTest { + + @Test + fun `forceLtrVisualTransformation adds LRO and PDF characters`() { + val text = AnnotatedString("password") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}password$PDF", result.text.text) + assertEquals(10, result.text.length) // Original 8 + LRO + PDF + } + + @Test + fun `forceLtrVisualTransformation with empty string`() { + val text = AnnotatedString("") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("$LRO$PDF", result.text.text) + assertEquals(2, result.text.length) + } + + @Test + fun `forceLtrVisualTransformation originalToTransformed adds 1 to offset`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // LRO is inserted at position 0, so all original offsets shift by 1 + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(2, result.offsetMapping.originalToTransformed(1)) + assertEquals(3, result.offsetMapping.originalToTransformed(2)) + assertEquals(4, result.offsetMapping.originalToTransformed(3)) + assertEquals(5, result.offsetMapping.originalToTransformed(4)) + } + + @Test + fun `forceLtrVisualTransformation transformedToOriginal subtracts 1 and coerces`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // Transformed text is "[LRO]test[PDF]" (length 6) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) // LRO position + assertEquals(0, result.offsetMapping.transformedToOriginal(1)) // First char + assertEquals(1, result.offsetMapping.transformedToOriginal(2)) + assertEquals(2, result.offsetMapping.transformedToOriginal(3)) + assertEquals(3, result.offsetMapping.transformedToOriginal(4)) + assertEquals(4, result.offsetMapping.transformedToOriginal(5)) // PDF position + } + + @Test + fun `forceLtrVisualTransformation transformedToOriginal coerces negative offsets to 0`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // When transformedToOriginal receives 0, it computes (0 - 1) = -1 + // This should be coerced to 0 + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + } + + @Test + fun `forceLtrVisualTransformation transformedToOriginal coerces beyond text length`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // Transformed text length is 6, but we test with larger offset + val beyondEnd = 10 + val mappedOffset = result.offsetMapping.transformedToOriginal(beyondEnd) + + // Should be coerced to original text length (4) + assertEquals(4, mappedOffset) + } + + @Test + fun `forceLtrVisualTransformation preserves AnnotatedString spans`() { + val text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("bold") + } + append("normal") + } + + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // The transformed text should still have spans (though offset by 1) + assertTrue(result.text.text.startsWith(LRO)) + assertTrue(result.text.text.endsWith(PDF)) + assertTrue(result.text.text.contains("boldnormal")) + } + + @Test + fun `forceLtrVisualTransformation with RTL characters`() { + // Arabic text "مرحبا" (Hello) + val text = AnnotatedString("مرحبا") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + // Should wrap RTL text with LTR control characters + assertEquals("${LRO}مرحبا$PDF", result.text.text) + assertTrue(result.text.text.startsWith(LRO)) + assertTrue(result.text.text.endsWith(PDF)) + } + + @Test + fun `forceLtrVisualTransformation with mixed LTR and RTL characters`() { + // Mixed English and Arabic + val text = AnnotatedString("Hello مرحبا World") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}Hello مرحبا World$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with special characters`() { + val text = AnnotatedString("p@ssw0rd!#$%") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}p@ssw0rd!#$%$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with numbers only`() { + val text = AnnotatedString("123456") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}123456$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation offset mapping is consistent at boundaries`() { + val text = AnnotatedString("abc") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // Test at start boundary + val startOriginal = 0 + val startTransformed = result.offsetMapping.originalToTransformed(startOriginal) + val backToStart = result.offsetMapping.transformedToOriginal(startTransformed) + assertEquals(startOriginal, backToStart) + + // Test at end boundary + val endOriginal = text.length + val endTransformed = result.offsetMapping.originalToTransformed(endOriginal) + val backToEnd = result.offsetMapping.transformedToOriginal(endTransformed) + assertEquals(endOriginal, backToEnd) + } + + @Test + fun `forceLtrVisualTransformation with very long text`() { + val longText = "a".repeat(10000) + val text = AnnotatedString(longText) + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + // Should handle long strings without issues + assertEquals(10002, result.text.length) // 10000 + LRO + PDF + assertTrue(result.text.text.startsWith(LRO)) + assertTrue(result.text.text.endsWith(PDF)) + + // Test offset mapping at various points in long text + assertEquals(5001, result.offsetMapping.originalToTransformed(5000)) + assertEquals(5000, result.offsetMapping.transformedToOriginal(5001)) + } + + @Test + fun `forceLtrVisualTransformation with whitespace`() { + val text = AnnotatedString(" ") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("$LRO $PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with newlines`() { + val text = AnnotatedString("line1\nline2\nline3") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}line1\nline2\nline3$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with existing unicode control characters`() { + // Text already containing direction control characters + val text = AnnotatedString("${LRO}test$PDF") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + // Should add additional control characters + assertEquals("$LRO${LRO}test$PDF$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation round trip maintains offset relationships`() { + val text = AnnotatedString("password123") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // For each original offset, going to transformed and back should preserve the offset + for (originalOffset in 0..text.length) { + val transformed = result.offsetMapping.originalToTransformed(originalOffset) + val backToOriginal = result.offsetMapping.transformedToOriginal(transformed) + assertEquals( + originalOffset, + backToOriginal, + "Round trip failed for offset $originalOffset", + ) + } + } + + @Test + fun `forceLtrVisualTransformation with single character`() { + val text = AnnotatedString("a") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}a$PDF", result.text.text) + assertEquals(3, result.text.length) + + // Test offset mappings + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(2, result.offsetMapping.originalToTransformed(1)) + assertEquals(0, result.offsetMapping.transformedToOriginal(1)) + assertEquals(1, result.offsetMapping.transformedToOriginal(2)) + } +}