diff --git a/5calls/app/build.gradle b/5calls/app/build.gradle index 9a7b9b04..11906605 100644 --- a/5calls/app/build.gradle +++ b/5calls/app/build.gradle @@ -12,7 +12,7 @@ android { targetSdkVersion 36 versionCode 86 versionName '2.4.5' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + testInstrumentationRunner "org.a5calls.android.a5calls.LocaleAwareTestRunner" signingConfig signingConfigs.debug } buildTypes { diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java new file mode 100644 index 00000000..116c2da5 --- /dev/null +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/BaseIntegrationTest.java @@ -0,0 +1,169 @@ +package org.a5calls.android.a5calls; + +import static androidx.test.espresso.matcher.ViewMatchers.isClickable; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static org.hamcrest.Matchers.allOf; +import static org.junit.Assert.assertNotNull; + +import android.content.Context; +import android.os.SystemClock; +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import java.util.Locale; + +import com.android.volley.toolbox.BasicNetwork; +import com.google.android.material.appbar.CollapsingToolbarLayout; + +import org.a5calls.android.a5calls.model.AccountManager; +import org.a5calls.android.a5calls.net.FakeRequestQueue; +import org.a5calls.android.a5calls.net.FiveCallsApi; +import org.a5calls.android.a5calls.net.MockHttpStack; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; + +/** + * Base class for all instrumentation tests in the app. + * Handles common setup for mocking network requests and accessing context. + */ +@RunWith(AndroidJUnit4.class) +public abstract class BaseIntegrationTest { + + protected Context mContext; + protected MockHttpStack mHttpStack; + protected FakeRequestQueue mRequestQueue; + protected FiveCallsApi mApi; + protected Locale mLocale; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + mLocale = Locale.getDefault(); + + // Use a fixed caller ID for consistent test results + AccountManager.Instance.setCallerID(mContext, "itMe"); + + mHttpStack = new MockHttpStack(); + BasicNetwork basicNetwork = new BasicNetwork(mHttpStack); + mRequestQueue = new FakeRequestQueue(basicNetwork); + + mApi = new FiveCallsApi("itMe", mRequestQueue, mContext); + } + + @After + public void tearDown() { + if (mRequestQueue != null) { + mRequestQueue.mRequest = null; + } + } + + /** + * Helper to wait for asynchronous network requests to complete in tests. + */ + protected void waitForHttpRequestComplete() { + assertNotNull(mRequestQueue.mRequest); + mRequestQueue.start(); + + // Wait for the async stuff. + // TODO: Use a more robust synchronization mechanism like IdlingResource + SystemClock.sleep(200); + } + + /** + * Custom matcher to check if a RecyclerView has exactly one item + */ + public static Matcher hasExactlyOneItem() { + return new TypeSafeMatcher<>() { + @Override + protected boolean matchesSafely(View view) { + if (!(view instanceof RecyclerView recyclerView)) { + return false; + } + return recyclerView.getAdapter() != null && recyclerView.getAdapter().getItemCount() == 1; + } + + @Override + public void describeTo(Description description) { + description.appendText("RecyclerView with exactly one item"); + } + }; + } + + // Custom matcher that matches only the first view matching the given matcher. + public static Matcher first(final Matcher matcher) { + return new TypeSafeMatcher<>() { + boolean matched = false; + + @Override + public boolean matchesSafely(View view) { + if (matched) { + return false; + } + if (matcher.matches(view)) { + matched = true; + return true; + } + return false; + } + + @Override + public void describeTo(Description description) { + description.appendText("first view matching: "); + matcher.describeTo(description); + } + }; + } + + // Custom matcher to check if a CollapsingToolbarLayout's title contains specific text + public static Matcher withCollapsingToolbarTitle(final Matcher textMatcher) { + return new TypeSafeMatcher<>() { + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof CollapsingToolbarLayout toolbarLayout)) { + return false; + } + CharSequence title = toolbarLayout.getTitle(); + return title != null && textMatcher.matches(title.toString()); + } + + @Override + public void describeTo(Description description) { + description.appendText("with toolbar title: "); + textMatcher.describeTo(description); + } + }; + } + + /** + * A custom click action that only requires the view to be displayed, + * bypassing the 90% visibility constraint. + */ + public static ViewAction clickVisible() { + return new ViewAction() { + @Override + public Matcher getConstraints() { + return allOf(isDisplayed(), isClickable()); + } + + @Override + public String getDescription() { + return "click visible view"; + } + + @Override + public void perform(UiController uiController, View view) { + view.performClick(); + } + }; + } + +} diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt new file mode 100644 index 00000000..0e0fb5fe --- /dev/null +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/LocaleAwareTestRunner.kt @@ -0,0 +1,72 @@ +package org.a5calls.android.a5calls + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.LocaleList +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.test.runner.AndroidJUnitRunner +import java.util.Locale + +class LocaleAwareTestRunner : AndroidJUnitRunner() { + private var mArguments: Bundle? = null + + override fun onCreate(arguments: Bundle) { + mArguments = arguments + val localeTag = arguments.getString("locale") + System.err.println("LocaleAwareTestRunner intercepted localeTag: $localeTag") + + if (!localeTag.isNullOrEmpty()) { + val locale = Locale.forLanguageTag(localeTag) + setGlobalLocale(locale) + } + super.onCreate(arguments) + } + + override fun onStart() { + // Use AppCompatDelegate to set locales globally for the app. + // This is the most reliable way for AppCompat-based activities. + val localeTag = if (mArguments != null) mArguments!!.getString("locale") else null + runOnMainSync { + if (!localeTag.isNullOrEmpty()) { + System.err.println("LocaleAwareTestRunner setting AppCompatDelegate locales to: $localeTag") + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags( + localeTag + ) + ) + } else { + // Reset to system default if no locale specified to avoid persistence from previous runs + AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) + } + } + super.onStart() + } + + @Suppress("deprecation") + private fun setGlobalLocale(locale: Locale) { + System.err.println("LocaleAwareTestRunner Setting global locale to $locale") + Locale.setDefault(locale) + + // Update configuration for both target context and application context + updateContextLocale(targetContext, locale) + updateContextLocale(targetContext.applicationContext, locale) + } + + @Suppress("deprecation") + private fun updateContextLocale(context: Context?, locale: Locale) { + if (context == null) return + + val resources = context.resources + val configuration = resources.configuration + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + configuration.setLocales(LocaleList(locale)) + } else { + configuration.locale = locale + } + + resources.updateConfiguration(configuration, resources.displayMetrics) + } +} diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java index 46685186..687abd07 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityBaseTest.java @@ -1,30 +1,23 @@ package org.a5calls.android.a5calls.controller; -import android.content.Context; - import androidx.test.core.app.ActivityScenario; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; import com.android.volley.RequestQueue; import com.android.volley.toolbox.BasicNetwork; import org.a5calls.android.a5calls.AppSingleton; +import org.a5calls.android.a5calls.BaseIntegrationTest; import org.a5calls.android.a5calls.model.AccountManager; import org.a5calls.android.a5calls.net.FakeRequestQueue; import org.a5calls.android.a5calls.net.FiveCallsApi; -import org.a5calls.android.a5calls.net.MockHttpStack; import org.junit.After; import org.junit.Before; -import org.junit.runner.RunWith; /** * Base class for MainActivity integration tests that contains shared setup and utility methods. */ -@RunWith(AndroidJUnit4.class) -public abstract class MainActivityBaseTest { +public abstract class MainActivityBaseTest extends BaseIntegrationTest { - protected MockHttpStack mHttpStack; protected RequestQueue mOriginalRequestQueue; protected FiveCallsApi mOriginalApi; protected String mOriginalAddress; @@ -32,34 +25,29 @@ public abstract class MainActivityBaseTest { @Before public void setUp() { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + super.setUp(); // Save original state - mOriginalRequestQueue = AppSingleton.getInstance(context).getRequestQueue(); - mOriginalApi = AppSingleton.getInstance(context).getJsonController(); + mOriginalRequestQueue = AppSingleton.getInstance(mContext).getRequestQueue(); + mOriginalApi = AppSingleton.getInstance(mContext).getJsonController(); // Save original location - mOriginalAddress = AccountManager.Instance.getAddress(context); + mOriginalAddress = AccountManager.Instance.getAddress(mContext); // Set a mock location to avoid location prompts - AccountManager.Instance.setAddress(context, "90210"); + AccountManager.Instance.setAddress(mContext, "90210"); // Mark tutorial as seen to bypass onboarding screen - AccountManager.Instance.setTutorialSeen(context, true); - - // Create mock HTTP stack - mHttpStack = new MockHttpStack(); + AccountManager.Instance.setTutorialSeen(mContext, true); } - @After public void tearDown() { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); // Restore original state - AppSingleton.getInstance(context).setRequestQueue(mOriginalRequestQueue); - AppSingleton.getInstance(context).setFiveCallsApi(mOriginalApi); + AppSingleton.getInstance(mContext).setRequestQueue(mOriginalRequestQueue); + AppSingleton.getInstance(mContext).setFiveCallsApi(mOriginalApi); // Restore original location - AccountManager.Instance.setAddress(context, mOriginalAddress); + AccountManager.Instance.setAddress(mContext, mOriginalAddress); // Close the activity scenario if it's open if (scenario != null) { @@ -71,24 +59,22 @@ public void tearDown() { * Sets up the mock request queue and API */ protected void setupMockRequestQueue() { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); // Create a custom RequestQueue with our mock HTTP stack BasicNetwork basicNetwork = new BasicNetwork(mHttpStack); FakeRequestQueue requestQueue = new FakeRequestQueue(basicNetwork); requestQueue.start(); // Replace the app's RequestQueue with our mock - AppSingleton.getInstance(context).setRequestQueue(requestQueue); + AppSingleton.getInstance(mContext).setRequestQueue(requestQueue); // Create a new FiveCallsApi with our mock RequestQueue - String callerId = AccountManager.Instance.getCallerID(context); - FiveCallsApi api = new FiveCallsApi(callerId, requestQueue, context); - AppSingleton.getInstance(context).setFiveCallsApi(api); + String callerId = AccountManager.Instance.getCallerID(mContext); + FiveCallsApi api = new FiveCallsApi(callerId, requestQueue, mContext); + AppSingleton.getInstance(mContext).setFiveCallsApi(api); } /** * Launches the MainActivity and waits for it to load - * * @param waitTimeMs time to wait for the activity to load */ protected void launchMainActivity(int waitTimeMs) { diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java index 2396a371..979ca842 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityErrorTest.java @@ -8,19 +8,10 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; -import android.view.View; - -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.ext.junit.runners.AndroidJUnit4; - import com.android.volley.toolbox.HttpResponse; import org.a5calls.android.a5calls.R; -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeMatcher; import org.junit.Test; -import org.junit.runner.RunWith; import java.io.IOException; import java.util.ArrayList; @@ -28,30 +19,8 @@ /** * Integration test for MainActivity that tests error handling. */ -@RunWith(AndroidJUnit4.class) public class MainActivityErrorTest extends MainActivityBaseTest { - /** - * Custom matcher to check if a RecyclerView has exactly one item - */ - public static Matcher hasExactlyOneItem() { - return new TypeSafeMatcher() { - @Override - protected boolean matchesSafely(View view) { - if (!(view instanceof RecyclerView)) { - return false; - } - RecyclerView recyclerView = (RecyclerView) view; - return recyclerView.getAdapter() != null && recyclerView.getAdapter().getItemCount() == 1; - } - - @Override - public void describeTo(Description description) { - description.appendText("RecyclerView with exactly one item"); - } - }; - } - /** * Verifies that error UI is displayed correctly. */ diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java index 09f43f04..9e6482e4 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java @@ -4,7 +4,6 @@ import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isClickable; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; @@ -15,111 +14,29 @@ import static org.junit.Assert.assertTrue; import android.content.Context; -import android.view.View; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; -import androidx.test.espresso.UiController; -import androidx.test.espresso.ViewAction; -import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; import com.android.volley.toolbox.HttpResponse; -import com.google.android.material.appbar.CollapsingToolbarLayout; import org.a5calls.android.a5calls.AppSingleton; import org.a5calls.android.a5calls.FakeJSONData; import org.a5calls.android.a5calls.R; import org.a5calls.android.a5calls.model.AccountManager; import org.a5calls.android.a5calls.model.DatabaseHelper; -import org.a5calls.android.a5calls.test.RandomOrdering; -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeMatcher; import org.json.JSONArray; import org.json.JSONObject; import org.junit.Test; -import org.junit.runner.OrderWith; -import org.junit.runner.RunWith; import java.util.ArrayList; /** * Integration test for MainActivity that tests the happy path. */ -@RunWith(AndroidJUnit4.class) -@OrderWith(RandomOrdering.Factory.class) public class MainActivityHappyPathTest extends MainActivityBaseTest { - // Custom matcher that matches only the first view matching the given matcher. - public static Matcher first(final Matcher matcher) { - return new TypeSafeMatcher<>() { - boolean matched = false; - - @Override - public boolean matchesSafely(View view) { - if (matched) { - return false; - } - if (matcher.matches(view)) { - matched = true; - return true; - } - return false; - } - - @Override - public void describeTo(Description description) { - description.appendText("first view matching: "); - matcher.describeTo(description); - } - }; - } - - // Custom matcher to check if a CollapsingToolbarLayout's title contains specific text - public static Matcher withCollapsingToolbarTitle(final Matcher textMatcher) { - return new TypeSafeMatcher<>() { - @Override - public boolean matchesSafely(View view) { - if (!(view instanceof CollapsingToolbarLayout)) { - return false; - } - CollapsingToolbarLayout toolbarLayout = (CollapsingToolbarLayout) view; - CharSequence title = toolbarLayout.getTitle(); - return title != null && textMatcher.matches(title.toString()); - } - - @Override - public void describeTo(Description description) { - description.appendText("with toolbar title: "); - textMatcher.describeTo(description); - } - }; - } - - /** - * A custom click action that only requires the view to be displayed, - * bypassing the 90% visibility constraint. - */ - private static ViewAction clickVisible() { - return new ViewAction() { - @Override - public Matcher getConstraints() { - return allOf(isDisplayed(), isClickable()); - } - - @Override - public String getDescription() { - return "click visible view"; - } - - @Override - public void perform(UiController uiController, View view) { - view.performClick(); - } - }; - } - /** * Sets up mock responses for API calls */ @@ -157,7 +74,11 @@ public void testMainUILoadsCorrectly() { launchMainActivity(1000); // Verify the location placeholder in the header is not shown. - onView(withContentDescription("5 Calls for BOWLING GREEN")).check(matches(isDisplayed())); + if (mLocale.getLanguage().equals("es")) { + onView(withContentDescription("5 Calls para BOWLING GREEN")).check(matches(isDisplayed())); + } else { + onView(withContentDescription("5 Calls for BOWLING GREEN")).check(matches(isDisplayed())); + } // Verify that the toolbar is displayed onView(withId(R.id.toolbar)).check(matches(isDisplayed())); @@ -363,11 +284,11 @@ public void testNavigationDrawerOpens() { onView(withId(R.id.navigation_view)).check(matches(isDisplayed())); // Verify that navigation menu items are displayed - onView(withText("About 5 Calls")).check(matches(isDisplayed())); - onView(withText("Your impact")).check(matches(isDisplayed())); - onView(withText("Settings")).check(matches(isDisplayed())); - onView(withText("FAQ")).check(matches(isDisplayed())); - onView(withText("Update location")).check(matches(isDisplayed())); + onView(withText(R.string.menu_about)).check(matches(isDisplayed())); + onView(withText(R.string.menu_stats)).check(matches(isDisplayed())); + onView(withText(R.string.settings)).check(matches(isDisplayed())); + onView(withText(R.string.menu_faq)).check(matches(isDisplayed())); + onView(withText(R.string.menu_location)).check(matches(isDisplayed())); } @Test diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java index 2a7e4b03..6f314407 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FakeRequestQueue.java @@ -6,7 +6,7 @@ import com.android.volley.toolbox.NoCache; public class FakeRequestQueue extends RequestQueue { - protected Request mRequest = null; + public Request mRequest = null; public FakeRequestQueue(BasicNetwork network) { super(new NoCache(), network); diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java index 8d1ec9bd..a0288afa 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/net/FiveCallsApiTest.java @@ -6,23 +6,18 @@ import com.android.volley.toolbox.BasicNetwork; import com.android.volley.toolbox.HttpResponse; +import org.a5calls.android.a5calls.BaseIntegrationTest; import org.a5calls.android.a5calls.model.Contact; import org.a5calls.android.a5calls.model.Issue; import org.a5calls.android.a5calls.model.Outcome; import org.json.JSONException; import org.json.JSONObject; -import org.junit.After; -import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; import java.io.IOException; import java.util.ArrayList; import java.util.List; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - import static org.a5calls.android.a5calls.FakeJSONData.ISSUE_DATA; import static org.a5calls.android.a5calls.FakeJSONData.REPORT_DATA; import static org.a5calls.android.a5calls.FakeJSONData.REPS_DATA_SUFFIX; @@ -30,8 +25,7 @@ import static org.a5calls.android.a5calls.FakeJSONData.REPS_DATA_SPLIT_PREFIX; import static org.junit.Assert.*; -@RunWith(AndroidJUnit4.class) -public class FiveCallsApiTest { +public class FiveCallsApiTest extends BaseIntegrationTest { static class TestCallListener implements FiveCallsApi.CallRequestListener { protected int mCallError = 0; @@ -133,24 +127,6 @@ public void onError() { } - private FiveCallsApi mApi; - private FakeRequestQueue mRequestQueue; - private MockHttpStack mHttpStack; - - - @Before - public void setUp() { - mHttpStack = new MockHttpStack(); - BasicNetwork basicNetwork = new BasicNetwork(mHttpStack); - mRequestQueue = new FakeRequestQueue(basicNetwork); - mApi = new FiveCallsApi("itMe", mRequestQueue, - InstrumentationRegistry.getInstrumentation().getTargetContext()); - } - - @After - public void tearDown() { - mRequestQueue.mRequest = null; - } @Test public void testGetCallCount() { @@ -463,12 +439,4 @@ private void testReportCallStatus(Outcome.Status status, String outcomeString) { mApi.unregisterCallRequestListener(testCallListener); } - private void waitForHttpRequestComplete() { - assertNotNull(mRequestQueue.mRequest); - mRequestQueue.start(); - - // Wait for the async stuff. - // TODO: I'm sure there's a better way to do this... - SystemClock.sleep(200); - } } \ No newline at end of file diff --git a/README.md b/README.md index 0e624d11..915baded 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ For instrumentation testing, connect a device or start an emulator, then: cd 5calls ./gradlew connectedAndroidTest ``` +For instrumentation testing running the app in Spanish: +``` +cd 5calls +./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.locale=es-US +``` ### Notifications To test snoozing notifications, change `FREQUENT_NOTIFICATION_DEBUG_MODE` to `true` in `NotificationUtils.java`. Note that notifications