From 9cd6fdea6d4aa0a924dc4bd05f5c6b01e5bdc963 Mon Sep 17 00:00:00 2001 From: pollymce Date: Wed, 1 Apr 2026 12:49:27 +0100 Subject: [PATCH 1/2] Bug 2039562: Fix initial state flicker. Also added some ui tests around the new game screen. The flicker is handled by alpha-ing out the components if they're null & it's not currently possible through the compose test apis to make assertions about the alpha state, so that part was verified visually, but we can check that the other behaviours of ui components are wired up as expected. Did a small refactor around this to hoist state and expose smaller more testable params to NewGameScreen. --- mobile/android/fenix/app/longfox/build.gradle | 18 ++- .../fenix/longfox/LongFoxGameScreen.kt | 12 +- .../mozilla/fenix/longfox/NewGameScreen.kt | 51 +++---- .../fenix/longfox/NewGameScreenTest.kt | 141 ++++++++++++++++++ .../src/test/resources/robolectric.properties | 7 + 5 files changed, 192 insertions(+), 37 deletions(-) create mode 100644 mobile/android/fenix/app/longfox/src/test/kotlin/org/mozilla/fenix/longfox/NewGameScreenTest.kt create mode 100644 mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties 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..12022f986c2fa --- /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 \ No newline at end of file From 5efd2080021ecd0589a3724a9032cd714aa8161b Mon Sep 17 00:00:00 2001 From: pollymce Date: Thu, 21 May 2026 14:34:00 +0100 Subject: [PATCH 2/2] Bug 2039562: Fix initial state flicker. here is a new line --- .../fenix/app/longfox/src/test/resources/robolectric.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties b/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties index 12022f986c2fa..cc8d30167ee91 100644 --- a/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties +++ b/mobile/android/fenix/app/longfox/src/test/resources/robolectric.properties @@ -4,4 +4,4 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # -sdk=35 \ No newline at end of file +sdk=35