Merge pull request #18 from hmalik144/testsuite_expansion

Testsuite expansion
This commit is contained in:
2023-08-29 14:08:43 +01:00
committed by GitHub
54 changed files with 2348 additions and 787 deletions

View File

@@ -1,26 +1,130 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/configuration-reference
# See: https://circleci.com/docs/2.0/configuration-reference
# For a detailed guide to building and testing on Android, read the docs:
# https://circleci.com/docs/2.0/language-android/ for more details.
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/configuration-reference/#jobs
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/configuration-reference/#executor-job
docker:
- image: cimg/base:stable
# Add steps to the job
# See: https://circleci.com/docs/configuration-reference/#steps
# 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@2.3.0
commands:
setup_repo:
description: checkout repo and android dependencies
steps:
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/configuration-reference/#workflows
name: Give gradle permissions
command: |
sudo chmod +x ./gradlew
- android/restore-gradle-cache
run_tests:
description: run tests for flavour specified
steps:
# The next step will run the unit tests
- run:
name: Run local unit tests
command: |
./gradlew testDebugUnitTest
- android/save-gradle-cache
- store_artifacts:
path: app/build/reports
destination: reports
- store_test_results:
path: app/build/test-results
run_ui_tests:
description: run instrumentation and espresso tests
steps:
- android/start-emulator-and-run-tests:
post-emulator-launch-assemble-command: ./gradlew assembleAndroidTest
test-command: ./gradlew connectedDebugAndroidTest --continue
system-image: system-images;android-26;google_apis;x86
# store screenshots for failed ui tests
- when:
condition: on_fail
steps:
- store_artifacts:
path: app/build/outputs/connected_android_test_additional_output/debugAndroidTest/connected
destination: connected_android_test
# store test reports
- store_artifacts:
path: app/build/reports/androidTests/connected
destination: reports
- store_test_results:
path: app/build/outputs/androidTest-results/connected
deploy_to_play_store:
description: deploy to playstore
steps:
# The next step will run the unit tests
- android/decode-keystore:
keystore-location: "./app/keystore.jks"
- run:
name: Setup playstore key
command: |
echo "$GOOGLE_PLAY_KEY" > "google-play-key.json"
- run:
name: Run fastlane command to deploy to playstore
command: |
pwd
bundle exec fastlane deploy
- store_test_results:
path: fastlane/report.xml
# 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
tag: 2023.05.1
# Add steps to the job
# See: https://circleci.com/docs/2.0/configuration-reference/#steps
steps:
- setup_repo
- run_tests
run_instrumentation_test:
# 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_ui_tests
deploy-to-playstore:
docker:
- image: cimg/android:2023.07-browsers
auth:
username: ${DOCKER_USERNAME}
password: ${DOCKER_PASSWORD}
steps:
- setup_repo
- deploy_to_play_store
# Invoke jobs via workflows
# See: https://circleci.com/docs/2.0/configuration-reference/#workflows
workflows:
say-hello-workflow:
version: 2
build-release:
jobs:
- say-hello
- build-and-test:
context: appttude
- run_instrumentation_test:
context: appttude
filters:
branches:
only:
- master
- release
- deploy-to-playstore:
context: appttude
filters:
branches:
only:
- release
requires:
- build-and-test

2
.gitignore vendored
View File

@@ -88,7 +88,7 @@ gen-external-apklibs
.idea/uiDesigner.xml
.idea/assetWizardSettings.xml
.idea/gradle.xml
.idea/jarRepositorie
.idea/jarRepositories.xml
# Gem/fastlane
Gemfile.lock

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="1283002349">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_6_Pro_API_31" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -1,127 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WizardSettings">
<option name="children">
<map>
<entry key="imageWizard">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="imageAssetPanel">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="actionbar">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="opacityPercent" value="80" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="theme" value="HOLO_DARK" />
<entry key="themeColor" value="ffffff" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="launcher">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="foregroundImage">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="scalingPercent" value="69" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="foregroundImage" value="C:\Users\h_mal\Desktop\Farmr\farmicon.png" />
<entry key="outputName" value="ic_launcher_release" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="launcherLegacy">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="notification">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</component>
</project>

Binary file not shown.

Binary file not shown.

6
.idea/compiler.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

21
.idea/gradle.xml generated
View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="delegatedBuild" value="false" />
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
</component>
</project>

47
.idea/misc.xml generated
View File

@@ -1,47 +0,0 @@
<project version="4">
<component name="NullableNotNullManager">
<option name="myDefaultNullable" value="android.support.annotation.Nullable" />
<option name="myDefaultNotNull" value="android.support.annotation.NonNull" />
<option name="myNullables">
<value>
<list size="12">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
<item index="2" class="java.lang.String" itemvalue="javax.annotation.CheckForNull" />
<item index="3" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
<item index="4" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.Nullable" />
<item index="6" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" />
<item index="7" class="java.lang.String" itemvalue="android.annotation.Nullable" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" />
<item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" />
<item index="10" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" />
<item index="11" class="java.lang.String" itemvalue="com.android.annotations.Nullable" />
</list>
</value>
</option>
<option name="myNotNulls">
<value>
<list size="11">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
<item index="4" class="java.lang.String" itemvalue="androidx.annotation.NonNull" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
<item index="6" class="java.lang.String" itemvalue="android.annotation.NonNull" />
<item index="7" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" />
<item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" />
<item index="10" class="java.lang.String" itemvalue="com.android.annotations.NonNull" />
</list>
</value>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

12
.idea/modules.xml generated
View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/Farmr.iml" filepath="$PROJECT_DIR$/.idea/modules/Farmr.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/app/Farmr.app.iml" filepath="$PROJECT_DIR$/.idea/modules/app/Farmr.app.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/app/Farmr.app.androidTest.iml" filepath="$PROJECT_DIR$/.idea/modules/app/Farmr.app.androidTest.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/app/Farmr.app.main.iml" filepath="$PROJECT_DIR$/.idea/modules/app/Farmr.app.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/app/Farmr.app.unitTest.iml" filepath="$PROJECT_DIR$/.idea/modules/app/Farmr.app.unitTest.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -10,7 +10,7 @@ android {
targetSdkVersion 31
versionCode 1
versionName "1.0"
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
testInstrumentationRunner 'com.appttude.h_mal.farmr.application.TestRunner'
vectorDrawables.useSupportLibrary = true
}
buildTypes {
@@ -19,6 +19,7 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
useLibrary 'android.test.mock'
}
dependencies {
@@ -34,9 +35,33 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation 'androidx.preference:preference:1.2.1'
testImplementation 'junit:junit:4.12'
implementation 'com.ajts.androidmads.SQLite2Excel:library:1.0.2'
/ * Unit testing * /
testImplementation 'junit:junit:4.13.2'
androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
/ * mockito and livedata testing * /
testImplementation 'org.mockito:mockito-inline:2.13.0'
testImplementation 'androidx.arch.core:core-testing:2.1.0'
/ * MockK * /
def mockk_ver = "1.10.5"
testImplementation "io.mockk:mockk:$mockk_ver"
androidTestImplementation "io.mockk:mockk-android:$mockk_ver"
/ * 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"
/ * Room database * /
def room_version = "2.4.3"
implementation "androidx.room:room-runtime:$room_version"
@@ -48,5 +73,4 @@ dependencies {
implementation "org.kodein.di:kodein-di-framework-android-x:$kodein_version"
/ * jxl * /
implementation 'net.sourceforge.jexcelapi:jxl:2.6.12'
}

View File

@@ -0,0 +1,38 @@
package com.appttude.h_mal.farmr.application
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.farmr.base.BaseApplication
import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
import com.appttude.h_mal.farmr.model.Shift
class TestAppClass : BaseApplication() {
private val idlingResources = CountingIdlingResource("Data_loader")
lateinit var database: LegacyDatabase
lateinit var preferenceProvider: PreferenceProvider
override fun onCreate() {
super.onCreate()
IdlingRegistry.getInstance().register(idlingResources)
}
override fun createDatabase(): LegacyDatabase {
database =
LegacyDatabase(InstrumentationRegistry.getInstrumentation().context.contentResolver)
return database
}
override fun createPrefs(): PreferenceProvider {
preferenceProvider = PreferenceProvider(this)
return preferenceProvider
}
fun addToDatabase(shift: Shift) = database.insertShiftDataIntoDatabase(shift)
fun addShiftsToDatabase(shifts: List<Shift>) = shifts.forEach { addToDatabase(it) }
fun clearDatabase() = database.deleteAllShiftsInDatabase()
fun cleanPrefs() = preferenceProvider.clearPrefs()
}

View File

@@ -0,0 +1,21 @@
package com.appttude.h_mal.farmr.application
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
class TestRunner : AndroidJUnitRunner() {
@Throws(
InstantiationException::class,
IllegalAccessException::class,
ClassNotFoundException::class
)
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, TestAppClass::class.java.name, context)
}
}

View File

@@ -19,6 +19,7 @@ import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry._ID
import com.appttude.h_mal.farmr.data.legacydb.ShiftProvider
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
import org.junit.After
import org.junit.Rule
import org.junit.Test
@@ -31,6 +32,11 @@ class ShiftProviderTest {
private val contentResolver: ContentResolver
get() = providerRule.resolver
@After
fun tearDown() {
contentResolver.delete(CONTENT_URI, null, null)
}
@Test
fun insertEntry_queryEntry_assertEntry() {
// Arrange

View File

@@ -0,0 +1,91 @@
package com.appttude.h_mal.farmr.data.legacydb
import androidx.test.rule.provider.ProviderTestRule
import com.appttude.h_mal.farmr.model.Shift
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class LegacyDatabaseTest {
@get:Rule
val providerRule: ProviderTestRule = ProviderTestRule
.Builder(ShiftProvider::class.java, ShiftsContract.CONTENT_AUTHORITY)
.build()
private lateinit var database: LegacyDatabase
@Before
fun setup() {
database = LegacyDatabase(providerRule.resolver)
}
@After
fun tearDown() {
database.deleteAllShiftsInDatabase()
}
@Test
fun insertShift_readShift_successfulRead() {
// Arrange
val shift = Shift("adsfadsf", "2020-12-12", 12f, 12f)
// Act
database.insertShiftDataIntoDatabase(shift)
val retrievedShift = database.readShiftsFromDatabase()?.first()
// Assert
assertEquals(retrievedShift?.description, shift.description)
assertEquals(retrievedShift?.date, shift.date)
assertEquals(retrievedShift?.units, shift.units)
assertEquals(retrievedShift?.rateOfPay, shift.rateOfPay)
}
@Test
fun insertShift_updateShift_successfulRead() {
// Arrange
val shift = Shift("adsfadsf", "2020-12-12", 12f, 12f)
val updateShift = Shift("dasdads", "2020-11-12", 10f, 10f)
// Act
database.insertShiftDataIntoDatabase(shift)
val id = database.readShiftsFromDatabase()?.first()!!.id
database.updateShiftDataIntoDatabase(
id = id,
typeString = updateShift.type.type,
descriptionString = updateShift.description,
dateString = updateShift.date,
timeInString = updateShift.timeIn ?: "",
timeOutString = updateShift.timeOut ?: "",
duration = updateShift.duration ?: 0f,
breaks = updateShift.breakMins ?: 0,
units = updateShift.units!!,
payRate = updateShift.rateOfPay,
totalPay = updateShift.totalPay
)
val retrievedShift = database.readSingleShiftWithId(id)
// Assert
assertEquals(retrievedShift?.description, updateShift.description)
assertEquals(retrievedShift?.date, updateShift.date)
assertEquals(retrievedShift?.units, updateShift.units)
assertEquals(retrievedShift?.rateOfPay, updateShift.rateOfPay)
}
@Test
fun insertShift_deleteShift_databaseEmpty() {
// Arrange
val shift = Shift("adsfadsf", "2020-12-12", 12f, 12f)
val updateShift = Shift("dasdads", "2020-11-12", 10f, 10f)
// Act
database.insertShiftDataIntoDatabase(shift)
database.insertShiftDataIntoDatabase(updateShift)
val id = database.readShiftsFromDatabase()?.first()!!.id
database.deleteSingleShift(id)
// Assert
assertEquals(database.readShiftsFromDatabase()?.size, 1)
}
}

View File

@@ -0,0 +1,110 @@
package com.appttude.h_mal.farmr.ui
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.RootMatchers.withDecorView
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.farmr.application.TestAppClass
import com.appttude.h_mal.farmr.di.ShiftApplication
import com.appttude.h_mal.farmr.ui.utils.getShifts
import kotlinx.coroutines.runBlocking
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.kodein.di.android.kodein
@Suppress("EmptyMethod")
open class BaseTest<A : Activity>(
private val activity: Class<A>,
private val intentBundle: Bundle? = null,
) {
lateinit var scenario: ActivityScenario<A>
private lateinit var testApp: TestAppClass
private lateinit var testActivity: Activity
private lateinit var decorView: View
@get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE)
@Before
open fun setUp() {
val startIntent =
Intent(InstrumentationRegistry.getInstrumentation().targetContext, activity)
if (intentBundle != null) {
startIntent.replaceExtras(intentBundle)
}
testApp =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
kodein(testApp)
runBlocking {
beforeLaunch()
}
scenario = ActivityScenario.launch(startIntent)
scenario.onActivity {
testApp =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as TestAppClass
onLaunch()
decorView = it.window.decorView
testActivity = it
}
afterLaunch()
}
fun getActivity() = testActivity
@After
fun tearDown() {
testFinished()
}
open fun beforeLaunch() {}
open fun onLaunch() {}
open fun afterLaunch() {}
open fun testFinished() {}
fun waitFor(delay: Long) {
Espresso.onView(ViewMatchers.isRoot()).perform(object : ViewAction {
override fun getConstraints(): Matcher<View> = ViewMatchers.isRoot()
override fun getDescription(): String = "wait for $delay milliseconds"
override fun perform(uiController: UiController, v: View?) {
uiController.loopMainThreadForAtLeast(delay)
}
})
}
@Suppress("DEPRECATION")
fun checkToastMessage(message: String) {
Espresso.onView(ViewMatchers.withText(message)).inRoot(withDecorView(Matchers.not(decorView)))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
waitFor(3500)
}
}
fun navigateBack() = Espresso.pressBack()
fun addRandomShifts() {
testApp.addShiftsToDatabase(getShifts())
}
fun clearDataBase() = testApp.clearDatabase()
fun clearPrefs() = testApp.cleanPrefs()
}

View File

@@ -0,0 +1,219 @@
package com.appttude.h_mal.farmr.ui
import android.content.res.Resources
import android.view.View
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatButton
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
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.matches
import androidx.test.espresso.contrib.PickerActions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.farmr.ui.utils.EspressoHelper.waitForView
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.anything
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.Matcher
import org.hamcrest.Matchers
@SuppressWarnings("unused")
open class BaseTestRobot {
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(click())
// fun clickMenu(menuId: Int): ViewInteraction = onView()
fun matchView(resId: Int): ViewInteraction = onView(withId(resId))
fun matchViewWaitFor(resId: Int): ViewInteraction = waitForView(withId(resId))
fun matchDisplayed(resId: Int): ViewInteraction = matchView(resId).check(matches(isDisplayed()))
fun matchText(viewInteraction: ViewInteraction, text: String): ViewInteraction = viewInteraction
.check(matches(withText(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(click())
}
fun clickOnMenuItem(menuId: Int) {
openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().context)
onView(withText(menuId)).perform(click())
}
fun clickDialogButton(text: String) {
onView(withText(text)).inRoot(isDialog())
.check(matches(isDisplayed()))
.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, resIdForString: Int) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.actionOnItem<VH>(
hasDescendant(withText(resIdForString)),
click()
)
)
}
fun <VH : ViewHolder> clickRecyclerAtPosition(recyclerId: Int, position: Int) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollToPosition<VH>(position),
RecyclerViewActions.actionOnItemAtPosition<VH>(position, click()),
)
}
fun <VH : ViewHolder> clickViewInRecyclerAtPosition(recyclerId: Int, position: Int, subViewId: Int) {
matchView(recyclerId)
.perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollToPosition<VH>(position),
RecyclerViewActions.actionOnItemAtPosition<VH>(position, object : ViewAction {
override fun getDescription(): String {
return "click on subview in RecyclerView at position: $position"
}
override fun getConstraints(): Matcher<View> {
return Matchers.allOf(
isAssignableFrom(
RecyclerView::class.java
), isDisplayed()
)
}
override fun perform(uiController: UiController?, view: View?) {
view?.findViewById<View>(subViewId)?.performClick()
}
}),
)
}
fun <VH : ViewHolder> clickOnRecyclerItemWithText(recyclerId: Int, text: String) {
matchView(recyclerId).perform(
// scrollTo will fail the test if no item matches.
RecyclerViewActions.scrollTo<VH>(
hasDescendant(withText(text))
),
RecyclerViewActions.actionOnItem<VH>(
hasDescendant(withText(text)),
click()
)
)
}
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())
}
fun selectDateInPicker(year: Int, month: Int, day: Int) {
onView(withClassName(equalTo(DatePicker::class.java.name))).perform(
PickerActions.setDate(
year,
month,
day
)
)
onView(
allOf(
withClassName(equalTo(AppCompatButton::class.java.name)),
withText("OK")
)
).perform(
click()
)
}
fun selectTextInSpinner(id: Int, text: String) {
clickButton(id)
onView(withSpinnerText(text)).perform(click())
}
fun selectTimeInPicker(hours: Int, minutes: Int) {
onView(withClassName(equalTo(TimePicker::class.java.name))).perform(
PickerActions.setTime(
hours, minutes
)
)
onView(
allOf(
withClassName(equalTo(AppCompatButton::class.java.name)),
withText("OK")
)
).perform(
click()
)
}
}

View File

@@ -0,0 +1,41 @@
package com.appttude.h_mal.farmr.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.ui.BaseTestRobot
fun addScreen(func: AddItemScreenRobot.() -> Unit) = AddItemScreenRobot().apply { func() }
class AddItemScreenRobot : BaseTestRobot() {
fun clickShiftType(type: ShiftType) {
when (type) {
ShiftType.HOURLY -> clickButton(R.id.hourly)
ShiftType.PIECE -> clickButton(R.id.piecerate)
}
}
fun setDescription(text: String?) = fillEditText(R.id.locationEditText, text)
fun setDate(year: Int, month: Int, day: Int) {
clickButton(R.id.dateEditText)
selectDateInPicker(year, month, day)
}
fun setTimeIn(hour: Int, minutes: Int) {
clickButton(R.id.timeInEditText)
selectTimeInPicker(hour, minutes)
}
fun setTimeOut(hour: Int, minutes: Int) {
clickButton(R.id.timeOutEditText)
selectTimeInPicker(hour, minutes)
}
fun setBreakTime(mins: Int) = fillEditText(R.id.breakEditText, mins.toString())
fun setUnits(units: Float) = fillEditText(R.id.unitET, units.toString())
fun setRateOfPay(rateOfPay: Float) = fillEditText(R.id.payrateET, rateOfPay.toString())
fun submit() = clickButton(R.id.submit)
fun assertTotalPay(pay: String) = matchText(R.id.totalpayval, pay)
fun assertDuration(duration: String) = matchText(R.id.ShiftDuration, duration)
}

View File

@@ -0,0 +1,29 @@
package com.appttude.h_mal.farmr.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.ui.BaseTestRobot
import com.appttude.h_mal.farmr.model.ShiftType
fun filterScreen(func: FilterScreenRobot.() -> Unit) = FilterScreenRobot().apply { func() }
class FilterScreenRobot : BaseTestRobot() {
fun setDescription(text: String?) = fillEditText(R.id.filterLocationEditText, text)
fun setDateIn(year: Int, month: Int, day: Int) {
clickButton(R.id.fromdateInEditText)
selectDateInPicker(year, month, day)
}
fun setDateOut(year: Int, month: Int, day: Int) {
clickButton(R.id.filterDateOutEditText)
selectDateInPicker(year, month, day)
}
fun setType(type: ShiftType?) = when(type) {
ShiftType.HOURLY -> selectTextInSpinner(R.id.TypeFilterEditText, type.type)
ShiftType.PIECE -> selectTextInSpinner(R.id.TypeFilterEditText, type.type)
null -> selectTextInSpinner(R.id.TypeFilterEditText, "")
}
fun submit() = clickButton(R.id.submitFiltered)
}

View File

@@ -0,0 +1,29 @@
package com.appttude.h_mal.farmr.ui.robots
import androidx.test.espresso.matcher.RootMatchers.isDialog
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.base.BaseRecyclerAdapter.CurrentViewHolder
import com.appttude.h_mal.farmr.model.Order
import com.appttude.h_mal.farmr.model.Sortable
import com.appttude.h_mal.farmr.ui.BaseTestRobot
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
class HomeScreenRobot : BaseTestRobot() {
fun clickOnItemWithText(text: String) = clickOnRecyclerItemWithText<CurrentViewHolder>(R.id.list_item_view, text)
fun clickOnItemAtPosition(position: Int) = clickRecyclerAtPosition<CurrentViewHolder>(R.id.list_item_view, position)
fun clickOnEdit(position: Int) = clickViewInRecyclerAtPosition<CurrentViewHolder>(R.id.list_item_view, position, R.id.imageView)
fun clickFab() = clickButton(R.id.fab1)
fun clickOnInfoIcon() = clickButton(R.id.action_favorite)
fun clickFilterInMenu() = clickOnMenuItem(R.string.filter)
fun clickClearFilterInMenu() = clickOnMenuItem(R.string.clear)
fun clickSortInMenu() = clickOnMenuItem(R.string.sort)
fun applySort(sortable: Sortable, order: Order = Order.ASCENDING) {
clickSortInMenu()
val label = sortable.label
clickDialogButton(label)
val orderLabel = order.label
clickDialogButton(orderLabel)
}
}

View File

@@ -0,0 +1,33 @@
package com.appttude.h_mal.farmr.ui.robots
import com.appttude.h_mal.farmr.R
import com.appttude.h_mal.farmr.ui.BaseTestRobot
import com.appttude.h_mal.farmr.model.ShiftType
fun viewScreen(func: ViewItemScreenRobot.() -> Unit) = ViewItemScreenRobot().apply { func() }
class ViewItemScreenRobot : BaseTestRobot() {
fun matchShiftType(type: ShiftType) {
when (type) {
ShiftType.HOURLY -> matchText(R.id.details_shift, type.type)
ShiftType.PIECE -> matchText(R.id.details_shift, type.type)
}
}
fun matchDescription(text: String) = matchText(R.id.details_desc, text)
fun matchDate(date: String) {
matchText(R.id.details_date, date)
}
fun matchTime(timeIn: String, timeOut: String) {
matchText(R.id.details_time, "$timeIn-$timeOut")
}
fun matchBreakTime(mins: Int) = matchText(R.id.details_breaks, mins.toString())
fun matchUnits(units: Float) = fillEditText(R.id.details_units, units.toString())
fun matchRateOfPay(rateOfPay: Float) = fillEditText(R.id.details_pay_rate, rateOfPay.toString())
fun matchTotalPay(pay: String) = matchText(R.id.details_totalpay, pay)
fun matchDuration(duration: String) = matchText(R.id.details_duration, duration)
fun clickEdit() = clickButton(R.id.details_edit)
}

View File

@@ -0,0 +1,142 @@
package com.appttude.h_mal.farmr.ui.tests
import com.appttude.h_mal.farmr.model.Order
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.Sortable
import com.appttude.h_mal.farmr.ui.BaseTest
import com.appttude.h_mal.farmr.ui.MainActivity
import com.appttude.h_mal.farmr.ui.robots.addScreen
import com.appttude.h_mal.farmr.ui.robots.filterScreen
import com.appttude.h_mal.farmr.ui.robots.homeScreen
import com.appttude.h_mal.farmr.ui.robots.viewScreen
import com.appttude.h_mal.farmr.utils.ID
import org.junit.Test
class ShiftTests : BaseTest<MainActivity>(MainActivity::class.java) {
override fun afterLaunch() {
super.afterLaunch()
addRandomShifts()
// Content resolver hard to mock
// Dirty technique to have a populated list
homeScreen {
clickFab()
navigateBack()
}
}
override fun testFinished() {
super.testFinished()
clearDataBase()
clearPrefs()
}
// Add a shift successfully
@Test
fun openAddScreen_addNewShift_newShiftCreated() {
homeScreen {
clickFab()
}
addScreen {
setDescription("This is a description")
setDate(2023, 2, 11)
clickShiftType(ShiftType.HOURLY)
setTimeIn(12, 0)
setTimeOut(14, 30)
setBreakTime(30)
setRateOfPay(10f)
assertDuration("2.0 hours")
assertTotalPay("£20.00")
submit()
}
homeScreen {
clickOnItemWithText("This is a description")
}
}
// Edit a shift successfully
@Test
fun test2() {
homeScreen {
clickOnEdit(0)
}
addScreen {
setDescription("Edited this shift")
setTimeIn(12, 0)
setTimeOut(14, 30)
setBreakTime(30)
setRateOfPay(20f)
assertDuration("2.0 hours")
assertTotalPay("£40.00")
submit()
}
homeScreen {
clickOnItemWithText("Edited this shift")
}
viewScreen {
matchDescription("Edited this shift")
matchDuration("2 Hours 0 Minutes (+ 30 minutes break)")
matchTotalPay("2.0 Hours @ £20.00 per Hour\nEquals: £40.00")
}
}
// filter the list with date from
@Test
fun test3() {
homeScreen {
applySort(Sortable.TYPE, Order.DESCENDING)
clickOnItemAtPosition(0)
viewScreen {
matchDescription("Day five")
matchShiftType(ShiftType.PIECE)
}
}
}
// filter the list with date to
@Test
fun test4() {
homeScreen {
clickFilterInMenu()
}
filterScreen {
setDateIn(2023,8,3)
setDateOut(2023,8,6)
submit()
}
homeScreen {
clickOnItemAtPosition(0)
}
}
// Add a shift as piece rate
@Test
fun test5() {
homeScreen {
clickFab()
}
addScreen {
setDescription("This is a description")
setDate(2023, 2, 11)
clickShiftType(ShiftType.PIECE)
setRateOfPay(10f)
setUnits(1f)
assertTotalPay("£10.00")
submit()
}
homeScreen {
clickOnItemWithText("This is a description")
}
}
// Validate the details screen
@Test
fun test6() {
}
// filter, sort, order and then reset
@Test
fun test7() {
}
}

View File

@@ -0,0 +1 @@
package com.appttude.h_mal.farmr.ui.utils

View File

@@ -0,0 +1,103 @@
package com.appttude.h_mal.farmr.ui.utils
import com.appttude.h_mal.farmr.model.Shift
import com.appttude.h_mal.farmr.model.ShiftType
fun getShifts() = listOf(
Shift(
ShiftType.HOURLY,
"Day one",
"2023-08-01",
"12:00",
"13:00",
1f,
0,
0f,
10f,
10f
),
Shift(
ShiftType.HOURLY,
"Day two",
"2023-08-02",
"12:00",
"13:00",
1f,
0,
0f,
10f,
10f
),
Shift(
ShiftType.HOURLY,
"Day three",
"2023-08-03",
"12:00",
"13:00",
1f,
30,
0f,
10f,
5f
),
Shift(
ShiftType.HOURLY,
"Day four",
"2023-08-04",
"12:00",
"13:00",
1f,
30,
0f,
10f,
5f
),
Shift(
ShiftType.PIECE,
"Day five",
"2023-08-05",
"",
"",
0f,
0,
1f,
10f,
10f
),
Shift(
ShiftType.PIECE,
"Day six",
"2023-08-06",
"",
"",
0f,
0,
1f,
10f,
10f
),
Shift(
ShiftType.PIECE,
"Day seven",
"2023-08-07",
"",
"",
0f,
0,
1f,
10f,
10f
),
Shift(
ShiftType.PIECE,
"Day eight",
"2023-08-08",
"",
"",
0f,
0,
1f,
10f,
10f
)
)

View File

@@ -0,0 +1,123 @@
package com.appttude.h_mal.farmr.ui.utils
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 child views
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,32 @@
package com.appttude.h_mal.farmr.ui.utils
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}

View File

@@ -1,26 +1,10 @@
package com.appttude.h_mal.farmr.base
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelLazy
import com.appttude.h_mal.farmr.utils.displayToast
import com.appttude.h_mal.farmr.utils.getGenericClassAt
import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
abstract class BaseActivity<V : BaseViewModel> : AppCompatActivity(), KodeinAware {
override val kodein by kodein()
private val factory by instance<ApplicationViewModelFactory>()
val viewModel: V by getViewModel()
private fun getViewModel(): Lazy<V> =
ViewModelLazy(getGenericClassAt(0), storeProducer = { viewModelStore },
factoryProducer = { factory } )
abstract class BaseActivity : AppCompatActivity() {
/**

View File

@@ -0,0 +1,31 @@
package com.appttude.h_mal.farmr.base
import android.app.Application
import com.appttude.h_mal.farmr.data.RepositoryImpl
import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
abstract class BaseApplication() : Application(), KodeinAware {
// Kodein creation of modules to be retrieve within the app
override val kodein = Kodein.lazy {
import(androidXModule(this@BaseApplication))
bind() from singleton { createDatabase() }
bind() from singleton { createPrefs() }
bind() from singleton { RepositoryImpl(instance(), instance()) }
bind() from provider { ApplicationViewModelFactory(instance()) }
}
abstract fun createDatabase(): LegacyDatabase
abstract fun createPrefs(): PreferenceProvider
}

View File

@@ -5,11 +5,13 @@ import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.Fragment
import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.ViewModelLazy
import com.appttude.h_mal.farmr.model.ViewState
import com.appttude.h_mal.farmr.utils.getGenericClassAt
import com.appttude.h_mal.farmr.utils.popBackStack
import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
import kotlin.properties.Delegates
@@ -21,14 +23,13 @@ abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int)
override val kodein by kodein()
private val factory by instance<ApplicationViewModelFactory>()
val viewModel: V by getActivityViewModel()
val viewModel: V by getViewModel()
private fun getActivityViewModel() = createViewModelLazy<V>(
getGenericClassAt(0),
{ requireActivity().viewModelStore },
{ factory })
private fun getViewModel(): Lazy<V> =
ViewModelLazy(getGenericClassAt(0), storeProducer = { viewModelStore },
factoryProducer = { factory } )
var mActivity: BaseActivity<*>? = null
var mActivity: BaseActivity? = null
private var shortAnimationDuration by Delegates.notNull<Int>()
@@ -39,7 +40,7 @@ abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mActivity = requireActivity() as BaseActivity<*>
mActivity = requireActivity() as BaseActivity
configureObserver()
}
@@ -75,7 +76,7 @@ abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int)
}
fun setTitle(title: String) {
(requireActivity() as BaseActivity<*>).setTitleInActionBar(title)
(requireActivity() as BaseActivity).setTitleInActionBar(title)
}
fun popBackStack() = mActivity?.popBackStack()

View File

@@ -3,12 +3,13 @@ package com.appttude.h_mal.farmr.data.legacydb
import android.content.ContentResolver
import android.net.Uri
import android.provider.BaseColumns
import com.appttude.h_mal.farmr.BuildConfig
/**
* Created by h_mal on 26/12/2017.
*/
object ShiftsContract {
const val CONTENT_AUTHORITY = "com.appttude.h_mal.farmr"
const val CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID
val BASE_CONTENT_URI = Uri.parse("content://$CONTENT_AUTHORITY")
const val PATH_SHIFTS = "shifts"

View File

@@ -13,8 +13,8 @@ const val SORT = "SORT"
const val ORDER = "ORDER"
const val DESCRIPTION = "DESCRIPTION"
const val TIME_IN = "TIME_IN"
const val TIME_OUT = "TIME_OUT"
const val DATE_IN = "TIME_IN"
const val DATE_OUT = "TIME_OUT"
const val TYPE = "TYPE"
class PreferenceProvider(
@@ -47,8 +47,8 @@ class PreferenceProvider(
) {
preference.edit()
.putString(DESCRIPTION, description)
.putString(TIME_IN, timeIn)
.putString(TIME_OUT, timeOut)
.putString(DATE_IN, timeIn)
.putString(DATE_OUT, timeOut)
.putString(TYPE, type)
.apply()
}
@@ -56,10 +56,14 @@ class PreferenceProvider(
fun getFilteringDetails(): Map<String, String?> {
return mapOf(
Pair(DESCRIPTION, preference.getString(DESCRIPTION, null)),
Pair(TIME_IN, preference.getString(TIME_IN, null)),
Pair(TIME_OUT, preference.getString(TIME_OUT, null)),
Pair(DATE_IN, preference.getString(DATE_IN, null)),
Pair(DATE_OUT, preference.getString(DATE_OUT, null)),
Pair(TYPE, preference.getString(TYPE, null))
)
}
fun clearPrefs() {
preference.edit().clear().apply()
}
}

View File

@@ -1,11 +1,10 @@
package com.appttude.h_mal.farmr.di
import android.app.Application
import com.appttude.h_mal.farmr.base.BaseApplication
import com.appttude.h_mal.farmr.data.RepositoryImpl
import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
import com.appttude.h_mal.farmr.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.androidXModule
@@ -14,15 +13,12 @@ import org.kodein.di.generic.instance
import org.kodein.di.generic.provider
import org.kodein.di.generic.singleton
class ShiftApplication: Application(), KodeinAware {
// Kodein creation of modules to be retrieve within the app
override val kodein = Kodein.lazy {
import(androidXModule(this@ShiftApplication))
class ShiftApplication: BaseApplication() {
bind() from singleton { LegacyDatabase(contentResolver) }
bind() from singleton { PreferenceProvider(this@ShiftApplication) }
bind() from singleton { RepositoryImpl(instance(), instance()) }
bind() from provider { ApplicationViewModelFactory(instance()) }
override fun createDatabase(): LegacyDatabase {
return LegacyDatabase(contentResolver)
}
}
override fun createPrefs() = PreferenceProvider(this)
}

View File

@@ -11,5 +11,9 @@ enum class Sortable(val label: String) {
companion object {
val entries = Sortable.values()
fun getEnumByType(label: String): Sortable {
return Sortable.values().first { it.label == label }
}
}
}

View File

@@ -14,9 +14,9 @@ import com.appttude.h_mal.farmr.base.BaseFragment
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.setDatePicker
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import com.appttude.h_mal.farmr.viewmodel.FilterViewModel
class FilterDataFragment : BaseFragment<MainViewModel>(R.layout.fragment_filter_data),
class FilterDataFragment : BaseFragment<FilterViewModel>(R.layout.fragment_filter_data),
AdapterView.OnItemSelectedListener, OnClickListener {
private val spinnerList: Array<String> =
arrayOf("", ShiftType.HOURLY.type, ShiftType.PIECE.type)
@@ -26,10 +26,10 @@ class FilterDataFragment : BaseFragment<MainViewModel>(R.layout.fragment_filter_
private lateinit var dateToET: EditText
private lateinit var typeSpinner: Spinner
private var description: String? = null
private var dateFrom: String? = null
private var dateTo: String? = null
private var type: String? = null
private var descriptionString: String? = null
private var dateFromString: String? = null
private var dateToString: String? = null
private var typeString: String? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -47,21 +47,29 @@ class FilterDataFragment : BaseFragment<MainViewModel>(R.layout.fragment_filter_
val filterDetails = viewModel.getFiltrationDetails()
filterDetails.let {
LocationET.setText(it.description)
dateFromET.setText(it.dateFrom)
dateToET.setText(it.dateTo)
it.type?.let { t ->
val spinnerPosition: Int = adapter.getPosition(t)
filterDetails.run {
description?.let {
LocationET.setText(it)
descriptionString = it
}
dateFrom?.let {
dateFromET.setText(it)
dateFromString = it
}
dateTo?.let {
dateToET.setText(it)
dateToString = it
}
type?.let {
typeString = it
val spinnerPosition: Int = adapter.getPosition(it)
typeSpinner.setSelection(spinnerPosition)
}
}
LocationET.doAfterTextChanged { description = it.toString() }
dateFromET.setDatePicker { dateFrom = it }
dateToET.setDatePicker { dateTo = it }
LocationET.doAfterTextChanged { descriptionString = it.toString() }
dateFromET.setDatePicker { dateFromString = it }
dateToET.setDatePicker { dateToString = it }
typeSpinner.onItemSelectedListener = this
submit.setOnClickListener(this)
@@ -73,7 +81,7 @@ class FilterDataFragment : BaseFragment<MainViewModel>(R.layout.fragment_filter_
position: Int,
id: Long
) {
type = when (position) {
typeString = when (position) {
1 -> ShiftType.HOURLY.type
2 -> ShiftType.PIECE.type
else -> return
@@ -83,7 +91,7 @@ class FilterDataFragment : BaseFragment<MainViewModel>(R.layout.fragment_filter_
override fun onNothingSelected(parentView: AdapterView<*>?) {}
private fun submitFiltrationDetails() {
viewModel.setFiltrationDetails(description, dateFrom, dateTo, type)
viewModel.applyFilters(descriptionString, dateFromString, dateToString, typeString)
}
override fun onClick(p0: View?) {

View File

@@ -18,6 +18,7 @@ import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.ID
import com.appttude.h_mal.farmr.utils.createDialog
import com.appttude.h_mal.farmr.utils.displayToast
import com.appttude.h_mal.farmr.utils.formatAsCurrencyString
import com.appttude.h_mal.farmr.utils.formatToTwoDpString
import com.appttude.h_mal.farmr.utils.hide
import com.appttude.h_mal.farmr.utils.popBackStack
@@ -26,8 +27,9 @@ import com.appttude.h_mal.farmr.utils.setTimePicker
import com.appttude.h_mal.farmr.utils.show
import com.appttude.h_mal.farmr.utils.validateField
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import com.appttude.h_mal.farmr.viewmodel.SubmissionViewModel
class FragmentAddItem : BaseFragment<MainViewModel>(R.layout.fragment_add_item),
class FragmentAddItem : BaseFragment<SubmissionViewModel>(R.layout.fragment_add_item),
RadioGroup.OnCheckedChangeListener, BackPressedListener {
private lateinit var mHourlyRadioButton: RadioButton
@@ -157,8 +159,8 @@ class FragmentAddItem : BaseFragment<MainViewModel>(R.layout.fragment_add_item),
mUnits = units
}
}
mPayRateEditText.setText(rateOfPay.formatToTwoDpString())
mTotalPayTextView.text = totalPay.formatToTwoDpString()
mPayRateEditText.setText(rateOfPay.formatAsCurrencyString())
mTotalPayTextView.text = totalPay.formatAsCurrencyString()
calculateTotalPay()
}
@@ -262,12 +264,11 @@ class FragmentAddItem : BaseFragment<MainViewModel>(R.layout.fragment_add_item),
StringBuilder().append(mDuration).append(" hours").toString()
mDuration!! * mPayRate
}
ShiftType.PIECE -> {
(mUnits ?: 0f) * mPayRate
}
}
mTotalPayTextView.text = total.formatToTwoDpString()
mTotalPayTextView.text = total.formatAsCurrencyString()
}
}

View File

@@ -70,7 +70,6 @@ class FragmentMain : BaseFragment<MainViewModel>(R.layout.fragment_main), BackPr
override fun onStart() {
super.onStart()
viewModel.refreshLiveData()
}
@@ -112,7 +111,7 @@ class FragmentMain : BaseFragment<MainViewModel>(R.layout.fragment_main), BackPr
}
R.id.clear_filter -> {
viewModel.setFiltrationDetails(null, null, null, null)
viewModel.clearFilters()
return true
}
@@ -156,7 +155,7 @@ class FragmentMain : BaseFragment<MainViewModel>(R.layout.fragment_main), BackPr
.setSingleChoiceItems(
groupName,
checkedItem
) { p0, p1 -> sort = Sortable.valueOf(groupName[p1]) }
) { p0, p1 -> sort = Sortable.getEnumByType(groupName[p1]) }
.setPositiveButton("Ascending") { dialog, id ->
viewModel.setSortAndOrder(sort)
dialog.dismiss()

View File

@@ -11,13 +11,14 @@ import com.appttude.h_mal.farmr.base.BaseFragment
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.utils.CURRENCY
import com.appttude.h_mal.farmr.utils.ID
import com.appttude.h_mal.farmr.utils.formatAsCurrencyString
import com.appttude.h_mal.farmr.utils.formatToTwoDpString
import com.appttude.h_mal.farmr.utils.hide
import com.appttude.h_mal.farmr.utils.navigateToFragment
import com.appttude.h_mal.farmr.utils.show
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import com.appttude.h_mal.farmr.viewmodel.InfoViewModel
class FurtherInfoFragment : BaseFragment<MainViewModel>(R.layout.fragment_futher_info) {
class FurtherInfoFragment : BaseFragment<InfoViewModel>(R.layout.fragment_futher_info) {
private lateinit var typeTV: TextView
private lateinit var descriptionTV: TextView
private lateinit var dateTV: TextView
@@ -52,60 +53,50 @@ class FurtherInfoFragment : BaseFragment<MainViewModel>(R.layout.fragment_futher
hourlyDetailHolder = view.findViewById(R.id.details_hourly_details)
unitsHolder = view.findViewById(R.id.details_units_holder)
val id = arguments!!.getLong(ID)
editButton.setOnClickListener {
navigateToFragment(FragmentAddItem(), name = "additem", bundle = arguments!!)
}
setupView(id)
viewModel.retrieveData(arguments)
}
private fun setupView(id: Long) {
viewModel.getCurrentShift(id)?.run {
typeTV.text = type
descriptionTV.text = description
dateTV.text = date
payRateTV.text = rateOfPay.toString()
totalPayTV.text = StringBuilder(CURRENCY).append(totalPay).toString()
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is ShiftObject) data.setupView()
}
when (ShiftType.getEnumByType(type)) {
ShiftType.HOURLY -> {
hourlyDetailHolder.show()
unitsHolder.hide()
times.text = StringBuilder(timeIn).append("-").append(timeOut).toString()
breakTV.text = StringBuilder(breakMins).append("mins").toString()
durationTV.text = buildDurationSummary(this)
val paymentSummary =
StringBuilder().append(duration).append(" Hours @ ").append(CURRENCY)
.append(rateOfPay).append(" per Hour").append("\n")
.append("Equals: ").append(CURRENCY).append(totalPay)
totalPayTV.text = paymentSummary
}
private fun ShiftObject.setupView() {
typeTV.text = type
descriptionTV.text = description
dateTV.text = date
payRateTV.text = rateOfPay.toString()
totalPayTV.text = StringBuilder(CURRENCY).append(totalPay).toString()
ShiftType.PIECE -> {
hourlyDetailHolder.hide()
unitsHolder.show()
unitsTV.text = units.toString()
when (ShiftType.getEnumByType(type)) {
ShiftType.HOURLY -> {
hourlyDetailHolder.show()
unitsHolder.hide()
times.text = StringBuilder(timeIn).append("-").append(timeOut).toString()
breakTV.text = StringBuilder().append(breakMins).append(" mins").toString()
durationTV.text = viewModel.buildDurationSummary(this)
val paymentSummary =
StringBuilder().append(duration).append(" Hours @ ")
.append(rateOfPay.formatAsCurrencyString()).append(" per Hour").append("\n")
.append("Equals: ").append(totalPay.formatAsCurrencyString())
totalPayTV.text = paymentSummary
}
val paymentSummary =
StringBuilder().append(units).append(" Units @ ").append(CURRENCY)
.append(rateOfPay).append(" per Unit").append("\n")
.append("Equals: ").append(CURRENCY).append(totalPay)
totalPayTV.text = paymentSummary
}
ShiftType.PIECE -> {
hourlyDetailHolder.hide()
unitsHolder.show()
unitsTV.text = units.toString()
val paymentSummary =
StringBuilder().append(units.formatAsCurrencyString()).append(" Units @ ")
.append(rateOfPay.formatAsCurrencyString()).append(" per Unit").append("\n")
.append("Equals: ").append(totalPay.formatAsCurrencyString())
totalPayTV.text = paymentSummary
}
}
}
private fun buildDurationSummary(shiftObject: ShiftObject): String {
val time = shiftObject.getHoursMinutesPairFromDuration()
val stringBuilder = StringBuilder().append(time.first).append(" Hours ").append(time.second)
.append(" Minutes ")
if (shiftObject.breakMins > 0) {
stringBuilder.append(" (+ ").append(shiftObject.breakMins).append(" minutes break)")
}
return stringBuilder.toString()
}
}

View File

@@ -19,7 +19,7 @@ import com.appttude.h_mal.farmr.utils.popBackStack
import com.appttude.h_mal.farmr.viewmodel.MainViewModel
import kotlin.system.exitProcess
class MainActivity : BaseActivity<MainViewModel>() {
class MainActivity : BaseActivity() {
private lateinit var toolbar: Toolbar
override fun onCreate(savedInstanceState: Bundle?) {

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.RelativeLayout
import androidx.core.app.ActivityOptionsCompat
@@ -16,8 +17,7 @@ class SplashScreen : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
val bundle = ActivityOptionsCompat.makeCustomAnimation(this, R.anim.hyperspace_jump, android.R.anim.fade_out).toBundle()
val relativeLayout = findViewById<View>(R.id.splash_layout) as RelativeLayout
val i = Intent(this@SplashScreen, MainActivity::class.java)
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK)
Handler().postDelayed({
@@ -27,11 +27,11 @@ class SplashScreen : Activity() {
startActivity(i)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
// finish();
}, SPLASH_TIME_OUT.toLong())
}, SPLASH_TIME_OUT)
}
companion object {
// Splash screen timer
private const val SPLASH_TIME_OUT = 2000
const val SPLASH_TIME_OUT: Long = 2000
}
}

View File

@@ -1,8 +1,10 @@
package com.appttude.h_mal.farmr.utils
import java.io.IOException
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Currency
import java.util.Date
import java.util.Locale
@@ -16,6 +18,14 @@ fun Float.formatToTwoDp(): Float {
return formattedString.toFloat()
}
fun Float.formatAsCurrencyString(): String? {
val format: NumberFormat = NumberFormat.getCurrencyInstance()
format.maximumFractionDigits = 2
format.currency = Currency.getInstance("GBP")
return format.format(this)
}
fun Float.formatToTwoDpString(): String {
return formatToTwoDp().toString()
}

View File

@@ -14,6 +14,9 @@ class ApplicationViewModelFactory(
with(modelClass) {
return when {
isAssignableFrom(MainViewModel::class.java) -> MainViewModel(repository)
isAssignableFrom(SubmissionViewModel::class.java) -> SubmissionViewModel(repository)
isAssignableFrom(InfoViewModel::class.java) -> InfoViewModel(repository)
isAssignableFrom(FilterViewModel::class.java) -> FilterViewModel(repository)
else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T
}

View File

@@ -0,0 +1,21 @@
package com.appttude.h_mal.farmr.viewmodel
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.model.Success
class FilterViewModel(
repository: Repository
) : ShiftViewModel(repository) {
fun applyFilters(
description: String?,
dateFrom: String?,
dateTo: String?,
type: String?
) {
super.setFiltrationDetails(description, dateFrom, dateTo, type)
onSuccess(Success("Filter(s) have been applied"))
}
}

View File

@@ -0,0 +1,40 @@
package com.appttude.h_mal.farmr.viewmodel
import android.os.Bundle
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.utils.ID
class InfoViewModel(
repository: Repository
) : ShiftViewModel(repository) {
fun retrieveData(bundle: Bundle?) {
val id = bundle?.getLong(ID)
if (id == null) {
onError("Failed to retrieve shift")
return
}
val shift = getCurrentShift(id)
if (shift == null) {
onError("Failed to retrieve shift")
return
}
onSuccess(shift)
}
fun buildDurationSummary(shiftObject: ShiftObject): String {
val time = shiftObject.getHoursMinutesPairFromDuration()
val stringBuilder = StringBuilder().append(time.first).append(" Hours ").append(time.second)
.append(" Minutes ")
if (shiftObject.breakMins > 0) {
stringBuilder.append(" (+ ").append(shiftObject.breakMins).append(" minutes break)")
}
return stringBuilder.toString()
}
}

View File

@@ -1,13 +1,10 @@
package com.appttude.h_mal.farmr.viewmodel
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.os.Build
import android.os.Environment
import androidx.annotation.RequiresPermission
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.appttude.h_mal.farmr.base.BaseViewModel
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_SHIFT_BREAK
@@ -21,24 +18,14 @@ import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_SHIFT_TYPE
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry.COLUMN_SHIFT_UNIT
import com.appttude.h_mal.farmr.data.legacydb.ShiftsContract.ShiftsEntry._ID
import com.appttude.h_mal.farmr.data.prefs.DESCRIPTION
import com.appttude.h_mal.farmr.data.prefs.TIME_IN
import com.appttude.h_mal.farmr.data.prefs.TIME_OUT
import com.appttude.h_mal.farmr.data.prefs.TYPE
import com.appttude.h_mal.farmr.model.FilterStore
import com.appttude.h_mal.farmr.model.Order
import com.appttude.h_mal.farmr.model.Shift
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.Sortable
import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.CURRENCY
import com.appttude.h_mal.farmr.utils.calculateDuration
import com.appttude.h_mal.farmr.utils.convertDateString
import com.appttude.h_mal.farmr.utils.dateStringIsValid
import com.appttude.h_mal.farmr.utils.formatToTwoDp
import com.appttude.h_mal.farmr.utils.getTimeString
import com.appttude.h_mal.farmr.utils.formatAsCurrencyString
import com.appttude.h_mal.farmr.utils.sortedByOrder
import com.appttude.h_mal.farmr.utils.timeStringIsValid
import jxl.Workbook
import jxl.WorkbookSettings
import jxl.write.Label
@@ -46,25 +33,24 @@ import jxl.write.WritableWorkbook
import jxl.write.WriteException
import java.io.File
import java.io.IOException
import java.util.Calendar
import java.util.Locale
class MainViewModel(
private val repository: Repository
) : BaseViewModel() {
) : ShiftViewModel(repository) {
private val _shiftLiveData = MutableLiveData<List<ShiftObject>>()
val shiftLiveData: LiveData<List<ShiftObject>> = _shiftLiveData
private val shiftLiveData: LiveData<List<ShiftObject>> = _shiftLiveData
private var mSort: Sortable = Sortable.ID
private var mOrder: Order = Order.ASCENDING
private var mFilterStore: FilterStore? = null
private val observer = Observer<List<ShiftObject>> {
val result = it.applyFilters().sortList(mSort, mOrder)
onSuccess(result)
it?.let {
val result = it.applyFilters().sortList(mSort, mOrder)
onSuccess(result)
}
}
init {
@@ -148,8 +134,9 @@ class MainViewModel(
var countOfTypeP = 0
var totalUnits = 0f
var totalPay = 0f
val lines = _shiftLiveData.value?.size ?: 0
_shiftLiveData.value?.forEach {
var lines = 0
_shiftLiveData.value?.applyFilters()?.forEach {
lines += 1
totalDuration += it.duration
when (ShiftType.getEnumByType(it.type)) {
ShiftType.HOURLY -> countOfTypeH += 1
@@ -169,161 +156,6 @@ class MainViewModel(
)
}
fun getCurrentShift(id: Long) = repository.readSingleShiftFromDatabase(id)
fun insertHourlyShift(
description: String,
date: String,
rateOfPay: Float,
timeIn: String?,
timeOut: String?,
breakMins: Int?,
) {
// Validate inputs from the edit texts
(description.length > 3).validateField {
onError("Description length should be longer")
return
}
date.dateStringIsValid().validateField {
onError("Date format is invalid")
return
}
(rateOfPay >= 0.00).validateField {
onError("Rate of pay is invalid")
return
}
timeIn?.timeStringIsValid()?.validateField {
onError("Time in format is in correct")
return
}
timeOut?.timeStringIsValid()?.validateField {
onError("Time out format is in correct")
return
}
breakMins?.let { it > 0 }?.validateField {
onError("Break in minutes is invalid")
return
}
doTry {
val result = insertShiftIntoDatabase(
ShiftType.HOURLY,
description,
date,
rateOfPay.formatToTwoDp(),
timeIn,
timeOut,
breakMins,
null
)
if (result) onSuccess(Success("Shift successfully added"))
}
}
fun insertPieceRateShift(
description: String,
date: String,
units: Float,
rateOfPay: Float
) {
// Validate inputs from the edit texts
(description.length > 3).validateField {
onError("Description length should be longer")
return
}
date.dateStringIsValid().validateField {
onError("Date format is invalid")
return
}
(rateOfPay >= 0.00).validateField {
onError("Rate of pay is invalid")
return
}
(units.toInt() >= 0).validateField {
onError("Units cannot be below zero")
return
}
doTry {
val result = insertShiftIntoDatabase(
type = ShiftType.PIECE,
description = description,
date = date,
rateOfPay = rateOfPay.formatToTwoDp(),
null,
null,
null,
units = units
)
if (result) onSuccess(Success("New shift successfully added"))
}
}
fun updateShift(
id: Long,
type: String? = null,
description: String? = null,
date: String? = null,
rateOfPay: Float? = null,
timeIn: String? = null,
timeOut: String? = null,
breakMins: Int? = null,
units: Float? = null,
) {
description?.let {
(it.length > 3).validateField {
onError("Description length should be longer")
return
}
}
date?.dateStringIsValid()?.validateField {
onError("Date format is invalid")
return
}
rateOfPay?.let {
(it >= 0.00).validateField {
onError("Rate of pay is invalid")
return
}
}
units?.let {
(it.toInt() >= 0).validateField {
onError("Units cannot be below zero")
return
}
}
timeIn?.timeStringIsValid()?.validateField {
onError("Time in format is in correct")
return
}
timeOut?.timeStringIsValid()?.validateField {
onError("Time out format is in correct")
return
}
breakMins?.let { it >= 0 }?.validateField {
onError("Break in minutes is invalid")
return
}
doTry {
val result = updateShiftInDatabase(
id,
type = type?.let { ShiftType.getEnumByType(it) },
description = description,
date = date,
rateOfPay = rateOfPay,
timeIn = timeIn,
timeOut = timeOut,
breakMins = breakMins,
units = units
)
if (result) onSuccess(Success("Shift successfully updated"))
}
}
fun deleteShift(id: Long) {
if (!repository.deleteSingleShiftFromDatabase(id)) {
onError("Failed to delete shift")
@@ -340,134 +172,6 @@ class MainViewModel(
}
}
private fun updateShiftInDatabase(
id: Long,
type: ShiftType? = null,
description: String? = null,
date: String? = null,
rateOfPay: Float? = null,
timeIn: String? = null,
timeOut: String? = null,
breakMins: Int? = null,
units: Float? = null,
): Boolean {
val currentShift = repository.readSingleShiftFromDatabase(id)?.copyToShift()
?: throw IOException("Cannot update shift as it does not exist")
val shift = when (type) {
ShiftType.HOURLY -> {
// Shift type has changed so mandatory fields for hourly shift are now required as well
val insertTimeIn =
(timeIn ?: currentShift.timeIn) ?: throw IOException("No time in inserted")
val insertTimeOut =
(timeOut ?: currentShift.timeOut) ?: throw IOException("No time out inserted")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
timeIn = insertTimeIn,
timeOut = insertTimeOut,
breakMins = breakMins ?: currentShift.breakMins,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
ShiftType.PIECE -> {
// Shift type has changed so mandatory fields for piece rate shift are now required as well
val insertUnits = (units ?: currentShift.units)
?: throw IOException("Units must be inserted for piece rate shifts")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
units = insertUnits,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
else -> {
if (timeIn == null && timeOut == null && units == null && breakMins == null && rateOfPay == null) {
// Updates to description or date field
currentShift.copy(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
)
} else {
// Updating shifts where shift type has remained the same
when (currentShift.type) {
ShiftType.HOURLY -> {
val insertTimeIn = (timeIn ?: currentShift.timeIn) ?: throw IOException(
"No time in inserted"
)
val insertTimeOut = (timeOut ?: currentShift.timeOut)
?: throw IOException("No time out inserted")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
timeIn = insertTimeIn,
timeOut = insertTimeOut,
breakMins = breakMins ?: currentShift.breakMins,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
ShiftType.PIECE -> {
val insertUnits = (units ?: currentShift.units)
?: throw IOException("Units must be inserted for piece rate shifts")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
units = insertUnits,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
}
}
}
}
return repository.updateShiftIntoDatabase(id, shift)
}
private fun insertShiftIntoDatabase(
type: ShiftType,
description: String,
date: String,
rateOfPay: Float,
timeIn: String?,
timeOut: String?,
breakMins: Int?,
units: Float?,
): Boolean {
val shift = when (type) {
ShiftType.HOURLY -> {
if (timeIn.isNullOrBlank() && timeOut.isNullOrBlank()) throw IOException("Time in and time out are null")
val calendar by lazy { Calendar.getInstance() }
val insertTimeIn = timeIn ?: calendar.getTimeString()
val insertTimeOut = timeOut ?: calendar.getTimeString()
Shift(
description = description,
date = date,
timeIn = insertTimeIn,
timeOut = insertTimeOut,
breakMins = breakMins,
rateOfPay = rateOfPay
)
}
ShiftType.PIECE -> {
Shift(
description = description,
date = date,
units = units!!,
rateOfPay = rateOfPay,
)
}
}
return repository.insertShiftIntoDatabase(shift)
}
private fun buildInfoString(
totalDuration: Float,
countOfHourly: Int,
@@ -488,63 +192,21 @@ class MainViewModel(
stringBuilder.append("Total Units: ").append(totalUnits).append("\n")
}
if (totalPay != 0f) {
stringBuilder.append("Total Pay: ").append(CURRENCY).append(totalPay).append("\n")
stringBuilder.append("Total Pay: ").append(totalPay.formatAsCurrencyString())
}
return stringBuilder.toString()
}
fun refreshLiveData() {
_shiftLiveData.postValue(repository.readShiftsFromDatabase())
repository.readShiftsFromDatabase()?.let { _shiftLiveData.postValue(it) }
}
private inline fun Boolean.validateField(failureCallback: () -> Unit) {
if (!this) failureCallback.invoke()
}
/**
* Lambda function that will invoke onError(...) on failure
* but update live data when successful
*/
private inline fun doTry(operation: () -> Unit) {
try {
operation.invoke()
refreshLiveData()
} catch (e: Exception) {
onError(e)
}
}
fun setFiltrationDetails(
description: String?,
dateFrom: String?,
dateTo: String?,
type: String?
) {
repository.setFilteringDetailsInPrefs(description, dateFrom, dateTo, type)
onSuccess(Success("Filter(s) successfully applied"))
fun clearFilters() {
super.setFiltrationDetails(null, null, null, null)
onSuccess(Success("Filters have been cleared"))
refreshLiveData()
}
fun getFiltrationDetails(): FilterStore {
val prefs = repository.retrieveFilteringDetailsInPrefs()
mFilterStore = FilterStore(
prefs[DESCRIPTION],
prefs[TIME_IN],
prefs[TIME_OUT],
prefs[TYPE]
)
return mFilterStore!!
}
fun retrieveDurationText(mTimeIn: String?, mTimeOut: String?, mBreaks: Int?): Float? {
try {
return calculateDuration(mTimeIn, mTimeOut, mBreaks)
} catch (e: IOException) {
onError(e)
}
return null
}
@RequiresPermission(WRITE_EXTERNAL_STORAGE)
fun createExcelSheet(file: File): File? {
val wbSettings = WorkbookSettings().apply {
@@ -574,7 +236,8 @@ class MainViewModel(
return null
}
val sortAndOrder = getSortAndOrder()
val data = shiftLiveData.value!!.applyFilters().sortList(sortAndOrder.first, sortAndOrder.second)
val data = shiftLiveData.value!!.applyFilters()
.sortList(sortAndOrder.first, sortAndOrder.second)
var currentRow = 0
val cells = data.mapIndexed { index, shift ->
currentRow += 1

View File

@@ -0,0 +1,52 @@
package com.appttude.h_mal.farmr.viewmodel
import com.appttude.h_mal.farmr.base.BaseViewModel
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.data.prefs.DESCRIPTION
import com.appttude.h_mal.farmr.data.prefs.DATE_IN
import com.appttude.h_mal.farmr.data.prefs.DATE_OUT
import com.appttude.h_mal.farmr.data.prefs.TYPE
import com.appttude.h_mal.farmr.model.FilterStore
open class ShiftViewModel(
private val repository: Repository
) : BaseViewModel() {
/*
* Add Item & Further info
*/
fun getCurrentShift(id: Long) = repository.readSingleShiftFromDatabase(id)
/**
* Lambda function that will invoke onError(...) on failure
* but update live data when successful
*/
private inline fun doTry(operation: () -> Unit) {
try {
operation.invoke()
} catch (e: Exception) {
onError(e)
}
}
open fun setFiltrationDetails(
description: String?,
dateFrom: String?,
dateTo: String?,
type: String?
) {
repository.setFilteringDetailsInPrefs(description, dateFrom, dateTo, type)
}
open fun getFiltrationDetails(): FilterStore {
val prefs = repository.retrieveFilteringDetailsInPrefs()
return FilterStore(
prefs[DESCRIPTION],
prefs[DATE_IN],
prefs[DATE_OUT],
prefs[TYPE]
)
}
}

View File

@@ -0,0 +1,308 @@
package com.appttude.h_mal.farmr.viewmodel
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.model.Shift
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.Success
import com.appttude.h_mal.farmr.utils.calculateDuration
import com.appttude.h_mal.farmr.utils.dateStringIsValid
import com.appttude.h_mal.farmr.utils.formatToTwoDp
import com.appttude.h_mal.farmr.utils.getTimeString
import com.appttude.h_mal.farmr.utils.timeStringIsValid
import java.io.IOException
import java.util.Calendar
class SubmissionViewModel(
private val repository: Repository
) : ShiftViewModel(repository) {
fun insertHourlyShift(
description: String,
date: String,
rateOfPay: Float,
timeIn: String?,
timeOut: String?,
breakMins: Int?,
) {
// Validate inputs from the edit texts
(description.length > 3).validateField {
onError("Description length should be longer")
return
}
date.dateStringIsValid().validateField {
onError("Date format is invalid")
return
}
(rateOfPay >= 0.00).validateField {
onError("Rate of pay is invalid")
return
}
timeIn?.timeStringIsValid()?.validateField {
onError("Time in format is in correct")
return
}
timeOut?.timeStringIsValid()?.validateField {
onError("Time out format is in correct")
return
}
breakMins?.let { it >= 0 }?.validateField {
onError("Break in minutes is invalid")
return
}
val result = insertShiftIntoDatabase(
ShiftType.HOURLY,
description,
date,
rateOfPay.formatToTwoDp(),
timeIn,
timeOut,
breakMins,
null
)
if (result) onSuccess(Success("New shift successfully added"))
else onError("Cannot insert shift")
}
fun insertPieceRateShift(
description: String,
date: String,
units: Float,
rateOfPay: Float
) {
// Validate inputs from the edit texts
(description.length > 3).validateField {
onError("Description length should be longer")
return
}
date.dateStringIsValid().validateField {
onError("Date format is invalid")
return
}
(rateOfPay >= 0.00).validateField {
onError("Rate of pay is invalid")
return
}
(units.toInt() >= 0).validateField {
onError("Units cannot be below zero")
return
}
val result = insertShiftIntoDatabase(
type = ShiftType.PIECE,
description = description,
date = date,
rateOfPay = rateOfPay.formatToTwoDp(),
null,
null,
null,
units = units
)
if (result) onSuccess(Success("New shift successfully added"))
else onError("Cannot insert shift")
}
fun updateShift(
id: Long,
type: String? = null,
description: String? = null,
date: String? = null,
rateOfPay: Float? = null,
timeIn: String? = null,
timeOut: String? = null,
breakMins: Int? = null,
units: Float? = null,
) {
description?.let {
(it.length > 3).validateField {
onError("Description length should be longer")
return
}
}
date?.dateStringIsValid()?.validateField {
onError("Date format is invalid")
return
}
rateOfPay?.let {
(it >= 0.00).validateField {
onError("Rate of pay is invalid")
return
}
}
units?.let {
(it.toInt() >= 0).validateField {
onError("Units cannot be below zero")
return
}
}
timeIn?.timeStringIsValid()?.validateField {
onError("Time in format is in correct")
return
}
timeOut?.timeStringIsValid()?.validateField {
onError("Time out format is in correct")
return
}
breakMins?.let { it >= 0 }?.validateField {
onError("Break in minutes is invalid")
return
}
val result = updateShiftInDatabase(
id,
type = type?.let { ShiftType.getEnumByType(it) },
description = description,
date = date,
rateOfPay = rateOfPay,
timeIn = timeIn,
timeOut = timeOut,
breakMins = breakMins,
units = units
)
if (result) onSuccess(Success("Shift successfully updated"))
else onError("Cannot update shift")
}
private fun updateShiftInDatabase(
id: Long,
type: ShiftType? = null,
description: String? = null,
date: String? = null,
rateOfPay: Float? = null,
timeIn: String? = null,
timeOut: String? = null,
breakMins: Int? = null,
units: Float? = null,
): Boolean {
val currentShift = repository.readSingleShiftFromDatabase(id)?.copyToShift()
?: throw IOException("Cannot update shift as it does not exist")
val shift = when (type) {
ShiftType.HOURLY -> {
// Shift type has changed so mandatory fields for hourly shift are now required as well
val insertTimeIn =
(timeIn ?: currentShift.timeIn) ?: throw IOException("No time in inserted")
val insertTimeOut =
(timeOut ?: currentShift.timeOut) ?: throw IOException("No time out inserted")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
timeIn = insertTimeIn,
timeOut = insertTimeOut,
breakMins = breakMins ?: currentShift.breakMins,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
ShiftType.PIECE -> {
// Shift type has changed so mandatory fields for piece rate shift are now required as well
val insertUnits = (units ?: currentShift.units)
?: throw IOException("Units must be inserted for piece rate shifts")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
units = insertUnits,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
else -> {
if (timeIn == null && timeOut == null && units == null && breakMins == null && rateOfPay == null) {
// Updates to description or date field
currentShift.copy(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
)
} else {
// Updating shifts where shift type has remained the same
when (currentShift.type) {
ShiftType.HOURLY -> {
val insertTimeIn = (timeIn ?: currentShift.timeIn) ?: throw IOException(
"No time in inserted"
)
val insertTimeOut = (timeOut ?: currentShift.timeOut)
?: throw IOException("No time out inserted")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
timeIn = insertTimeIn,
timeOut = insertTimeOut,
breakMins = breakMins ?: currentShift.breakMins,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
ShiftType.PIECE -> {
val insertUnits = (units ?: currentShift.units)
?: throw IOException("Units must be inserted for piece rate shifts")
Shift(
description = description ?: currentShift.description,
date = date ?: currentShift.date,
units = insertUnits,
rateOfPay = rateOfPay ?: currentShift.rateOfPay
)
}
}
}
}
}
return repository.updateShiftIntoDatabase(id, shift)
}
private fun insertShiftIntoDatabase(
type: ShiftType,
description: String,
date: String,
rateOfPay: Float,
timeIn: String?,
timeOut: String?,
breakMins: Int?,
units: Float?,
): Boolean {
val shift = when (type) {
ShiftType.HOURLY -> {
if (timeIn.isNullOrBlank() && timeOut.isNullOrBlank()) throw IOException("Time in and time out are null")
val calendar by lazy { Calendar.getInstance() }
val insertTimeIn = timeIn ?: calendar.getTimeString()
val insertTimeOut = timeOut ?: calendar.getTimeString()
Shift(
description = description,
date = date,
timeIn = insertTimeIn,
timeOut = insertTimeOut,
breakMins = breakMins,
rateOfPay = rateOfPay
)
}
ShiftType.PIECE -> {
Shift(
description = description,
date = date,
units = units!!,
rateOfPay = rateOfPay,
)
}
}
return repository.insertShiftIntoDatabase(shift)
}
private inline fun Boolean.validateField(failureCallback: () -> Unit) {
if (!this) failureCallback.invoke()
}
fun retrieveDurationText(mTimeIn: String?, mTimeOut: String?, mBreaks: Int?): Float? {
try {
return calculateDuration(mTimeIn, mTimeOut, mBreaks)
} catch (e: IOException) {
onError(e)
}
return null
}
}

View File

@@ -1,17 +0,0 @@
package com.appttude.h_mal.farmr;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@@ -0,0 +1,52 @@
package com.appttude.h_mal.farmr.data
import com.appttude.h_mal.farmr.data.legacydb.LegacyDatabase
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.data.prefs.PreferenceProvider
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers.anyLong
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertIs
class RepositoryImplTest {
private lateinit var repository: RepositoryImpl
@MockK
lateinit var db: LegacyDatabase
@MockK
lateinit var prefs: PreferenceProvider
@Before
fun setUp() {
MockKAnnotations.init(this)
repository = RepositoryImpl(db, prefs)
}
@Test
fun readDatabase_validResponse() {
// Arrange
val elements = listOf<ShiftObject>(
mockk { every { id } returns anyLong() },
mockk { every { id } returns anyLong() },
mockk { every { id } returns anyLong() },
mockk { every { id } returns anyLong() }
)
//Act
every { db.readShiftsFromDatabase() } returns elements
// Assert
val result = repository.readShiftsFromDatabase()
assertIs<List<ShiftObject>>(result)
assertEquals(result.first().id, anyLong())
}
}

View File

@@ -0,0 +1,39 @@
package com.appttude.h_mal.farmr.utils
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
fun sleep(millis: Long = 1000) {
runBlocking(Dispatchers.Default) { delay(millis) }
}

View File

@@ -0,0 +1,239 @@
package com.appttude.h_mal.farmr.viewmodel
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.data.prefs.DATE_IN
import com.appttude.h_mal.farmr.data.prefs.DATE_OUT
import com.appttude.h_mal.farmr.data.prefs.DESCRIPTION
import com.appttude.h_mal.farmr.data.prefs.TYPE
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.ViewState
import com.appttude.h_mal.farmr.utils.getOrAwaitValue
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.ArgumentMatchers.anyFloat
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
import java.util.concurrent.TimeoutException
import kotlin.test.assertEquals
class MainViewModelTest {
@get:Rule
val rule = InstantTaskExecutorRule()
private lateinit var repository: Repository
private lateinit var viewModel: MainViewModel
@Before
fun setUp() {
repository = mockk()
every { repository.readShiftsFromDatabase() }.returns(null)
every { repository.retrieveFilteringDetailsInPrefs() }.returns(getFilter())
viewModel = MainViewModel(repository)
}
@Test
fun initViewModel_liveDataIsEmpty() {
// Assert
assertThrows(TimeoutException::class.java) { viewModel.uiState.getOrAwaitValue() }
}
@Test
fun getShiftsFromRepository_liveDataIsShown() {
// Arrange
val listOfShifts = anyList<ShiftObject>()
// Act
every { repository.readShiftsFromDatabase() }.returns(listOfShifts)
viewModel.refreshLiveData()
// Assert
assertEquals(retrieveCurrentData(), listOfShifts)
}
@Test
fun getShiftsFromRepository_liveDataIsShown_defaultFiltersAndSortsValid() {
// Arrange
val listOfShifts = getShifts()
// Act
every { repository.readShiftsFromDatabase() }.returns(listOfShifts)
viewModel.refreshLiveData()
val retrievedShifts = retrieveCurrentData()
val description = viewModel.getInformation()
// Assert
assertEquals(retrievedShifts, listOfShifts)
assertEquals(
description, "8 Shifts\n" +
" (4 Hourly/4 Piece Rate)\n" +
"Total Hours: 4.0\n" +
"Total Units: 4.0\n" +
"Total Pay: £70.00"
)
}
@Test
fun getShiftsFromRepository_applyFiltersThenClearFilters_descriptionIsValid() {
// Arrange
val listOfShifts = getShifts()
val filteredShifts = getShifts().filter { it.type == ShiftType.HOURLY.type }
// Act
every { repository.readShiftsFromDatabase() }.returns(listOfShifts)
every { repository.retrieveFilteringDetailsInPrefs() }.returns(getFilter(type = ShiftType.HOURLY.type))
viewModel.refreshLiveData()
val retrievedShifts = retrieveCurrentData()
val description = viewModel.getInformation()
every { repository.setFilteringDetailsInPrefs(null, null, null, null) }.returns(Unit)
every { repository.retrieveFilteringDetailsInPrefs() }.returns(getFilter())
viewModel.clearFilters()
val descriptionAfterClearedFilter = viewModel.getInformation()
// Assert
assertEquals(retrievedShifts, filteredShifts)
assertEquals(
description, "4 Shifts\n" +
"Total Hours: 4.0\n" +
"Total Pay: £30.00"
)
assertEquals(
descriptionAfterClearedFilter, "8 Shifts\n" +
" (4 Hourly/4 Piece Rate)\n" +
"Total Hours: 4.0\n" +
"Total Units: 4.0\n" +
"Total Pay: £70.00"
)
}
private fun retrieveCurrentData() =
(viewModel.uiState.getOrAwaitValue() as ViewState.HasData<*>).data
private fun getFilter(
description: String? = null,
type: String? = null,
dateIn: String? = null,
dateOut: String? = null
): Map<String, String?> =
mapOf(
Pair(DESCRIPTION, description),
Pair(DATE_IN, dateIn),
Pair(DATE_OUT, dateOut),
Pair(TYPE, type)
)
private fun getShifts() = listOf(
ShiftObject(
anyLong(),
ShiftType.HOURLY.type,
"Day one",
"2023-08-01",
"12:00",
"13:00",
1f,
anyInt(),
anyFloat(),
10f,
10f
),
ShiftObject(
anyLong(),
ShiftType.HOURLY.type,
"Day two",
"2023-08-02",
"12:00",
"13:00",
1f,
anyInt(),
anyFloat(),
10f,
10f
),
ShiftObject(
anyLong(),
ShiftType.HOURLY.type,
"Day three",
"2023-08-03",
"12:00",
"13:00",
1f,
30,
anyFloat(),
10f,
5f
),
ShiftObject(
anyLong(),
ShiftType.HOURLY.type,
"Day four",
"2023-08-04",
"12:00",
"13:00",
1f,
30,
anyFloat(),
10f,
5f
),
ShiftObject(
anyLong(),
ShiftType.PIECE.type,
"Day five",
"2023-08-05",
anyString(),
anyString(),
anyFloat(),
anyInt(),
1f,
10f,
10f
),
ShiftObject(
anyLong(),
ShiftType.PIECE.type,
"Day six",
"2023-08-06",
anyString(),
anyString(),
anyFloat(),
anyInt(),
1f,
10f,
10f
),
ShiftObject(
anyLong(),
ShiftType.PIECE.type,
"Day seven",
"2023-08-07",
anyString(),
anyString(),
anyFloat(),
anyInt(),
1f,
10f,
10f
),
ShiftObject(
anyLong(),
ShiftType.PIECE.type,
"Day eight",
"2023-08-08",
anyString(),
anyString(),
anyFloat(),
anyInt(),
1f,
10f,
10f
),
)
}

View File

@@ -0,0 +1,171 @@
package com.appttude.h_mal.farmr.viewmodel
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.appttude.h_mal.farmr.data.Repository
import com.appttude.h_mal.farmr.data.legacydb.ShiftObject
import com.appttude.h_mal.farmr.model.ShiftType
import com.appttude.h_mal.farmr.model.ViewState
import com.appttude.h_mal.farmr.utils.getOrAwaitValue
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.RelaxedMockK
import org.junit.Before
import org.junit.Rule
import org.mockito.ArgumentMatchers
open class ShiftViewModelTest<V : ShiftViewModel> {
@get:Rule
val rule = InstantTaskExecutorRule()
@RelaxedMockK
lateinit var repository: Repository
@InjectMockKs
lateinit var viewModel: V
@Before
fun setUp() {
MockKAnnotations.init(this)
}
fun retrieveCurrentData() =
(viewModel.uiState.getOrAwaitValue() as ViewState.HasData<*>).data
fun retrieveCurrentError() =
(viewModel.uiState.getOrAwaitValue() as ViewState.HasError<*>).error
fun getHourlyShift() = ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.HOURLY.type,
"Day one",
"2023-08-01",
"12:00",
"13:00",
1f,
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyFloat(),
10f,
10f
)
fun getPieceRateShift() = ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.PIECE.type,
"Day five",
"2023-08-05",
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyFloat(),
ArgumentMatchers.anyInt(),
1f,
10f,
10f
)
fun getShifts() = listOf(
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.HOURLY.type,
"Day one",
"2023-08-01",
"12:00",
"13:00",
1f,
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyFloat(),
10f,
10f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.HOURLY.type,
"Day two",
"2023-08-02",
"12:00",
"13:00",
1f,
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyFloat(),
10f,
10f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.HOURLY.type,
"Day three",
"2023-08-03",
"12:00",
"13:00",
1f,
30,
ArgumentMatchers.anyFloat(),
10f,
5f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.HOURLY.type,
"Day four",
"2023-08-04",
"12:00",
"13:00",
1f,
30,
ArgumentMatchers.anyFloat(),
10f,
5f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.PIECE.type,
"Day five",
"2023-08-05",
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyFloat(),
ArgumentMatchers.anyInt(),
1f,
10f,
10f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.PIECE.type,
"Day six",
"2023-08-06",
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyFloat(),
ArgumentMatchers.anyInt(),
1f,
10f,
10f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.PIECE.type,
"Day seven",
"2023-08-07",
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyFloat(),
ArgumentMatchers.anyInt(),
1f,
10f,
10f
),
ShiftObject(
ArgumentMatchers.anyLong(),
ShiftType.PIECE.type,
"Day eight",
"2023-08-08",
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyFloat(),
ArgumentMatchers.anyInt(),
1f,
10f,
10f
),
)
}

View File

@@ -0,0 +1,85 @@
package com.appttude.h_mal.farmr.viewmodel
import com.appttude.h_mal.farmr.model.Success
import io.mockk.every
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.test.assertIs
class SubmissionViewModelTest : ShiftViewModelTest<SubmissionViewModel>() {
@Test
fun insertHourlyShifts_validParameters_successfulInsertions() {
// Arrange
val hourly = getHourlyShift()
// Act
every { repository.insertShiftIntoDatabase(hourly.copyToShift()) }.returns(true)
hourly.run {
viewModel.insertHourlyShift(description, date, rateOfPay, timeIn, timeOut, breakMins)
}
// Assert
assertIs<Success>(retrieveCurrentData())
assertEquals(
(retrieveCurrentData() as Success).successMessage,
"New shift successfully added"
)
}
@Test
fun insertPieceShifts_validParameters_successfulInsertions() {
// Arrange
val piece = getPieceRateShift()
// Act
every { repository.insertShiftIntoDatabase(piece.copyToShift()) }.returns(true)
piece.run {
viewModel.insertPieceRateShift(description, date, units, rateOfPay)
}
// Assert
assertIs<Success>(retrieveCurrentData())
assertEquals(
(retrieveCurrentData() as Success).successMessage,
"New shift successfully added"
)
}
@Test
fun insertHourlyShifts_validParameters_unsuccessfulInsertions() {
// Arrange
val hourly = getHourlyShift()
// Act
every { repository.insertShiftIntoDatabase(hourly.copyToShift()) }.returns(false)
hourly.run {
viewModel.insertHourlyShift(description, date, rateOfPay, timeIn, timeOut, breakMins)
}
// Assert
assertEquals(
retrieveCurrentError(),
"Cannot insert shift"
)
}
@Test
fun insertPieceShifts_validParameters_unsuccessfulInsertions() {
// Arrange
val piece = getPieceRateShift()
// Act
every { repository.insertShiftIntoDatabase(piece.copyToShift()) }.returns(false)
piece.run {
viewModel.insertPieceRateShift(description, date, units, rateOfPay)
}
// Assert
assertEquals(
retrieveCurrentError(),
"Cannot insert shift"
)
}
}