// Copyright 2015 The Project Buendia Authors // // Licensed under the Apache License, Version 2.0 (the "License"); you may not // use this file except in compliance with the License. You may obtain a copy // of the License at: http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software distrib- // uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES // OR CONDITIONS OF ANY KIND, either express or implied. See the License for // specific language governing permissions and limitations under the License. package org.projectbuendia.client.ui; import android.app.Activity; import android.content.res.Resources; import android.support.annotation.Nullable; import android.support.test.espresso.Espresso; import android.support.test.espresso.IdlingPolicies; import android.support.test.espresso.NoActivityResumedException; import android.support.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; import android.support.test.runner.lifecycle.Stage; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentActivity; import com.google.common.base.Optional; import com.google.common.collect.Iterables; import com.squareup.spoon.Spoon; import org.joda.time.DateTime; import org.joda.time.LocalDate; import org.joda.time.Period; import org.projectbuendia.client.R; import org.projectbuendia.client.events.data.ItemCreatedEvent; import org.projectbuendia.client.events.sync.SyncSucceededEvent; import org.projectbuendia.client.events.user.KnownUsersLoadedEvent; import org.projectbuendia.client.models.Patient; import org.projectbuendia.client.models.PatientDelta; import org.projectbuendia.client.json.JsonPatient; import org.projectbuendia.client.ui.login.LoginActivity; import org.projectbuendia.client.ui.matchers.TestCaseWithMatcherMethods; import org.projectbuendia.client.ui.sync.EventBusIdlingResource; import org.projectbuendia.client.utils.EventBusRegistrationInterface; import org.projectbuendia.client.utils.EventBusWrapper; import org.projectbuendia.client.utils.Logger; import java.util.Date; import java.util.UUID; import java.util.concurrent.TimeUnit; import de.greenrobot.event.EventBus; import static android.support.test.espresso.Espresso.pressBack; import static android.support.test.espresso.matcher.RootMatchers.isDialog; import static org.hamcrest.Matchers.is; import static org.projectbuendia.client.ui.matchers.AppPatientMatchers.isPatientWithId; /** * Base class for functional tests that sets timeouts to be permissive, optionally logs in as a * user before continuing, and provides some utility functions for convenience. */ public class FunctionalTestCase extends TestCaseWithMatcherMethods<LoginActivity> { private static final Logger LOG = Logger.create(); public static final String LOCATION_NAME = "ITFC ICU"; // For now, we create a new demo patient for tests using the real patient // creation UI on each test run (see {@link #inUserLoginInitDemoPatient()}). // TODO/robustness: Use externally preloaded demo data instead. protected static String sDemoPatientId = null; private boolean mWaitForUserSync = true; protected EventBusRegistrationInterface mEventBus; public FunctionalTestCase() { super(LoginActivity.class); } @Override public void setUp() throws Exception { // Give additional leeway for idling resources, as sync may be slow, especially on Edisons. // Increased to 5 minutes as certain operations (like initial sync) may take an exceedingly // long time. IdlingPolicies.setIdlingResourceTimeout(300, TimeUnit.SECONDS); IdlingPolicies.setMasterPolicyTimeout(300, TimeUnit.SECONDS); mEventBus = new EventBusWrapper(EventBus.getDefault()); // Wait for users to sync. if (mWaitForUserSync) { EventBusIdlingResource<KnownUsersLoadedEvent> resource = new EventBusIdlingResource<>("USERS", mEventBus); Espresso.registerIdlingResources(resource); } super.setUp(); getActivity(); } public void setWaitForUserSync(boolean waitForUserSync) { mWaitForUserSync = waitForUserSync; } @Override public void tearDown() { // Remove activities from the stack until the app is closed. If we don't do this, the test // runner sometimes has trouble launching the activity to start the next test. try { closeAllActivities(); } catch (Exception e) { LOG.e("Error tearing down test case; test isolation may be broken", e); } } /** Closes all activities on the stack. */ protected void closeAllActivities() throws Exception { try { for (int i = 0; i < 20; i++) { pressBack(); Thread.sleep(100); } } catch (NoActivityResumedException | InterruptedException e) { // nothing left to close } } protected void screenshot(String tag) { try { Spoon.screenshot(getCurrentActivity(), tag.replace(" ", "")); } catch (Throwable throwable) { LOG.w("Could not create screenshot with tag %s", tag); } } /** * Determines the currently loaded activity, rather than {@link #getActivity()}, which will * always return {@link LoginActivity}. */ protected Activity getCurrentActivity() throws Throwable { getInstrumentation().waitForIdleSync(); final Activity[] activity = new Activity[1]; runTestOnUiThread(new Runnable() { @Override public void run() { java.util.Collection<Activity> activities = ActivityLifecycleMonitorRegistry.getInstance() .getActivitiesInStage(Stage.RESUMED); activity[0] = Iterables.getOnlyElement(activities); } }); return activity[0]; } /** Idles until sync has completed. */ protected void waitForInitialSync() { // Use a UUID as a tag so that we can wait for an arbitrary number of events, since // EventBusIdlingResource<> only works for a single event. LOG.i("Registering resource to wait for initial sync."); EventBusIdlingResource<SyncSucceededEvent> syncSucceededResource = new EventBusIdlingResource<>(UUID.randomUUID().toString(), mEventBus); Espresso.registerIdlingResources(syncSucceededResource); } // Broken, but hopefully fixed in Espresso 2.0. private void selectDateFromDatePickerDialog(DateTime dateTime) { selectDateFromDatePicker(dateTime); click(viewWithText("Set").inRoot(isDialog())); } protected void selectDateFromDatePicker(DateTime dateTime) { String year = dateTime.toString("yyyy"); String monthOfYear = dateTime.toString("MMM"); String dayOfMonth = dateTime.toString("dd"); selectDateFromDatePicker(year, monthOfYear, dayOfMonth); } protected void selectDateFromDatePicker( @Nullable String year, @Nullable String monthOfYear, @Nullable String dayOfMonth) { LOG.e("Year: %s, Month: %s, Day: %s", year, monthOfYear, dayOfMonth); if (year != null) { setDateSpinner("year", year); } if (monthOfYear != null) { setDateSpinner("month", monthOfYear); } if (dayOfMonth != null) { setDateSpinner("day", dayOfMonth); } } // Broken, but hopefully fixed in Espresso 2.0. protected void setDateSpinner(String spinnerName, String value) { int numberPickerId = Resources.getSystem().getIdentifier("numberpicker_input", "id", "android"); int spinnerId = Resources.getSystem().getIdentifier(spinnerName, "id", "android"); LOG.i("%s: %s", spinnerName, value); LOG.i("numberPickerId: %d", numberPickerId); LOG.i("spinnerId: %d", spinnerId); type(value, viewThat(hasId(numberPickerId), whoseParent(hasId(spinnerId)))); } /** * Prevents the current demo patient from being reused for the next test. * The default behaviour is to reuse the same demo patient for each test; * if a test modifies patient data, it should call this method so that the * next test will use a fresh demo patient. */ protected void invalidateDemoPatient() { sDemoPatientId = null; } /** * Navigates to the location selection activity with a list of all the * patients opened (from tapping the search button). Assumes that the UI is * in the user login activity. Note: this function will not work during * {@link #setUp()} as it uses {@link #waitForProgressFragment()}. */ protected void inUserLoginGoToPatientList() { inUserLoginGoToLocationSelection(); // There may be a small delay before the search button becomes visible; // the button is not displayed while locations are loading. expectVisibleWithin(3000, viewThat(hasId(R.id.action_search))); // Tap the search button to open the list of all patients. click(viewWithId(R.id.action_search)); } /** * Navigates to the patient chart for the shared demo patient, creating the * demo patient if it doesn't exist yet. Assumes that the UI is in the * user login activity. Note: this function will not work during * {@link #setUp()} as it uses {@link #waitForProgressFragment()}. */ protected String inUserLoginGoToDemoPatientChart() { // Create the patient inUserLoginGoToPatientCreation(); screenshot("Test Start"); String id = generateId(); populateNewPatientFields(id); click(viewWithText("OK")); waitForProgressFragment(); screenshot("On Patient Chart"); return id; } /** * Navigates to the patient creation activity. Assumes that the UI is * in the user login activity. Note: this function will not work during * {@link #setUp()} as it uses {@link #waitForProgressFragment()}. */ protected void inUserLoginGoToPatientCreation() { inUserLoginGoToLocationSelection(); click(viewWithId(R.id.action_new_patient)); expectVisible(viewWithText("New patient")); } /** * Navigates to the location selection activity from the user login * activity. Note: this function will not work during {@link #setUp()} * as it uses {@link #waitForProgressFragment()}. */ protected void inUserLoginGoToLocationSelection() { click(viewWithText("Guest User")); waitForProgressFragment(); // wait for locations to load } /** * Instructs espresso to wait for the {@link ProgressFragment} contained in the current * activity to finish loading, if such a fragment is present. Espresso will also wait every * subsequent time the {@link ProgressFragment} returns to the busy state, and * will period check whether or not the fragment is currently idle. * <p/> * <p>If the current activity does not contain a progress fragment, then this function will * throw an {@link IllegalArgumentException}. * <p/> * <p>Warning: This function will not work properly in setUp() as the current activity won't * be available. If you need to call this function during setUp(), use * {@link #waitForProgressFragment(ProgressFragment)}. * TODO/robustness: Investigate why the current activity isn't available during setUp(). */ protected void waitForProgressFragment() { Activity activity; try { activity = getCurrentActivity(); } catch (Throwable throwable) { throw new IllegalStateException("Error retrieving current activity", throwable); } if (!(activity instanceof FragmentActivity)) { throw new IllegalStateException("Activity is not a FragmentActivity"); } FragmentActivity fragmentActivity = (FragmentActivity) activity; try { for (Fragment fragment : fragmentActivity.getSupportFragmentManager().getFragments()) { if (fragment instanceof ProgressFragment) { waitForProgressFragment((ProgressFragment) fragment); return; } } } catch (NullPointerException e) { LOG.w("Unable to wait for ProgressFragment to initialize."); return; } throw new IllegalStateException("Could not find a progress fragment to wait on."); } /** * Instructs espresso to wait for a {@link ProgressFragment} to finish loading. Espresso will * also wait every subsequent time the {@link ProgressFragment} returns to the busy state, and * will period check whether or not the fragment is currently idle. */ protected void waitForProgressFragment(ProgressFragment progressFragment) { // Use the ProgressFragment hashCode as the identifier so that multiple ProgressFragments // can be tracked, but only one resource will be registered to each fragment. ProgressFragmentIdlingResource idlingResource = new ProgressFragmentIdlingResource( Integer.toString(progressFragment.hashCode()), progressFragment); Espresso.registerIdlingResources(idlingResource); } /** Checks that the expected zones and tents are shown. */ protected void inLocationSelectionCheckZonesAndTentsDisplayed() { // Should be at location selection screen expectVisibleSoon(viewWithText("ALL PRESENT PATIENTS")); // Zones and tents should be visible expectVisible(viewWithText("Triage")); expectVisible(viewWithText(LOCATION_NAME)); expectVisible(viewWithText("Discharged")); } /** In the location selection activity, click a location tile. */ protected void inLocationSelectionClickLocation(String name) { click(viewThat(hasText(name))); waitForProgressFragment(); // Wait for search fragment to load. } /** In a patient list, click the first patient. */ protected void inPatientListClickFirstPatient() { click(dataThat(is(Patient.class)) .inAdapterView(hasId(R.id.fragment_patient_list)) .atPosition(0)); } /** In a patient list, click the patient with a specified ID. */ protected void inPatientListClickPatientWithId(String id) { click(dataThat(isPatientWithId(id)) .inAdapterView(hasId(R.id.fragment_patient_list)) .atPosition(0)); } /** Generates IDs to identify the newly created patient. */ protected String generateId() { return "" + (new Date().getTime() % 100000); } /** Populates all the fields on the New Patient screen. */ protected void populateNewPatientFields(String id) { screenshot("Before Patient Populated"); String given = "Given" + id; String family = "Family" + id; type(id, viewWithId(R.id.patient_id)); type(given, viewWithId(R.id.patient_given_name)); type(family, viewWithId(R.id.patient_family_name)); type(id.substring(id.length() - 2), viewWithId(R.id.patient_age_years)); type(id.substring(id.length() - 2), viewWithId(R.id.patient_age_months)); int sex = Integer.parseInt(id) % 2 == 0 ? R.id.patient_sex_female : R.id.patient_sex_male; click(viewWithId(sex)); screenshot("After Patient Populated"); } }