From 133c897731f9045dcf0af1338b183ae88dbb78f4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 13 Oct 2025 18:37:28 +0200 Subject: [PATCH 1/2] Added junit and mockK for androidTest UI dependencies Added failing androidTest for mixed image/video parsing --- amethyst/build.gradle | 4 + .../amethyst/ParagraphParserTest.kt | 217 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 223 insertions(+) create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index aa3084380..d1c269684 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -155,6 +155,8 @@ android { packaging { resources { resources.pickFirsts.add('builddef.lst') + resources.pickFirsts.add('META-INF/LICENSE.md') + resources.pickFirsts.add('META-INF/LICENSE-notice.md') } } @@ -361,6 +363,8 @@ dependencies { androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.junit.ktx androidTestImplementation libs.androidx.espresso.core + androidTestImplementation libs.androidx.ui.test.junit4 + androidTestImplementation libs.mockk.android debugImplementation platform(libs.androidx.compose.bom) debugImplementation libs.androidx.ui.tooling diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt new file mode 100644 index 000000000..8e33ba4f7 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt @@ -0,0 +1,217 @@ +/** + * 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. + */ +package com.vitorpamplona.amethyst + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.ui.components.ParagraphParser +import com.vitorpamplona.amethyst.ui.components.RenderContext +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ParagraphParserTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testMixedImageAndVideoRenderedIndividually() { + // Test the bug: when mixed image + video, both should be rendered individually + val text = + "Renfield (2023)\n" + + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + + "https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Extract the image segments from the parsed paragraphs + val imageSegments = mutableListOf() + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { word -> + if (word is ImageSegment) { + imageSegments.add(word) + } + } + } + + // Should have 2 image segments (image + video URLs) + assertEquals(2, imageSegments.size) + + // Track what gets rendered + val singleWordRenders = mutableListOf() + val galleryRenders = mutableListOf>() + + // Set up the test with mocked dependencies + val context = + RenderContext( + state = state, + backgroundColor = mutableStateOf(Color.White), + quotesLeft = 3, + callbackUri = null, + accountViewModel = mockk(relaxed = true), + nav = mockk(relaxed = true), + ) + + // Execute the actual ParagraphParser method + composeTestRule.setContent { + ParagraphParser().ProcessWordsWithImageGrouping( + words = imageSegments.toImmutableList(), + context = context, + renderSingleWord = { segment, _ -> + singleWordRenders.add(segment) + }, + renderGallery = { images, _ -> + galleryRenders.add(images) + }, + ) + } + + composeTestRule.waitForIdle() + + assertTrue( + "Mixed image/video should be rendered individually (2 renders), not as gallery (0 renders). " + + "Found: $singleWordRenders individual, $galleryRenders gallery", + singleWordRenders.size == 2 && galleryRenders.isEmpty(), + ) + } + + @Test + fun testMultipleImagesRenderedAsGallery() { + // Test that multiple images (no videos) are correctly grouped as gallery + val text = + "Gallery:\n" + + "https://example.com/image1.jpg\n" + + "https://example.com/image2.png" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Extract the image segments + val imageSegments = mutableListOf() + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { word -> + if (word is ImageSegment) { + imageSegments.add(word) + } + } + } + + assertEquals(2, imageSegments.size) + + // Track renders + val singleWordRenders = mutableListOf() + val galleryRenders = mutableListOf>() + + val context = + RenderContext( + state = state, + backgroundColor = mutableStateOf(Color.White), + quotesLeft = 3, + callbackUri = null, + accountViewModel = mockk(relaxed = true), + nav = mockk(relaxed = true), + ) + + composeTestRule.setContent { + ParagraphParser().ProcessWordsWithImageGrouping( + words = imageSegments.toImmutableList(), + context = context, + renderSingleWord = { segment, _ -> + singleWordRenders.add(segment) + }, + renderGallery = { images, _ -> + galleryRenders.add(images) + }, + ) + } + + composeTestRule.waitForIdle() + + // Should render as gallery (1 gallery with 2 images) + assertEquals("Should render 1 gallery", 1, galleryRenders.size) + assertEquals("Gallery should contain 2 images", 2, galleryRenders[0].size) + assertEquals("Should not render individually", 0, singleWordRenders.size) + } + + @Test + fun testSingleImageRenderedIndividually() { + // Test that a single image is rendered individually, not as gallery + val text = "Single image:\nhttps://example.com/image.jpg" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + val imageSegments = mutableListOf() + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { word -> + if (word is ImageSegment) { + imageSegments.add(word) + } + } + } + + assertEquals(1, imageSegments.size) + + val singleWordRenders = mutableListOf() + val galleryRenders = mutableListOf>() + + val context = + RenderContext( + state = state, + backgroundColor = mutableStateOf(Color.White), + quotesLeft = 3, + callbackUri = null, + accountViewModel = mockk(relaxed = true), + nav = mockk(relaxed = true), + ) + + composeTestRule.setContent { + ParagraphParser().ProcessWordsWithImageGrouping( + words = imageSegments.toImmutableList(), + context = context, + renderSingleWord = { segment, _ -> + singleWordRenders.add(segment) + }, + renderGallery = { images, _ -> + galleryRenders.add(images) + }, + ) + } + + composeTestRule.waitForIdle() + + // Should render individually (not as gallery) + assertEquals("Should render 1 individual image", 1, singleWordRenders.size) + assertEquals("Should not render as gallery", 0, galleryRenders.size) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1527b5dd..9f8a20577 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,6 +102,7 @@ androidx-security-crypto-ktx = { group = "androidx.security", name = "security-c androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } audiowaveform = { group = "com.github.lincollincol", name = "compose-audiowaveform", version.ref = "audiowaveform" } @@ -128,6 +129,7 @@ markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", nam markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } From 349156f3806ea67daf9a01a0ca19b9f8af702227 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 13 Oct 2025 18:40:57 +0200 Subject: [PATCH 2/2] fix: Only render as gallery if all segments are images --- .../amethyst/ui/components/ParagraphParser.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt index a943b37af..e26cefc06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt @@ -197,19 +197,26 @@ class ParagraphParser { j++ } - if (imageSegments.size > 1) { - val imageContents = - imageSegments - .mapNotNull { segment -> - val imageUrl = segment.segmentText - context.state.imagesForPager[imageUrl] as? MediaUrlImage - }.toImmutableList() - - if (imageContents.isNotEmpty()) { - renderGallery(imageContents, context.accountViewModel) - } - } else { + if (imageSegments.size <= 1) { renderSingleWord(imageSegments.firstOrNull() ?: word, context) + } else { + val resolvedImages = + imageSegments.mapNotNull { segment -> + val imageUrl = segment.segmentText + context.state.imagesForPager[imageUrl] as? MediaUrlImage + } + + // Render gallery only if all segments are images + if (resolvedImages.size == imageSegments.size) { + renderGallery( + resolvedImages.toImmutableList(), + context.accountViewModel, + ) + } else { + imageSegments.forEach { segment -> + renderSingleWord(segment, context) + } + } } i = j // jump past processed run