package io.github.hidroh.materialistic; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.design.widget.AppBarLayout; import android.support.design.widget.TabLayout; import android.support.v7.widget.RecyclerView; import android.view.GestureDetector; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.widget.TextView; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.experimental.categories.Category; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.MockitoAnnotations; import org.robolectric.Robolectric; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; import org.robolectric.fakes.RoboMenuItem; import org.robolectric.internal.ShadowExtractor; import org.robolectric.res.builder.RobolectricPackageManager; import org.robolectric.shadows.ShadowApplication; import org.robolectric.shadows.ShadowContentObserver; import org.robolectric.shadows.ShadowGestureDetector; import org.robolectric.shadows.ShadowLooper; import org.robolectric.shadows.ShadowPopupMenu; import org.robolectric.shadows.ShadowResolveInfo; import org.robolectric.shadows.ShadowToast; import org.robolectric.shadows.support.v4.ShadowLocalBroadcastManager; import org.robolectric.util.ActivityController; import java.io.IOException; import javax.inject.Inject; import javax.inject.Named; import io.github.hidroh.materialistic.accounts.UserServices; import io.github.hidroh.materialistic.data.FavoriteManager; import io.github.hidroh.materialistic.data.HackerNewsClient; import io.github.hidroh.materialistic.data.Item; import io.github.hidroh.materialistic.data.ItemManager; import io.github.hidroh.materialistic.data.MaterialisticProvider; import io.github.hidroh.materialistic.data.ResponseListener; import io.github.hidroh.materialistic.data.TestHnItem; import io.github.hidroh.materialistic.data.WebItem; import io.github.hidroh.materialistic.test.TestItem; import io.github.hidroh.materialistic.test.TestRunner; import io.github.hidroh.materialistic.test.TestWebItem; import io.github.hidroh.materialistic.test.shadow.ShadowFloatingActionButton; import io.github.hidroh.materialistic.test.shadow.ShadowRecyclerView; import io.github.hidroh.materialistic.test.suite.SlowTest; import static io.github.hidroh.materialistic.test.shadow.CustomShadows.customShadowOf; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; import static org.assertj.android.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; @Category(SlowTest.class) @SuppressWarnings("ConstantConditions") @RunWith(TestRunner.class) public class ItemActivityTest { private ActivityController<ItemActivity> controller; private ItemActivity activity; @Inject @Named(ActivityModule.HN) ItemManager hackerNewsClient; @Inject FavoriteManager favoriteManager; @Inject UserServices userServices; @Inject KeyDelegate keyDelegate; @Captor ArgumentCaptor<ResponseListener<Item>> listener; @Captor ArgumentCaptor<UserServices.Callback> userServicesCallback; @Before public void setUp() { MockitoAnnotations.initMocks(this); TestApplication.applicationGraph.inject(this); reset(hackerNewsClient); reset(favoriteManager); reset(userServices); reset(keyDelegate); controller = Robolectric.buildActivity(ItemActivity.class); activity = controller.get(); } @Test public void testCustomScheme() { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setData(Uri.parse(BuildConfig.APPLICATION_ID + "://item/1")); controller.withIntent(intent).create().start().resume(); verify(hackerNewsClient).getItem(eq("1"), eq(ItemManager.MODE_DEFAULT), any(ResponseListener.class)); } @Test public void testJobGivenDeepLink() { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setData(Uri.parse("https://news.ycombinator.com/item?id=1")); controller.withIntent(intent).create().start().resume(); verify(hackerNewsClient).getItem(eq("1"), eq(ItemManager.MODE_DEFAULT), listener.capture()); listener.getValue().onResponse(new TestItem() { @NonNull @Override public String getType() { return JOB_TYPE; } @Override public String getId() { return "1"; } @Override public String getUrl() { return String.format(HackerNewsClient.WEB_ITEM_PATH, "1"); } @Override public String getSource() { return "http://example.com"; } @Override public boolean isStoryType() { return true; } }); assertEquals(R.drawable.ic_work_white_18dp, shadowOf(((TextView) activity.findViewById(R.id.posted)) .getCompoundDrawables()[0]).getCreatedFromResId()); assertThat((TextView) activity.findViewById(R.id.source)).hasText("http://example.com"); reset(hackerNewsClient); shadowOf(activity).recreate(); verify(hackerNewsClient, never()).getItem(any(), eq(ItemManager.MODE_DEFAULT), any(ResponseListener.class)); } @Test public void testPoll() { Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return POLL_TYPE; } @Override public boolean isStoryType() { return true; } @Override public String getUrl() { return "http://example.com"; } }); controller.withIntent(intent).create().start().resume(); assertThat(activity.findViewById(R.id.source)).isNotVisible(); assertEquals(R.drawable.ic_poll_white_18dp, shadowOf(((TextView) activity.findViewById(R.id.posted)) .getCompoundDrawables()[0]).getCreatedFromResId()); } @SuppressLint("NewApi") @Test public void testOptionExternal() { RobolectricPackageManager packageManager = (RobolectricPackageManager) RuntimeEnvironment.application.getPackageManager(); packageManager.addResolveInfoForIntent( new Intent(Intent.ACTION_VIEW, Uri.parse("http://example.com")), ShadowResolveInfo.newResolveInfo("label", "com.android.chrome", "DefaultActivity")); packageManager.addResolveInfoForIntent( new Intent(Intent.ACTION_VIEW, Uri.parse(String.format(HackerNewsClient.WEB_ITEM_PATH, "1"))), ShadowResolveInfo.newResolveInfo("label", "com.android.chrome", "DefaultActivity")); Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return STORY_TYPE; } @Override public String getUrl() { return "http://example.com"; } @Override public boolean isStoryType() { return true; } @Override public String getId() { return "1"; } }); controller.withIntent(intent).create().start().resume(); // inflate menu, see https://github.com/robolectric/robolectric/issues/1326 ShadowLooper.pauseMainLooper(); controller.visible(); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); // open article shadowOf(activity).clickMenuItem(R.id.menu_external); shadowOf(ShadowPopupMenu.getLatestPopupMenu()) .getOnMenuItemClickListener() .onMenuItemClick(new RoboMenuItem(R.id.menu_article)); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); assertThat(shadowOf(activity).getNextStartedActivity()).hasAction(Intent.ACTION_VIEW); // open item shadowOf(activity).clickMenuItem(R.id.menu_external); shadowOf(ShadowPopupMenu.getLatestPopupMenu()) .getOnMenuItemClickListener() .onMenuItemClick(new RoboMenuItem(R.id.menu_comments)); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); assertThat(shadowOf(activity).getNextStartedActivity()).hasAction(Intent.ACTION_VIEW); } @SuppressLint("NewApi") @Test public void testShare() { TestApplication.addResolver(new Intent(Intent.ACTION_SEND)); Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return STORY_TYPE; } @Override public String getUrl() { return "http://example.com"; } @Override public boolean isStoryType() { return true; } @Override public String getId() { return "1"; } }); controller.withIntent(intent).create().start().resume(); // inflate menu, see https://github.com/robolectric/robolectric/issues/1326 ShadowLooper.pauseMainLooper(); controller.visible(); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); // share article shadowOf(activity).clickMenuItem(R.id.menu_share); shadowOf(ShadowPopupMenu.getLatestPopupMenu()) .getOnMenuItemClickListener() .onMenuItemClick(new RoboMenuItem(R.id.menu_article)); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); Intent actual = shadowOf(activity).getNextStartedActivity(); assertThat(actual) .hasAction(Intent.ACTION_SEND); // share item shadowOf(activity).clickMenuItem(R.id.menu_share); shadowOf(ShadowPopupMenu.getLatestPopupMenu()) .getOnMenuItemClickListener() .onMenuItemClick(new RoboMenuItem(R.id.menu_comments)); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); actual = shadowOf(activity).getNextStartedActivity(); assertThat(actual) .hasAction(Intent.ACTION_SEND); } @Test public void testHeaderOpenExternal() { PreferenceManager.getDefaultSharedPreferences(activity) .edit() .putBoolean(activity.getString(R.string.pref_custom_tab), false) .apply(); TestApplication.addResolver(new Intent(Intent.ACTION_VIEW, Uri.parse("http://example.com"))); Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return STORY_TYPE; } @Override public String getUrl() { return "http://example.com"; } @Override public boolean isStoryType() { return true; } @Override public String getId() { return "1"; } }); PreferenceManager.getDefaultSharedPreferences(activity) .edit() .putBoolean(activity.getString(R.string.pref_external), true) .apply(); controller.withIntent(intent).create().start().resume(); activity.findViewById(R.id.button_article).performClick(); assertThat(shadowOf(activity).getNextStartedActivity()).hasAction(Intent.ACTION_VIEW); } @Test public void testFavoriteStory() { Intent intent = new Intent(); TestHnItem item = new TestHnItem(1L) { @NonNull @Override public String getType() { return STORY_TYPE; } }; item.setFavorite(true); intent.putExtra(ItemActivity.EXTRA_ITEM, item); controller.withIntent(intent).create().start().resume(); assertTrue(item.isFavorite()); ShadowContentObserver observer = shadowOf(shadowOf(activity.getContentResolver()) .getContentObservers(MaterialisticProvider.URI_FAVORITE) .iterator() .next()); activity.findViewById(R.id.bookmarked).performClick(); verify(favoriteManager).remove(any(Context.class), eq("1")); observer.dispatchChange(false, MaterialisticProvider.URI_FAVORITE .buildUpon() .appendPath("remove") .appendPath("1") .build()); assertFalse(item.isFavorite()); assertThat((TextView) activity.findViewById(R.id.snackbar_text)) .isNotNull() .containsText(R.string.toast_removed); activity.findViewById(R.id.snackbar_action).performClick(); observer.dispatchChange(false, MaterialisticProvider.URI_FAVORITE .buildUpon() .appendPath("add") .appendPath("1") .build()); assertTrue(item.isFavorite()); } @Test public void testNonFavoriteStory() { TestHnItem item = new TestHnItem(1L) { @NonNull @Override public String getType() { return STORY_TYPE; } }; Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, item); controller.withIntent(intent).create().start().resume(); assertFalse(item.isFavorite()); activity.findViewById(R.id.bookmarked).performClick(); ShadowContentObserver observer = shadowOf(shadowOf(activity.getContentResolver()) .getContentObservers(MaterialisticProvider.URI_FAVORITE) .iterator() .next()); observer.dispatchChange(false, MaterialisticProvider.URI_FAVORITE .buildUpon() .appendPath("add") .appendPath("1") .build()); assertTrue(item.isFavorite()); } @Config(shadows = ShadowRecyclerView.class) @Test public void testScrollToTop() { Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return STORY_TYPE; } @Override public String getId() { return "1"; } @Override public boolean isStoryType() { return true; } @Override public int getKidCount() { return 10; } @Override public String getUrl() { return "http://example.com"; } }); controller.withIntent(intent).create().start().resume(); // see https://github.com/robolectric/robolectric/issues/1326 ShadowLooper.pauseMainLooper(); controller.visible(); ShadowApplication.getInstance().getForegroundThreadScheduler().advanceToLastPostedRunnable(); RecyclerView recyclerView = (RecyclerView) activity.findViewById(R.id.recycler_view); recyclerView.smoothScrollToPosition(1); assertThat(customShadowOf(recyclerView).getScrollPosition()).isEqualTo(1); TabLayout tabLayout = (TabLayout) activity.findViewById(R.id.tab_layout); assertThat(tabLayout.getTabCount()).isEqualTo(2); tabLayout.getTabAt(1).select(); tabLayout.getTabAt(0).select(); tabLayout.getTabAt(0).select(); assertThat(customShadowOf(recyclerView).getScrollPosition()).isEqualTo(0); } @Test public void testDefaultReadabilityView() { PreferenceManager.getDefaultSharedPreferences(activity) .edit() .putString(activity.getString(R.string.pref_story_display), activity.getString(R.string.pref_story_display_value_readability)) .apply(); Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return STORY_TYPE; } @Override public String getId() { return "1"; } @Override public boolean isStoryType() { return true; } @Override public int getKidCount() { return 10; } @Override public String getUrl() { return "http://example.com"; } }); controller.withIntent(intent).create().start().resume(); TabLayout tabLayout = (TabLayout) activity.findViewById(R.id.tab_layout); assertEquals(2, tabLayout.getTabCount()); assertEquals(1, tabLayout.getSelectedTabPosition()); } @Test public void testVotePromptToLogin() { Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestHnItem(1)); controller.withIntent(intent).create().start().resume(); activity.findViewById(R.id.vote_button).performClick(); verify(userServices).voteUp(any(Context.class), eq("1"), userServicesCallback.capture()); userServicesCallback.getValue().onDone(false); assertThat(shadowOf(activity).getNextStartedActivity()) .hasComponent(activity, LoginActivity.class); } @Test public void testVote() { Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestHnItem(1)); controller.withIntent(intent).create().start().resume(); activity.findViewById(R.id.vote_button).performClick(); verify(userServices).voteUp(any(Context.class), eq("1"), userServicesCallback.capture()); userServicesCallback.getValue().onDone(true); assertEquals(activity.getString(R.string.voted), ShadowToast.getTextOfLatestToast()); } @Test public void testVoteError() { Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestHnItem(1)); controller.withIntent(intent).create().start().resume(); activity.findViewById(R.id.vote_button).performClick(); verify(userServices).voteUp(any(Context.class), eq("1"), userServicesCallback.capture()); userServicesCallback.getValue().onError(new IOException()); assertEquals(activity.getString(R.string.vote_failed), ShadowToast.getTextOfLatestToast()); } @Test public void testReply() { PreferenceManager.getDefaultSharedPreferences(activity) .edit() .putString(activity.getString(R.string.pref_story_display), activity.getString(R.string.pref_story_display_value_comments)) .apply(); Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestHnItem(1L)); controller.withIntent(intent).create().start().resume(); activity.findViewById(R.id.reply_button).performClick(); assertThat(shadowOf(activity).getNextStartedActivity()) .hasComponent(activity, ComposeActivity.class); } @Test public void testVolumeNavigation() { Intent intent = new Intent(); WebItem webItem = new TestWebItem() { @Override public String getUrl() { return "http://example.com"; } @Override public String getId() { return "1"; } }; intent.putExtra(ItemActivity.EXTRA_ITEM, webItem); controller.withIntent(intent).create().start().resume().visible(); activity.onKeyDown(KeyEvent.KEYCODE_VOLUME_UP, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_UP)); verify(keyDelegate).setScrollable(any(Scrollable.class), any(AppBarLayout.class)); verify(keyDelegate).onKeyDown(anyInt(), any(KeyEvent.class)); activity.onKeyUp(KeyEvent.KEYCODE_VOLUME_UP, new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_VOLUME_UP)); verify(keyDelegate).onKeyUp(anyInt(), any(KeyEvent.class)); activity.onKeyLongPress(KeyEvent.KEYCODE_VOLUME_UP, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_VOLUME_UP)); verify(keyDelegate).onKeyLongPress(anyInt(), any(KeyEvent.class)); } @Test public void testBackPressed() { Intent intent = new Intent(); WebItem webItem = new TestWebItem() { @Override public String getUrl() { return "http://example.com"; } @Override public String getId() { return "1"; } }; intent.putExtra(ItemActivity.EXTRA_ITEM, webItem); controller.withIntent(intent).create().start().resume().visible(); activity.onKeyDown(KeyEvent.KEYCODE_BACK, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK)); verify(keyDelegate).setBackInterceptor(any()); verify(keyDelegate).onKeyDown(anyInt(), any(KeyEvent.class)); } @Config(shadows = {ShadowFloatingActionButton.class}) @Test public void testFullscreen() { Intent intent = new Intent(); WebItem webItem = new TestWebItem() { @Override public String getUrl() { return "http://example.com"; } @Override public String getId() { return "1"; } }; intent.putExtra(ItemActivity.EXTRA_ITEM, webItem); controller.withIntent(intent).create().start().resume().visible(); ShadowFloatingActionButton shadowFab = (ShadowFloatingActionButton) ShadowExtractor .extract(activity.findViewById(R.id.reply_button)); assertTrue(shadowFab.isVisible()); ShadowLocalBroadcastManager.getInstance(activity) .sendBroadcast(new Intent(WebFragment.ACTION_FULLSCREEN) .putExtra(WebFragment.EXTRA_FULLSCREEN, true)); assertFalse(shadowFab.isVisible()); ShadowLocalBroadcastManager.getInstance(activity) .sendBroadcast(new Intent(WebFragment.ACTION_FULLSCREEN) .putExtra(WebFragment.EXTRA_FULLSCREEN, false)); assertTrue(shadowFab.isVisible()); } @Test public void testFullscreenBackPressed() { Intent intent = new Intent(); WebItem webItem = new TestWebItem() { @Override public String getUrl() { return "http://example.com"; } @Override public String getId() { return "1"; } }; intent.putExtra(ItemActivity.EXTRA_ITEM, webItem); controller.withIntent(intent).create().start().resume().visible(); ShadowLocalBroadcastManager.getInstance(activity) .sendBroadcast(new Intent(WebFragment.ACTION_FULLSCREEN) .putExtra(WebFragment.EXTRA_FULLSCREEN, true)); activity.onBackPressed(); assertThat(activity).isNotFinishing(); activity.onBackPressed(); assertThat(activity).isFinishing(); } @Test public void testItemChanged() { startWithIntent(); TabLayout tabLayout = (TabLayout) activity.findViewById(R.id.tab_layout); assertEquals(activity.getResources().getQuantityString(R.plurals.comments_count, 0, 0), tabLayout.getTabAt(0).getText()); activity.onItemChanged(new TestHnItem(1L) { @Override public int getKidCount() { return 10; } }); assertEquals(activity.getResources().getQuantityString(R.plurals.comments_count, 10, 10), tabLayout.getTabAt(0).getText()); } @Test public void testNavButtonHint() { PreferenceManager.getDefaultSharedPreferences(activity) .edit() .putString(activity.getString(R.string.pref_story_display), activity.getString(R.string.pref_story_display_value_comments)) .putBoolean(activity.getString(R.string.pref_navigation), true) .apply(); startWithIntent(); View navButton = activity.findViewById(R.id.navigation_button); assertThat(navButton).isVisible(); ((GestureDetector.SimpleOnGestureListener) getDetector(navButton).getListener()) .onSingleTapConfirmed(mock(MotionEvent.class)); assertThat(ShadowToast.getTextOfLatestToast()) .contains(activity.getString(R.string.hint_nav_short)); } @Test public void testNavButtonDrag() { PreferenceManager.getDefaultSharedPreferences(activity) .edit() .putString(activity.getString(R.string.pref_story_display), activity.getString(R.string.pref_story_display_value_comments)) .putBoolean(activity.getString(R.string.pref_navigation), true) .apply(); startWithIntent(); View navButton = activity.findViewById(R.id.navigation_button); assertThat(navButton).isVisible(); getDetector(navButton).getListener().onLongPress(mock(MotionEvent.class)); assertThat(ShadowToast.getTextOfLatestToast()) .contains(activity.getString(R.string.hint_drag)); MotionEvent motionEvent = mock(MotionEvent.class); when(motionEvent.getAction()).thenReturn(MotionEvent.ACTION_MOVE); when(motionEvent.getRawX()).thenReturn(1f); when(motionEvent.getRawY()).thenReturn(1f); shadowOf(navButton).getOnTouchListener().onTouch(navButton, motionEvent); motionEvent = mock(MotionEvent.class); when(motionEvent.getAction()).thenReturn(MotionEvent.ACTION_UP); shadowOf(navButton).getOnTouchListener().onTouch(navButton, motionEvent); assertThat(navButton).hasX(1f).hasY(1f); } @After public void tearDown() { reset(hackerNewsClient); reset(favoriteManager); controller.pause().stop().destroy(); } private void startWithIntent() { Intent intent = new Intent(); intent.putExtra(ItemActivity.EXTRA_ITEM, new TestItem() { @NonNull @Override public String getType() { return STORY_TYPE; } @Override public String getUrl() { return "http://example.com"; } @Override public boolean isStoryType() { return true; } @Override public String getId() { return "1"; } }); controller.withIntent(intent).create().start().resume().visible(); } private ShadowGestureDetector getDetector(View view) { shadowOf(view).getOnTouchListener() .onTouch(view, MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0)); return shadowOf(ShadowGestureDetector.getLastActiveDetector()); } }