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) <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-04-27 10:51:53 +02:00
parent 338080115f
commit c78c133675
@@ -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)
}
}