~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
141 lines · 5.4 KB Raw History
package social.grain

import androidx.compose.ui.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.model.Facet
import social.grain.ui.components.buildRichText

/**
 * Facet indices are UTF-8 **byte** offsets, not character offsets.
 *
 * Slicing the string by character index looks correct in English and silently
 * mislabels every link the moment a comment contains an emoji or an accent —
 * the highlight drifts left and the tap target lands on the wrong word. These
 * pin the byte-accurate path.
 */
@RunWith(AndroidJUnit4::class)
class RichTextFacetTest {

    private val json = Json { ignoreUnknownKeys = true }

    private fun facet(start: Int, end: Int, type: String, key: String, value: String): Facet =
        Facet(
            index = Facet.ByteSlice(start, end),
            features = listOf(
                json.parseToJsonElement("""{"${'$'}type":"$type","$key":"$value"}""").jsonObject,
            ),
        )

    private fun render(text: String, facets: List<Facet>?) =
        buildRichText(text, facets, Color.Black, Color.Blue, null, null)

    private fun mention(start: Int, end: Int, did: String = "did:plc:abc") =
        facet(start, end, "app.bsky.richtext.facet#mention", "did", did)

    @Test
    fun facetSpanIsPlacedByByteOffsetNotCharacterOffset() {
        // "📷" is four UTF-8 bytes but two Kotlin chars, so byte and char
        // offsets diverge by two from here on.
        val text = "📷 shot by @alice.test today"
        val handle = "@alice.test"
        val byteStart = text.substringBefore(handle).toByteArray(Charsets.UTF_8).size
        val byteEnd = byteStart + handle.toByteArray(Charsets.UTF_8).size

        val annotated = render(text, listOf(mention(byteStart, byteEnd)))

        assertEquals("Rendered text must match the input exactly", text, annotated.text)

        val links = annotated.getLinkAnnotations(0, annotated.length)
        assertEquals(1, links.size)
        assertEquals(
            "The link must cover exactly the handle",
            handle,
            annotated.text.substring(links[0].start, links[0].end),
        )
    }

    @Test
    fun accentedTextKeepsLinkAlignment() {
        val text = "café visit with @bob.test"
        val handle = "@bob.test"
        val byteStart = text.substringBefore(handle).toByteArray(Charsets.UTF_8).size
        val byteEnd = byteStart + handle.toByteArray(Charsets.UTF_8).size

        val annotated = render(text, listOf(mention(byteStart, byteEnd)))
        val link = annotated.getLinkAnnotations(0, annotated.length).single()
        assertEquals(handle, annotated.text.substring(link.start, link.end))
    }

    @Test
    fun multipleFacetsAllLandOnTheirOwnSpans() {
        val text = "@alice.test and @bob.test shot #35mm"
        fun spanOf(needle: String): Pair<Int, Int> {
            val start = text.substringBefore(needle).toByteArray(Charsets.UTF_8).size
            return start to start + needle.toByteArray(Charsets.UTF_8).size
        }

        val (aStart, aEnd) = spanOf("@alice.test")
        val (bStart, bEnd) = spanOf("@bob.test")
        val (tStart, tEnd) = spanOf("#35mm")

        val annotated = render(
            text,
            listOf(
                mention(aStart, aEnd, "did:plc:alice"),
                mention(bStart, bEnd, "did:plc:bob"),
                facet(tStart, tEnd, "app.bsky.richtext.facet#tag", "tag", "35mm"),
            ),
        )

        val spans = annotated.getLinkAnnotations(0, annotated.length)
            .map { annotated.text.substring(it.start, it.end) }
        assertEquals(listOf("@alice.test", "@bob.test", "#35mm"), spans)
        assertEquals(text, annotated.text)
    }

    @Test
    fun anOutOfRangeFacetIsIgnoredRatherThanCrashing() {
        // The appview can hand back a facet whose end runs past the text if the
        // record was written with a different encoding assumption.
        val text = "short"
        val annotated = render(text, listOf(mention(0, 900)))
        assertEquals(text, annotated.text)
    }

    @Test
    fun textWithNoFacetsFallsBackToDetectingMentionsAndTags() {
        val annotated = render("hi @alice.test see #35mm", null)
        val spans = annotated.getLinkAnnotations(0, annotated.length)
            .map { annotated.text.substring(it.start, it.end) }
        assertEquals(listOf("@alice.test", "#35mm"), spans)
    }

    @Test
    fun aTagStartingWithADigitIsStillLinkified() {
        // "#35mm" and "#120film" are the tags this app exists for.
        val annotated = render("shot on #35mm and #120film", null)
        val spans = annotated.getLinkAnnotations(0, annotated.length)
            .map { annotated.text.substring(it.start, it.end) }
        assertEquals(listOf("#35mm", "#120film"), spans)
    }

    @Test
    fun aPurelyNumericHashIsNotATag() {
        // "ranked #1" is prose, not a tag.
        val annotated = render("ranked #1 in #2024", null)
        assertEquals(0, annotated.getLinkAnnotations(0, annotated.length).size)
    }

    @Test
    fun aUrlIsNotCarvedUpByAHashInIt() {
        val annotated = render("see https://grain.social/x#tag now", null)
        val spans = annotated.getLinkAnnotations(0, annotated.length)
            .map { annotated.text.substring(it.start, it.end) }
        assertEquals(listOf("https://grain.social/x#tag"), spans)
    }
}