1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
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.feature.feed.FeedScreen
import social.grain.feature.feed.FeedViewModel
import social.grain.support.Fixtures
import social.grain.support.Harness
import social.grain.support.awaitContentDescription
import social.grain.support.awaitText
import social.grain.support.MainDispatcherRule
import social.grain.support.awaitCall
import social.grain.support.json
import social.grain.ui.theme.GrainTheme
/**
* The feed as a reader sees it: the pin picker, the two composers behind the
* +, and the empty state.
*
* The empty state matters more than it looks — an account that follows nobody
* sees it on every launch, and "Nothing here yet" over a feed that simply
* failed to load is the difference between a prompt and a lie.
*/
@RunWith(AndroidJUnit4::class)
class FeedScreenTest {
@get:Rule
val mainDispatcher = MainDispatcherRule()
@get:Rule
val composeRule = createComposeRule()
private lateinit var harness: Harness
@Before
fun setUp() {
harness = Harness()
runBlocking { harness.feedCache.purge(harness.did) }
harness.server
.on("social.grain.actor.getPreferences", """{"preferences":[]}""")
.on("social.grain.story.getAuthors", """{"authors":[]}""")
.on("social.grain.feed.getFeed", """{"items":[${json(Fixtures.gallery())}]}""")
}
@After
fun tearDown() {
runBlocking { harness.feedCache.purge(harness.did) }
harness.tearDown()
}
private fun show(
onOpenProfile: (String) -> Unit = {},
onCreate: () -> Unit = {},
onOpenStories: (String) -> Unit = {},
onCreateStory: () -> Unit = {},
onOpenHashtag: (String) -> Unit = {},
onOpenLocation: (String, String) -> Unit = { _, _ -> },
) {
val model = FeedViewModel(
client = harness.client,
auth = harness.auth,
feedCache = harness.feedCache,
viewedStories = harness.viewedStories,
storyStatusCache = harness.storyStatus,
labelDefsCache = harness.labelDefinitions,
)
composeRule.setContent {
GrainTheme {
FeedScreen(
onOpenProfile = onOpenProfile,
onCreate = onCreate,
onOpenStories = onOpenStories,
onCreateStory = onCreateStory,
onOpenFavorites = {},
onOpenHashtag = onOpenHashtag,
onOpenLocation = onOpenLocation,
viewModel = model,
)
}
}
composeRule.waitForIdle()
}
@Test
fun theFeedIsNamedAfterThePinItIsShowing() {
show()
composeRule.awaitText("Recent")
}
@Test
fun aLoadedFeedDrawsItsCards() {
show()
composeRule.awaitText("A walk in the fog")
composeRule.awaitText("@alice.grain.social")
}
@Test
fun anEmptyFeedPromptsRatherThanShowingNothing() {
harness.server.on("social.grain.feed.getFeed", """{"items":[]}""")
show()
composeRule.awaitText("Nothing here yet")
composeRule.onNodeWithText("Follow some photographers, or post the first gallery.")
.assertExists()
val before = harness.server.countOf("social.grain.feed.getFeed")
composeRule.onNodeWithText("Retry").performClick()
composeRule.waitUntil(10_000) { harness.server.countOf("social.grain.feed.getFeed") > before }
}
@Test
fun thePinPickerListsEveryPinnedFeedAndSwitchesToTheOneTapped() {
harness.server.on(
"social.grain.actor.getPreferences",
"""{"preferences":[{"${'$'}type":"social.grain.actor.defs#pinnedFeedsPref","items":[
{"id":"recent","label":"Recent","type":"feed","path":"/"},
{"id":"following","label":"Following","type":"feed","path":"/f"}
]}]}""",
)
show()
composeRule.awaitContentDescription("Switch feed")
composeRule.onNodeWithText("Recent").performClick()
composeRule.onNodeWithText("Following").performClick()
composeRule.waitForIdle()
assertEquals(
"following",
harness.server.lastCall("social.grain.feed.getFeed").params["feed"]?.substringAfterLast('/'),
)
}
@Test
fun anAccountWithNoStoredPinsGetsTheThreeGrainShipsWith() {
show()
composeRule.onNodeWithText("Recent").performClick()
composeRule.awaitText("Following")
composeRule.awaitText("For you")
}
@Test
fun bothComposersHangOffTheOnePlus() {
var galleries = 0
var stories = 0
show(onCreate = { galleries += 1 }, onCreateStory = { stories += 1 })
composeRule.onNodeWithContentDescription("Create").performClick()
composeRule.awaitText("New story")
composeRule.onNodeWithText("New gallery").performClick()
assertEquals(1, galleries)
composeRule.onNodeWithContentDescription("Create").performClick()
composeRule.onNodeWithText("New story").performClick()
assertEquals(1, stories)
}
@Test
fun tappingACardsAuthorOpensTheirProfile() {
var opened: String? = null
show(onOpenProfile = { opened = it })
composeRule.awaitText("Alice")
composeRule.onNodeWithText("Alice").performClick()
assertEquals("did:plc:alice", opened)
}
@Test
fun favoritingFromTheFeedWritesTheRecord() {
harness.server.on("com.atproto.repo.createRecord", """{"uri":"at://did:plc:me/fav/1"}""")
show()
composeRule.onNodeWithContentDescription("Favorite").performScrollTo().performClick()
composeRule.waitUntil(10_000) { harness.server.countOf("com.atproto.repo.createRecord") >= 1 }
}
}