diff --git a/mobile/android/fenix/app/longfox/build.gradle b/mobile/android/fenix/app/longfox/build.gradle index a89e749b4d52d..e5bc2562907ff 100644 --- a/mobile/android/fenix/app/longfox/build.gradle +++ b/mobile/android/fenix/app/longfox/build.gradle @@ -4,12 +4,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - plugins { alias(libs.plugins.kotlin.compose) } @@ -26,6 +20,11 @@ android { minSdk config.minSdkVersion testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } + testOptions { + unitTests { + includeAndroidResources = true + } + } } dependencies { @@ -44,7 +43,14 @@ dependencies { implementation libs.androidx.datastore.preferences implementation libs.google.material testImplementation libs.junit4 + testImplementation platform(libs.androidx.compose.bom) + testImplementation libs.androidx.compose.ui.test + testImplementation libs.androidx.test.core + testImplementation libs.androidx.test.junit + testImplementation libs.kotlinx.coroutines.test + testImplementation libs.robolectric androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.androidx.test.espresso.core debugImplementation libs.androidx.compose.ui.tooling + debugImplementation libs.androidx.compose.ui.test.manifest } diff --git a/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/LongFoxGameScreen.kt b/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/LongFoxGameScreen.kt index ca9aa35f6a7fa..c141adca4a6aa 100644 --- a/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/LongFoxGameScreen.kt +++ b/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/LongFoxGameScreen.kt @@ -66,6 +66,7 @@ fun LongFoxGameScreen() { } val restartGame = { gameState = GameState(numCells = numCells, size = Size(canvasSizePx, canvasSizePx)) } SideEffect { + @Suppress("AssignedValueIsNeverRead") if (gameState.shouldCelebrateScore && !celebrationShown) celebrationSeed = gameState.score celebrationShown = gameState.shouldCelebrateScore } @@ -85,9 +86,11 @@ fun LongFoxGameScreen() { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val longFoxDataStore = remember(context) { LongFoxDataStore(context) } + val hiscore by longFoxDataStore.hiscoreFlow() + .collectAsState(initial = null, coroutineScope.coroutineContext) val soundOn by longFoxDataStore.soundOnFlow() - .collectAsState(initial = false, coroutineScope.coroutineContext) - val soundEffectsPlayer = remember(soundOn) { SoundEffectsPlayer(context, soundOn) } + .collectAsState(initial = null, coroutineScope.coroutineContext) + val soundEffectsPlayer = remember(soundOn) { SoundEffectsPlayer(context, soundOn == true) } DisposableEffect(soundEffectsPlayer) { onDispose { soundEffectsPlayer.release() } @@ -148,9 +151,12 @@ fun LongFoxGameScreen() { ) { if (gameState.isGameOver) { NewGameScreen( - longFoxDataStore = longFoxDataStore, initialGameState = gameState, + hiscore = hiscore, + soundOn = soundOn, + onToggleSoundOn = { coroutineScope.launch { longFoxDataStore.toggleSoundOn() } }, startGame = restartGame, + shareHiscore = { coroutineScope.launch { longFoxDataStore.shareHiscore(it) }} ) } else { GameCanvas(gameState) diff --git a/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/NewGameScreen.kt b/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/NewGameScreen.kt index 6742a5631c68d..2683384ff9f08 100644 --- a/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/NewGameScreen.kt +++ b/mobile/android/fenix/app/longfox/src/main/kotlin/org/mozilla/fenix/longfox/NewGameScreen.kt @@ -6,7 +6,6 @@ package org.mozilla.fenix.longfox -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -23,49 +21,44 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.mozilla.fenix.longfox.GameState.Companion.CELL_SIZE_DP import org.mozilla.fenix.longfox.GameState.Companion.GAME_INTERVAL_TIME_MS /** * A little intro screen to launch the game and provide high score and sound on/off switch. * @param initialGameState the current game state. - * @param longFoxDataStore a data store to save game default preferences. + * @param hiscore the persisted high score, or `null` while the data store is still loading. + * @param soundOn the persisted sound setting, or `null` while the data store is still loading. + * @param onToggleSoundOn invoked when the user taps the sound toggle. * @param startGame a callback to start the game. + * @param shareHiscore a callback to share the hiscore with other apps. */ @Composable fun NewGameScreen( initialGameState: GameState, - longFoxDataStore: LongFoxDataStore, + hiscore: Int?, + soundOn: Boolean?, + onToggleSoundOn: () -> Unit, startGame: () -> Unit, + shareHiscore: (Int) -> Unit, ) { - val coroutineScope = rememberCoroutineScope() - - val hiscore by longFoxDataStore.hiscoreFlow() - .collectAsState(initial = 0, coroutineScope.coroutineContext) - val soundOn by longFoxDataStore.soundOnFlow() - .collectAsState(initial = false, coroutineScope.coroutineContext) var gameState by remember(initialGameState.numCells) { mutableStateOf( initialGameState.copy( @@ -126,16 +119,17 @@ fun NewGameScreen( ) TextButton( modifier = Modifier - .alpha(if (hiscore > 0) 1f else 0f) .padding(top = 36.dp, bottom = 36.dp) - .background(Color.Transparent), - onClick = { if (hiscore > 0) longFoxDataStore.shareHiscore(hiscore) }, + .alpha(if (hiscore == null) 0f else 1f), + onClick = { if (hiscore != null) shareHiscore(hiscore) }, ) { Text( - fontSize = 22.sp, + modifier = Modifier + .alpha(if (hiscore == null) 0f else 1f), + fontSize = 18.sp, fontFamily = LongFoxText.zx, color = Color.Cyan, - text = stringResource(R.string.hiscore, hiscore) + text = stringResource(R.string.hiscore, hiscore ?: 0) ) Spacer(Modifier.width(12.dp)) Icon( @@ -146,13 +140,14 @@ fun NewGameScreen( } Text( modifier = Modifier - .border(width = 2.dp, color = if (soundOn) Color.White else Color.Gray) + .clickable { onToggleSoundOn() } + .border(width = 2.dp, color = if (soundOn == true) Color.White else Color.Gray) .padding(8.dp) - .clickable { coroutineScope.launch { longFoxDataStore.toggleSoundOn() } }, + .alpha(if (soundOn == null) 0f else 1f), fontFamily = LongFoxText.zx, fontSize = 16.sp, - color = if (soundOn) Color.White else Color.Gray, - text = if (soundOn) stringResource(R.string.sound_on) else stringResource(R.string.sound_off) + color = if (soundOn == true) Color.White else Color.Gray, + text = if (soundOn == true) stringResource(R.string.sound_on) else stringResource(R.string.sound_off) ) } } @@ -169,10 +164,10 @@ fun NewGameScreenPreview() { size = Size(canvasSizePx, canvasSizePx), isGameOver = true ), - longFoxDataStore = LongFoxDataStore( - context = LocalContext.current, - initialHiscore = 5 - ), + hiscore = 0, + soundOn = false, + onToggleSoundOn = {}, startGame = {}, + shareHiscore = {}, ) } diff --git a/mobile/android/fenix/app/longfox/src/test/kotlin/org/mozilla/fenix/longfox/NewGameScreenTest.kt b/mobile/android/fenix/app/longfox/src/test/kotlin/org/mozilla/fenix/longfox/NewGameScreenTest.kt new file mode 100644 index 0000000000000..332e00bf75299 --- /dev/null +++ b/mobile/android/fenix/app/longfox/src/test/kotlin/org/mozilla/fenix/longfox/NewGameScreenTest.kt @@ -0,0 +1,141 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.mozilla.fenix.longfox + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for the new game screen rendering. + */ +@RunWith(AndroidJUnit4::class) +class NewGameScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context = ApplicationProvider.getApplicationContext() + private val soundOnText = context.getString(R.string.sound_on) + private val soundOffText = context.getString(R.string.sound_off) + private fun hiscoreText(value: Int? = null) = context.getString(R.string.hiscore, value) + + private val initialGameState = + GameState(numCells = 20, size = Size(400f, 400f), isGameOver = true) + + @Test + fun `if you don't have a hiscore yet, no hiscore is shown`() { + composeTestRule.setContent { + NewGameScreen( + initialGameState = initialGameState, + hiscore = null, + soundOn = false, + onToggleSoundOn = {}, + startGame = {}, + shareHiscore = {}, + ) + } + + composeTestRule.onNodeWithText(hiscoreText()).assertIsNotDisplayed() + } + + @Test + fun `if you do have a hiscore, it is shown`() { + composeTestRule.setContent { + NewGameScreen( + initialGameState = initialGameState, + hiscore = 42, + soundOn = false, + onToggleSoundOn = {}, + startGame = {}, + shareHiscore = {}, + ) + } + + composeTestRule.onNodeWithText(hiscoreText(42)).assertIsDisplayed() + } + + @Test + fun `when sound is switched on, sound on label is shown`() { + composeTestRule.setContent { + NewGameScreen( + initialGameState = initialGameState, + hiscore = 0, + soundOn = true, + onToggleSoundOn = {}, + startGame = {}, + shareHiscore = {}, + ) + } + composeTestRule.onNodeWithText(soundOnText).assertIsDisplayed() + } + + @Test + fun `when sound is switched off, sound off label is shown`() { + composeTestRule.setContent { + NewGameScreen( + initialGameState = initialGameState, + hiscore = 0, + soundOn = false, + onToggleSoundOn = {}, + startGame = {}, + shareHiscore = {}, + ) + } + composeTestRule.onNodeWithText(soundOffText).assertIsDisplayed() + } + + @Test + fun `clicking sound off button toggles sound on`() { + var toggleCount = 0 + composeTestRule.setContent { + NewGameScreen( + initialGameState = initialGameState, + hiscore = 0, + soundOn = false, + onToggleSoundOn = { toggleCount++ }, + startGame = {}, + shareHiscore = {}, + ) + } + composeTestRule.onNodeWithText(soundOffText).performClick() + + assertEquals(1, toggleCount) + } + + @Test + fun `clicking sound off button changes text to sound on`() { + composeTestRule.setContent { + var soundOn by remember { mutableStateOf(false) } + NewGameScreen( + initialGameState = initialGameState, + hiscore = 0, + soundOn = soundOn, + onToggleSoundOn = { soundOn = !soundOn }, + startGame = {}, + shareHiscore = {}, + ) + } + composeTestRule.onNodeWithText(soundOffText).performClick() + + composeTestRule.onNodeWithText(soundOnText).assertIsDisplayed() + } +} diff --git a/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties b/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties new file mode 100644 index 0000000000000..cc8d30167ee91 --- /dev/null +++ b/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties @@ -0,0 +1,7 @@ +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# + +sdk=35