diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67560032..06b91b53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,6 +12,8 @@ plugins { // Firebase Crashlytics id("com.google.firebase.crashlytics") + +// id("io.freefair.lombok") version "6.6" } val composeVersion = "1.4.0-alpha02" @@ -151,6 +153,8 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test:runner:1.5.1") androidTestImplementation("androidx.test:rules:1.5.0") + testImplementation("org.robolectric:robolectric:4.9") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") // Compose and navigation implementation("androidx.compose.ui:ui:$composeVersion") @@ -200,6 +204,6 @@ dependencies { // ComposeReorderable implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") - // EvalEx - implementation("com.ezylang:EvalEx:3.0.1") -} \ No newline at end of file + // ExprK + implementation("com.github.sadellie:ExprK:e55cba8f41") +} diff --git a/app/src/main/java/com/sadellie/unitto/data/KeypadSymbols.kt b/app/src/main/java/com/sadellie/unitto/data/KeypadSymbols.kt index 60dfa642..5e920963 100644 --- a/app/src/main/java/com/sadellie/unitto/data/KeypadSymbols.kt +++ b/app/src/main/java/com/sadellie/unitto/data/KeypadSymbols.kt @@ -44,12 +44,26 @@ const val KEY_DIVIDE_DISPLAY = "÷" const val KEY_MULTIPLY = "*" const val KEY_MULTIPLY_DISPLAY = "×" +const val KEY_LEFT_BRACKET = "(" +const val KEY_RIGHT_BRACKET = ")" + +const val KEY_EXPONENT = "^" +const val KEY_EXPONENT_DISPLAY = "^" + +const val KEY_SQRT = "√" + val OPERATORS = listOf( KEY_PLUS, KEY_MINUS, - KEY_MINUS_DISPLAY, KEY_MULTIPLY, - KEY_MULTIPLY_DISPLAY, KEY_DIVIDE, - KEY_DIVIDE_DISPLAY, + KEY_SQRT, + KEY_EXPONENT, ) + +val INTERNAL_DISPLAY: Map = hashMapOf( + KEY_MINUS to KEY_MINUS_DISPLAY, + KEY_MULTIPLY to KEY_MULTIPLY_DISPLAY, + KEY_DIVIDE to KEY_DIVIDE_DISPLAY, + KEY_EXPONENT to KEY_EXPONENT_DISPLAY, +) \ No newline at end of file diff --git a/app/src/main/java/com/sadellie/unitto/screens/main/MainViewModel.kt b/app/src/main/java/com/sadellie/unitto/screens/main/MainViewModel.kt index a8d784de..d9c70465 100644 --- a/app/src/main/java/com/sadellie/unitto/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/sadellie/unitto/screens/main/MainViewModel.kt @@ -24,9 +24,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ezylang.evalex.BaseException -import com.ezylang.evalex.Expression +import com.github.keelar.exprk.ExpressionException +import com.github.keelar.exprk.Expressions import com.sadellie.unitto.FirebaseHelper +import com.sadellie.unitto.data.INTERNAL_DISPLAY import com.sadellie.unitto.data.KEY_0 import com.sadellie.unitto.data.KEY_1 import com.sadellie.unitto.data.KEY_2 @@ -39,9 +40,12 @@ import com.sadellie.unitto.data.KEY_8 import com.sadellie.unitto.data.KEY_9 import com.sadellie.unitto.data.KEY_DIVIDE import com.sadellie.unitto.data.KEY_DOT +import com.sadellie.unitto.data.KEY_LEFT_BRACKET import com.sadellie.unitto.data.KEY_MINUS import com.sadellie.unitto.data.KEY_MULTIPLY import com.sadellie.unitto.data.KEY_PLUS +import com.sadellie.unitto.data.KEY_RIGHT_BRACKET +import com.sadellie.unitto.data.KEY_SQRT import com.sadellie.unitto.data.OPERATORS import com.sadellie.unitto.data.preferences.UserPreferences import com.sadellie.unitto.data.preferences.UserPreferencesRepository @@ -70,11 +74,13 @@ import javax.inject.Inject class MainViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, private val basedUnitRepository: MyBasedUnitsRepository, - private val application: Application, + private val mContext: Application, private val allUnitsRepository: AllUnitsRepository ) : ViewModel() { - private val _inputValue: MutableStateFlow = MutableStateFlow(KEY_0) + val inputValue: MutableStateFlow = MutableStateFlow(KEY_0) + private val latestInputStack: MutableList = mutableListOf(KEY_0) + private val _inputDisplayValue: MutableStateFlow = MutableStateFlow(KEY_0) private val _deleteButtonEnabled: MutableStateFlow = MutableStateFlow(false) private val _negateButtonEnabled: MutableStateFlow = MutableStateFlow(false) private val _isLoadingDatabase: MutableStateFlow = MutableStateFlow(true) @@ -88,15 +94,13 @@ class MainViewModel @Inject constructor( ) val mainFlow = combine( - _inputValue, _isLoadingDatabase, _isLoadingNetwork, _showError, _userPrefs - ) { inputValue, isLoadingDatabase, isLoadingNetwork, showError, _ -> + _inputDisplayValue, _isLoadingDatabase, _isLoadingNetwork, _showError, _userPrefs + ) { inputDisplayValue, isLoadingDatabase, isLoadingNetwork, showError, _ -> return@combine MainScreenUIState( - inputValue = inputValue, + inputValue = inputDisplayValue, resultValue = convertValue(), - deleteButtonEnabled = _deleteButtonEnabled.value, - dotButtonEnabled = !_inputValue.value.takeLastWhile { - it.toString() !in OPERATORS.minus(KEY_DOT) - }.contains(KEY_DOT), + deleteButtonEnabled = inputValue.value != KEY_0, + dotButtonEnabled = canEnterDot(), negateButtonEnabled = _negateButtonEnabled.value, isLoadingDatabase = isLoadingDatabase, isLoadingNetwork = isLoadingNetwork, @@ -122,31 +126,37 @@ class MainViewModel @Inject constructor( */ private fun convertValue(): String { - val cleanInput = _inputValue.value.dropLastWhile { !it.isDigit() } + var cleanInput = inputValue.value.dropLastWhile { !it.isDigit() } + + // AUTOCLOSE ALL BRACKETS + val leftBrackets = inputValue.value.count { it.toString() == KEY_LEFT_BRACKET } + val rightBrackets = inputValue.value.count { it.toString() == KEY_RIGHT_BRACKET } + + val neededBrackets = leftBrackets - rightBrackets + if (neededBrackets > 0) { + cleanInput += KEY_RIGHT_BRACKET.repeat(neededBrackets) + } // Kotlin doesn't have a multi catch val calculatedInput = try { - val evaluated = Expression(cleanInput) + val evaluated = Expressions() // Optimal precision, not too low, not too high. Balanced for performance and UX. - .evaluate() - .numberValue + .eval(cleanInput) .setScale(_userPrefs.value.digitsPrecision, RoundingMode.HALF_EVEN) .stripTrailingZeros() if (evaluated.compareTo(BigDecimal.ZERO) == 0) BigDecimal.ZERO else evaluated } catch (e: Exception) { // Kotlin doesn't have a multi catch when (e) { - is BaseException, is ArrayIndexOutOfBoundsException, is NumberFormatException, is ArithmeticException -> return mainFlow.value.resultValue + is ExpressionException, is ArrayIndexOutOfBoundsException, is IndexOutOfBoundsException, is NumberFormatException, is ArithmeticException -> return mainFlow.value.resultValue else -> throw e } } // Ugly way of determining when to hide calculated value - _calculatedValue.value = if (calculatedInput.toPlainString() != cleanInput) { - // Expression is not same as the result, show result + _calculatedValue.value = if (!latestInputStack.none { it in OPERATORS }) { calculatedInput.toStringWith(_userPrefs.value.outputFormat) } else { - // Expression is same as the result, don't show result, the input text is enough null } @@ -182,7 +192,7 @@ class MainViewModel @Inject constructor( _negateButtonEnabled.update { clickedUnit.group.canNegate } // Now we change to positive if the group we switched to supports negate if (!clickedUnit.group.canNegate) { - _inputValue.update { _inputValue.value.removePrefix(KEY_MINUS) } + inputValue.update { inputValue.value.removePrefix(KEY_MINUS) } } // Now setting up right unit (pair for the left one) @@ -293,90 +303,146 @@ class MainViewModel @Inject constructor( * @param[symbolToAdd] Digit/Symbol we want to add, can be any digit 0..9 or a dot symbol */ fun processInput(symbolToAdd: String) { - val lastSymbol = _inputValue.value.last().toString() - val lastSecondSymbol = _inputValue.value.takeLast(2).dropLast(1) + val lastTwoSymbols = latestInputStack.takeLast(2) + val lastSymbol: String = lastTwoSymbols.getOrNull(1) ?: lastTwoSymbols[0] + val lastSecondSymbol: String? = lastTwoSymbols.getOrNull(0) _deleteButtonEnabled.update { true } when (symbolToAdd) { - KEY_0 -> { - // Don't add zero if the input is already a zero - if (_inputValue.value == KEY_0) return - // Don't add zero if there is a zero and operator in front - if ((lastSecondSymbol in OPERATORS) and (lastSymbol == KEY_0)) return - _inputValue.update { _inputValue.value + symbolToAdd } - } - KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9 -> { - // Replace single zero (default input) if it's here - if (_inputValue.value == KEY_0) { - _inputValue.update { symbolToAdd } - } else { - _inputValue.update { _inputValue.value + symbolToAdd } - } - } - KEY_DOT -> { - _inputValue.update { _inputValue.value + symbolToAdd } - } - KEY_MINUS -> { - when { - // Replace single zero with minus (to support negative numbers) - (_inputValue.value == KEY_0) -> { - if (symbolToAdd == KEY_MINUS) _inputValue.update { symbolToAdd } - } - // Don't allow multiple minuses near each other - (lastSymbol == KEY_MINUS) -> {} - // Don't allow plus and minus be near each other - (lastSymbol == KEY_PLUS) -> { - _inputValue.update { _inputValue.value.dropLast(1) + symbolToAdd } - } - else -> { - _inputValue.update { _inputValue.value + symbolToAdd } - } - } - } KEY_PLUS, KEY_DIVIDE, KEY_MULTIPLY -> { when { // Don't need expressions that start with zero - (_inputValue.value == KEY_0) or (_inputValue.value == KEY_MINUS) -> {} + (inputValue.value == KEY_0) or (inputValue.value == KEY_MINUS) -> {} /** * For situations like "50+-", when user clicks "/" we delete "-" so it becomes * "50+". We don't add "/' here. User will click "/" second time and the input * will be "50/". */ - (lastSecondSymbol in OPERATORS) and (lastSymbol == KEY_MINUS) -> { + (lastSecondSymbol in OPERATORS) and (lastSymbol == KEY_MINUS)-> { deleteDigit() } // Don't allow multiple operators near each other (lastSymbol in OPERATORS) -> { - _inputValue.update { _inputValue.value.dropLast(1) + symbolToAdd } + deleteDigit() + setInputSymbols(symbolToAdd) } else -> { - _inputValue.update { _inputValue.value + symbolToAdd } + setInputSymbols(symbolToAdd) + } + } + } + KEY_0 -> { + // Don't add zero if the input is already a zero + if (inputValue.value == KEY_0) return + // Prevents things like "-00" and "4+000" + if ((lastSecondSymbol in OPERATORS) and (lastSymbol == KEY_0)) return + setInputSymbols(symbolToAdd) + } + KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9 -> { + // Replace single zero (default input) if it's here + if (inputValue.value == KEY_0) { + setInputSymbols(symbolToAdd, false) + } else { + setInputSymbols(symbolToAdd) + } + } + KEY_MINUS -> { + when { + // Replace single zero with minus (to support negative numbers) + (inputValue.value == KEY_0) -> { + setInputSymbols(symbolToAdd, false) + } + // Don't allow multiple minuses near each other + (lastSymbol.compareTo(KEY_MINUS) == 0) -> {} + // Don't allow plus and minus be near each other + (lastSymbol == KEY_PLUS) -> { + deleteDigit() + setInputSymbols(symbolToAdd) + } + else -> { + setInputSymbols(symbolToAdd) + } + } + } + KEY_DOT -> { + setInputSymbols(symbolToAdd) + } + KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET -> { + when { + // Replace single zero with minus (to support negative numbers) + (inputValue.value == KEY_0) -> { + setInputSymbols(symbolToAdd, false) + } + else -> { + setInputSymbols(symbolToAdd) + } + } + } + KEY_SQRT -> { + when { + // Replace single zero with minus (to support negative numbers) + (inputValue.value == KEY_0) -> { + setInputSymbols(symbolToAdd, false) + } + else -> { + setInputSymbols(symbolToAdd) + } + } + } + else -> { + when { + // Replace single zero with minus (to support negative numbers) + (inputValue.value == KEY_0) -> { + setInputSymbols(symbolToAdd, false) + } + else -> { + setInputSymbols(symbolToAdd) } } } } + } /** * Deletes last symbol from input and handles buttons state (enabled/disabled) */ fun deleteDigit() { - // Deleting last symbol - var inputToSet = _inputValue.value.dropLast(1) + val lastSymbol = latestInputStack.removeLast() - /* - Now we check what we have left - We deleted last symbol and we got Empty string, just minus symbol, or zero - Do not allow deleting anything beyond this (disable button) - Set input to default (zero) - Skipping this block means that we are left we acceptable value, i.e. 123.03 - */ - if (inputToSet in listOf(String(), KEY_MINUS, KEY_0)) { - _deleteButtonEnabled.update { false } - inputToSet = KEY_0 + // We will need to delete last symbol from both values + val displayRepresentation: String = INTERNAL_DISPLAY[lastSymbol] ?: lastSymbol + + // If this value are same, it means that after deleting there will be no symbols left, set to default + if (lastSymbol == inputValue.value) { + setInputSymbols(KEY_0, false) + } else { + inputValue.update { it.removeSuffix(lastSymbol) } + _inputDisplayValue.update { it.removeSuffix(displayRepresentation) } } + } - _inputValue.update { inputToSet } + /** + * Adds given [symbol] to [inputValue] and [_inputDisplayValue] and updates [latestInputStack]. + * + * By default add symbol, but if [add] is False, will replace current input (when replacing + * default [KEY_0] input). + */ + private fun setInputSymbols(symbol: String, add: Boolean = true) { + val displaySymbol: String = INTERNAL_DISPLAY[symbol] ?: symbol + + when { + add -> { + inputValue.update { it + symbol } + _inputDisplayValue.update { it + displaySymbol } + latestInputStack.add(symbol) + } + else -> { + inputValue.update { symbol } + _inputDisplayValue.update { displaySymbol } + latestInputStack.add(symbol) + } + } } /** @@ -384,7 +450,7 @@ class MainViewModel @Inject constructor( */ fun clearInput() { _deleteButtonEnabled.update { false } - _inputValue.update { KEY_0 } + setInputSymbols(KEY_0, false) } /** @@ -392,6 +458,13 @@ class MainViewModel @Inject constructor( */ fun inputValue() = (mainFlow.value.calculatedValue ?: mainFlow.value.inputValue).toBigDecimal() + /** + * Returns True if can be placed. + */ + private fun canEnterDot(): Boolean = !inputValue.value.takeLastWhile { + it.toString() !in OPERATORS.minus(KEY_DOT) + }.contains(KEY_DOT) + /** * Saves latest pair of units into datastore */ @@ -418,7 +491,7 @@ class MainViewModel @Inject constructor( // Now we load units data from database val allBasedUnits = basedUnitRepository.getAll() - allUnitsRepository.loadFromDatabase(application, allBasedUnits) + allUnitsRepository.loadFromDatabase(mContext, allBasedUnits) // User is free to convert values and units on units screen can be sorted properly _negateButtonEnabled.update { unitFrom.group.canNegate } diff --git a/app/src/main/java/com/sadellie/unitto/screens/main/components/Keyboard.kt b/app/src/main/java/com/sadellie/unitto/screens/main/components/Keyboard.kt index 67f0c508..a62d4bb4 100644 --- a/app/src/main/java/com/sadellie/unitto/screens/main/components/Keyboard.kt +++ b/app/src/main/java/com/sadellie/unitto/screens/main/components/Keyboard.kt @@ -39,11 +39,16 @@ import com.sadellie.unitto.data.KEY_CLEAR import com.sadellie.unitto.data.KEY_DIVIDE import com.sadellie.unitto.data.KEY_DIVIDE_DISPLAY import com.sadellie.unitto.data.KEY_DOT +import com.sadellie.unitto.data.KEY_EXPONENT +import com.sadellie.unitto.data.KEY_EXPONENT_DISPLAY +import com.sadellie.unitto.data.KEY_LEFT_BRACKET import com.sadellie.unitto.data.KEY_MINUS import com.sadellie.unitto.data.KEY_MINUS_DISPLAY import com.sadellie.unitto.data.KEY_MULTIPLY import com.sadellie.unitto.data.KEY_MULTIPLY_DISPLAY import com.sadellie.unitto.data.KEY_PLUS +import com.sadellie.unitto.data.KEY_RIGHT_BRACKET +import com.sadellie.unitto.data.KEY_SQRT import com.sadellie.unitto.screens.Formatter /** @@ -76,24 +81,28 @@ fun Keyboard( // Column modifier val cModifier = Modifier.weight(1f) Column(cModifier) { + KeyboardButton(bModifier, KEY_LEFT_BRACKET, isPrimary = false, onClick = addDigit) KeyboardButton(bModifier, KEY_7, onClick = addDigit) KeyboardButton(bModifier, KEY_4, onClick = addDigit) KeyboardButton(bModifier, KEY_1, onClick = addDigit) KeyboardButton(bModifier, KEY_0, onClick = addDigit) } Column(cModifier) { + KeyboardButton(bModifier, KEY_RIGHT_BRACKET, isPrimary = false, onClick = addDigit) KeyboardButton(bModifier, KEY_8, onClick = addDigit) KeyboardButton(bModifier, KEY_5, onClick = addDigit) KeyboardButton(bModifier, KEY_2, onClick = addDigit) KeyboardButton(bModifier, Formatter.fractional, enabled = dotButtonEnabled) { addDigit(KEY_DOT) } } Column(cModifier) { + KeyboardButton(bModifier, KEY_EXPONENT_DISPLAY, isPrimary = false, onClick = { addDigit(KEY_EXPONENT) }) KeyboardButton(bModifier, KEY_9, onClick = addDigit) KeyboardButton(bModifier, KEY_6, onClick = addDigit) KeyboardButton(bModifier, KEY_3, onClick = addDigit) KeyboardButton(bModifier, KEY_CLEAR, enabled = deleteButtonEnabled, onLongClick = clearInput) { deleteDigit() } } Column(cModifier) { + KeyboardButton(bModifier, KEY_SQRT, isPrimary = false, onClick = { addDigit(KEY_SQRT) }) KeyboardButton(bModifier, KEY_DIVIDE_DISPLAY, isPrimary = false) { addDigit(KEY_DIVIDE) } KeyboardButton(bModifier, KEY_MULTIPLY_DISPLAY, isPrimary = false) { addDigit(KEY_MULTIPLY) } KeyboardButton(bModifier, KEY_MINUS_DISPLAY, isPrimary = false) { addDigit(KEY_MINUS) } diff --git a/app/src/main/java/com/sadellie/unitto/screens/main/components/TopScreen.kt b/app/src/main/java/com/sadellie/unitto/screens/main/components/TopScreen.kt index 5274b76d..ea69eb4b 100644 --- a/app/src/main/java/com/sadellie/unitto/screens/main/components/TopScreen.kt +++ b/app/src/main/java/com/sadellie/unitto/screens/main/components/TopScreen.kt @@ -85,7 +85,7 @@ fun TopScreenPart( Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { MyTextField( Modifier.fillMaxWidth(), diff --git a/app/src/test/java/com/sadellie/unitto/screens/MainViewModelTest.kt b/app/src/test/java/com/sadellie/unitto/screens/MainViewModelTest.kt new file mode 100644 index 00000000..2d30c6ae --- /dev/null +++ b/app/src/test/java/com/sadellie/unitto/screens/MainViewModelTest.kt @@ -0,0 +1,394 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2022-2022 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.screens + +import android.os.StrictMode +import androidx.room.Room +import com.sadellie.unitto.data.KEY_0 +import com.sadellie.unitto.data.KEY_1 +import com.sadellie.unitto.data.KEY_2 +import com.sadellie.unitto.data.KEY_3 +import com.sadellie.unitto.data.KEY_4 +import com.sadellie.unitto.data.KEY_5 +import com.sadellie.unitto.data.KEY_6 +import com.sadellie.unitto.data.KEY_7 +import com.sadellie.unitto.data.KEY_8 +import com.sadellie.unitto.data.KEY_9 +import com.sadellie.unitto.data.KEY_COMMA +import com.sadellie.unitto.data.KEY_DIVIDE +import com.sadellie.unitto.data.KEY_DOT +import com.sadellie.unitto.data.KEY_EXPONENT +import com.sadellie.unitto.data.KEY_LEFT_BRACKET +import com.sadellie.unitto.data.KEY_MINUS +import com.sadellie.unitto.data.KEY_MULTIPLY +import com.sadellie.unitto.data.KEY_PLUS +import com.sadellie.unitto.data.KEY_RIGHT_BRACKET +import com.sadellie.unitto.data.KEY_SQRT +import com.sadellie.unitto.data.preferences.DataStoreModule +import com.sadellie.unitto.data.preferences.UserPreferencesRepository +import com.sadellie.unitto.data.units.AllUnitsRepository +import com.sadellie.unitto.data.units.database.MyBasedUnitDatabase +import com.sadellie.unitto.data.units.database.MyBasedUnitsRepository +import com.sadellie.unitto.screens.main.MainViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + + +@ExperimentalCoroutinesApi +class CoroutineTestRule(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : + TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } + +} + + +@OptIn(ExperimentalCoroutinesApi::class) +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class MainViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var viewModel: MainViewModel + private val allUnitsRepository = AllUnitsRepository() + + @Before + fun setUp() { + viewModel = MainViewModel( + UserPreferencesRepository( + DataStoreModule().provideUserPreferencesDataStore( + RuntimeEnvironment.getApplication() + ) + ), MyBasedUnitsRepository( + Room.inMemoryDatabaseBuilder( + RuntimeEnvironment.getApplication(), MyBasedUnitDatabase::class.java + ).build().myBasedUnitDao() + ), RuntimeEnvironment.getApplication(), allUnitsRepository + ) + } + + @Test + fun processInputTest() = runTest { + // https://github.com/robolectric/robolectric/issues/6377#issuecomment-843779001 + StrictMode.enableDefaults() + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + Dispatchers.setMain(testDispatcher) + viewModel.mainFlow.launchIn(CoroutineScope(testDispatcher)) + + /** + * For simplicity comments will have structure: + * currentInput | userInput | processed internal input | processed display output + */ + `test 0`() + `test digits from 1 to 9`() + `test plus, divide and multiply operators`() + `test dot`() + `test minus`() + `test brackets`() + + Dispatchers.resetMain() + } + + private fun `test 0`() { + // 0 | 000 | 0 | 0 + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + assertEquals("0", viewModel.inputValue.value) + assertEquals("0", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 123 | 000 | 123000 + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_3) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + assertEquals("123000", viewModel.inputValue.value) + assertEquals("123000", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 123. | 000 | 123.000 + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_3) + viewModel.processInput(KEY_DOT) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + assertEquals("123.000", viewModel.inputValue.value) + assertEquals("123.000", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // - | 000 | -0 + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + assertEquals("-0", viewModel.inputValue.value) + assertEquals("–0", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 12+ | 000 | 12+0 + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + assertEquals("12+0", viewModel.inputValue.value) + assertEquals("12+0", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // √0 | 000 | √0 + viewModel.processInput(KEY_SQRT) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_0) + assertEquals("√0", viewModel.inputValue.value) + assertEquals("√0", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + } + + private fun `test digits from 1 to 9`() { + // 0 | 123456789 | 123456789 + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_3) + viewModel.processInput(KEY_4) + viewModel.processInput(KEY_5) + viewModel.processInput(KEY_6) + viewModel.processInput(KEY_7) + viewModel.processInput(KEY_8) + viewModel.processInput(KEY_9) + assertEquals("123456789", viewModel.inputValue.value) + assertEquals("123456789", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + } + + private fun `test plus, divide and multiply operators`() { + // 0 | +++ | 0 + viewModel.processInput(KEY_PLUS) + assertEquals("0", viewModel.inputValue.value) + assertEquals("0", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 123 | +++ | 123+ + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_3) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_PLUS) + assertEquals("123+", viewModel.inputValue.value) + assertEquals("123+", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 1- | *** | 1* + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MULTIPLY) + viewModel.processInput(KEY_MULTIPLY) + viewModel.processInput(KEY_MULTIPLY) + assertEquals("1*", viewModel.inputValue.value) + assertEquals("1×", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 1/- | +++ | 1+ + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_DIVIDE) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_PLUS) + assertEquals("1+", viewModel.inputValue.value) + assertEquals("1+", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + } + + private fun `test dot`() { + // 0 | . | 0. + viewModel.processInput(KEY_DOT) + assertEquals("0.", viewModel.inputValue.value) + assertEquals("0.", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 1 | . | 1. + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_DOT) + assertEquals("1.", viewModel.inputValue.value) + assertEquals("1.", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 1+ | . | 1+. + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_DOT) + assertEquals("1+.", viewModel.inputValue.value) + assertEquals("1+.", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // √ | . | √. + viewModel.processInput(KEY_SQRT) + viewModel.processInput(KEY_DOT) + assertEquals("√.", viewModel.inputValue.value) + assertEquals("√.", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + } + + private fun `test minus`() { + // 0 | --- | - + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + assertEquals("-", viewModel.inputValue.value) + assertEquals("–", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 12 | --- | 12- + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + assertEquals("12-", viewModel.inputValue.value) + assertEquals("12–", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 12+ | --- | 12- + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + assertEquals("12-", viewModel.inputValue.value) + assertEquals("12–", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 12/ | --- | 12/- + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_DIVIDE) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + assertEquals("12/-", viewModel.inputValue.value) + assertEquals("12÷–", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // √ | --- | √- + viewModel.processInput(KEY_SQRT) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + viewModel.processInput(KEY_MINUS) + assertEquals("√-", viewModel.inputValue.value) + assertEquals("√–", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + } + + private fun `test brackets`() { + // 0 | ( | ( + viewModel.processInput(KEY_LEFT_BRACKET) + assertEquals("(", viewModel.inputValue.value) + assertEquals("(", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // 0 | ((( | ((( + viewModel.processInput(KEY_LEFT_BRACKET) + viewModel.processInput(KEY_LEFT_BRACKET) + viewModel.processInput(KEY_LEFT_BRACKET) + assertEquals("(((", viewModel.inputValue.value) + assertEquals("(((", viewModel.mainFlow.value.inputValue) + viewModel.clearInput() + + // √ | (10+2) | √(10+2) + viewModel.processInput(KEY_SQRT) + viewModel.processInput(KEY_LEFT_BRACKET) + viewModel.processInput(KEY_1) + viewModel.processInput(KEY_0) + viewModel.processInput(KEY_PLUS) + viewModel.processInput(KEY_2) + viewModel.processInput(KEY_RIGHT_BRACKET) + assertEquals("√(10+2)", viewModel.inputValue.value) + assertEquals("√(10+2)", viewModel.mainFlow.value.inputValue) + } + + @Test + fun deleteSymbolTest() = runTest { + // https://github.com/robolectric/robolectric/issues/6377#issuecomment-843779001 + StrictMode.enableDefaults() + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + Dispatchers.setMain(testDispatcher) + viewModel.mainFlow.launchIn(CoroutineScope(testDispatcher)) + + listOf( + KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0, + KEY_DOT, KEY_COMMA, KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET, + KEY_PLUS, KEY_MINUS, KEY_DIVIDE, KEY_MULTIPLY, KEY_EXPONENT, KEY_SQRT, + ).forEach { + // We enter one symbol and delete it, should be default as a result + viewModel.processInput(it) + viewModel.deleteDigit() + assertEquals("0", viewModel.mainFlow.value.inputValue) + assertEquals("0", viewModel.inputValue.value) + } + viewModel.clearInput() + + // Now we check that we can delete multiple values + viewModel.processInput(KEY_3) + viewModel.processInput(KEY_SQRT) + viewModel.processInput(KEY_9) + viewModel.deleteDigit() + assertEquals("3√", viewModel.inputValue.value) + assertEquals("3√", viewModel.mainFlow.value.inputValue) + + Dispatchers.resetMain() + } + +} \ No newline at end of file