Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
Kotlin
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522package social.grain
import androidx.lifecycle.SavedStateHandleimport androidx.test.ext.junit.runners.AndroidJUnit4import org.junit.Afterimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertFalseimport org.junit.Assert.assertNotNullimport org.junit.Assert.assertNullimport org.junit.Assert.assertTrueimport org.junit.Beforeimport org.junit.Ruleimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.model.GrainProfileimport social.grain.data.api.markStoriesViewedimport kotlinx.serialization.json.decodeFromJsonElementimport social.grain.support.testJsonimport social.grain.data.model.StoryViewerStateimport social.grain.feature.stories.StoryViewerViewModelimport social.grain.support.Fixturesimport social.grain.support.Harnessimport social.grain.support.MainDispatcherRuleimport social.grain.support.awaitCallimport social.grain.support.awaitValueimport social.grain.support.jsonimport social.grain.support.settle
/** * The story viewer, which is a queue of authors inside a queue of stories. * * Three ways in, and they behave differently on purpose: the strip walks on to * the next author, a ring anywhere else plays one account and stops, and the * archive plays one expired story on its own. Getting that wrong means tapping * past a friend's last story drops you into a stranger's. */@RunWith(AndroidJUnit4::class)class StoryViewerViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private lateinit var harness: Harness
private val alice = Fixtures.profile(did = "did:plc:alice", handle = "alice.test") private val bob = Fixtures.profile(did = "did:plc:bob", handle = "bob.test", displayName = "Bob")
private fun stories(creator: GrainProfile, vararg rkeys: String) = """{"stories":[${rkeys.joinToString(",") { json(Fixtures.story(rkey = it, creator = creator)) }}]}"""
/** * Answered per actor rather than with one fixed reply. * * The viewer prefetches the authors either side of the one on screen, so a * fixed reply gets cached against whichever author the prefetch happened to * ask about first — and the test then plays alice's stories under bob's * name. */ private fun serveStories(aliceStories: List<String> = listOf("a1", "a2")) { harness.server.on("social.grain.unspecced.getStories") { call -> when (call.params["actor"]) { bob.did -> stories(bob, "b1") alice.did -> stories(alice, *aliceStories.toTypedArray()) else -> """{"stories":[]}""" } } }
@Before fun setUp() { harness = Harness() harness.server.on("dev.hatk.describeLabels", """{"definitions":[]}""") serveStories() }
@After fun tearDown() = harness.tearDown()
private fun viewModel( did: String = alice.did, story: String = "", solo: Boolean = false, ) = 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), ), )
/** * Two authors in the strip, alice first. * * The timestamps are pinned rather than left to default: the strip is * ordered newest story first, so authors seeded a millisecond apart come * back in whichever order the clock happened to give them. */ 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), ), ), ) }
@Test fun aWatchedStoryIsReportedToTheAppview() { seedStrip() harness.viewedStories.uploader = { uris -> harness.client.markStoriesViewed(uris, social.grain.support.testAuth()) } val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
val call = harness.server.awaitCall("social.grain.unspecced.markStoriesViewed")
assertEquals("POST", call.method) assertEquals( listOf("at://did:plc:alice/social.grain.story/a1"), call.json["stories"]!!.let { testJson.decodeFromJsonElement<List<String>>(it) }, ) }
@Test fun aStoryWatchedElsewhereIsSkippedOver() { seedStrip() harness.server.on("social.grain.unspecced.getStories") { call -> when (call.params["actor"]) { alice.did -> """{"stories":[ ${json(Fixtures.story(rkey = "a1", creator = alice).copy(viewer = StoryViewerState(viewed = true)))}, ${json(Fixtures.story(rkey = "a2", creator = alice))} ]}""" bob.did -> stories(bob, "b1") else -> """{"stories":[]}""" } } val model = viewModel() val state = model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
assertEquals("Opens on the story the web has not shown yet", "a2", state.story!!.rkey) }
@Test fun openingFromTheStripStartsOnTheTappedAuthor() { seedStrip() val model = viewModel() val state = model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
assertEquals(2, state.authors.size) assertEquals("Newest story first", alice.did, state.authors.first().did) assertEquals(alice.did, state.author!!.did) assertEquals("a1", state.story!!.rkey) assertFalse(state.isLoading) assertFalse("Alice's story is not the viewer's", state.isOwnStory) }
@Test fun tappingForwardWalksTheStoriesThenTheAuthors() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
model.next() assertEquals("a2", model.state.value.story!!.rkey)
model.next()
val onBob = model.state.awaitValue("bob's story") { it.author?.did == bob.did && it.stories.isNotEmpty() } assertEquals("b1", onBob.story!!.rkey)
model.next() model.state.awaitValue("the end") { it.isFinished } }
@Test fun tappingBackFromAnAuthorsFirstStoryLandsOnThePreviousAuthorsLast() { seedStrip() val model = viewModel(did = bob.did) model.state.awaitValue("bob's story") { it.stories.isNotEmpty() }
model.previous()
val back = model.state.awaitValue("alice's last story") { it.author?.did == alice.did && it.stories.isNotEmpty() } assertEquals("Coming backwards resumes at the end", "a2", back.story!!.rkey) }
@Test fun tappingBackAtTheVeryStartStaysPut() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
model.previous() settle()
assertEquals("a1", model.state.value.story!!.rkey) assertFalse(model.state.value.isFinished) }
@Test fun aSwipeJumpsWholeAuthorsRatherThanSingleStories() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
model.nextAuthor() model.state.awaitValue("bob") { it.author?.did == bob.did && it.stories.isNotEmpty() }
model.previousAuthor() model.state.awaitValue("alice again") { it.author?.did == alice.did && it.stories.isNotEmpty() }
model.previousAuthor() settle() assertEquals("Nobody before the first author", alice.did, model.state.value.author!!.did) }
@Test fun swipingPastTheLastAuthorCloses() { seedStrip() val model = viewModel(did = bob.did) model.state.awaitValue("bob's story") { it.stories.isNotEmpty() }
model.nextAuthor() model.state.awaitValue("the end") { it.isFinished } }
@Test fun aDragMountsTheAuthorOnTheFarSideAndCanBeTurnedBack() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
assertEquals(true, model.beginAuthorSwipe(forward = true)) val pending = model.state.awaitValue("bob mounted") { it.pending?.stories?.isNotEmpty() == true } assertEquals(bob.did, pending.pending!!.author.did) assertNotNull(pending.pending!!.story) assertEquals("The author being watched is untouched", alice.did, pending.author!!.did)
model.cancelAuthorSwipe() assertNull(model.state.value.pending) assertEquals(alice.did, model.state.value.author!!.did) }
@Test fun aCommittedDragBecomesTheAuthorBeingWatched() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
model.beginAuthorSwipe(forward = true) model.state.awaitValue("bob mounted") { it.pending?.stories?.isNotEmpty() == true } model.commitAuthorSwipe()
val state = model.state.value assertEquals(bob.did, state.author!!.did) assertEquals("b1", state.story!!.rkey) assertNull(state.pending) }
@Test fun aDragWithNobodyOnThatSideStaysInert() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
assertNull("There is nobody before alice", model.beginAuthorSwipe(forward = false)) assertNull(model.state.value.pending) }
@Test fun aDragThatCatchesAHandOffCarriesOnWithTheAuthorAlreadyComingIn() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
model.beginAuthorSwipe(forward = true) assertEquals( "The second drag keeps bob rather than mounting a second face", true, model.beginAuthorSwipe(forward = false), ) }
@Test fun committingADragWhoseStoriesNeverArrivedFallsBackToAPlainLoad() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
harness.server.on("social.grain.unspecced.getStories", """{"error":"no"}""", code = 500) model.beginAuthorSwipe(forward = true) settle() model.commitAuthorSwipe()
model.state.awaitValue("bob") { it.author?.did == bob.did } }
@Test fun aRingOutsideTheStripPlaysThatAccountAndStops() { val model = viewModel(solo = true) val state = model.state.awaitValue("the story") { it.stories.isNotEmpty() }
assertEquals("Nobody to walk on to", 1, state.authors.size) assertEquals(alice.did, state.author!!.did)
model.next() assertEquals("a2", model.state.value.story!!.rkey) model.next() model.state.awaitValue("the end") { it.isFinished } }
@Test fun anAccountWithNothingLiveJustCloses() { harness.server.on("social.grain.unspecced.getStories", """{"stories":[]}""") val model = viewModel(solo = true) model.state.awaitValue("the close") { it.isFinished } }
@Test fun anExpiredStoryIsNotPlayedAsALiveOne() { harness.server.on( "social.grain.unspecced.getStories", """{"stories":[${json(Fixtures.story(rkey = "old", creator = alice, expired = true))}]}""", ) val model = viewModel(solo = true) model.state.awaitValue("the close") { it.isFinished } }
@Test fun theArchiveOpensOneStoryOnItsOwn() { val uri = "at://did:plc:alice/social.grain.story/old" harness.server.on( "social.grain.unspecced.getStory", """{"story":${json(Fixtures.story(rkey = "old", creator = alice, expired = true))}}""", ) val model = viewModel(story = uri) val state = model.state.awaitValue("the archived story") { it.stories.isNotEmpty() }
assertEquals(1, state.stories.size) assertEquals(1, state.authors.size) assertEquals("old", state.story!!.rkey) assertEquals(uri, harness.server.lastCall("social.grain.unspecced.getStory").params["story"]) }
@Test fun anArchivedStoryThatIsGoneJustCloses() { harness.server.on("social.grain.unspecced.getStory", """{"story":null}""") val model = viewModel(story = "at://x/social.grain.story/gone") model.state.awaitValue("the close") { it.isFinished } }
@Test fun anAuthorTheStripHasNeverHeardOfIsRefetchedThenPlayedAlone() { harness.server.on( "social.grain.unspecced.getStoryAuthors", """{"authors":[${json(Fixtures.storyAuthor(profile = bob, storyCount = 1))}]}""", ) val model = viewModel()
val state = model.state.awaitValue("alice alone") { it.stories.isNotEmpty() } assertEquals("Alice isn't in the refetched strip, so she plays alone", 1, state.authors.size) assertEquals(alice.did, state.author!!.did) harness.server.awaitCall("social.grain.unspecced.getStoryAuthors") }
@Test fun watchingAStoryMarksItSeen() { seedStrip() val model = viewModel() val state = model.state.awaitValue("the first story") { it.stories.isNotEmpty() } // The mark is written just after the state that announced the story. settle()
assertTrue(harness.viewedStories.isViewed(state.story!!.uri)) }
@Test fun theTimerTokenMovesWheneverTheStoryOnScreenDoes() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() } val before = model.storyToken.value
model.next()
assertTrue(model.storyToken.value > before) }
@Test fun holdingTheScreenPausesAndReleasingResumes() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
model.setPaused(true) assertTrue(model.state.value.isPaused) model.setPaused(false) assertFalse(model.state.value.isPaused) }
@Test fun favoritingAStoryFlipsItThenKeepsTheRecordUri() { harness.server.on("dev.hatk.createRecord", """{"uri":"at://did:plc:me/fav/s1"}""") val model = viewModel(solo = true) model.state.awaitValue("the story") { it.stories.isNotEmpty() }
model.toggleFavorite()
assertTrue(model.state.value.story!!.isFavorited) model.state.awaitValue("the record uri") { it.story?.viewer?.fav == "at://did:plc:me/fav/s1" } }
@Test fun aStoryFavoriteThatFailsIsPutBack() { harness.server.on("dev.hatk.createRecord", """{"error":"no"}""", code = 500) val model = viewModel(solo = true) model.state.awaitValue("the story") { it.stories.isNotEmpty() }
model.toggleFavorite()
model.state.awaitValue("the revert") { it.story?.isFavorited == false } }
@Test fun unfavoritingAStoryDeletesTheRecord() { harness.server.on( "social.grain.unspecced.getStories", """{"stories":[${json( Fixtures.story( rkey = "a1", creator = alice, favUri = "at://did:plc:me/social.grain.favorite/sf1", ), )}]}""", ) val model = viewModel(solo = true) model.state.awaitValue("the story") { it.story?.isFavorited == true }
model.toggleFavorite()
assertTrue(harness.server.awaitCall("dev.hatk.deleteRecord").body.contains("sf1")) }
@Test fun deletingTheLastStoryClosesTheViewer() { serveStories(aliceStories = listOf("a1")) val model = viewModel(solo = true) model.state.awaitValue("the story") { it.stories.isNotEmpty() }
model.deleteCurrentStory()
model.state.awaitValue("the close") { it.isFinished } }
@Test fun deletingOneOfSeveralStoriesStaysOpen() { val model = viewModel(solo = true) model.state.awaitValue("the story") { it.stories.size == 2 }
model.deleteCurrentStory()
val state = model.state.awaitValue("the remaining story") { it.stories.size == 1 } assertEquals("a2", state.story!!.rkey) assertFalse(state.isFinished) }
@Test fun aStoryDeleteThatFailsSaysSo() { harness.server.on("dev.hatk.deleteRecord", """{"error":"no"}""", code = 500) val model = viewModel(solo = true) model.state.awaitValue("the story") { it.stories.isNotEmpty() }
model.deleteCurrentStory()
val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("Couldn't delete that story.", state.errorMessage)
model.dismissError() assertNull(model.state.value.errorMessage) }
@Test fun theViewersOwnStoryIsMarkedAsTheirs() { val me = Fixtures.profile(did = harness.did, handle = harness.handle) harness.server.on("social.grain.unspecced.getStories", stories(me, "m1")) val model = viewModel(did = harness.did, solo = true)
assertTrue(model.state.awaitValue("the story") { it.stories.isNotEmpty() }.isOwnStory) }
@Test fun theNeighboursFirstFramesArePreloaded() { seedStrip() val model = viewModel() model.state.awaitValue("the first story") { it.stories.isNotEmpty() }
val urls = model.preloadUrls.awaitValue("the preload list") { it.isNotEmpty() } assertTrue(urls.all { it.isNotEmpty() }) }}