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?) = 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 { 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) } }