Ci integration upgrade (#14)

- Circleci setup
 - gradle version updated
 - snapshots added
 - separated test files by flavour
This commit is contained in:
2023-07-26 22:54:08 +01:00
committed by GitHub
parent 3d5cb4e9fe
commit 4a37b724a6
28 changed files with 920 additions and 158 deletions

View File

@@ -7,20 +7,11 @@ version: 2.1
# Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects.
# See: https://circleci.com/docs/2.0/orb-intro/
orbs:
android: circleci/android@1.0.3
android: circleci/android@2.3.0
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
jobs:
# Below is the definition of your job to build and test your app, you can rename and customize it as you want.
build-and-test:
# These next lines define the Android machine image executor.
# See: https://circleci.com/docs/2.0/executor-types/
executor:
name: android/android-machine
# Add steps to the job
# See: https://circleci.com/docs/2.0/configuration-reference/#steps
commands:
setup_repo:
description: checkout repo and android dependencies
steps:
- checkout
- restore_cache:
@@ -35,19 +26,118 @@ jobs:
paths:
- ~/.gradle
key: jars-{{ checksum "build.gradle" }}-{{ checksum "app/build.gradle" }}
run_tests:
description: run tests for flavour specified
parameters:
flavour:
type: string
default: "AtlasWeather"
steps:
# The next step will run the unit tests
- android/run-tests:
test-command: ./gradlew test<< parameters.flavour >>DebugUnitTest --continue
- store_artifacts:
path: app/build/reports
destination: reports
- store_test_results:
path: app/build/test-results
run_ui_tests:
description: run tests for flavour specified
parameters:
flavour:
type: string
default: "AtlasWeather"
steps:
- android/start-emulator-and-run-tests:
post-emulator-launch-assemble-command: ./gradlew assemble<< parameters.flavour >>DebugAndroidTest
test-command: ./gradlew connected<< parameters.flavour >>DebugAndroidTest
system-image: system-images;android-25;google_apis;x86
max-tries: 1
kill-emulators: false
- run:
name: Pull screenshots from device
command: |
mkdir ~/screenshots
adb pull /storage/emulated/0/Android/data/com.appttude.h_mal.atlas_weather/files/screengrab/en-US/images/screenshots ~/screenshots
when: on_fail
# store test reports
- store_artifacts:
path: app/build/reports/androidTests/connected
destination: reports
# store screenshots for failed ui tests
- store_artifacts:
path: ~/screenshots
destination: screenshots
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
jobs:
# Below is the definition of your job to build and test your app, you can rename and customize it as you want.
build-and-test:
# Parameters used for determining
parameters:
flavour:
type: string
default: "AtlasWeather"
# These next lines define the Android machine image executor.
# See: https://circleci.com/docs/2.0/executor-types/
executor:
name: android/android-machine
tag: 2023.05.1
# Add steps to the job
# See: https://circleci.com/docs/2.0/configuration-reference/#steps
steps:
- setup_repo
- run_tests:
flavour: << parameters.flavour >>
ui-test-and-release:
# Parameters used for determining
parameters:
flavour:
type: string
default: "AtlasWeather"
executor:
name: android/android-machine
tag: 2023.05.1
steps:
- setup_repo
- run_ui_tests
- run:
name: Run Tests
command: ./gradlew lint test
- store_artifacts:
path: app/build/reports
destination: reports
- store_test_results:
path: app/build/test-results
name: Setup variables for release
command: |
echo "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "android/app/release_keystore.jks"
echo "$GOOGLE_PLAY_KEY" > "android/playstore.json"
# And finally run the release build
- run:
name: Assemble and Upload to PlayStore
command: |
pwd
bundle exec fastlane deploy<< parameters.flavour >>
# Invoke jobs via workflows
# See: https://circleci.com/docs/2.0/configuration-reference/#workflows
workflows:
sample: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
version: 2
build-release-atlas:
jobs:
- build-and-test
- build-and-test:
flavour: "AtlasWeather"
- ui-test-and-release:
flavour: "AtlasWeather"
filters:
branches:
only:
- main_atlas
requires:
- build-and-test
build-release-mono:
jobs:
- build-and-test:
flavour: "MonoWeather"
- ui-test-and-release:
flavour: "MonoWeather"
filters:
branches:
only: main_admin
requires:
- build-and-test

View File

@@ -41,5 +41,10 @@
<option name="name" value="maven" />
<option name="url" value="https://maven.tomtom.com:8443/nexus/content/repositories/releases/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://repositories.tomtom.com/artifactory/maven" />
</remote-repository>
</component>
</project>

View File

@@ -1,20 +1,19 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
// kotlin kapt
apply plugin: 'kotlin-kapt'
// Android navigation
apply plugin: 'androidx.navigation.safeargs'
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
id 'androidx.navigation.safeargs'
}
android {
lintOptions {
abortOnError false
}
compileSdkVersion 32
compileSdkVersion 33
defaultConfig {
applicationId "com.appttude.h_mal.atlas_weather"
minSdkVersion 26
targetSdkVersion 32
targetSdkVersion 33
versionCode 5
versionName "3.0"
testInstrumentationRunner "com.appttude.h_mal.atlas_weather.application.TestRunner"
@@ -65,12 +64,16 @@ android {
}
flavorDimensions "default"
productFlavors{
atlasWeather{
applicationIdSuffix ".atlasWeather"
productFlavors {
atlasWeather {
applicationId "com.appttude.h_mal.atlas_weather"
versionCode 5
versionName "3.0.0"
}
monoWeather{
applicationIdSuffix ".monoWeather"
monoWeather {
applicationId "com.appttude.h_mal.atlas_weather.monoWeather"
versionCode 5
versionName "3.0.0"
}
}
sourceSets {
@@ -86,8 +89,6 @@ android {
}
}
}
dependencies {
@@ -115,16 +116,28 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
// android unit testing and espresso
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test:rules:1.4.1-alpha06'
androidTestImplementation "androidx.test:core:1.4.0"
/ * Android Espresso */
def testJunitVersion = "1.1.5"
def testRunnerVersion = "1.5.2"
def espressoVersion = "3.5.1"
androidTestImplementation "androidx.test.ext:junit:$testJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espressoVersion"
implementation "androidx.test.espresso:espresso-idling-resource:$espressoVersion"
androidTestImplementation "androidx.test:runner:$testRunnerVersion"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion"
androidTestImplementation "org.hamcrest:hamcrest:2.2"
//mock websever for testing retrofit responses
testImplementation "com.squareup.okhttp3:mockwebserver:4.6.0"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
//mockito and livedata testing
testImplementation 'org.mockito:mockito-inline:2.13.0'
implementation 'android.arch.core:core-testing'
implementation 'androidx.arch.core:core-testing:2.2.0'
// Mockk
def mockk_ver = "1.10.5"
@@ -171,4 +184,6 @@ dependencies {
/ * Glide */
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
/ * screenshot library */
androidTestImplementation 'tools.fastlane:screengrab:2.1.1'
}

View File

@@ -0,0 +1,141 @@
//package com.appttude.h_mal.atlas_weather
//
//import android.Manifest
//import android.R
//import android.app.Activity
//import android.content.Context
//import android.os.Build
//import android.view.View
//import android.view.WindowManager
//import androidx.annotation.StringRes
//import androidx.test.core.app.ActivityScenario
//import androidx.test.espresso.*
//import androidx.test.espresso.Espresso.onView
//import androidx.test.espresso.assertion.ViewAssertions.matches
//import androidx.test.espresso.matcher.ViewMatchers.*
//import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
//import androidx.test.rule.GrantPermissionRule
//import com.appttude.h_mal.atlas_weather.atlasWeather.ui.BaseActivity
////import h_mal.appttude.com.driver.base.BaseActivity
//import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
//import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
//import org.hamcrest.CoreMatchers
//import org.hamcrest.Description
//import org.hamcrest.Matcher
//import org.hamcrest.TypeSafeMatcher
//import org.hamcrest.core.AllOf
//import org.junit.After
//import org.junit.Before
//import org.junit.Rule
//import tools.fastlane.screengrab.Screengrab
//import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy
//import tools.fastlane.screengrab.locale.LocaleTestRule
//
//
//open class BaseUiTest<T : BaseActivity>(
// private val activity: Class<T>
//) {
//
// private lateinit var mActivityScenarioRule: ActivityScenario<T>
// private var mIdlingResource: IdlingResource? = null
//
// private lateinit var currentActivity: Activity
//
// @get:Rule
// var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE)
//
// @get:Rule
// var snapshotRule: SnapshotRule = SnapshotRule()
//
// @Rule
// @JvmField
// var localeTestRule = LocaleTestRule()
//
// @Before
// fun setup() {
// Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy())
// beforeLaunch()
// mActivityScenarioRule = ActivityScenario.launch(activity)
// mActivityScenarioRule.onActivity {
//// mIdlingResource = it.getIdlingResource()!!
//// IdlingRegistry.getInstance().register(mIdlingResource)
// afterLaunch(it)
// }
// }
//
// @After
// fun tearDown() {
// mIdlingResource?.let {
// IdlingRegistry.getInstance().unregister(it)
// }
// }
//
// fun getResourceString(@StringRes stringRes: Int): String {
// return getInstrumentation().targetContext.resources.getString(
// stringRes
// )
// }
//
// fun waitFor(delay: Long) {
// onView(isRoot()).perform(object : ViewAction {
// override fun getConstraints(): Matcher<View> = isRoot()
// override fun getDescription(): String = "wait for $delay milliseconds"
// override fun perform(uiController: UiController, v: View?) {
// uiController.loopMainThreadForAtLeast(delay)
// }
// })
// }
//
// open fun beforeLaunch() {}
// open fun afterLaunch(context: Context) {}
//
//
// @Suppress("DEPRECATION")
// fun checkToastMessage(message: String) {
// onView(withText(message)).inRoot(object : TypeSafeMatcher<Root>() {
// override fun describeTo(description: Description?) {
// description?.appendText("is toast")
// }
//
// override fun matchesSafely(root: Root): Boolean {
// root.run {
// if (windowLayoutParams.get().type == WindowManager.LayoutParams.TYPE_TOAST) {
// decorView.run {
// if (windowToken === applicationWindowToken) {
// // windowToken == appToken means this window isn't contained by any other windows.
// // if it was a window for an activity, it would have TYPE_BASE_APPLICATION.
// return true
// }
// }
// }
// }
// return false
// }
// }
// ).check(matches(isDisplayed()))
// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// waitFor(3500)
// }
// }
//
// fun checkSnackBarDisplayedByMessage(message: String) {
// onView(
// CoreMatchers.allOf(
// withId(com.google.android.material.R.id.snackbar_text),
// withText(message)
// )
// ).check(matches(isDisplayed()))
// }
//
// private fun getCurrentActivity(): Activity {
// onView(AllOf.allOf(withId(R.id.content), isDisplayed()))
// .perform(object : BaseViewAction() {
// override fun setPerform(uiController: UiController?, view: View?) {
// if (view?.context is Activity) {
// currentActivity = view.context as Activity
// }
// }
// })
// return currentActivity
// }
//}

View File

@@ -0,0 +1,54 @@
package com.appttude.h_mal.atlas_weather
import android.graphics.drawable.BitmapDrawable
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import com.google.android.material.textfield.TextInputLayout
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
/**
* Matcher for testing error of TextInputLayout
*/
fun checkErrorMessage(expectedErrorText: String): Matcher<View?> {
return object : TypeSafeMatcher<View?>() {
override fun matchesSafely(view: View?): Boolean {
if (view is EditText) {
return view.error.toString() == expectedErrorText
}
if (view !is TextInputLayout) return false
val error = view.error ?: return false
return expectedErrorText == error.toString()
}
override fun describeTo(d: Description?) {}
}
}
fun checkImage(): Matcher<View?> {
return object : TypeSafeMatcher<View?>() {
override fun matchesSafely(view: View?): Boolean {
if (view is ImageView) {
return hasImage(view)
}
return false
}
override fun describeTo(d: Description?) {}
private fun hasImage(view: ImageView): Boolean {
val drawable = view.drawable
var hasImage = drawable != null
if (hasImage && drawable is BitmapDrawable) {
hasImage = drawable.bitmap != null
}
return hasImage
}
}
}

View File

@@ -6,8 +6,7 @@ import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import kotlinx.coroutines.runBlocking
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.*
import org.hamcrest.Matcher.*
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@@ -27,7 +26,8 @@ class LocationProviderImplTest {
@Before
fun setUp() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val appContext =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
locationProvider = LocationProviderImpl(appContext)
}
@@ -51,7 +51,7 @@ class LocationProviderImplTest {
try {
// Act
locationProvider.getLatLongFromLocationName(randomString)
}catch (e: IOException){
} catch (e: IOException) {
// Assert
assertEquals(e.message, "No location found")
}
@@ -70,16 +70,14 @@ class LocationProviderImplTest {
@Test
fun getLocationNameFromLatLong_locationTypeIsCity_correctLocationReturned() = runBlocking {
// Act
val retrievedLocation = locationProvider.getLocationNameFromLatLong(lat, long, LocationType.City)
val retrievedLocation =
locationProvider.getLocationNameFromLatLong(lat, long, LocationType.City)
// Assert
assertEquals(retrievedLocation, city)
}
private fun assertRangeOfDouble(input: Double, expected: Double, range: Double) {
assertThat(expected, allOf(
greaterThanOrEqualTo(input - range),
lessThanOrEqualTo(input + range))
)
assertEquals(expected, input, range)
}
}

View File

@@ -0,0 +1,17 @@
package com.appttude.h_mal.atlas_weather.helpers
import android.view.View
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
class BaseMatcher: BaseMatcher<View>() {
override fun describeTo(description: Description?) {
TODO("Not yet implemented")
}
override fun matches(actual: Any?): Boolean {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,27 @@
package com.appttude.h_mal.atlas_weather.helpers
import android.view.View
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import org.hamcrest.Matcher
open class BaseViewAction: ViewAction {
override fun getDescription(): String? = setDescription()
override fun getConstraints(): Matcher<View> = setConstraints()
override fun perform(uiController: UiController?, view: View?) {
setPerform(uiController, view)
}
open fun setDescription(): String? {
return null
}
open fun setConstraints(): Matcher<View> {
return isAssignableFrom(View::class.java)
}
open fun setPerform(uiController: UiController?, view: View?) { }
}

View File

@@ -0,0 +1,14 @@
package com.appttude.h_mal.atlas_weather.helpers
import android.os.Environment
import java.io.File
/**
* File paths for images on device
*/
fun getImagePath(imageConst: String): String {
return File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM),
"/Camera/images/$imageConst"
).absolutePath
}

View File

@@ -0,0 +1,33 @@
package com.appttude.h_mal.atlas_weather.helpers
import android.content.ClipData
import android.content.ClipData.Item
import android.net.Uri
import java.io.File
object DataHelper {
fun createClipItem(filePath: String) = Item(
Uri.fromFile(
File(filePath)
)
)
fun createClipData(item: Item, mimeType: String = "text/uri-list") =
ClipData(null, arrayOf(mimeType), item)
fun createClipData(filePath: String) = createClipData(createClipItem(filePath))
fun createClipData(filePaths: Array<String>): ClipData {
val clipData = createClipData(filePaths[0])
val remainingFiles = filePaths.copyOfRange(1, filePaths.size - 1)
clipData.addFilePaths(remainingFiles)
return clipData
}
fun createClipData(uri: Uri) = createClipData(Item(uri))
fun ClipData.addFilePaths(filePaths: Array<String>) {
filePaths.forEach { addItem(createClipItem(it)) }
}
}

View File

@@ -0,0 +1,123 @@
package com.appttude.h_mal.atlas_weather.helpers
import android.os.SystemClock.sleep
import android.view.View
import android.widget.CheckBox
import android.widget.Checkable
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.util.TreeIterables
import org.hamcrest.BaseMatcher
import org.hamcrest.CoreMatchers.isA
import org.hamcrest.Description
import org.hamcrest.Matcher
object EspressoHelper {
/**
* Perform action of waiting for a certain view within a single root view
* @param viewMatcher Generic Matcher used to find our view
*/
fun searchFor(viewMatcher: Matcher<View>): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isRoot()
override fun getDescription(): String {
return "searching for view $this in the root view"
}
override fun perform(uiController: UiController, view: View) {
var tries = 0
val childViews: Iterable<View> = TreeIterables.breadthFirstViewTraversal(view)
// Look for the match in the tree of childviews
childViews.forEach {
tries++
if (viewMatcher.matches(it)) {
// found the view
return
}
}
throw NoMatchingViewException.Builder()
.withRootView(view)
.withViewMatcher(viewMatcher)
.build()
}
}
}
/**
* Performs an action to check/uncheck a checkbox
*
*/
fun setChecked(checked: Boolean): ViewAction {
return object : ViewAction {
override fun getConstraints(): BaseMatcher<View> {
return object : BaseMatcher<View>() {
override fun describeTo(description: Description?) {}
override fun matches(actual: Any?): Boolean {
return isA(CheckBox::class.java).matches(actual)
}
}
}
override fun getDescription(): String {
return ""
}
override fun perform(uiController: UiController, view: View) {
val checkableView = view as Checkable
checkableView.isChecked = checked
}
}
}
/**
* Perform action of implicitly waiting for a certain view.
* This differs from EspressoExtensions.searchFor in that,
* upon failure to locate an element, it will fetch a new root view
* in which to traverse searching for our @param match
*
* @param viewMatcher ViewMatcher used to find our view
*/
fun waitForView(
viewMatcher: Matcher<View>,
waitMillis: Int = 5000,
waitMillisPerTry: Long = 100
): ViewInteraction {
// Derive the max tries
val maxTries = waitMillis / waitMillisPerTry.toInt()
var tries = 0
for (i in 0..maxTries)
try {
// Track the amount of times we've tried
tries++
// Search the root for the view
onView(isRoot()).perform(searchFor(viewMatcher))
// If we're here, we found our view. Now return it
return onView(viewMatcher)
} catch (e: Exception) {
if (tries == maxTries) {
throw e
}
sleep(waitMillisPerTry)
}
throw Exception("Error finding a view matching $viewMatcher")
}
}

View File

@@ -0,0 +1,19 @@
package com.appttude.h_mal.atlas_weather.helpers
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import tools.fastlane.screengrab.Screengrab
/**
* Junit rule that takes a screenshot when a test fails.
*/
class SnapshotRule : TestWatcher() {
override fun failed(e: Throwable, description: Description) {
// Catch a screenshot on failure
Screengrab.screenshot("FAILURE-" + getScreenshotName(description))
}
fun getScreenshotName(description: Description): String {
return description.className.replace(".", "-") + "_" + description.methodName.replace(".", "-")
}
}

View File

@@ -1,41 +1,56 @@
package com.appttude.h_mal.atlas_weather.monoWeather.testsuite
import android.content.Intent
import android.Manifest
import android.app.Activity
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.pressBack
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass
import com.appttude.h_mal.atlas_weather.monoWeather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
import com.appttude.h_mal.atlas_weather.utils.Stubs
import org.junit.After
import org.junit.Rule
import tools.fastlane.screengrab.Screengrab
import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy
open class BaseTest {
open class BaseTest<A : Activity> {
lateinit var testApp: TestAppClass
@get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
@get:Rule
var snapshotRule: SnapshotRule = SnapshotRule()
@Rule
@JvmField
var mActivityTestRule: ActivityTestRule<MainActivity> = object : ActivityTestRule<MainActivity>(MainActivity::class.java) {
override fun beforeActivityLaunched() {
super.beforeActivityLaunched()
var mActivityTestRule: ActivityTestRule<A> =
object : ActivityTestRule<A>(getGenericClassAt<A>(0).java) {
override fun beforeActivityLaunched() {
super.beforeActivityLaunched()
Screengrab.setDefaultScreenshotStrategy(UiAutomatorScreenshotStrategy())
testApp = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
setupFeed()
testApp =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
setupFeed()
}
override fun afterActivityLaunched() {
// Dismiss dialog
onView(withText("AGREE")).inRoot(isDialog()).check(matches(isDisplayed()))
.perform(ViewActions.click())
}
}
override fun afterActivityLaunched() {
// Dismiss dialog
onView(withText("AGREE")).inRoot(isDialog()).check(matches(isDisplayed())).perform(ViewActions.click())
}
}
fun stubEndpoint(url: String, stub: Stubs) {
testApp.stubUrl(url, stub.id)
}
@@ -45,7 +60,8 @@ open class BaseTest {
}
@After
fun tearDown() {}
fun tearDown() {
}
open fun setupFeed() {}
}

View File

@@ -1,6 +1,7 @@
package com.appttude.h_mal.atlas_weather.monoWeather.testsuite
import android.Manifest
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ActivityScenario.launch
@@ -14,7 +15,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass
import com.appttude.h_mal.atlas_weather.monoWeather.robot.homeScreen
import com.appttude.h_mal.atlas_weather.robot.homeScreen
import com.appttude.h_mal.atlas_weather.monoWeather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.utils.Stubs
import kotlinx.coroutines.runBlocking
@@ -27,12 +28,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4ClassRunner::class)
class HomePageUITestScenario : BaseMainScenario() {
@Rule
@JvmField
var mGrantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
"android.permission.ACCESS_COARSE_LOCATION")
override fun setupFeed() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
}
@@ -49,12 +44,14 @@ class HomePageUITestScenario : BaseMainScenario() {
open class BaseMainScenario {
lateinit var scenario: ActivityScenario<MainActivity>
private lateinit var testApp : TestAppClass
private lateinit var testApp: TestAppClass
@get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
@Before
fun setUp() {
scenario = launch(MainActivity::class.java)
scenario.moveToState(Lifecycle.State.INITIALIZED)
scenario.onActivity {
runBlocking {
testApp = it.application as TestAppClass
@@ -62,12 +59,11 @@ open class BaseMainScenario {
}
}
scenario.moveToState(Lifecycle.State.CREATED).onActivity {
Espresso.onView(ViewMatchers.withText("AGREE"))
.inRoot(RootMatchers.isDialog())
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.perform(ViewActions.click())
}
// Dismiss dialog on start up
Espresso.onView(ViewMatchers.withText("AGREE"))
.inRoot(RootMatchers.isDialog())
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.perform(ViewActions.click())
}
fun stubEndpoint(url: String, stub: Stubs) {
@@ -79,7 +75,10 @@ open class BaseMainScenario {
}
@After
fun tearDown() {}
fun tearDown() {
testFinished()
}
open fun setupFeed() {}
open fun testFinished() {}
}

View File

@@ -16,13 +16,14 @@ class WidgetLocationPermissionActivityTest {
@Rule
@JvmField
var mActivityTestRule : ActivityTestRule<WidgetLocationPermissionActivity> =
ActivityTestRule<WidgetLocationPermissionActivity>(WidgetLocationPermissionActivity::class.java, false, false)
ActivityTestRule(WidgetLocationPermissionActivity::class.java, false, false)
@Test
fun demo_test() {
val i = Intent()
i.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 112)
mActivityTestRule.launchActivity(i)
val startIntent = Intent().apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 112)
}
mActivityTestRule.launchActivity(startIntent)
Espresso.onView((ViewMatchers.withId(R.id.declaration_text))).check(matches(isDisplayed()));
}

View File

@@ -1,51 +1,157 @@
package com.appttude.h_mal.atlas_weather.utils
import android.content.res.Resources
import android.view.View
import android.widget.DatePicker
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.swipeDown
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.PickerActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withClassName
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.appttude.h_mal.atlas_weather.checkErrorMessage
import com.appttude.h_mal.atlas_weather.checkImage
import com.appttude.h_mal.atlas_weather.helpers.EspressoHelper.waitForView
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.anything
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.Matcher
@SuppressWarnings("unused")
open class BaseTestRobot {
fun fillEditText(resId: Int, text: String): ViewInteraction =
onView(withId(resId)).perform(ViewActions.replaceText(text), ViewActions.closeSoftKeyboard())
fun fillEditText(resId: Int, text: String?): ViewInteraction =
onView(withId(resId)).perform(
ViewActions.replaceText(text),
ViewActions.closeSoftKeyboard()
)
fun clickButton(resId: Int): ViewInteraction = onView((withId(resId))).perform(ViewActions.click())
fun clickButton(resId: Int): ViewInteraction =
onView((withId(resId))).perform(click())
fun textView(resId: Int): ViewInteraction = onView(withId(resId))
fun matchView(resId: Int): ViewInteraction = onView(withId(resId))
fun matchViewWaitFor(resId: Int): ViewInteraction = waitForView(withId(resId))
fun matchText(viewInteraction: ViewInteraction, text: String): ViewInteraction = viewInteraction
.check(ViewAssertions.matches(ViewMatchers.withText(text)))
.check(matches(withText(text)))
fun matchText(resId: Int, text: String): ViewInteraction = matchText(textView(resId), text)
fun matchText(viewId: Int, textId: Int): ViewInteraction = onView(withId(viewId))
.check(matches(withText(textId)))
fun matchText(resId: Int, text: String): ViewInteraction = matchText(matchView(resId), text)
fun clickListItem(listRes: Int, position: Int) {
onData(anything())
.inAdapterView(allOf(withId(listRes)))
.atPosition(position).perform(ViewActions.click())
.inAdapterView(allOf(withId(listRes)))
.atPosition(position).perform(click())
}
fun <VH : ViewHolder> scrollToRecyclerItem(recyclerId: Int, text: String): ViewInteraction? {
return matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollTo<VH>(
hasDescendant(withText(text))
)
)
}
fun <VH : ViewHolder> scrollToRecyclerItem(
recyclerId: Int,
resIdForString: Int
): ViewInteraction? {
return matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollTo<VH>(
hasDescendant(withText(resIdForString))
)
)
}
fun <VH : ViewHolder> scrollToRecyclerItemByPosition(
recyclerId: Int,
position: Int
): ViewInteraction? {
return matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollToPosition<VH>(position)
)
}
fun <VH : ViewHolder> clickViewInRecycler(recyclerId: Int, text: String) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(hasDescendant(withText(text)), click())
)
}
fun <VH : ViewHolder> clickViewInRecycler(recyclerId: Int, resIdForString: Int) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(
hasDescendant(withText(resIdForString)),
click()
)
)
}
fun <VH : ViewHolder> clickSubViewInRecycler(recyclerId: Int, text: String, subView: Int) {
scrollToRecyclerItem<VH>(recyclerId, text)
?.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(
hasDescendant(withText(text)), object : ViewAction {
override fun getDescription(): String = "Matching recycler descendant"
override fun getConstraints(): Matcher<View>? = isRoot()
override fun perform(uiController: UiController?, view: View?) {
view?.findViewById<View>(subView)?.performClick()
}
}
)
)
}
fun checkErrorOnTextEntry(resId: Int, errorMessage: String): ViewInteraction =
onView(withId(resId)).check(matches(checkErrorMessage(errorMessage)))
fun checkImageViewHasImage(resId: Int): ViewInteraction =
onView(withId(resId)).check(matches(checkImage()))
fun swipeDown(resId: Int): ViewInteraction =
onView(withId(resId)).perform(swipeDown())
fun getStringFromResource(@StringRes resId: Int): String =
Resources.getSystem().getString(resId)
fun pullToRefresh(resId: Int){
onView(allOf(withId(resId), isDisplayed())).perform(swipeDown())
onView(allOf(withId(resId), ViewMatchers.isDisplayed())).perform(swipeDown())
}
fun waitFor(delay: Long): ViewAction? {
return object : ViewAction {
override fun getConstraints(): Matcher<View> = isRoot()
override fun getDescription(): String = "wait for $delay milliseconds"
override fun perform(uiController: UiController, v: View?) {
uiController.loopMainThreadForAtLeast(delay)
}
}
fun selectDateInPicker(year: Int, month: Int, day: Int) {
onView(withClassName(equalTo(DatePicker::class.java.name))).perform(
PickerActions.setDate(
year,
month,
day
)
)
}
}

View File

@@ -1,4 +1,4 @@
package com.appttude.h_mal.atlas_weather.monoWeather.robot
package com.appttude.h_mal.atlas_weather.robot
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.utils.BaseTestRobot

View File

@@ -1,18 +1,15 @@
package com.appttude.h_mal.atlas_weather.monoWeather.testsuite
package com.appttude.h_mal.atlas_weather.tests
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.RootMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.monoWeather.robot.homeScreen
import com.appttude.h_mal.atlas_weather.atlasWeather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.robot.homeScreen
import com.appttude.h_mal.atlas_weather.monoWeather.testsuite.BaseTest
import com.appttude.h_mal.atlas_weather.utils.Stubs
import org.junit.Rule
import org.junit.Test
class HomePageUITest : BaseTest() {
class HomePageUITest : BaseTest<MainActivity>() {
@Rule
@JvmField

View File

@@ -0,0 +1,11 @@
package com.appttude.h_mal.atlas_weather.robot
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.utils.BaseTestRobot
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
class HomeScreenRobot : BaseTestRobot() {
fun verifyCurrentTemperature(temperature: Int) = matchText(R.id.temp_main_4, temperature.toString())
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh)
}

View File

@@ -0,0 +1,25 @@
package com.appttude.h_mal.atlas_weather.tests
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.robot.homeScreen
import com.appttude.h_mal.atlas_weather.monoWeather.testsuite.BaseTest
import com.appttude.h_mal.atlas_weather.monoWeather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.utils.Stubs
import org.junit.Rule
import org.junit.Test
class HomePageUITest : BaseTest<MainActivity>() {
override fun setupFeed() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
}
@Test
fun loadApp_validWeatherResponse_returnsValidPage() {
homeScreen {
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
}
}
}

View File

@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools"
package="com.appttude.h_mal.atlas_weather"
tools:node="merge">
<application
android:name="com.appttude.h_mal.atlas_weather.application.AppClass"
@@ -12,10 +14,11 @@
android:theme="@style/AppTheme"
tools:node="merge">
<activity android:name=".ui.MainActivity"
<activity android:name=".atlasWeather.ui.MainActivity"
android:label="@string/app_name"
android:launchMode="singleTop"
android:theme="@style/AppTheme.NoActionBar">
android:theme="@style/AppTheme.NoActionBar"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
@@ -25,14 +28,17 @@
</activity>
<activity
android:name=".ui.settings.UnitSettingsActivity"
android:label="Settings" />
android:name=".atlasWeather.ui.settings.UnitSettingsActivity"
android:label="Settings"
android:exported="true"/>
<receiver
android:name=".notification.NotificationReceiver"
android:parentActivityName=".MainActivity" />
android:name=".atlasWeather.notification.NotificationReceiver"
android:parentActivityName=".MainActivity"
android:exported="true"/>
<receiver android:name=".widget.NewAppWidget">
<receiver android:name=".atlasWeather.widget.NewAppWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_ENABLED" />
@@ -46,7 +52,7 @@
<service
android:name=".widget.WidgetRemoteViewsService"
android:name=".atlasWeather.widget.WidgetRemoteViewsService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
</application>

View File

@@ -17,6 +17,8 @@ import kotlinx.android.synthetic.atlasWeather.activity_main.*
class MainActivity : BaseActivity(){
lateinit var navHost: NavHostFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -24,7 +26,7 @@ class MainActivity : BaseActivity(){
val navView: BottomNavigationView = findViewById(R.id.nav_view)
setSupportActionBar(toolbar)
val navHost = supportFragmentManager
navHost = supportFragmentManager
.findFragmentById(R.id.container) as NavHostFragment
val navController = navHost.navController
navController.setGraph(R.navigation.main_navigation)

View File

@@ -1,5 +1,6 @@
package com.appttude.h_mal.atlas_weather.atlasWeather.ui.home.adapter
import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
@@ -13,6 +14,7 @@ class WeatherRecyclerAdapter(
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var weather: WeatherDisplay? = null
@SuppressLint("NotifyDataSetChanged")
fun addCurrent(current: WeatherDisplay){
weather = current
notifyDataSetChanged()
@@ -71,7 +73,6 @@ class WeatherRecyclerAdapter(
when (getDataType(getItemViewType(position))){
is ViewType.Empty -> {
holder as EmptyViewHolder
}
is ViewType.Current -> {
val viewHolderCurrent = holder as ViewHolderCurrent

View File

@@ -12,24 +12,24 @@ import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
interface WeatherDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertFullWeather(item: EntityItem)
fun upsertFullWeather(item: EntityItem)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertListOfFullWeather(items: List<EntityItem>)
fun upsertListOfFullWeather(items: List<EntityItem>)
@Query("SELECT * FROM EntityItem WHERE id = :userId LIMIT 1")
fun getCurrentFullWeather(userId: String) : LiveData<EntityItem>
@Query("SELECT * FROM EntityItem WHERE id = :userId LIMIT 1")
suspend fun getCurrentFullWeatherSingle(userId: String) : EntityItem
fun getCurrentFullWeatherSingle(userId: String) : EntityItem
@Query("SELECT * FROM EntityItem WHERE id != :id")
fun getAllFullWeatherWithoutCurrent(id: String = CURRENT_LOCATION) : LiveData<List<EntityItem>>
@Query("SELECT * FROM EntityItem WHERE id != :id")
suspend fun getWeatherListWithoutCurrent(id: String = CURRENT_LOCATION) : List<EntityItem>
fun getWeatherListWithoutCurrent(id: String = CURRENT_LOCATION) : List<EntityItem>
@Query("DELETE FROM EntityItem WHERE id = :userId")
suspend fun deleteEntry(userId: String): Int
fun deleteEntry(userId: String): Int
}

View File

@@ -0,0 +1,51 @@
package com.appttude.h_mal.atlas_weather.helper
import android.view.LayoutInflater
import android.view.ViewGroup
import java.lang.reflect.ParameterizedType
import kotlin.reflect.KClass
object GenericsHelper {
@Suppress("UNCHECKED_CAST")
fun <CLASS : Any> Any.getGenericClassAt(position: Int): KClass<CLASS> =
((javaClass.genericSuperclass as? ParameterizedType)
?.actualTypeArguments?.getOrNull(position) as? Class<CLASS>)
?.kotlin
?: throw IllegalStateException("Can not find class from generic argument")
// /**
// * Create a view binding out of the the generic [VB]
// *
// * @sample inflateBindingByType(getGenericClassAt(0), layoutInflater)
// */
// fun <VB: ViewBinding> inflateBindingByType(
// genericClassAt: KClass<VB>,
// layoutInflater: LayoutInflater
// ): VB = try {
// @Suppress("UNCHECKED_CAST")
//
// genericClassAt.java.methods.first { viewBinding ->
// viewBinding.parameterTypes.size == 1
// && viewBinding.parameterTypes.getOrNull(0) == LayoutInflater::class.java
// }.invoke(null, layoutInflater) as VB
// } catch (exception: Exception) {
// println ("generic class failed at = $genericClassAt")
// exception.printStackTrace()
// throw IllegalStateException("Can not inflate binding from generic")
// }
//
// fun <VB: ViewBinding> LayoutInflater.inflateBindingByType(
// container: ViewGroup?,
// genericClassAt: KClass<VB>
// ): VB = try {
// @Suppress("UNCHECKED_CAST")
// genericClassAt.java.methods.first { inflateFun ->
// inflateFun.parameterTypes.size == 3
// && inflateFun.parameterTypes.getOrNull(0) == LayoutInflater::class.java
// && inflateFun.parameterTypes.getOrNull(1) == ViewGroup::class.java
// && inflateFun.parameterTypes.getOrNull(2) == Boolean::class.java
// }.invoke(null, this, container, false) as VB
// } catch (exception: Exception) {
// throw IllegalStateException("Can not inflate binding from generic")
// }
}

View File

@@ -1,36 +1,26 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
kotlin_version = '1.3.72'
kotlin_version = '1.5.20'
}
repositories {
google()
jcenter()
maven { url "https://www.jitpack.io" }
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
def nav_version = "2.3.0"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.4.1")
classpath ('com.android.tools.build:gradle:7.2.2')
classpath ("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20")
}
}
allprojects {
repositories {
jcenter()
google()
maven {
url 'https://maven.tomtom.com:8443/nexus/content/repositories/releases/'
}
}
plugins {
id 'com.android.application' version '7.2.2' apply false
id 'com.android.library' version '7.2.2' apply false
id 'com.google.gms.google-services' version '4.3.15' apply false
id 'androidx.navigation.safeargs.kotlin' version '2.4.0' apply false
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
}

View File

@@ -1,6 +1,6 @@
#Fri Dec 07 02:26:23 AEST 2018
#Wed Jul 26 10:14:37 BST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip

View File

@@ -1 +1,22 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
jcenter()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url "https://www.jitpack.io" }
jcenter()
maven {
url "https://repositories.tomtom.com/artifactory/maps-sdk-legacy-android"
}
}
}
rootProject.name = "Driver"
include ':app'