Skip to content

Commit c37eeb8

Browse files
Add unit tests for the inbox toolbar
1 parent eb2771e commit c37eeb8

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

iterableapi-ui/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ android {
4040
enableAndroidTestCoverage true
4141
}
4242
}
43+
44+
testOptions.unitTests.includeAndroidResources = true
4345
}
4446

4547
dependencies {
@@ -54,6 +56,9 @@ dependencies {
5456
implementation 'com.google.android.material:material:1.12.0'
5557

5658
testImplementation 'junit:junit:4.13.2'
59+
testImplementation 'androidx.test:core:1.6.1'
60+
testImplementation 'androidx.test.ext:junit:1.2.1'
61+
testImplementation 'org.robolectric:robolectric:4.14.1'
5762
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
5863
androidTestImplementation 'androidx.test:runner:1.6.2'
5964
androidTestImplementation 'androidx.test:rules:1.6.1'
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.iterable.iterableapi.ui.inbox
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNotEquals
5+
import org.junit.Assert.assertSame
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
import java.io.ByteArrayInputStream
9+
import java.io.ByteArrayOutputStream
10+
import java.io.ObjectInputStream
11+
import java.io.ObjectOutputStream
12+
13+
class InboxToolbarOptionTest {
14+
15+
@Test
16+
fun customCarriesLayoutRes() {
17+
val option = InboxToolbarOption.Custom(layoutRes = 42)
18+
assertEquals(42, option.layoutRes)
19+
}
20+
21+
@Test
22+
fun customDataClassEqualityIsByValue() {
23+
assertEquals(InboxToolbarOption.Custom(7), InboxToolbarOption.Custom(7))
24+
assertNotEquals(InboxToolbarOption.Custom(7), InboxToolbarOption.Custom(8))
25+
}
26+
27+
@Test
28+
fun dataObjectsAreReferenceSingletonsInProcess() {
29+
// Belt-and-suspenders: in-process the data object should be the same instance.
30+
assertSame(InboxToolbarOption.None, InboxToolbarOption.None)
31+
assertSame(InboxToolbarOption.Default, InboxToolbarOption.Default)
32+
assertSame(InboxToolbarOption.WithBackButton, InboxToolbarOption.WithBackButton)
33+
}
34+
35+
@Test
36+
fun dataObjectsRoundTripThroughJavaSerializationViaEquals() {
37+
// Across deserialization the JVM creates a new instance, so reference identity
38+
// is intentionally not preserved (we documented `==` is unsafe for callers).
39+
// Structural equality must hold so `when` branches still route correctly.
40+
assertEquals(InboxToolbarOption.None, roundTrip(InboxToolbarOption.None))
41+
assertEquals(InboxToolbarOption.Default, roundTrip(InboxToolbarOption.Default))
42+
assertEquals(InboxToolbarOption.WithBackButton, roundTrip(InboxToolbarOption.WithBackButton))
43+
}
44+
45+
@Test
46+
fun customRoundTripsThroughJavaSerialization() {
47+
val original = InboxToolbarOption.Custom(layoutRes = 1234)
48+
val restored = roundTrip(original)
49+
assertEquals(original, restored)
50+
assertEquals(1234, (restored as InboxToolbarOption.Custom).layoutRes)
51+
}
52+
53+
@Test
54+
fun whenBranchExhaustivelyDispatchesEachVariant() {
55+
val variants: List<InboxToolbarOption> = listOf(
56+
InboxToolbarOption.None,
57+
InboxToolbarOption.Default,
58+
InboxToolbarOption.WithBackButton,
59+
InboxToolbarOption.Custom(layoutRes = 99)
60+
)
61+
val labels = variants.map { option ->
62+
when (option) {
63+
InboxToolbarOption.None -> "none"
64+
InboxToolbarOption.Default -> "default"
65+
InboxToolbarOption.WithBackButton -> "back"
66+
is InboxToolbarOption.Custom -> "custom-${option.layoutRes}"
67+
}
68+
}
69+
assertEquals(listOf("none", "default", "back", "custom-99"), labels)
70+
}
71+
72+
private fun roundTrip(option: InboxToolbarOption): InboxToolbarOption {
73+
val bytes = ByteArrayOutputStream().use { byteStream ->
74+
ObjectOutputStream(byteStream).use { it.writeObject(option) }
75+
byteStream.toByteArray()
76+
}
77+
return ObjectInputStream(ByteArrayInputStream(bytes)).use { input ->
78+
val restored = input.readObject()
79+
assertTrue("Round-tripped value must remain an InboxToolbarOption",
80+
restored is InboxToolbarOption)
81+
restored as InboxToolbarOption
82+
}
83+
}
84+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.iterable.iterableapi.ui.inbox
2+
3+
import android.view.ContextThemeWrapper
4+
import android.view.View
5+
import androidx.activity.ComponentActivity
6+
import com.google.android.material.appbar.MaterialToolbar
7+
import com.iterable.iterableapi.ui.R
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertFalse
10+
import org.junit.Assert.assertNotNull
11+
import org.junit.Assert.assertNull
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import org.robolectric.Robolectric
16+
import org.robolectric.RobolectricTestRunner
17+
import org.robolectric.annotation.Config
18+
import org.robolectric.shadows.ShadowView
19+
20+
@RunWith(RobolectricTestRunner::class)
21+
@Config(sdk = [33])
22+
class IterableInboxToolbarViewTest {
23+
24+
private fun newToolbar(): IterableInboxToolbarView {
25+
val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
26+
// The toolbar layout references Material attributes (?attr/colorSurface),
27+
// so a Material theme is required - AppCompat alone is not enough.
28+
val themed = ContextThemeWrapper(
29+
activity,
30+
com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar
31+
)
32+
return IterableInboxToolbarView(themed)
33+
}
34+
35+
private fun materialToolbar(view: IterableInboxToolbarView): MaterialToolbar =
36+
view.findViewById(R.id.iterableInboxMaterialToolbar)
37+
38+
@Test
39+
fun applyNone_hidesTheView() {
40+
val view = newToolbar()
41+
view.apply(InboxToolbarOption.None, title = "ignored")
42+
assertEquals(View.GONE, view.visibility)
43+
}
44+
45+
@Test
46+
fun applyDefault_showsTitleWithoutNavigationIcon() {
47+
val view = newToolbar()
48+
view.apply(InboxToolbarOption.Default, title = "My Inbox")
49+
val toolbar = materialToolbar(view)
50+
assertEquals(View.VISIBLE, view.visibility)
51+
assertEquals("My Inbox", toolbar.title)
52+
assertNull("Default should not set a navigation icon", toolbar.navigationIcon)
53+
}
54+
55+
@Test
56+
fun applyDefault_withNullTitle_fallsBackToDefaultString() {
57+
val view = newToolbar()
58+
view.apply(InboxToolbarOption.Default, title = null)
59+
val expected = view.context.getString(R.string.iterable_inbox_default_title)
60+
assertEquals(expected, materialToolbar(view).title)
61+
}
62+
63+
@Test
64+
fun applyWithBackButton_setsNavigationIconAndClickListener() {
65+
val view = newToolbar()
66+
view.apply(InboxToolbarOption.WithBackButton, title = "Inbox")
67+
val toolbar = materialToolbar(view)
68+
assertEquals(View.VISIBLE, view.visibility)
69+
assertEquals("Inbox", toolbar.title)
70+
assertNotNull("WithBackButton must set a navigation icon", toolbar.navigationIcon)
71+
}
72+
73+
@Test
74+
fun setOnBackClickListener_isInvokedWhenNavigationIconClicked() {
75+
val view = newToolbar()
76+
view.apply(InboxToolbarOption.WithBackButton, title = null)
77+
78+
var overrideFired = false
79+
view.setOnBackClickListener { overrideFired = true }
80+
81+
// MaterialToolbar exposes the click via the listener registered with
82+
// setNavigationOnClickListener. Robolectric's ShadowView.innerText is brittle
83+
// for the nav icon child; instead we read back the listener and invoke it.
84+
materialToolbar(view).navigationOnClickListener().onClick(view)
85+
86+
assertTrue("Override back-click listener was not invoked", overrideFired)
87+
}
88+
89+
@Test
90+
fun setOnBackClickListener_clearedWithNull_fallsBackToDefault() {
91+
val view = newToolbar()
92+
view.apply(InboxToolbarOption.WithBackButton, title = null)
93+
94+
var overrideFired = false
95+
view.setOnBackClickListener { overrideFired = true }
96+
view.setOnBackClickListener(null)
97+
98+
materialToolbar(view).navigationOnClickListener().onClick(view)
99+
100+
assertFalse("Override should have been cleared", overrideFired)
101+
}
102+
103+
@Test
104+
fun customToDefaultTransition_restoresSdkToolbar() {
105+
val view = newToolbar()
106+
107+
// Start in Default - the SDK's MaterialToolbar should be present.
108+
view.apply(InboxToolbarOption.Default, title = "Inbox")
109+
assertNotNull(view.findViewById<MaterialToolbar>(R.id.iterableInboxMaterialToolbar))
110+
111+
// Switch to Custom using a layout we know exists in the SDK. After this,
112+
// the integrator-supplied layout replaces the SDK's toolbar tree.
113+
view.apply(InboxToolbarOption.Custom(R.layout.iterable_inbox_item), title = null)
114+
assertEquals(View.VISIBLE, view.visibility)
115+
116+
// Switch back to Default - the SDK's MaterialToolbar must be re-inflated
117+
// and the new title must be applied.
118+
view.apply(InboxToolbarOption.Default, title = "Back")
119+
val restored: MaterialToolbar? = view.findViewById(R.id.iterableInboxMaterialToolbar)
120+
assertNotNull("Default after Custom must restore the SDK toolbar", restored)
121+
assertEquals("Back", restored!!.title)
122+
}
123+
}
124+
125+
/**
126+
* Reads back the navigation OnClickListener that was registered via
127+
* [androidx.appcompat.widget.Toolbar.setNavigationOnClickListener]. Toolbar stores
128+
* the listener on its internal navigation button child; the cleanest way under
129+
* Robolectric is to attach our own click to `null`-safe traverse via a shadow.
130+
*/
131+
private fun MaterialToolbar.navigationOnClickListener(): View.OnClickListener {
132+
// Toolbar always creates an internal ImageButton for the nav icon when one is
133+
// set; locate it and return its registered click listener via Robolectric shadow.
134+
for (i in 0 until childCount) {
135+
val child = getChildAt(i)
136+
if (child is android.widget.ImageButton) {
137+
val shadow = org.robolectric.Shadows.shadowOf(child) as ShadowView
138+
return shadow.onClickListener
139+
?: error("Navigation icon has no click listener attached")
140+
}
141+
}
142+
error("Toolbar has no navigation icon child")
143+
}

0 commit comments

Comments
 (0)