package social.grain import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeLeft import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.data.model.GrainProfile import social.grain.feature.stories.StoryCommentsViewModel import social.grain.feature.stories.StoryViewerScreen import social.grain.feature.stories.StoryViewerViewModel import social.grain.support.Fixtures import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.awaitContentDescription import social.grain.support.awaitText import social.grain.support.json import social.grain.ui.theme.GrainTheme /** * The story viewer as it appears: a full-bleed photo with controls over it. * * Almost everything here is drawn in white over an unknown photograph, and the * whole screen has no chrome to fall back on — a control that fails to compose * leaves a reader tapping a black rectangle with no way out. */ @RunWith(AndroidJUnit4::class) class StoryViewerScreenTest { @get:Rule val mainDispatcher = MainDispatcherRule() @get:Rule val composeRule = createComposeRule() private lateinit var harness: Harness private val alice = Fixtures.profile(did = "did:plc:alice", handle = "alice.test") private fun stories(creator: GrainProfile, vararg rkeys: String) = """{"stories":[${rkeys.joinToString(",") { json(Fixtures.story(rkey = it, creator = creator)) }}]}""" @Before fun setUp() { harness = Harness() harness.server .on("dev.hatk.describeLabels", """{"definitions":[]}""") .on("social.grain.unspecced.getStories", stories(alice, "a1", "a2")) .on("social.grain.unspecced.getCommentThread", """{"comments":[],"totalCount":0}""") } @After fun tearDown() = harness.tearDown() private lateinit var viewer: StoryViewerViewModel private fun show( did: String = alice.did, story: String = "", solo: Boolean = true, onClose: () -> Unit = {}, onOpenProfile: (String) -> Unit = {}, ) { viewer = StoryViewerViewModel( client = harness.client, auth = harness.auth, viewedStore = harness.viewedStories, statusCache = harness.storyStatus, labelDefsCache = harness.labelDefinitions, savedStateHandle = SavedStateHandle( mapOf("did" to did, "story" to story, "solo" to solo), ), ) val comments = StoryCommentsViewModel(harness.client, harness.auth) composeRule.setContent { GrainTheme { StoryViewerScreen( onClose = onClose, onOpenProfile = onOpenProfile, viewModel = viewer, commentsViewModel = comments, ) } } composeRule.waitForIdle() } @Test fun theViewerNamesTheAuthorAndOffersAWayOut() { show() composeRule.awaitText("Alice") composeRule.awaitContentDescription("Close") } @Test fun closingReportsItsTap() { var closed = 0 show(onClose = { closed += 1 }) composeRule.awaitContentDescription("Close") composeRule.onNodeWithContentDescription("Close").performClick() // Leaving plays a short animation before it hands back. composeRule.waitUntil(10_000) { closed == 1 } } @Test fun aStoryOffersToBeFavoritedAndCommentedOn() { show() composeRule.awaitContentDescription("Favorite") composeRule.awaitContentDescription("Comments") } @Test fun favoritingWritesTheRecord() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/1"}""") show() composeRule.awaitContentDescription("Favorite") composeRule.onNodeWithContentDescription("Favorite").performClick() composeRule.waitUntil(10_000) { harness.server.countOf("dev.hatk.createRecord") >= 1 } } @Test fun anAlreadyFavoritedStoryOffersTheUndo() { harness.server.on( "social.grain.unspecced.getStories", """{"stories":[${json( Fixtures.story(rkey = "a1", creator = alice, favUri = "at://fav/1"), )}]}""", ) show() composeRule.awaitContentDescription("Unfavorite") } @Test fun somebodyElsesStoryOffersReportingRatherThanDeleting() { show() composeRule.awaitContentDescription("Report story") composeRule.onNodeWithContentDescription("Delete story").assertDoesNotExist() } @Test fun theViewersOwnStoryOffersDeletingBehindAConfirmation() { val me = Fixtures.profile(did = harness.did, handle = harness.handle) harness.server.on("social.grain.unspecced.getStories", stories(me, "m1")) show(did = harness.did) composeRule.awaitContentDescription("Delete story") composeRule.onNodeWithContentDescription("Delete story").performClick() composeRule.awaitText("Delete story?") composeRule.onNodeWithText("This will permanently delete this story.").assertExists() composeRule.onNodeWithText("Cancel").performClick() composeRule.waitForIdle() assertEquals(0, harness.server.countOf("dev.hatk.deleteRecord")) } @Test fun confirmingTheDeleteWritesIt() { val me = Fixtures.profile(did = harness.did, handle = harness.handle) harness.server.on("social.grain.unspecced.getStories", stories(me, "m1")) show(did = harness.did) composeRule.awaitContentDescription("Delete story") composeRule.onNodeWithContentDescription("Delete story").performClick() composeRule.awaitText("Delete story?") composeRule.onNodeWithText("Delete").performClick() composeRule.waitUntil(10_000) { harness.server.countOf("dev.hatk.deleteRecord") >= 1 } } @Test fun tappingTheAuthorOpensTheirProfile() { var opened: String? = null show(onOpenProfile = { opened = it }) composeRule.awaitText("Alice") composeRule.onNodeWithText("Alice").performClick() composeRule.waitUntil(10_000) { opened == "did:plc:alice" } } // MARK: - Gestures private val bob = Fixtures.profile(did = "did:plc:bob", handle = "bob.test", displayName = "Bob") /** Two authors in the strip, alice first — the strip is newest-first. */ private fun seedStrip() { harness.storyStatus.update( listOf( Fixtures.storyAuthor( profile = alice, storyCount = 2, latestAt = Fixtures.recentIso(minutesAgo = 1), ), Fixtures.storyAuthor( profile = bob, storyCount = 1, latestAt = Fixtures.recentIso(minutesAgo = 30), ), ), ) harness.server.on("social.grain.unspecced.getStories") { call -> when (call.params["actor"]) { bob.did -> stories(bob, "b1") else -> stories(alice, "a1", "a2") } } } @Test fun draggingSidewaysHandsOverToTheNextAuthor() { seedStrip() show(solo = false) composeRule.awaitText("Alice") composeRule.onRoot().performTouchInput { swipeLeft(startX = right * 0.9f, endX = left + 1f, durationMillis = 200) } composeRule.waitUntil(10_000) { viewer.state.value.author?.did == bob.did } } @Test fun draggingDownThrowsTheViewerAway() { seedStrip() var closed = 0 show(solo = false, onClose = { closed += 1 }) composeRule.awaitText("Alice") composeRule.onRoot().performTouchInput { swipeDown(startY = top + 1f, endY = bottom - 1f, durationMillis = 200) } composeRule.waitUntil(10_000) { closed == 1 || viewer.state.value.isFinished } } @Test fun aDragThatGoesNowhereLeavesTheStoryWhereItWas() { seedStrip() show(solo = false) composeRule.awaitText("Alice") composeRule.onRoot().performTouchInput { down(center) moveTo(center + androidx.compose.ui.geometry.Offset(20f, 0f)) up() } composeRule.waitForIdle() assertEquals(alice.did, viewer.state.value.author?.did) } @Test fun anArchivedStoryOpensOnItsOwn() { harness.server.on( "social.grain.unspecced.getStory", """{"story":${json(Fixtures.story(rkey = "old", creator = alice, expired = true))}}""", ) show(story = "at://did:plc:alice/social.grain.story/old", solo = false) composeRule.awaitText("Alice") assertEquals(1, viewer.state.value.stories.size) } }