From c78c1336759ad44fcb67161ca43f1fb75c7c2341 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 27 Apr 2026 10:51:53 +0200 Subject: [PATCH] test(compose): instrumented coverage for MentionPreservingInputTransformation 11 cases driving the predicate matrix against a real TextFieldState on device: - mention-free text passes through - pure delete fully covering a mention is allowed - partial overlap (at start, at end, inside) collapses atomically - scope-exact replace with non-empty text collapses (SwiftKey case) - scope-broader replace passes through - append after mention preserves it - trailing space and trailing newline are consumed during atomic collapse - multiple mentions: only the touched one collapses - cheap-gate path (mention-free original) is verified All 11 pass on Pixel 9a; gives the predicate a regression net so future predicate-tuning doesn't reintroduce the @Vitor Pamplona bug. Run via: ./gradlew :amethyst:connectedPlayDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.class=\ com.vitorpamplona.amethyst.MentionPreservingInputTransformationTest Co-Authored-By: Claude Opus 4.7 (1M context) --- ...entionPreservingInputTransformationTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt new file mode 100644 index 000000000..227812f32 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +@file:OptIn(ExperimentalFoundationApi::class) + +package com.vitorpamplona.amethyst + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives [MentionPreservingInputTransformation] against a real [TextFieldState] + * with simulated IME edits. The npub literal has no metadata loaded — these + * tests only exercise the input-side guard, which keys off the underlying bech32 + * text rather than any display-name resolution. + */ +@RunWith(AndroidJUnit4::class) +class MentionPreservingInputTransformationTest { + /** 64 characters: leading `@` + bech32 (`npub1` + 58 chars). */ + private val npub = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + + /** + * Apply [stage] inside an edit session, then run the InputTransformation + * exactly as the framework would, and return the committed text. + */ + private fun TextFieldState.applyChange(stage: TextFieldBuffer.() -> Unit): String { + edit { + stage() + with(MentionPreservingInputTransformation) { + transformInput() + } + } + return text.toString() + } + + @Test + fun mentionFreeText_passesThrough() { + val state = TextFieldState("hello world") + val result = state.applyChange { replace(0, 5, "HELLO") } + assertEquals("HELLO world", result) + } + + @Test + fun pureDeleteFullyCoveringMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteInsideMention_collapsesAtomically() { + val state = TextFieldState(npub) + // delete a chunk near the end of the bech32 + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteAtMentionStart_collapsesAtomically() { + val state = TextFieldState(npub) + // delete the leading "@npub" prefix only + val result = state.applyChange { replace(0, 5, "") } + assertEquals("", result) + } + + @Test + fun scopeExactReplaceWithNonEmpty_collapsesAtomically() { + // SwiftKey case: IME fully covers the mention range and writes a + // shortened replacement (e.g. one of the multi-word display tokens). + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "@John") } + assertEquals("", result) + } + + @Test + fun scopeBroaderReplace_passesThrough() { + // Select-all + type: change covers the mention plus surrounding text. + // Treated as a deliberate broader edit; the typed character is preserved. + val state = TextFieldState("hi $npub world") + val result = state.applyChange { replace(0, length, "x") } + assertEquals("x", result) + } + + @Test + fun appendAfterMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { append(" hello") } + assertEquals("$npub hello", result) + } + + @Test + fun mentionWithTrailingSpace_collapseConsumesSpace() { + val state = TextFieldState("$npub hello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun mentionWithTrailingNewline_collapseConsumesNewline() { + val state = TextFieldState("$npub\nhello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun multipleMentions_partialOnSecond_onlySecondCollapsed() { + val text = "$npub and $npub" + val state = TextFieldState(text) + // partial delete inside the second mention only + val result = state.applyChange { replace(text.length - 4, text.length, "") } + assertEquals("$npub and ", result) + } + + @Test + fun mentionFreeChange_skipsRegexEntirely() { + // No "npub1" or "nprofile1" substring in the original text — the + // cheap-gate path should exit before any regex work. + val state = TextFieldState("hello world this is plain text") + val result = state.applyChange { replace(5, 11, "") } + assertEquals("hello this is plain text", result) + } +}