diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd94dae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel previous runs on the same PR/branch when a new commit lands. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # ---------------------------------------------------------------------- + # Unit tests for all KMP modules (run on macOS — needed for iosSimulator). + # ---------------------------------------------------------------------- + tests: + name: Tests (iosSimulatorArm64) + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Run all unit tests + run: ./gradlew allTests --stacktrace + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + **/build/reports/tests/** + **/build/test-results/** + retention-days: 7 + + # ---------------------------------------------------------------------- + # Android build smoke check (Linux is faster and cheaper than macOS). + # Catches androidApp + composeApp Android compile regressions without + # the cost of a full release pipeline. + # ---------------------------------------------------------------------- + android-build: + name: Android assembleDebug + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Assemble debug + run: ./gradlew :androidApp:assembleDebug --stacktrace diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fcf0292..5e06cba 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { api(projects.core.common) implementation(projects.core.domain) implementation(projects.core.data) + implementation(projects.core.telemetry) api(projects.feature.root) implementation(projects.feature.home) diff --git a/composeApp/src/androidMain/kotlin/ge/yet3/blokblast/di/AndroidAppGraph.kt b/composeApp/src/androidMain/kotlin/ge/yet3/blokblast/di/AndroidAppGraph.kt index 8e450f3..7e2503f 100644 --- a/composeApp/src/androidMain/kotlin/ge/yet3/blokblast/di/AndroidAppGraph.kt +++ b/composeApp/src/androidMain/kotlin/ge/yet3/blokblast/di/AndroidAppGraph.kt @@ -13,6 +13,7 @@ import ge.yet.blockblast.feature.settings.di.SettingsBindings import ge.yet.blokblast.data.di.AndroidDataBindings import ge.yet.blokblast.data.di.DataBindings import ge.yet.blokblast.domain.di.DomainBindings +import ge.yet.blokblast.telemetry.di.TelemetryBindings @DependencyGraph( scope = AppScope::class, @@ -21,6 +22,7 @@ import ge.yet.blokblast.domain.di.DomainBindings DomainBindings::class, DataBindings::class, AndroidDataBindings::class, + TelemetryBindings::class, ComposeAppBindings::class, RootBindings::class, HomeBindings::class, diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GameOverOverlay.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GameOverOverlay.kt index 61acdc8..7ebaab6 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GameOverOverlay.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GameOverOverlay.kt @@ -45,7 +45,7 @@ import ge.yet3.blokblast.component.button.PrimaryTerracottaButton import ge.yet3.blokblast.component.modifier.ringShadow import ge.yet3.blokblast.component.modifier.whisperShadow import ge.yet3.blokblast.component.score.AnimatedCounter -import ge.yet3.blokblast.utils.formatScore +import com.app.common.utils.formatScore /** * The end-of-round overlay. Animates in with a custom dialog motion (spring diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt index 222ab8c..0bebb8c 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle -import ge.yet3.blokblast.utils.formatScore +import com.app.common.utils.formatScore /** * Animated number readout — each digit slides vertically when it changes, diff --git a/composeApp/src/commonTest/kotlin/ge/yet3/blokblast/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/ge/yet3/blokblast/ComposeAppCommonTest.kt deleted file mode 100644 index 43ec8bc..0000000 --- a/composeApp/src/commonTest/kotlin/ge/yet3/blokblast/ComposeAppCommonTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ge.yet3.blokblast - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ComposeAppCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/di/NativeAppGraph.kt b/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/di/NativeAppGraph.kt index bf70ef8..1acf02a 100644 --- a/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/di/NativeAppGraph.kt +++ b/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/di/NativeAppGraph.kt @@ -12,6 +12,7 @@ import ge.yet.blockblast.feature.settings.di.SettingsBindings import ge.yet.blokblast.data.di.DataBindings import ge.yet.blokblast.data.di.NativeDataBindings import ge.yet.blokblast.domain.di.DomainBindings +import ge.yet.blokblast.telemetry.di.TelemetryBindings @DependencyGraph( @@ -21,6 +22,7 @@ import ge.yet.blokblast.domain.di.DomainBindings DomainBindings::class, DataBindings::class, NativeDataBindings::class, + TelemetryBindings::class, ComposeAppBindings::class, RootBindings::class, HomeBindings::class, diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/utils/formatScore.kt b/core/common/src/commonMain/kotlin/com/app/common/utils/formatScore.kt similarity index 91% rename from composeApp/src/commonMain/kotlin/ge/yet3/blokblast/utils/formatScore.kt rename to core/common/src/commonMain/kotlin/com/app/common/utils/formatScore.kt index 1382f79..51f8a22 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/utils/formatScore.kt +++ b/core/common/src/commonMain/kotlin/com/app/common/utils/formatScore.kt @@ -1,4 +1,4 @@ -package ge.yet3.blokblast.utils +package com.app.common.utils import kotlin.math.abs diff --git a/core/common/src/commonTest/kotlin/com/app/common/utils/FormatScoreTest.kt b/core/common/src/commonTest/kotlin/com/app/common/utils/FormatScoreTest.kt new file mode 100644 index 0000000..8094b91 --- /dev/null +++ b/core/common/src/commonTest/kotlin/com/app/common/utils/FormatScoreTest.kt @@ -0,0 +1,51 @@ +package com.app.common.utils + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FormatScoreTest { + + @Test + fun zero() { + assertEquals("0", 0L.formatScore()) + assertEquals("0", 0.formatScore()) + } + + @Test + fun under_thousand_no_separator() { + assertEquals("1", 1L.formatScore()) + assertEquals("42", 42L.formatScore()) + assertEquals("999", 999L.formatScore()) + } + + @Test + fun thousands() { + assertEquals("1,000", 1_000L.formatScore()) + assertEquals("12,345", 12_345L.formatScore()) + assertEquals("999,999", 999_999L.formatScore()) + } + + @Test + fun millions_and_above() { + assertEquals("1,000,000", 1_000_000L.formatScore()) + assertEquals("123,456,789", 123_456_789L.formatScore()) + } + + @Test + fun negatives_have_leading_minus() { + assertEquals("-1", (-1L).formatScore()) + assertEquals("-1,234", (-1_234L).formatScore()) + assertEquals("-1,000,000", (-1_000_000L).formatScore()) + } + + @Test + fun int_overload_matches_long() { + assertEquals(1_234L.formatScore(), 1_234.formatScore()) + assertEquals((-1).toLong().formatScore(), (-1).formatScore()) + } + + @Test + fun max_long_is_grouped() { + assertEquals("9,223,372,036,854,775,807", Long.MAX_VALUE.formatScore()) + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index a3d50be..be5548f 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -7,9 +7,6 @@ kotlin { sourceSets { androidMain.dependencies { - // Pins the unversioned Firebase Android artifacts pulled in - // transitively by dev.gitlive:firebase-crashlytics. - implementation(project.dependencies.platform(libs.firebase.android.bom)) implementation(libs.android.play.review) implementation(libs.android.play.review.ktx) } @@ -19,8 +16,6 @@ kotlin { implementation(libs.bundles.multiplatform.settings) - implementation(libs.gitlive.firebase.kotlin.crashlytics) - implementation(libs.gitlive.firebase.kotlin.analytics) } commonTest.dependencies { implementation(libs.bundles.testing) diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/di/DataBindings.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/di/DataBindings.kt index fb1bc90..5895da5 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/di/DataBindings.kt +++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/di/DataBindings.kt @@ -10,15 +10,11 @@ import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn -import ge.yet.blokblast.data.repository.AnalyticRepositoryImpl -import ge.yet.blokblast.data.repository.CrashlyticsRepositoryImpl import ge.yet.blokblast.data.repository.DefaultAudioRepository import ge.yet.blokblast.data.repository.DefaultVibrationRepository import ge.yet.blokblast.data.repository.SettingsBackedGameSaveRepository import ge.yet.blokblast.data.repository.SettingsBackedSettingsRepository -import ge.yet.blokblast.domain.repository.AnalyticRepository import ge.yet.blokblast.domain.repository.AudioRepository -import ge.yet.blokblast.domain.repository.CrashlyticsRepository import ge.yet.blokblast.domain.repository.GameSaveRepository import ge.yet.blokblast.domain.repository.SettingsRepository import ge.yet.blokblast.domain.repository.VibrationRepository @@ -50,12 +46,6 @@ abstract class DataBindings { @Binds internal abstract val DefaultVibrationRepository.bindVibrationRepository: VibrationRepository - @Binds - internal abstract val CrashlyticsRepositoryImpl.bindCrashlyticsRepository: CrashlyticsRepository - - @Binds - internal abstract val AnalyticRepositoryImpl.bindAnalyticRepository: AnalyticRepository - /** * Widening binding so consumers that only need the base [Settings] API share * the same singleton instance as [SettingsBackedSettingsRepository] — no diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt new file mode 100644 index 0000000..f03b938 --- /dev/null +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt @@ -0,0 +1,166 @@ +package ge.yet.blokblast.data.repository + +import ge.yet.blokblast.data.platform.PlatformSoundPlayer +import ge.yet.blokblast.domain.model.FeedbackType +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultAudioRepositoryTest { + + /** + * The repo's init-time combine collector emits its initial value (a Stop, + * since musicRequested starts at false). Tests care about post-init + * transitions, so we snapshot and discard that initial emission. + */ + private fun setup( + sound: Boolean = true, + ): Triple { + val player = RecordingPlayer() + val settings = FakeSettings(soundEnabled = sound) + val scope = CoroutineScope(UnconfinedTestDispatcher()) + val repo = DefaultAudioRepository(player, settings, scope) + player.calls.clear() + return Triple(repo, player, settings) + } + + // ── Music gating ───────────────────────────────────────────────────── + + @Test + fun startMusic_starts_when_fg_and_sound_on() = runTest { + val (repo, player) = setup() + repo.startMusic() + assertEquals(listOf(PlayerCall.Start), player.calls) + } + + @Test + fun startMusic_does_not_start_when_sound_off() = runTest { + val (repo, player) = setup(sound = false) + repo.startMusic() + assertTrue(player.calls.isEmpty()) + } + + @Test + fun stopMusic_stops_active_music() = runTest { + val (repo, player) = setup() + repo.startMusic() + repo.stopMusic() + assertEquals(listOf(PlayerCall.Start, PlayerCall.Stop), player.calls) + } + + @Test + fun repeat_startMusic_does_not_double_start() = runTest { + val (repo, player) = setup() + repo.startMusic() + repo.startMusic() + repo.startMusic() + assertEquals(listOf(PlayerCall.Start), player.calls) + } + + @Test + fun background_then_foreground_pauses_and_resumes() = runTest { + val (repo, player) = setup() + repo.startMusic() + repo.onAppBackground() + repo.onAppForeground() + assertEquals( + listOf(PlayerCall.Start, PlayerCall.Stop, PlayerCall.Start), + player.calls, + ) + } + + @Test + fun toggling_sound_off_then_on_during_music_stops_then_starts() = runTest { + val (repo, player, settings) = setup() + repo.startMusic() + settings.soundFlow.value = false + settings.soundFlow.value = true + assertEquals( + listOf(PlayerCall.Start, PlayerCall.Stop, PlayerCall.Start), + player.calls, + ) + } + + @Test + fun stopMusic_while_backgrounded_does_not_emit_extra_stop() = runTest { + val (repo, player) = setup() + repo.startMusic() + repo.onAppBackground() + val mid = player.calls.size + repo.stopMusic() + // Already stopped; combined boolean still false → no new emission. + assertEquals(mid, player.calls.size) + } + + // ── SFX gating ─────────────────────────────────────────────────────── + + @Test + fun sfx_play_when_enabled() = runTest { + val (repo, player) = setup() + repo.playPlacementSound() + repo.playClearSound(2) + repo.playVoiceFeedback(FeedbackType.GOOD) + repo.playVoiceCombo(3) + assertTrue(player.placement) + assertEquals(listOf(2), player.clears) + assertEquals(listOf(FeedbackType.GOOD), player.voiceFeedback) + assertEquals(listOf(3), player.voiceCombo) + } + + @Test + fun sfx_silent_when_disabled() = runTest { + val (repo, player) = setup(sound = false) + repo.playPlacementSound() + repo.playClearSound(2) + repo.playVoiceFeedback(FeedbackType.GOOD) + repo.playVoiceCombo(3) + assertEquals(false, player.placement) + assertTrue(player.clears.isEmpty()) + assertTrue(player.voiceFeedback.isEmpty()) + assertTrue(player.voiceCombo.isEmpty()) + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private enum class PlayerCall { Start, Stop } + + private class RecordingPlayer : PlatformSoundPlayer { + val calls = mutableListOf() + var placement = false + val clears = mutableListOf() + val voiceFeedback = mutableListOf() + val voiceCombo = mutableListOf() + override fun playPlacement() { placement = true } + override fun playClear(lines: Int) { clears += lines } + override fun playVoiceFeedback(type: FeedbackType) { voiceFeedback += type } + override fun playVoiceCombo(combo: Int) { voiceCombo += combo } + override fun startMusic() { calls += PlayerCall.Start } + override fun stopMusic() { calls += PlayerCall.Stop } + override fun release() {} + } + + private class FakeSettings(soundEnabled: Boolean = true) : SettingsRepository { + val soundFlow = MutableStateFlow(soundEnabled) + override val soundEnabled: StateFlow = soundFlow.asStateFlow() + override val vibrationEnabled = MutableStateFlow(true).asStateFlow() + override val darkTheme = MutableStateFlow(false).asStateFlow() + override val bestScore = MutableStateFlow(0L).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setVibrationEnabled(enabled: Boolean) {} + override suspend fun setDarkTheme(enabled: Boolean) {} + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() {} + } +} diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt new file mode 100644 index 0000000..8e9357a --- /dev/null +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt @@ -0,0 +1,79 @@ +package ge.yet.blokblast.data.repository + +import ge.yet.blokblast.data.platform.PlatformVibrator +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DefaultVibrationRepositoryTest { + + @Test + fun vibrate_light_calls_platform_when_enabled() = runTest { + val v = RecordingVibrator() + val repo = DefaultVibrationRepository(v, FakeSettings(true)) + repo.vibrateLight() + assertTrue(v.lightCalled) + assertFalse(v.heavyCalled) + } + + @Test + fun vibrate_heavy_calls_platform_when_enabled() = runTest { + val v = RecordingVibrator() + val repo = DefaultVibrationRepository(v, FakeSettings(true)) + repo.vibrateHeavy() + assertTrue(v.heavyCalled) + assertFalse(v.lightCalled) + } + + @Test + fun no_calls_when_disabled() = runTest { + val v = RecordingVibrator() + val repo = DefaultVibrationRepository(v, FakeSettings(false)) + repo.vibrateLight() + repo.vibrateHeavy() + assertFalse(v.lightCalled) + assertFalse(v.heavyCalled) + } + + @Test + fun gating_reads_flag_live() = runTest { + val v = RecordingVibrator() + val settings = FakeSettings(true) + val repo = DefaultVibrationRepository(v, settings) + repo.vibrateLight() + assertEquals(1, v.lightCount) + settings.vibrationFlow.value = false + repo.vibrateLight() + assertEquals(1, v.lightCount) + } + + private class RecordingVibrator : PlatformVibrator { + var lightCalled = false + var heavyCalled = false + var lightCount = 0 + override fun light() { lightCalled = true; lightCount += 1 } + override fun heavy() { heavyCalled = true } + } + + private class FakeSettings(vibration: Boolean) : SettingsRepository { + val vibrationFlow = MutableStateFlow(vibration) + override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow() + override val darkTheme = MutableStateFlow(false).asStateFlow() + override val bestScore = MutableStateFlow(0L).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } + override suspend fun setDarkTheme(enabled: Boolean) {} + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() {} + } +} diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepositoryTest.kt new file mode 100644 index 0000000..84c47ae --- /dev/null +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepositoryTest.kt @@ -0,0 +1,119 @@ +package ge.yet.blokblast.data.repository + +import com.app.common.AppDispatchers +import com.russhwolf.settings.MapSettings +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SettingsBackedGameSaveRepositoryTest { + + private fun newRepo(settings: MapSettings = MapSettings()) = + SettingsBackedGameSaveRepository( + settings = settings, + dispatchers = AppDispatchers( + default = Dispatchers.Unconfined, + io = Dispatchers.Unconfined, + ), + ) + + private val sampleState = GameState( + grid = Grid().withCell(2, 3, 5), + score = 1234L, + bestScore = 5000L, + comboLevel = 2, + currentPieces = listOf( + Piece( + pieceId = 7L, + shape = Polyomino(id = "h2", cells = listOf(Position(0, 0), Position(1, 0))), + colorId = 3, + ), + ), + isGameOver = false, + revivesUsed = 0, + bestAtRoundStart = 5000L, + reviewPromptFiredThisRound = true, + ) + + @Test + fun load_returns_null_on_empty_store() = runTest { + val repo = newRepo() + assertNull(repo.load()) + } + + @Test + fun save_then_load_round_trip() = runTest { + val repo = newRepo() + repo.save(sampleState) + val loaded = repo.load() + assertNotNull(loaded) + assertEquals(sampleState, loaded) + } + + @Test + fun load_returns_null_for_corrupt_json() = runTest { + val settings = MapSettings("blockblast.game_save" to "{not valid json") + val repo = newRepo(settings) + assertNull(repo.load()) + } + + @Test + fun clear_removes_persisted_save() = runTest { + val repo = newRepo() + repo.save(sampleState) + repo.clear() + assertNull(repo.load()) + } + + @Test + fun cache_warm_avoids_extra_disk_reads() = runTest { + val settings = CountingSettings() + val repo = SettingsBackedGameSaveRepository( + settings = settings, + dispatchers = AppDispatchers( + default = Dispatchers.Unconfined, + io = Dispatchers.Unconfined, + ), + ) + repo.save(sampleState) + // First load primes `loaded = true` from disk (one read). + repo.load() + val readsAfterPrime = settings.readCount + // Subsequent loads hit the cache. + repo.load() + repo.load() + assertEquals(readsAfterPrime, settings.readCount) + } + + @Test + fun load_returns_null_consistently_after_first_miss() = runTest { + val settings = MapSettings() + val repo = newRepo(settings) + assertNull(repo.load()) + // External writes after a miss aren't picked up — cache locked. + settings.putString( + "blockblast.game_save", + """{"score":1}""", + ) + assertNull(repo.load()) + } + + /** Wraps MapSettings to count getStringOrNull invocations. */ + private class CountingSettings( + private val delegate: MapSettings = MapSettings(), + ) : com.russhwolf.settings.Settings by delegate { + var readCount = 0 + override fun getStringOrNull(key: String): String? { + readCount += 1 + return delegate.getStringOrNull(key) + } + } +} diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt new file mode 100644 index 0000000..8c23bb8 --- /dev/null +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt @@ -0,0 +1,97 @@ +package ge.yet.blokblast.data.repository + +import com.app.common.AppDispatchers +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsBackedSettingsRepositoryTest { + + private val scope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher()) + private val settings = MapSettings() + private val repo = SettingsBackedSettingsRepository( + settings = settings, + scope = scope, + dispatchers = AppDispatchers( + default = Dispatchers.Unconfined, + io = Dispatchers.Unconfined, + ), + ) + + @AfterTest + fun tearDown() { + scope.coroutineContext[kotlinx.coroutines.Job]?.cancel() + } + + @Test + fun defaults() { + assertTrue(repo.soundEnabled.value) + assertTrue(repo.vibrationEnabled.value) + assertFalse(repo.darkTheme.value) + assertEquals(0L, repo.bestScore.value) + assertEquals(0, repo.reviewPromptCount.value) + assertFalse(repo.tutorialSeen.value) + } + + @Test + fun setSoundEnabled_updates_flow() = runTest { + repo.setSoundEnabled(false) + assertFalse(repo.soundEnabled.value) + } + + @Test + fun setVibrationEnabled_updates_flow() = runTest { + repo.setVibrationEnabled(false) + assertFalse(repo.vibrationEnabled.value) + } + + @Test + fun setDarkTheme_updates_flow() = runTest { + repo.setDarkTheme(true) + assertTrue(repo.darkTheme.value) + } + + @Test + fun setBestScore_is_monotonic() = runTest { + repo.setBestScore(100) + repo.setBestScore(50) // ignored + assertEquals(100L, repo.bestScore.value) + repo.setBestScore(200) + assertEquals(200L, repo.bestScore.value) + } + + @Test + fun incrementReviewPromptCount_serial() = runTest { + repo.incrementReviewPromptCount() + repo.incrementReviewPromptCount() + repo.incrementReviewPromptCount() + assertEquals(3, repo.reviewPromptCount.value) + } + + @Test + fun incrementReviewPromptCount_concurrent_no_lost_updates() = runTest { + val jobs = List(50) { + async(Dispatchers.Unconfined) { repo.incrementReviewPromptCount() } + } + jobs.awaitAll() + assertEquals(50, repo.reviewPromptCount.value) + } + + @Test + fun setTutorialSeen() = runTest { + repo.setTutorialSeen() + assertTrue(repo.tutorialSeen.value) + } +} diff --git a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt index b50f4a5..6d590d0 100644 --- a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt +++ b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt @@ -1,12 +1,21 @@ package ge.yet.blokblast.domain.engine +import app.cash.turbine.test +import ge.yet.blokblast.domain.model.FeedbackType +import ge.yet.blokblast.domain.model.GameEvent import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid import ge.yet.blokblast.domain.model.Polyomino import ge.yet.blokblast.domain.model.Position import ge.yet.blokblast.domain.repository.GameSaveRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -14,15 +23,15 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * Unit tests for [GameEngine] — focused on the regressions we caught during - * the audit (best-score seeding, save persistence, review-prompt one-shot). + * Unit tests for [GameEngine] — full behaviour coverage. */ class GameEngineTest { private val saveRepo = InMemorySaveRepo() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + private val fixedGen = ControllableShapeGenerator() private val engine = GameEngine( - shapeGenerator = FixedShapeGenerator(), + shapeGenerator = fixedGen, scoreCalculator = ScoreCalculator(), saveRepository = saveRepo, externalScope = scope, @@ -49,6 +58,13 @@ class GameEngineTest { assertEquals(5000, engine.state.value.bestScore) } + @Test + fun seedBestScore_keeps_bestAtRoundStart_when_already_higher() { + engine.startNewGame(bestScore = 5000) + engine.seedBestScore(3000) + assertEquals(5000, engine.state.value.bestAtRoundStart) + } + // ── startNewGame ───────────────────────────────────────────────────── @Test @@ -62,9 +78,16 @@ class GameEngineTest { assertEquals(900L, s.bestAtRoundStart) assertFalse(s.reviewPromptFiredThisRound) assertFalse(s.isGameOver) + assertEquals(0, s.revivesUsed) assertTrue(s.currentPieces.isNotEmpty()) } + @Test + fun startNewGame_initial_grid_is_empty() { + engine.startNewGame() + assertTrue(engine.state.value.grid.isBoardEmpty()) + } + // ── markReviewPromptFired ─────────────────────────────────────────── @Test @@ -87,17 +110,49 @@ class GameEngineTest { @Test fun restore_replaces_state() { - val snapshot = GameState( - score = 42L, - bestScore = 100L, - currentPieces = listOf(), - ) + val snapshot = GameState(score = 42L, bestScore = 100L, currentPieces = emptyList()) engine.restore(snapshot) assertEquals(42L, engine.state.value.score) assertEquals(100L, engine.state.value.bestScore) } - // ── canPlace boundaries ───────────────────────────────────────────── + @Test + fun restore_emits_GameStarted_for_playable_state() = runTest { + val playable = GameState( + grid = Grid().withCell(0, 0, 1), + currentPieces = listOf(piece(1, ONE_CELL)), + isGameOver = false, + ) + engine.events.test { + engine.restore(playable) + assertEquals(GameEvent.GameStarted, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun restore_does_not_emit_for_gameOver_state() = runTest { + val finished = GameState( + currentPieces = listOf(piece(1, ONE_CELL)), + isGameOver = true, + ) + engine.events.test { + engine.restore(finished) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun restore_does_not_emit_when_pieces_empty() = runTest { + engine.events.test { + engine.restore(GameState(currentPieces = emptyList(), isGameOver = false)) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + // ── canPlace ──────────────────────────────────────────────────────── @Test fun canPlace_rejects_out_of_bounds() { @@ -105,10 +160,364 @@ class GameEngineTest { val piece = engine.state.value.currentPieces.first() assertFalse(engine.canPlace(piece, x = -1, y = 0)) assertFalse(engine.canPlace(piece, x = 999, y = 0)) + assertFalse(engine.canPlace(piece, x = 0, y = -1)) + assertFalse(engine.canPlace(piece, x = 0, y = Grid.SIZE)) + } + + @Test + fun canPlace_rejects_overlap_with_existing_cell() { + fixedGen.nextTrayPieces = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + engine.startNewGame() + val first = engine.state.value.currentPieces[0] + assertTrue(engine.placePiece(first.pieceId, 0, 0)) + val second = engine.state.value.currentPieces.first() + assertFalse(engine.canPlace(second, 0, 0)) + } + + // ── placePiece basic ──────────────────────────────────────────────── + + @Test + fun placePiece_invalid_id_returns_false() { + engine.startNewGame() + assertFalse(engine.placePiece(pieceId = 99999L, x = 0, y = 0)) + } + + @Test + fun placePiece_when_gameOver_returns_false() { + val p = piece(1, ONE_CELL) + engine.restore(GameState(currentPieces = listOf(p), isGameOver = true)) + assertFalse(engine.placePiece(p.pieceId, 0, 0)) + } + + @Test + fun placePiece_overlap_returns_false_and_keeps_state() { + fixedGen.nextTrayPieces = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + engine.startNewGame() + val a = engine.state.value.currentPieces[0] + assertTrue(engine.placePiece(a.pieceId, 3, 3)) + val before = engine.state.value + val b = engine.state.value.currentPieces.first() + assertFalse(engine.placePiece(b.pieceId, 3, 3)) + assertEquals(before, engine.state.value) + } + + @Test + fun placePiece_awards_one_point_per_block_without_clear() { + fixedGen.nextTrayPieces = listOf(H2, ONE_CELL, ONE_CELL) + engine.startNewGame() + val p = engine.state.value.currentPieces.first { it.shape.id == "h2" } + assertTrue(engine.placePiece(p.pieceId, 0, 0)) + assertEquals(2L, engine.state.value.score) + assertEquals(0, engine.state.value.comboLevel) + } + + @Test + fun placePiece_removes_placed_piece_from_tray() { + fixedGen.nextTrayPieces = listOf(ONE_CELL, H2, ONE_CELL) + engine.startNewGame() + val before = engine.state.value.currentPieces.size + val p = engine.state.value.currentPieces.first { it.shape.id == "h2" } + engine.placePiece(p.pieceId, 0, 0) + assertEquals(before - 1, engine.state.value.currentPieces.size) + assertTrue(engine.state.value.currentPieces.none { it.pieceId == p.pieceId }) + } + + @Test + fun tray_refills_when_emptied() { + fixedGen.nextTrayPieces = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + engine.startNewGame() + repeat(3) { + val p = engine.state.value.currentPieces.first() + engine.placePiece(p.pieceId, it, 0) + } + assertEquals(3, engine.state.value.currentPieces.size) + } + + // ── Clearing lines / combos / feedback ────────────────────────────── + + @Test + fun clearing_one_row_awards_clear_points_and_combo_one() { + val grid = fillRow(row = 0, cols = 0..6) + val placePiece = piece(100, ONE_CELL) + engine.restore( + GameState( + grid = grid, + currentPieces = listOf(placePiece, piece(101, ONE_CELL), piece(102, ONE_CELL)), + ), + ) + assertTrue(engine.placePiece(placePiece.pieceId, 7, 0)) + val s = engine.state.value + // newCombo=1 is passed to clearPoints (engine increments BEFORE scoring), + // so multiplier = 1.5. placement=1, clear = 10*1*1.5 = 15 -> total 16. + assertEquals(16L, s.score) + assertEquals(1, s.comboLevel) + for (x in 0 until Grid.SIZE) assertTrue(s.grid.isEmpty(x, 0)) + } + + @Test + fun clearing_two_rows_simultaneously_emits_GOOD() = runTest { + var grid = fillRow(0, 0..6) + for (x in 0..6) grid = grid.withCell(x, 1, 1) + // Add an isolated cell elsewhere so the board isn't fully empty after clear + // (otherwise feedback escalates to UNBELIEVABLE). + grid = grid.withCell(4, 5, 1) + val placePiece = piece(1, V2) + engine.restore(GameState(grid = grid, currentPieces = listOf(placePiece))) + engine.events.test { + assertTrue(engine.placePiece(placePiece.pieceId, 7, 0)) + var saw: FeedbackType? = null + while (true) { + val ev = awaitItem() + if (ev is GameEvent.Feedback) { saw = ev.type; break } + if (ev is GameEvent.GameOver) break + } + assertEquals(FeedbackType.GOOD, saw) + cancelAndIgnoreRemainingEvents() + } + // newCombo=1 → multiplier 1.5; base 20 * sim 2 * 1.5 = 60; placement = 2 → 62 + assertEquals(62L, engine.state.value.score) + } + + @Test + fun cross_clear_is_excellent_and_isCrossClear_event_flag_set() = runTest { + var g = Grid() + for (x in 1 until Grid.SIZE) g = g.withCell(x, 0, 1) + for (y in 1 until Grid.SIZE) g = g.withCell(0, y, 1) + // Keep one cell off the cleared row/col so the board isn't empty post-clear. + g = g.withCell(4, 4, 1) + val p = piece(1, ONE_CELL) + engine.restore(GameState(grid = g, currentPieces = listOf(p))) + + engine.events.test { + engine.placePiece(p.pieceId, 0, 0) + // Expected sequence: PiecePlaced, LinesCleared(cross), Feedback (combo=1, no ComboActive) + var crossSeen = false + var feedback: FeedbackType? = null + repeat(3) { + val ev = awaitItem() + if (ev is GameEvent.LinesCleared && ev.isCrossClear) crossSeen = true + if (ev is GameEvent.Feedback) feedback = ev.type + } + assertTrue(crossSeen) + assertEquals(FeedbackType.EXCELLENT, feedback) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun clearing_full_board_emits_UNBELIEVABLE() { + var g = Grid() + for (y in 0 until Grid.SIZE) for (x in 0 until Grid.SIZE) g = g.withCell(x, y, 1) + g = g.clearedAt(setOf(Position(0, 0))) + val p = piece(1, ONE_CELL) + engine.restore(GameState(grid = g, currentPieces = listOf(p, piece(2, ONE_CELL)))) + engine.placePiece(p.pieceId, 0, 0) + assertTrue(engine.state.value.grid.isBoardEmpty()) + assertEquals(FeedbackType.UNBELIEVABLE, engine.state.value.lastFeedback.type) + } + + @Test + fun no_clear_resets_combo() { + val grid = fillRow(0, 0..6) + val first = piece(1, ONE_CELL) + val second = piece(2, ONE_CELL) + engine.restore(GameState(grid = grid, currentPieces = listOf(first, second))) + engine.placePiece(first.pieceId, 7, 0) + assertEquals(1, engine.state.value.comboLevel) + engine.placePiece(second.pieceId, 4, 4) + assertEquals(0, engine.state.value.comboLevel) + } + + @Test + fun consecutive_clears_increment_combo() { + var g = fillRow(0, 0..6) + for (x in 0..6) g = g.withCell(x, 1, 1) + val a = piece(1, ONE_CELL) + val b = piece(2, ONE_CELL) + engine.restore(GameState(grid = g, currentPieces = listOf(a, b))) + engine.placePiece(a.pieceId, 7, 0) + assertEquals(1, engine.state.value.comboLevel) + engine.placePiece(b.pieceId, 7, 1) + assertEquals(2, engine.state.value.comboLevel) + } + + @Test + fun bestScore_tracks_score_during_round() { + fixedGen.nextTrayPieces = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + engine.startNewGame(bestScore = 0) + val p = engine.state.value.currentPieces.first() + engine.placePiece(p.pieceId, 0, 0) + assertEquals(1L, engine.state.value.bestScore) + } + + @Test + fun bestScore_not_reduced_when_score_drops_below() { + engine.seedBestScore(500) + engine.startNewGame(bestScore = 500) + val p = engine.state.value.currentPieces.first() + engine.placePiece(p.pieceId, 0, 0) + assertEquals(500L, engine.state.value.bestScore) + } + + @Test + fun pointsAwarded_nonce_increments_only_when_points_gained() { + fixedGen.nextTrayPieces = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + engine.startNewGame() + val before = engine.state.value.lastPointsAwarded.nonce + val p = engine.state.value.currentPieces.first() + engine.placePiece(p.pieceId, 0, 0) + assertEquals(before + 1, engine.state.value.lastPointsAwarded.nonce) + } + + // ── Game over & revive ────────────────────────────────────────────── + + @Test + fun game_over_when_no_piece_fits() { + // Empties: row-0 has (0,0)+(1,0); col-0 has (0,0)+(0,1); plus diagonal + // (2,2)..(7,7) so every other row/col has exactly one empty. None of those + // empties are adjacent → after placing 1x1 at (0,0): + // - Row 0 still has (1,0) empty → no row clear + // - Col 0 still has (0,1) empty → no col clear + // - Other rows/cols still have their single diagonal empty → no clear + // Remaining empties {(1,0),(0,1),(2,2)..(7,7)} are all isolated → H2 fits nowhere. + val empties = buildSet { + add(Position(0, 0)); add(Position(1, 0)); add(Position(0, 1)) + for (i in 2 until Grid.SIZE) add(Position(i, i)) + } + var g = Grid() + for (y in 0 until Grid.SIZE) for (x in 0 until Grid.SIZE) { + if (Position(x, y) !in empties) g = g.withCell(x, y, 1) + } + val tray = listOf(piece(10, ONE_CELL), piece(11, H2), piece(12, H2)) + engine.restore(GameState(grid = g, currentPieces = tray)) + assertTrue(engine.placePiece(10L, 0, 0), "1x1 should fit at (0,0)") + assertTrue(engine.state.value.isGameOver, "no H2 should fit; expected game-over") + } + + @Test + fun revive_no_op_when_not_game_over() { + engine.startNewGame() + assertFalse(engine.continueWithSmallBlocks()) + } + + @Test + fun revive_succeeds_once_then_capped() { + engine.restore( + GameState( + grid = Grid(), + currentPieces = listOf(piece(1, ONE_CELL)), + isGameOver = true, + revivesUsed = 0, + ), + ) + assertTrue(engine.continueWithSmallBlocks()) + assertEquals(1, engine.state.value.revivesUsed) + assertFalse(engine.state.value.isGameOver) + assertEquals(0, engine.state.value.comboLevel) + engine.restore(engine.state.value.copy(isGameOver = true)) + assertFalse(engine.continueWithSmallBlocks()) + } + + @Test + fun revive_does_not_touch_grid_or_score() { + val grid = Grid().withCell(0, 0, 5) + engine.restore( + GameState( + grid = grid, + score = 1234L, + bestScore = 1234L, + currentPieces = listOf(piece(1, H2)), + isGameOver = true, + ), + ) + engine.continueWithSmallBlocks() + assertEquals(grid, engine.state.value.grid) + assertEquals(1234L, engine.state.value.score) + } + + @Test + fun revive_provides_three_small_pieces() { + engine.restore( + GameState(currentPieces = listOf(piece(1, H2)), isGameOver = true), + ) + engine.continueWithSmallBlocks() + assertEquals(3, engine.state.value.currentPieces.size) + for (p in engine.state.value.currentPieces) { + assertTrue(p.shape.size in 1..2) + } + } + + // ── Events ordering ───────────────────────────────────────────────── + + @Test + fun events_emitted_in_order_PiecePlaced_then_LinesCleared() = runTest { + val grid = fillRow(0, 0..6) + val p = piece(1, ONE_CELL) + engine.restore(GameState(grid = grid, currentPieces = listOf(p))) + engine.events.test { + engine.placePiece(p.pieceId, 7, 0) + assertTrue(awaitItem() is GameEvent.PiecePlaced) + assertTrue(awaitItem() is GameEvent.LinesCleared) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun ComboActive_not_emitted_below_level_two() = runTest { + val grid = fillRow(0, 0..6) + val p = piece(1, ONE_CELL) + engine.restore(GameState(grid = grid, currentPieces = listOf(p, piece(2, ONE_CELL)))) + engine.events.test { + engine.placePiece(p.pieceId, 7, 0) + val events = mutableListOf() + repeat(3) { events += awaitItem() } + assertTrue(events.none { it is GameEvent.ComboActive }) + cancelAndIgnoreRemainingEvents() + } + } + + // ── autoSave / debounce ───────────────────────────────────────────── + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun autoSave_debounces_to_single_write() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val testScope = CoroutineScope(SupervisorJob() + testDispatcher) + val repo = CountingSaveRepo() + val gen = ControllableShapeGenerator().apply { + nextTrayPieces = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + } + val engineLocal = GameEngine( + shapeGenerator = gen, + scoreCalculator = ScoreCalculator(), + saveRepository = repo, + externalScope = testScope, + ) + engineLocal.startNewGame() + repeat(3) { + val p = engineLocal.state.value.currentPieces.first() + engineLocal.placePiece(p.pieceId, it, 0) + } + advanceTimeBy(100) + runCurrent() + val midCount = repo.count + advanceTimeBy(400) + runCurrent() + assertTrue(repo.count > midCount) + testScope.coroutineContext[kotlinx.coroutines.Job]?.cancel() } // ── Helpers ───────────────────────────────────────────────────────── + private fun fillRow(row: Int, cols: IntRange): Grid { + var g = Grid() + for (x in cols) g = g.withCell(x, row, 1) + return g + } + + private fun piece(id: Long, shape: Polyomino) = + ge.yet.blokblast.domain.model.Piece(pieceId = id, shape = shape, colorId = 1) + private class InMemorySaveRepo : GameSaveRepository { var saved: GameState? = null override suspend fun save(state: GameState) { saved = state } @@ -116,10 +525,22 @@ class GameEngineTest { override suspend fun clear() { saved = null } } - private class FixedShapeGenerator : ShapeGenerator { - // 1x1 piece — easy to reason about in tests. - private val one = Polyomino(id = "1x1", cells = listOf(Position(0, 0))) - override fun nextTray(seed: Long?): List = listOf(one, one, one) - override fun smallReviveTray(): List = listOf(one, one, one) + private class CountingSaveRepo : GameSaveRepository { + var count = 0 + override suspend fun save(state: GameState) { count += 1 } + override suspend fun load(): GameState? = null + override suspend fun clear() {} + } + + private class ControllableShapeGenerator : ShapeGenerator { + var nextTrayPieces: List = listOf(ONE_CELL, ONE_CELL, ONE_CELL) + override fun nextTray(seed: Long?): List = nextTrayPieces + override fun smallReviveTray(): List = listOf(ONE_CELL, H2, V2) + } + + companion object { + private val ONE_CELL = Polyomino(id = "1x1", cells = listOf(Position(0, 0))) + private val H2 = Polyomino(id = "h2", cells = listOf(Position(0, 0), Position(1, 0))) + private val V2 = Polyomino(id = "v2", cells = listOf(Position(0, 0), Position(0, 1))) } } diff --git a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ScoreCalculatorTest.kt b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ScoreCalculatorTest.kt new file mode 100644 index 0000000..e43a9d8 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ScoreCalculatorTest.kt @@ -0,0 +1,76 @@ +package ge.yet.blokblast.domain.engine + +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScoreCalculatorTest { + + private val calc = ScoreCalculator() + + private fun shape(size: Int): Polyomino = + Polyomino(id = "s$size", cells = (0 until size).map { Position(it, 0) }) + + @Test + fun placementPoints_equals_shape_size() { + assertEquals(1L, calc.placementPoints(shape(1))) + assertEquals(2L, calc.placementPoints(shape(2))) + assertEquals(5L, calc.placementPoints(shape(5))) + assertEquals(9L, calc.placementPoints(shape(9))) + } + + @Test + fun clearPoints_zero_lines_is_zero() { + assertEquals(0L, calc.clearPoints(0, 0)) + assertEquals(0L, calc.clearPoints(0, 5)) + assertEquals(0L, calc.clearPoints(-1, 5)) + } + + @Test + fun clearPoints_one_line_no_combo() { + // base=10*1, simultaneous=1, combo=1.0 -> 10 + assertEquals(10L, calc.clearPoints(1, 0)) + } + + @Test + fun clearPoints_two_lines_no_combo() { + // base=20, simultaneous=2, combo=1.0 -> 40 + assertEquals(40L, calc.clearPoints(2, 0)) + } + + @Test + fun clearPoints_three_lines_no_combo() { + // base=30, simultaneous=3, combo=1.0 -> 90 + assertEquals(90L, calc.clearPoints(3, 0)) + } + + @Test + fun clearPoints_one_line_combo_two() { + // base=10, simultaneous=1, combo=2.0 -> 20 + assertEquals(20L, calc.clearPoints(1, 2)) + } + + @Test + fun clearPoints_two_lines_combo_three() { + // base=20, simultaneous=2, combo=2.5 -> 40*2.5 = 100 + assertEquals(100L, calc.clearPoints(2, 3)) + } + + @Test + fun clearPoints_negative_combo_treated_as_no_combo() { + assertEquals(10L, calc.clearPoints(1, -1)) + assertEquals(40L, calc.clearPoints(2, -5)) + } + + @Test + fun clearPoints_four_lines_combo_one_excellent_case() { + // base=40, simultaneous=4, combo=1.5 -> 160*1.5 = 240 + assertEquals(240L, calc.clearPoints(4, 1)) + } + + @Test + fun base_line_reward_is_ten() { + assertEquals(10, ScoreCalculator.BASE_LINE_REWARD) + } +} diff --git a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ShapeGeneratorTest.kt b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ShapeGeneratorTest.kt new file mode 100644 index 0000000..64895b5 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ShapeGeneratorTest.kt @@ -0,0 +1,70 @@ +package ge.yet.blokblast.domain.engine + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ShapeGeneratorTest { + + private val gen = WeightedShapeGenerator() + + @Test + fun nextTray_has_size_three() { + repeat(20) { seed -> + assertEquals(3, gen.nextTray(seed.toLong()).size) + } + } + + @Test + fun nextTray_deterministic_for_same_seed() { + val a = gen.nextTray(seed = 42L).map { it.id } + val b = gen.nextTray(seed = 42L).map { it.id } + assertEquals(a, b) + } + + @Test + fun nextTray_contains_one_small_and_one_medium_at_minimum() { + // First two slots before shuffle are always SMALL and MEDIUM; after + // shuffle they're still in the tray. So the tray's id-set always + // intersects SMALL and MEDIUM. + repeat(50) { seed -> + val ids = gen.nextTray(seed.toLong()).map { it.id }.toSet() + val smallIds = ShapeCatalog.SMALL.map { it.id }.toSet() + val mediumIds = ShapeCatalog.MEDIUM.map { it.id }.toSet() + assertTrue( + ids.any { it in smallIds }, + "tray $ids has no SMALL piece (seed=$seed)", + ) + assertTrue( + ids.any { it in mediumIds }, + "tray $ids has no MEDIUM piece (seed=$seed)", + ) + } + } + + @Test + fun smallReviveTray_is_three_size_two_shapes() { + val tray = gen.smallReviveTray() + assertEquals(3, tray.size) + // SMALL[0..2] = h2, v2, diag2_tlbr — all size 2 + assertTrue(tray.all { it.size == 2 }) + } + + @Test + fun smallReviveTray_is_stable() { + assertEquals( + gen.smallReviveTray().map { it.id }, + gen.smallReviveTray().map { it.id }, + ) + } + + @Test + fun nextTray_all_ids_are_from_catalog() { + val allIds = ShapeCatalog.ALL.map { it.id }.toSet() + repeat(100) { seed -> + for (piece in gen.nextTray(seed.toLong())) { + assertTrue(piece.id in allIds, "unknown id ${piece.id}") + } + } + } +} diff --git a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/model/GridTest.kt b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/model/GridTest.kt new file mode 100644 index 0000000..e3cf1c1 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/model/GridTest.kt @@ -0,0 +1,99 @@ +package ge.yet.blokblast.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class GridTest { + + @Test + fun fresh_grid_is_all_empty() { + val g = Grid() + assertTrue(g.isBoardEmpty()) + for (y in 0 until Grid.SIZE) for (x in 0 until Grid.SIZE) { + assertTrue(g.isEmpty(x, y)) + assertEquals(Grid.EMPTY, g.colorAt(x, y)) + } + } + + @Test + fun inBounds_corners_and_outside() { + val g = Grid() + assertTrue(g.inBounds(0, 0)) + assertTrue(g.inBounds(Grid.SIZE - 1, Grid.SIZE - 1)) + assertFalse(g.inBounds(-1, 0)) + assertFalse(g.inBounds(0, -1)) + assertFalse(g.inBounds(Grid.SIZE, 0)) + assertFalse(g.inBounds(0, Grid.SIZE)) + } + + @Test + fun withCell_does_not_mutate_original() { + val original = Grid() + val updated = original.withCell(2, 3, colorId = 5) + assertTrue(original.isEmpty(2, 3)) + assertFalse(updated.isEmpty(2, 3)) + assertEquals(5, updated.colorAt(2, 3)) + assertNotSame(original, updated) + } + + @Test + fun withCells_empty_list_returns_equivalent_grid() { + val g = Grid() + val same = g.withCells(emptyList(), colorId = 1) + assertEquals(g, same) + } + + @Test + fun withCells_stamps_all_positions() { + val cells = listOf(Position(0, 0), Position(1, 0), Position(0, 1)) + val g = Grid().withCells(cells, colorId = 3) + assertEquals(3, g.colorAt(0, 0)) + assertEquals(3, g.colorAt(1, 0)) + assertEquals(3, g.colorAt(0, 1)) + assertTrue(g.isEmpty(2, 0)) + } + + @Test + fun clearedAt_empty_set_returns_same_instance() { + val g = Grid().withCell(0, 0, 1) + assertSame(g, g.clearedAt(emptySet())) + } + + @Test + fun clearedAt_clears_positions() { + val g = Grid().withCells(listOf(Position(0, 0), Position(1, 1)), 2) + val cleared = g.clearedAt(setOf(Position(0, 0))) + assertTrue(cleared.isEmpty(0, 0)) + assertFalse(cleared.isEmpty(1, 1)) + } + + @Test + fun isBoardEmpty_after_partial_fill_is_false() { + val g = Grid().withCell(4, 4, 1) + assertFalse(g.isBoardEmpty()) + } + + @Test + fun equals_and_hashCode_are_content_based() { + val a = Grid().withCell(0, 0, 1) + val b = Grid().withCell(0, 0, 1) + val c = Grid().withCell(0, 0, 2) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertNotSame(a, b) + assertFalse(a == c) + } + + @Test + fun fully_filled_grid_is_not_empty() { + var g = Grid() + for (y in 0 until Grid.SIZE) for (x in 0 until Grid.SIZE) { + g = g.withCell(x, y, 1) + } + assertFalse(g.isBoardEmpty()) + } +} diff --git a/core/telemetry/build.gradle.kts b/core/telemetry/build.gradle.kts new file mode 100644 index 0000000..5dc4b89 --- /dev/null +++ b/core/telemetry/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.local.kotlin.multiplatform) + alias(libs.plugins.metro) +} + +kotlin { + + sourceSets { + commonMain.dependencies { + implementation(projects.core.domain) + + implementation(libs.gitlive.firebase.kotlin.crashlytics) + implementation(libs.gitlive.firebase.kotlin.analytics) + } + androidMain.dependencies { + implementation(project.dependencies.platform(libs.firebase.android.bom)) + } + } +} diff --git a/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/di/TelemetryBindings.kt b/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/di/TelemetryBindings.kt new file mode 100644 index 0000000..5160080 --- /dev/null +++ b/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/di/TelemetryBindings.kt @@ -0,0 +1,27 @@ +package ge.yet.blokblast.telemetry.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.CrashlyticsRepository +import ge.yet.blokblast.telemetry.repository.AnalyticRepositoryImpl +import ge.yet.blokblast.telemetry.repository.CrashlyticsRepositoryImpl + +/** + * Firebase-backed telemetry bindings, contributed to [AppScope]. + * + * Lives in its own module so [ge.yet.blokblast.data] (and its tests) can build + * and link on iOS without needing Firebase native frameworks at link time. + */ +@ContributesTo(AppScope::class) +@BindingContainer +abstract class TelemetryBindings { + + @Binds + internal abstract val CrashlyticsRepositoryImpl.bindCrashlyticsRepository: CrashlyticsRepository + + @Binds + internal abstract val AnalyticRepositoryImpl.bindAnalyticRepository: AnalyticRepository +} diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/AnalyticRepositoryImpl.kt b/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/repository/AnalyticRepositoryImpl.kt similarity index 93% rename from core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/AnalyticRepositoryImpl.kt rename to core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/repository/AnalyticRepositoryImpl.kt index 2bffb22..121e80b 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/AnalyticRepositoryImpl.kt +++ b/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/repository/AnalyticRepositoryImpl.kt @@ -1,4 +1,4 @@ -package ge.yet.blokblast.data.repository +package ge.yet.blokblast.telemetry.repository import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/CrashlyticsRepositoryImpl.kt b/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/repository/CrashlyticsRepositoryImpl.kt similarity index 96% rename from core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/CrashlyticsRepositoryImpl.kt rename to core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/repository/CrashlyticsRepositoryImpl.kt index a522ee9..138979f 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/CrashlyticsRepositoryImpl.kt +++ b/core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/repository/CrashlyticsRepositoryImpl.kt @@ -1,4 +1,4 @@ -package ge.yet.blokblast.data.repository +package ge.yet.blokblast.telemetry.repository import dev.gitlive.firebase.Firebase import dev.gitlive.firebase.crashlytics.crashlytics diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt new file mode 100644 index 0000000..407dcf8 --- /dev/null +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt @@ -0,0 +1,292 @@ +package ge.yet.blockblast.feature.game + +import com.app.common.config.AppConfig +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.destroy +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import ge.yet.blockblast.feature.game.store.GameStoreFactory +import ge.yet.blockblast.feature.settings.SettingsComponent +import ge.yet.blokblast.domain.engine.GameEngine +import ge.yet.blokblast.domain.engine.ScoreCalculator +import ge.yet.blokblast.domain.engine.ShapeGenerator +import ge.yet.blokblast.domain.model.FeedbackType +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.AudioRepository +import ge.yet.blokblast.domain.repository.GameSaveRepository +import ge.yet.blokblast.domain.repository.ReviewCode +import ge.yet.blokblast.domain.repository.SettingsRepository +import ge.yet.blokblast.domain.repository.StoreReviewRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultGameComponentTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { Dispatchers.setMain(testDispatcher) } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + + private fun build( + isNewGame: Boolean = true, + reviewCount: Int = 0, + bestScore: Long = 0L, + ): Setup { + val lifecycle = LifecycleRegistry() + val scope = CoroutineScope(testDispatcher + SupervisorJob()) + val analytics = RecordingAnalytics() + val audio = RecordingAudio() + val storeReview = RecordingStoreReview() + val settings = FakeSettings(bestScore = bestScore, reviewPromptCount = reviewCount) + val save = StubSaveRepo() + val engine = GameEngine( + shapeGenerator = OneByOneGenerator(), + scoreCalculator = ScoreCalculator(), + saveRepository = save, + externalScope = scope, + ) + val storeFactory = GameStoreFactory( + storeFactory = DefaultStoreFactory(), + engine = engine, + audio = audio, + storeReview = storeReview, + saveRepository = save, + settings = settings, + analytics = analytics, + ) + val exitCalls = mutableListOf() + val component = DefaultGameComponent( + componentContext = DefaultComponentContext(lifecycle), + gameStoreFactory = storeFactory, + settingsComponent = StubSettingsFactory(), + audio = audio, + settings = settings, + storeReview = storeReview, + analytics = analytics, + isNewGame = isNewGame, + onExitClickedCb = { exitCalls += Unit }, + ) + return Setup(component, lifecycle, scope, engine, audio, analytics, settings, storeReview, exitCalls) + } + + // ── Navigation: settings sheet ─────────────────────────────────────── + + @Test + fun onSettingsClicked_opens_settings_sheet_and_logs() { + val s = build() + s.component.onSettingsClicked() + assertIs(s.component.sheetSlot.value.child?.instance) + assertNotNull(s.analytics.events.find { it.first == "settings_opened" }) + s.dispose() + } + + @Test + fun onDismissSheet_closes_settings_and_logs() { + val s = build() + s.component.onSettingsClicked() + s.component.onDismissSheet() + assertNull(s.component.sheetSlot.value.child) + assertNotNull(s.analytics.events.find { it.first == "settings_closed" }) + s.dispose() + } + + // ── Exit ───────────────────────────────────────────────────────────── + + @Test + fun onExitClicked_invokes_callback_and_logs() { + val s = build() + s.component.onExitClicked() + assertEquals(1, s.exitCalls.size) + assertNotNull(s.analytics.events.find { it.first == "exit_clicked" }) + s.dispose() + } + + // ── Intent forwarding ──────────────────────────────────────────────── + + @Test + fun onCellClicked_forwards_Place_intent_to_store() { + val s = build() + val piece = s.engine.state.value.currentPieces.first() + s.component.onCellClicked(piece.pieceId, 0, 0) + assertTrue(s.analytics.events.any { it.first == "piece_place_success" }) + s.dispose() + } + + @Test + fun onRestartClicked_starts_new_round_via_engine() { + val s = build() + val piece = s.engine.state.value.currentPieces.first() + s.engine.placePiece(piece.pieceId, 0, 0) + assertTrue(s.engine.state.value.score > 0) + s.component.onRestartClicked() + assertEquals(0L, s.engine.state.value.score) + s.dispose() + } + + @Test + fun onReviveClicked_restores_play_when_game_over() { + val s = build() + s.engine.restore(s.engine.state.value.copy(isGameOver = true)) + s.component.onReviveClicked() + assertEquals(false, s.engine.state.value.isGameOver) + assertEquals(1, s.engine.state.value.revivesUsed) + s.dispose() + } + + // ── Review prompt sheet ────────────────────────────────────────────── + + @Test + fun review_request_label_activates_review_prompt_sheet() = runTest(testDispatcher) { + val s = build(reviewCount = 0) + // Trigger qualifying game-over → store publishes RequestReview label. + s.engine.restore( + s.engine.state.value.copy( + score = AppConfig.REVIEW_MIN_SCORE + AppConfig.REVIEW_BEST_SCORE_DELTA + 10L, + bestAtRoundStart = 0L, + isGameOver = true, + ), + ) + runCurrent() + assertIs(s.component.sheetSlot.value.child?.instance) + assertNotNull(s.analytics.events.find { it.first == "review_prompt_shown" }) + s.dispose() + } + + // ── Lifecycle ──────────────────────────────────────────────────────── + + @Test + fun destroy_stops_music() = runTest(testDispatcher) { + val s = build() + s.lifecycle.resume() + s.audio.stopMusicCount = 0 + s.lifecycle.destroy() + runCurrent() + assertTrue(s.audio.stopMusicCount >= 1) + s.dispose() + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private data class Setup( + val component: DefaultGameComponent, + val lifecycle: LifecycleRegistry, + val scope: CoroutineScope, + val engine: GameEngine, + val audio: RecordingAudio, + val analytics: RecordingAnalytics, + val settings: FakeSettings, + val storeReview: RecordingStoreReview, + val exitCalls: MutableList, + ) { + fun dispose() { scope.cancel() } + } + + private class OneByOneGenerator : ShapeGenerator { + private val one = Polyomino("1x1", listOf(Position(0, 0))) + override fun nextTray(seed: Long?): List = listOf(one, one, one) + override fun smallReviveTray(): List = listOf(one, one, one) + } + + private class StubSaveRepo : GameSaveRepository { + private var stored: GameState? = null + override suspend fun save(state: GameState) { stored = state } + override suspend fun load(): GameState? = stored + override suspend fun clear() { stored = null } + } + + private class FakeSettings(bestScore: Long = 0L, reviewPromptCount: Int = 0) : SettingsRepository { + private val bestScoreFlow = MutableStateFlow(bestScore) + private val reviewFlow = MutableStateFlow(reviewPromptCount) + override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val vibrationEnabled = MutableStateFlow(true).asStateFlow() + override val darkTheme = MutableStateFlow(false).asStateFlow() + override val bestScore: StateFlow = bestScoreFlow.asStateFlow() + override val reviewPromptCount: StateFlow = reviewFlow.asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setVibrationEnabled(enabled: Boolean) {} + override suspend fun setDarkTheme(enabled: Boolean) {} + override suspend fun setBestScore(score: Long) { + if (score > bestScoreFlow.value) bestScoreFlow.value = score + } + override suspend fun incrementReviewPromptCount() { reviewFlow.value += 1 } + override suspend fun setTutorialSeen() {} + } + + private class RecordingAudio : AudioRepository { + var stopMusicCount = 0 + override suspend fun playPlacementSound() {} + override suspend fun playClearSound(lines: Int) {} + override suspend fun playVoiceFeedback(type: FeedbackType) {} + override suspend fun playVoiceCombo(combo: Int) {} + override suspend fun startMusic() {} + override suspend fun stopMusic() { stopMusicCount += 1 } + override suspend fun onAppBackground() {} + override suspend fun onAppForeground() {} + } + + private class RecordingAnalytics : AnalyticRepository { + val events = mutableListOf>>() + override fun logEvent(eventName: String, params: Map?) { + events += eventName to (params ?: emptyMap()) + } + override fun deleteData() {} + } + + private class RecordingStoreReview : StoreReviewRepository { + var inAppRequests = 0 + override fun requestInAppReview(): Flow { + inAppRequests += 1 + return flowOf(ReviewCode.NO_ERROR) + } + override fun requestInMarketReview(): Flow = flowOf(ReviewCode.NO_ERROR) + } + + private class StubSettingsFactory : SettingsComponent.Factory { + override fun create( + componentContext: ComponentContext, + onBackClicked: () -> Unit, + ): SettingsComponent = StubSettingsComponent(componentContext, onBackClicked) + } + + private class StubSettingsComponent( + componentContext: ComponentContext, + val onBack: () -> Unit, + ) : SettingsComponent, ComponentContext by componentContext { + // Tests inspect only the sheet wrapper type, never this stack. + override val stack + get() = error("StubSettingsComponent.stack must not be read in tests") + override fun onBackClicked() = onBack() + } +} diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt new file mode 100644 index 0000000..531423e --- /dev/null +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt @@ -0,0 +1,509 @@ +package ge.yet.blockblast.feature.game.store + +import com.app.common.config.AppConfig +import com.arkivanov.mvikotlin.extensions.coroutines.labels +import com.arkivanov.mvikotlin.extensions.coroutines.states +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import ge.yet.blokblast.domain.engine.GameEngine +import ge.yet.blokblast.domain.engine.ScoreCalculator +import ge.yet.blokblast.domain.engine.ShapeGenerator +import ge.yet.blokblast.domain.model.FeedbackType +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.AudioRepository +import ge.yet.blokblast.domain.repository.GameSaveRepository +import ge.yet.blokblast.domain.repository.ReviewCode +import ge.yet.blokblast.domain.repository.SettingsRepository +import ge.yet.blokblast.domain.repository.StoreReviewRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.test.advanceTimeBy +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class GameStoreFactoryTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { Dispatchers.setMain(testDispatcher) } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + + // ── Bootstrap branches ─────────────────────────────────────────────── + + @Test + fun bootstrap_new_game_starts_engine() = runTest { + val deps = TestDeps() + deps.factory().create(isNewGame = true) + // engine should be in a fresh round + assertEquals(0L, deps.engine.state.value.score) + assertTrue(deps.engine.state.value.currentPieces.isNotEmpty()) + assertTrue(deps.analytics.has("game_started", mapOf("source" to "new"))) + deps.dispose() + } + + @Test + fun bootstrap_continue_with_no_save_starts_new_game() = runTest { + val deps = TestDeps() + deps.factory().create(isNewGame = false) + assertTrue(deps.engine.state.value.currentPieces.isNotEmpty()) + assertTrue(deps.analytics.has("game_started", mapOf("source" to "new"))) + deps.dispose() + } + + @Test + fun bootstrap_continue_with_playable_save_restores() = runTest { + val savedState = playableState(score = 77L) + val deps = TestDeps(savedState = savedState) + deps.factory().create(isNewGame = false) + assertEquals(77L, deps.engine.state.value.score) + assertTrue(deps.analytics.has("game_started", mapOf("source" to "continue"))) + deps.dispose() + } + + @Test + fun bootstrap_continue_with_gameOver_save_starts_new_game() = runTest { + val deps = TestDeps(savedState = playableState().copy(isGameOver = true)) + deps.factory().create(isNewGame = false) + assertFalse(deps.engine.state.value.isGameOver) + assertTrue(deps.analytics.has("game_started", mapOf("source" to "new"))) + deps.dispose() + } + + @Test + fun bootstrap_seeds_bestScore_from_settings() = runTest { + val deps = TestDeps(settingsBest = 2500L) + deps.factory().create(isNewGame = true) + assertEquals(2500L, deps.engine.state.value.bestScore) + deps.dispose() + } + + @Test + fun bootstrap_warm_continue_does_not_restart_engine() = runTest { + val deps = TestDeps() + // Pre-warm engine + deps.engine.startNewGame(bestScore = 0) + val pieceCountBefore = deps.engine.state.value.currentPieces.size + val firstPieceId = deps.engine.state.value.currentPieces.first().pieceId + deps.factory().create(isNewGame = false) + // Engine state untouched (same first piece id; engine.startNewGame was NOT called again) + assertEquals(pieceCountBefore, deps.engine.state.value.currentPieces.size) + assertEquals(firstPieceId, deps.engine.state.value.currentPieces.first().pieceId) + deps.dispose() + } + + // ── State snapshots ────────────────────────────────────────────────── + + @Test + fun engine_state_emissions_are_reflected_in_store_state() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val piece = deps.engine.state.value.currentPieces.first() + deps.engine.placePiece(piece.pieceId, 0, 0) + assertEquals(deps.engine.state.value, store.state.game) + deps.dispose() + } + + // ── Best-score persistence ─────────────────────────────────────────── + + @Test + fun bestScore_increase_is_persisted_to_settings() = runTest { + val deps = TestDeps(settingsBest = 0L) + deps.factory().create(isNewGame = true) + // Manually lift bestScore on the engine + deps.engine.seedBestScore(123L) + // collector listens distinctUntilChanged on bestScore — should propagate + assertEquals(123L, deps.settings.bestScore.value) + deps.dispose() + } + + // ── SFX wiring ─────────────────────────────────────────────────────── + + @Test + fun piece_placement_triggers_placement_sound() = runTest { + val deps = TestDeps() + deps.factory().create(isNewGame = true) + val piece = deps.engine.state.value.currentPieces.first() + deps.engine.placePiece(piece.pieceId, 0, 0) + assertTrue(deps.audio.placementCount >= 1) + deps.dispose() + } + + @Test + fun line_clear_triggers_clear_sound_and_analytics() = runTest { + val deps = TestDeps() + deps.factory().create(isNewGame = true) + // Build a clear: fill row 0 cols 0..6 then place 1x1 at (7,0). + // Use restore for deterministic setup. + var grid = Grid() + for (x in 0..6) grid = grid.withCell(x, 0, 1) + grid = grid.withCell(3, 5, 1) // avoid full-board UNBELIEVABLE + val placePiece = Piece( + pieceId = 999L, + shape = Polyomino("1x1", listOf(Position(0, 0))), + colorId = 1, + ) + deps.engine.restore(GameState(grid = grid, currentPieces = listOf(placePiece))) + deps.engine.placePiece(999L, 7, 0) + assertEquals(listOf(1), deps.audio.clearedLines) + assertTrue(deps.analytics.has("lines_cleared")) + deps.dispose() + } + + // ── Music gating ───────────────────────────────────────────────────── + + @Test + fun music_starts_on_active_round() = runTest { + val deps = TestDeps() + deps.factory().create(isNewGame = true) + assertTrue(deps.audio.startMusicCount >= 1) + deps.dispose() + } + + @Test + fun music_stops_when_game_over() = runTest { + val deps = TestDeps() + deps.factory().create(isNewGame = true) + deps.engine.restore(deps.engine.state.value.copy(isGameOver = true)) + assertTrue(deps.audio.stopMusicCount >= 1) + deps.dispose() + } + + // ── Game-over edge → countdown ─────────────────────────────────────── + + @Test + fun gameOver_edge_starts_countdown_at_five() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + deps.engine.restore(deps.engine.state.value.copy(isGameOver = true)) + runCurrent() + assertEquals(GameStoreState.CONTINUE_COUNTDOWN_SECONDS, store.state.continueCountdown) + deps.dispose() + } + + @Test + fun countdown_ticks_down_each_second() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + deps.engine.restore(deps.engine.state.value.copy(isGameOver = true)) + runCurrent() + assertEquals(5, store.state.continueCountdown) + advanceTimeBy(1100) + assertEquals(4, store.state.continueCountdown) + advanceTimeBy(4000) + assertEquals(0, store.state.continueCountdown) + deps.dispose() + } + + @Test + fun revive_clears_countdown() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + deps.engine.restore(deps.engine.state.value.copy(isGameOver = true)) + runCurrent() + assertEquals(5, store.state.continueCountdown) + // Simulate revive + deps.engine.restore(deps.engine.state.value.copy(isGameOver = false)) + runCurrent() + assertEquals(GameStoreState.COUNTDOWN_INACTIVE, store.state.continueCountdown) + deps.dispose() + } + + // ── Review prompt qualifier ────────────────────────────────────────── + + @Test + fun review_label_fires_when_all_conditions_met() = runTest { + val deps = TestDeps(settingsBest = 0L, reviewCount = 0) + val store = deps.factory().create(isNewGame = true) + val labels = mutableListOf() + val labelScope = CoroutineScope(testDispatcher + SupervisorJob()) + labelScope.launch { store.labels.collect { labels += it } } + // qualifying: score >= 500, beat best by >= 1000 + val qualifying = deps.engine.state.value.copy( + score = AppConfig.REVIEW_MIN_SCORE + AppConfig.REVIEW_BEST_SCORE_DELTA.toInt() + 10L, + bestAtRoundStart = 0L, + isGameOver = true, + reviewPromptFiredThisRound = false, + ) + deps.engine.restore(qualifying) + runCurrent() + assertEquals(listOf(GameStore.Label.RequestReview), labels) + assertTrue(deps.engine.state.value.reviewPromptFiredThisRound) + labelScope.cancel() + deps.dispose() + } + + @Test + fun review_label_does_not_fire_when_score_below_minimum() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val labels = mutableListOf() + val scope = CoroutineScope(testDispatcher + SupervisorJob()) + scope.launch { store.labels.collect { labels += it } } + deps.engine.restore( + deps.engine.state.value.copy( + score = (AppConfig.REVIEW_MIN_SCORE - 1).toLong(), + bestAtRoundStart = 0, + isGameOver = true, + ), + ) + runCurrent() + assertTrue(labels.isEmpty()) + scope.cancel() + deps.dispose() + } + + @Test + fun review_label_does_not_fire_when_delta_below_threshold() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val labels = mutableListOf() + val scope = CoroutineScope(testDispatcher + SupervisorJob()) + scope.launch { store.labels.collect { labels += it } } + deps.engine.restore( + deps.engine.state.value.copy( + score = AppConfig.REVIEW_MIN_SCORE.toLong() + 50, + // beats by only 50 — below DELTA(1000) + bestAtRoundStart = AppConfig.REVIEW_MIN_SCORE.toLong(), + isGameOver = true, + ), + ) + runCurrent() + assertTrue(labels.isEmpty()) + scope.cancel() + deps.dispose() + } + + @Test + fun review_label_does_not_fire_when_max_reached() = runTest { + val deps = TestDeps(reviewCount = AppConfig.REVIEW_MAX_PROMPTS) + val store = deps.factory().create(isNewGame = true) + val labels = mutableListOf() + val scope = CoroutineScope(testDispatcher + SupervisorJob()) + scope.launch { store.labels.collect { labels += it } } + deps.engine.restore( + deps.engine.state.value.copy( + score = 10_000L, + bestAtRoundStart = 0L, + isGameOver = true, + ), + ) + runCurrent() + assertTrue(labels.isEmpty()) + scope.cancel() + deps.dispose() + } + + @Test + fun review_label_does_not_re_fire_if_already_fired_this_round() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val labels = mutableListOf() + val scope = CoroutineScope(testDispatcher + SupervisorJob()) + scope.launch { store.labels.collect { labels += it } } + deps.engine.restore( + deps.engine.state.value.copy( + score = 10_000L, + bestAtRoundStart = 0L, + isGameOver = true, + reviewPromptFiredThisRound = true, + ), + ) + runCurrent() + assertTrue(labels.isEmpty()) + scope.cancel() + deps.dispose() + } + + // ── Intents ────────────────────────────────────────────────────────── + + @Test + fun place_intent_invokes_engine_and_logs() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val piece = deps.engine.state.value.currentPieces.first() + store.accept(GameStore.Intent.Place(piece.pieceId, 0, 0)) + assertTrue(deps.analytics.has("piece_place_attempt")) + assertTrue(deps.analytics.has("piece_place_success")) + deps.dispose() + } + + @Test + fun place_intent_logs_failed_on_overlap() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val piece = deps.engine.state.value.currentPieces.first() + store.accept(GameStore.Intent.Place(piece.pieceId, 0, 0)) + // Try placing another piece at same cell + val piece2 = deps.engine.state.value.currentPieces.first() + store.accept(GameStore.Intent.Place(piece2.pieceId, 0, 0)) + assertTrue(deps.analytics.has("piece_place_failed")) + deps.dispose() + } + + @Test + fun restart_intent_starts_new_game() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + val piece = deps.engine.state.value.currentPieces.first() + deps.engine.placePiece(piece.pieceId, 0, 0) + val scoreBefore = deps.engine.state.value.score + assertTrue(scoreBefore > 0) + store.accept(GameStore.Intent.Restart) + runCurrent() + assertEquals(0L, deps.engine.state.value.score) + assertTrue(deps.analytics.has("restart_clicked")) + deps.dispose() + } + + @Test + fun revive_intent_continues_with_small_blocks_when_game_over() = runTest { + val deps = TestDeps() + val store = deps.factory().create(isNewGame = true) + deps.engine.restore(deps.engine.state.value.copy(isGameOver = true)) + store.accept(GameStore.Intent.Revive) + runCurrent() + assertFalse(deps.engine.state.value.isGameOver) + assertEquals(1, deps.engine.state.value.revivesUsed) + assertTrue(deps.analytics.has("revive_clicked")) + deps.dispose() + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private fun playableState(score: Long = 0L): GameState = GameState( + grid = Grid().withCell(0, 0, 1), + score = score, + bestScore = score, + currentPieces = listOf( + Piece(42L, Polyomino("h2", listOf(Position(0, 0), Position(1, 0))), 1), + ), + isGameOver = false, + ) + + /** All collaborators wired together so each test gets a fresh engine + factory. */ + private inner class TestDeps( + savedState: GameState? = null, + settingsBest: Long = 0L, + reviewCount: Int = 0, + ) { + val scope = CoroutineScope(testDispatcher + SupervisorJob()) + val saveRepo = StubSaveRepo(savedState) + val settings = FakeSettings(bestScore = settingsBest, reviewPromptCount = reviewCount) + val audio = RecordingAudio() + val analytics = RecordingAnalytics() + val storeReview = NoopStoreReview() + val engine = GameEngine( + shapeGenerator = OneByOneGenerator(), + scoreCalculator = ScoreCalculator(), + saveRepository = saveRepo, + externalScope = scope, + ) + + fun factory(): GameStoreFactory = GameStoreFactory( + storeFactory = DefaultStoreFactory(), + engine = engine, + audio = audio, + storeReview = storeReview, + saveRepository = saveRepo, + settings = settings, + analytics = analytics, + ) + + fun dispose() { scope.cancel() } + } +} + +private class OneByOneGenerator : ShapeGenerator { + private val one = Polyomino("1x1", listOf(Position(0, 0))) + override fun nextTray(seed: Long?): List = listOf(one, one, one) + override fun smallReviveTray(): List = listOf(one, one, one) +} + +private class StubSaveRepo(initial: GameState? = null) : GameSaveRepository { + private var stored: GameState? = initial + override suspend fun save(state: GameState) { stored = state } + override suspend fun load(): GameState? = stored + override suspend fun clear() { stored = null } +} + +private class FakeSettings( + bestScore: Long = 0L, + reviewPromptCount: Int = 0, +) : SettingsRepository { + private val bestScoreFlow = MutableStateFlow(bestScore) + private val reviewFlow = MutableStateFlow(reviewPromptCount) + override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val vibrationEnabled = MutableStateFlow(true).asStateFlow() + override val darkTheme = MutableStateFlow(false).asStateFlow() + override val bestScore: StateFlow = bestScoreFlow.asStateFlow() + override val reviewPromptCount: StateFlow = reviewFlow.asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setVibrationEnabled(enabled: Boolean) {} + override suspend fun setDarkTheme(enabled: Boolean) {} + override suspend fun setBestScore(score: Long) { if (score > bestScoreFlow.value) bestScoreFlow.value = score } + override suspend fun incrementReviewPromptCount() { reviewFlow.value += 1 } + override suspend fun setTutorialSeen() {} +} + +private class RecordingAudio : AudioRepository { + var placementCount = 0 + val clearedLines = mutableListOf() + val feedback = mutableListOf() + val combos = mutableListOf() + var startMusicCount = 0 + var stopMusicCount = 0 + override suspend fun playPlacementSound() { placementCount += 1 } + override suspend fun playClearSound(lines: Int) { clearedLines += lines } + override suspend fun playVoiceFeedback(type: FeedbackType) { feedback += type } + override suspend fun playVoiceCombo(combo: Int) { combos += combo } + override suspend fun startMusic() { startMusicCount += 1 } + override suspend fun stopMusic() { stopMusicCount += 1 } + override suspend fun onAppBackground() {} + override suspend fun onAppForeground() {} +} + +private class RecordingAnalytics : AnalyticRepository { + val events = mutableListOf>>() + override fun logEvent(eventName: String, params: Map?) { + events += eventName to (params ?: emptyMap()) + } + override fun deleteData() {} + fun has(name: String, subset: Map = emptyMap()): Boolean = + events.any { (n, p) -> n == name && subset.all { (k, v) -> p[k] == v } } +} + +private class NoopStoreReview : StoreReviewRepository { + override fun requestInAppReview(): Flow = emptyFlow() + override fun requestInMarketReview(): Flow = emptyFlow() +} + diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt new file mode 100644 index 0000000..0b87fda --- /dev/null +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt @@ -0,0 +1,155 @@ +package ge.yet.blockblast.feature.home + +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.essenty.lifecycle.stop +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import ge.yet.blockblast.feature.home.store.HomeStoreFactory +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.GameSaveRepository +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultHomeComponentTest { + + @BeforeTest + fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + + private fun build( + saved: GameState? = null, + bestScore: Long = 0L, + ): Setup { + val lifecycle = LifecycleRegistry() + val analytics = RecordingAnalytics() + val component = DefaultHomeComponent( + componentContext = DefaultComponentContext(lifecycle), + homeStoreFactory = HomeStoreFactory( + storeFactory = DefaultStoreFactory(), + saveRepository = StubSaveRepo(saved), + settings = StubSettings(bestScore), + analytics = analytics, + ), + analytics = analytics, + onContinueClickedCb = { continueCalls += it }, + onNewGameClickedCb = { newGameCalls += it }, + ) + return Setup(component, lifecycle, analytics) + } + + private val continueCalls = mutableListOf() + private val newGameCalls = mutableListOf() + + @Test + fun model_reflects_initial_store_state() = runTest { + val (component, _, _) = build( + saved = playableSave(bestScore = 900L), + bestScore = 100L, + ) + assertEquals(900L, component.model.value.bestScore) // max + assertTrue(component.model.value.hasSavedGame) + } + + @Test + fun onContinueClicked_invokes_callback_with_false_and_logs() { + val (component, _, analytics) = build(saved = playableSave()) + component.onContinueClicked() + assertEquals(listOf(false), continueCalls) + assertNotNull(analytics.events.find { it.first == "continue_clicked" }) + } + + @Test + fun onNewGameClicked_invokes_callback_with_true_and_logs() { + val (component, _, analytics) = build() + component.onNewGameClicked() + assertEquals(listOf(true), newGameCalls) + assertNotNull(analytics.events.find { it.first == "new_game_clicked" }) + } + + @Test + fun lifecycle_resume_triggers_Refresh_and_home_shown_event() { + val (_, lifecycle, analytics) = build(saved = playableSave(bestScore = 500L)) + // resume = onCreate + onStart + onResume — doOnStart fires Refresh + lifecycle.resume() + val home = analytics.events.firstOrNull { it.first == "home_shown" } + assertNotNull(home) + assertEquals(500L, home.second["best_score"]) + assertEquals(true, home.second["has_saved_game"]) + } + + @Test + fun returning_to_home_re_fires_Refresh() { + val (_, lifecycle, analytics) = build(saved = playableSave()) + lifecycle.resume() + lifecycle.stop() + analytics.events.clear() + lifecycle.resume() + assertNotNull(analytics.events.firstOrNull { it.first == "home_shown" }) + } + + private fun playableSave(bestScore: Long = 0L): GameState = GameState( + grid = Grid().withCell(0, 0, 1), + bestScore = bestScore, + currentPieces = listOf( + Piece(1L, Polyomino("h2", listOf(Position(0, 0), Position(1, 0))), 1), + ), + isGameOver = false, + ) + + private data class Setup( + val component: DefaultHomeComponent, + val lifecycle: LifecycleRegistry, + val analytics: RecordingAnalytics, + ) + + private class StubSaveRepo(private val state: GameState?) : GameSaveRepository { + override suspend fun save(state: GameState) {} + override suspend fun load(): GameState? = state + override suspend fun clear() {} + } + + private class StubSettings(bestScore: Long) : SettingsRepository { + override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val vibrationEnabled = MutableStateFlow(true).asStateFlow() + override val darkTheme = MutableStateFlow(false).asStateFlow() + override val bestScore = MutableStateFlow(bestScore).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setVibrationEnabled(enabled: Boolean) {} + override suspend fun setDarkTheme(enabled: Boolean) {} + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() {} + } + + private class RecordingAnalytics : AnalyticRepository { + val events = mutableListOf>>() + override fun logEvent(eventName: String, params: Map?) { + events += eventName to (params ?: emptyMap()) + } + override fun deleteData() {} + } +} diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/integration/MappersTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/integration/MappersTest.kt new file mode 100644 index 0000000..691cf2a --- /dev/null +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/integration/MappersTest.kt @@ -0,0 +1,22 @@ +package ge.yet.blockblast.feature.home.integration + +import ge.yet.blockblast.feature.home.store.HomeStore +import kotlin.test.Test +import kotlin.test.assertEquals + +class MappersTest { + + @Test + fun maps_bestScore_and_hasSavedGame_through() { + val model = stateToModel(HomeStore.State(bestScore = 1234L, hasSavedGame = true)) + assertEquals(1234L, model.bestScore) + assertEquals(true, model.hasSavedGame) + } + + @Test + fun maps_default_state() { + val model = stateToModel(HomeStore.State()) + assertEquals(0L, model.bestScore) + assertEquals(false, model.hasSavedGame) + } +} diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt new file mode 100644 index 0000000..60b05e8 --- /dev/null +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt @@ -0,0 +1,149 @@ +package ge.yet.blockblast.feature.home.store + +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.GameSaveRepository +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeStoreFactoryTest { + + @BeforeTest + fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + + private fun factory( + saved: GameState? = null, + settingsBest: Long = 0L, + analytics: RecordingAnalytics = RecordingAnalytics(), + ): Pair { + val f = HomeStoreFactory( + storeFactory = DefaultStoreFactory(), + saveRepository = StubSaveRepo(saved), + settings = StubSettings(bestScore = settingsBest), + analytics = analytics, + ) + return f to analytics + } + + private fun playableState(best: Long = 0L): GameState = GameState( + grid = Grid().withCell(0, 0, 1), + bestScore = best, + currentPieces = listOf( + Piece(1L, Polyomino("h2", listOf(Position(0, 0), Position(1, 0))), 1), + ), + isGameOver = false, + ) + + @Test + fun no_save_yields_hasSavedGame_false_and_uses_settings_best() = runTest { + val (f, _) = factory(saved = null, settingsBest = 750L) + val store = f.create() + assertEquals(750L, store.state.bestScore) + assertFalse(store.state.hasSavedGame) + } + + @Test + fun playable_save_yields_hasSavedGame_true() = runTest { + val (f, _) = factory(saved = playableState(best = 1000L), settingsBest = 500L) + val store = f.create() + assertTrue(store.state.hasSavedGame) + assertEquals(1000L, store.state.bestScore) // max + } + + @Test + fun bestScore_is_max_of_settings_and_save() = runTest { + val (f, _) = factory(saved = playableState(best = 300L), settingsBest = 800L) + val store = f.create() + assertEquals(800L, store.state.bestScore) + } + + @Test + fun game_over_save_yields_hasSavedGame_false() = runTest { + val (f, _) = factory(saved = playableState().copy(isGameOver = true)) + val store = f.create() + assertFalse(store.state.hasSavedGame) + } + + @Test + fun empty_grid_save_yields_hasSavedGame_false() = runTest { + val (f, _) = factory( + saved = playableState().copy(grid = Grid()), + ) + val store = f.create() + assertFalse(store.state.hasSavedGame) + } + + @Test + fun refresh_re_reads_state_and_logs_home_shown() = runTest { + val (f, analytics) = factory(saved = playableState(best = 600L), settingsBest = 100L) + val store = f.create() + store.accept(HomeStore.Intent.Refresh) + assertTrue(store.state.hasSavedGame) + assertEquals(600L, store.state.bestScore) + val event = analytics.events.lastOrNull { it.first == "home_shown" } + assertNotNull(event) + assertEquals(600L, event.second["best_score"]) + assertEquals(true, event.second["has_saved_game"]) + } + + @Test + fun initial_load_does_not_log_home_shown() = runTest { + // home_shown is only emitted on explicit Refresh per current design. + val (_, analytics) = factory(saved = playableState()) + assertTrue(analytics.events.none { it.first == "home_shown" }) + } + + // ── Fakes ──────────────────────────────────────────────────────────── + + private class StubSaveRepo(private val state: GameState?) : GameSaveRepository { + override suspend fun save(state: GameState) {} + override suspend fun load(): GameState? = state + override suspend fun clear() {} + } + + private class StubSettings(bestScore: Long) : SettingsRepository { + override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val vibrationEnabled = MutableStateFlow(true).asStateFlow() + override val darkTheme = MutableStateFlow(false).asStateFlow() + override val bestScore = MutableStateFlow(bestScore).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setVibrationEnabled(enabled: Boolean) {} + override suspend fun setDarkTheme(enabled: Boolean) {} + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() {} + } + + private class RecordingAnalytics : AnalyticRepository { + val events = mutableListOf>>() + override fun logEvent(eventName: String, params: Map?) { + events += eventName to (params ?: emptyMap()) + } + override fun deleteData() {} + } +} diff --git a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt new file mode 100644 index 0000000..9f9aca4 --- /dev/null +++ b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt @@ -0,0 +1,201 @@ +package ge.yet.blockblast.feature.root + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.essenty.lifecycle.stop +import ge.yet.blockblast.feature.game.GameComponent +import ge.yet.blockblast.feature.home.HomeComponent +import ge.yet.blokblast.domain.model.FeedbackType +import ge.yet.blokblast.domain.repository.AudioRepository +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class DefaultRootComponentTest { + + private fun build(): Setup { + val lifecycle = LifecycleRegistry() + val audio = RecordingAudio() + val settings = FakeSettings() + val homeFactory = RecordingHomeFactory() + val gameFactory = RecordingGameFactory() + val component = DefaultRootComponent( + componentContext = DefaultComponentContext(lifecycle), + homeFactory = homeFactory, + gameFactory = gameFactory, + audio = audio, + settingsRepository = settings, + ) + return Setup(component, lifecycle, audio, settings, homeFactory, gameFactory) + } + + @Test + fun initial_stack_is_home() { + val (component, _, _, _, _) = build().destructure() + assertIs(component.stack.value.active.instance) + } + + @Test + fun darkTheme_vibration_sound_tutorial_flows_mirror_settings() { + val (component, _, _, settings, _, _) = build() + assertFalse(component.darkTheme.value) + settings.darkFlow.value = true + assertTrue(component.darkTheme.value) + settings.soundFlow.value = false + assertFalse(component.soundEnabled.value) + settings.vibrationFlow.value = false + assertFalse(component.vibrationEnabled.value) + settings.tutorialFlow.value = true + assertTrue(component.tutorialSeen.value) + } + + @Test + fun onTutorialSeen_persists_via_repository() = runTest { + val (component, _, _, settings, _, _) = build() + component.onTutorialSeen() + assertTrue(settings.tutorialFlow.value) + } + + @Test + fun resume_lifecycle_calls_audio_onAppForeground() = runTest { + val (_, lifecycle, audio, _, _, _) = build() + lifecycle.resume() + assertTrue(audio.foregroundCount >= 1) + } + + @Test + fun stop_lifecycle_calls_audio_onAppBackground() = runTest { + val (_, lifecycle, audio, _, _, _) = build() + lifecycle.resume() + lifecycle.stop() + assertTrue(audio.backgroundCount >= 1) + } + + @Test + fun home_continueClicked_navigates_to_game_with_isNewGame_false() { + val (component, _, _, _, homeFactory, gameFactory) = build() + homeFactory.created.first().onContinueClicked(false) + val child = component.stack.value.active.instance + assertIs(child) + assertEquals(listOf(false), gameFactory.requestedIsNewGame) + } + + @Test + fun home_newGameClicked_navigates_to_game_with_isNewGame_true() { + val (component, _, _, _, homeFactory, gameFactory) = build() + homeFactory.created.first().onNewGameClicked(true) + val child = component.stack.value.active.instance + assertIs(child) + assertEquals(listOf(true), gameFactory.requestedIsNewGame) + } + + @Test + fun onBackClicked_pops_back_to_home() { + val (component, _, _, _, homeFactory, _) = build() + homeFactory.created.first().onNewGameClicked(true) + assertIs(component.stack.value.active.instance) + component.onBackClicked() + assertIs(component.stack.value.active.instance) + } + + private fun Setup.destructure() = this + + private data class Setup( + val component: DefaultRootComponent, + val lifecycle: LifecycleRegistry, + val audio: RecordingAudio, + val settings: FakeSettings, + val homeFactory: RecordingHomeFactory, + val gameFactory: RecordingGameFactory, + ) + + // ── Fakes ──────────────────────────────────────────────────────────── + + private class RecordingHomeFactory : HomeComponent.Factory { + val created = mutableListOf() + override fun create( + componentContext: ComponentContext, + onContinueClicked: (Boolean) -> Unit, + onNewGameClicked: (Boolean) -> Unit, + ): HomeComponent = FakeHome(onContinueClicked, onNewGameClicked).also { created += it } + } + + private class FakeHome( + val onContinueClicked: (Boolean) -> Unit, + val onNewGameClicked: (Boolean) -> Unit, + ) : HomeComponent { + override val model = com.arkivanov.decompose.value.MutableValue( + HomeComponent.Model(bestScore = 0L, hasSavedGame = false), + ) + override fun onContinueClicked() = onContinueClicked(false) + override fun onNewGameClicked() = onNewGameClicked(true) + } + + private class RecordingGameFactory : GameComponent.Factory { + val requestedIsNewGame = mutableListOf() + override fun create( + componentContext: ComponentContext, + isNewGame: Boolean, + onExitClicked: () -> Unit, + ): GameComponent { + requestedIsNewGame += isNewGame + return FakeGame() + } + } + + private class FakeGame : GameComponent { + override val model = com.arkivanov.decompose.value.MutableValue( + ge.yet.blokblast.domain.model.GameState(), + ) + override val continueCountdown = com.arkivanov.decompose.value.MutableValue(-1) + override val sheetSlot = com.arkivanov.decompose.value.MutableValue( + com.arkivanov.decompose.router.slot.ChildSlot(child = null), + ) + override fun onCellClicked(pieceId: Long, x: Int, y: Int) {} + override fun onReviveClicked() {} + override fun onRestartClicked() {} + override fun onSettingsClicked() {} + override fun onExitClicked() {} + override fun onDismissSheet() {} + } + + private class RecordingAudio : AudioRepository { + var foregroundCount = 0 + var backgroundCount = 0 + override suspend fun playPlacementSound() {} + override suspend fun playClearSound(lines: Int) {} + override suspend fun playVoiceFeedback(type: FeedbackType) {} + override suspend fun playVoiceCombo(combo: Int) {} + override suspend fun startMusic() {} + override suspend fun stopMusic() {} + override suspend fun onAppBackground() { backgroundCount += 1 } + override suspend fun onAppForeground() { foregroundCount += 1 } + } + + private class FakeSettings : SettingsRepository { + val soundFlow = MutableStateFlow(true) + val vibrationFlow = MutableStateFlow(true) + val darkFlow = MutableStateFlow(false) + val tutorialFlow = MutableStateFlow(false) + override val soundEnabled = soundFlow.asStateFlow() + override val vibrationEnabled = vibrationFlow.asStateFlow() + override val darkTheme = darkFlow.asStateFlow() + override val tutorialSeen = tutorialFlow.asStateFlow() + override val bestScore = MutableStateFlow(0L).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } + override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() { tutorialFlow.value = true } + } +} diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt new file mode 100644 index 0000000..09ff129 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt @@ -0,0 +1,118 @@ +package ge.yet.blockblast.feature.settings + +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import ge.yet.blockblast.feature.settings.store.SettingsStore +import ge.yet.blockblast.feature.settings.store.SettingsStoreFactory +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultMainSettingsComponentTest { + + @BeforeTest + fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + + private fun build(): Triple> { + val settings = FakeSettings() + val store = SettingsStoreFactory( + storeFactory = DefaultStoreFactory(), + settingsRepository = settings, + analytics = NoopAnalytics(), + ).create() + val nav = mutableListOf() + val component = DefaultMainSettingsComponent( + componentContext = DefaultComponentContext(LifecycleRegistry()), + store = store, + onMoreClickedCb = { nav += "more" }, + onBackClickedCb = { nav += "back" }, + ) + return Triple(component, settings, nav) + } + + @Test + fun model_reflects_initial_state() = runTest { + val (component, _, _) = build() + assertTrue(component.model.value.soundEnabled) + assertTrue(component.model.value.vibrationEnabled) + assertFalse(component.model.value.darkTheme) + } + + @Test + fun onSoundToggled_propagates_to_repository_and_model() = runTest { + val (component, settings, _) = build() + component.onSoundToggled(false) + assertFalse(settings.soundFlow.value) + assertFalse(component.model.value.soundEnabled) + } + + @Test + fun onVibrationToggled_propagates() = runTest { + val (component, settings, _) = build() + component.onVibrationToggled(false) + assertFalse(settings.vibrationFlow.value) + } + + @Test + fun onDarkThemeToggled_propagates() = runTest { + val (component, settings, _) = build() + component.onDarkThemeToggled(true) + assertTrue(settings.darkFlow.value) + } + + @Test + fun onMoreClicked_invokes_callback() { + val (component, _, nav) = build() + component.onMoreClicked() + assertEquals(listOf("more"), nav) + } + + @Test + fun onBackClicked_invokes_callback() { + val (component, _, nav) = build() + component.onBackClicked() + assertEquals(listOf("back"), nav) + } + + private class FakeSettings : SettingsRepository { + val soundFlow = MutableStateFlow(true) + val vibrationFlow = MutableStateFlow(true) + val darkFlow = MutableStateFlow(false) + override val soundEnabled: StateFlow = soundFlow.asStateFlow() + override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow() + override val darkTheme: StateFlow = darkFlow.asStateFlow() + override val bestScore = MutableStateFlow(0L).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } + override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() {} + } + + private class NoopAnalytics : AnalyticRepository { + override fun logEvent(eventName: String, params: Map?) {} + override fun deleteData() {} + } +} diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt new file mode 100644 index 0000000..7d50642 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt @@ -0,0 +1,26 @@ +package ge.yet.blockblast.feature.settings.integration + +import ge.yet.blockblast.feature.settings.store.SettingsStore +import kotlin.test.Test +import kotlin.test.assertEquals + +class MappersTest { + + @Test + fun maps_three_flags_through() { + val model = stateToModel( + SettingsStore.State(sound = false, vibration = true, dark = true), + ) + assertEquals(false, model.soundEnabled) + assertEquals(true, model.vibrationEnabled) + assertEquals(true, model.darkTheme) + } + + @Test + fun maps_default_state() { + val model = stateToModel(SettingsStore.State()) + assertEquals(true, model.soundEnabled) + assertEquals(true, model.vibrationEnabled) + assertEquals(false, model.darkTheme) + } +} diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt new file mode 100644 index 0000000..09341a4 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt @@ -0,0 +1,124 @@ +package ge.yet.blockblast.feature.settings.store + +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import ge.yet.blokblast.domain.repository.AnalyticRepository +import ge.yet.blokblast.domain.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsStoreFactoryTest { + + @BeforeTest + fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + + private fun make( + sound: Boolean = true, + vibration: Boolean = true, + dark: Boolean = false, + ): Triple { + val settings = FakeSettings(sound, vibration, dark) + val analytics = RecordingAnalytics() + return Triple( + SettingsStoreFactory(DefaultStoreFactory(), settings, analytics), + settings, + analytics, + ) + } + + @Test + fun initial_state_mirrors_settings() = runTest { + val (f, _, _) = make(sound = false, vibration = true, dark = true) + val store = f.create() + assertFalse(store.state.sound) + assertTrue(store.state.vibration) + assertTrue(store.state.dark) + } + + @Test + fun external_settings_change_propagates_to_state() = runTest { + val (f, settings, _) = make() + val store = f.create() + settings.soundFlow.value = false + assertFalse(store.state.sound) + } + + @Test + fun setSound_writes_and_logs() = runTest { + val (f, settings, analytics) = make() + val store = f.create() + store.accept(SettingsStore.Intent.SetSound(false)) + assertFalse(settings.soundFlow.value) + val ev = analytics.events.last() + assertEquals("setting_changed", ev.first) + assertEquals("sound", ev.second["setting"]) + assertEquals(false, ev.second["enabled"]) + } + + @Test + fun setVibration_writes_and_logs() = runTest { + val (f, settings, analytics) = make() + val store = f.create() + store.accept(SettingsStore.Intent.SetVibration(false)) + assertFalse(settings.vibrationFlow.value) + assertNotNull(analytics.events.find { it.first == "setting_changed" && it.second["setting"] == "vibration" }) + } + + @Test + fun setDark_writes_and_logs() = runTest { + val (f, settings, analytics) = make() + val store = f.create() + store.accept(SettingsStore.Intent.SetDark(true)) + assertTrue(settings.darkFlow.value) + assertNotNull(analytics.events.find { it.first == "setting_changed" && it.second["setting"] == "dark_theme" }) + } + + // ── Fakes ──────────────────────────────────────────────────────────── + + private class FakeSettings( + sound: Boolean, + vibration: Boolean, + dark: Boolean, + ) : SettingsRepository { + val soundFlow = MutableStateFlow(sound) + val vibrationFlow = MutableStateFlow(vibration) + val darkFlow = MutableStateFlow(dark) + override val soundEnabled: StateFlow = soundFlow.asStateFlow() + override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow() + override val darkTheme: StateFlow = darkFlow.asStateFlow() + override val bestScore = MutableStateFlow(0L).asStateFlow() + override val reviewPromptCount = MutableStateFlow(0).asStateFlow() + override val tutorialSeen = MutableStateFlow(false).asStateFlow() + override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } + override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } + override suspend fun setBestScore(score: Long) {} + override suspend fun incrementReviewPromptCount() {} + override suspend fun setTutorialSeen() {} + } + + private class RecordingAnalytics : AnalyticRepository { + val events = mutableListOf>>() + override fun logEvent(eventName: String, params: Map?) { + events += eventName to (params ?: emptyMap()) + } + override fun deleteData() {} + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f9c7944..5671eda 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include(":core") include(":core:common") include(":core:domain") include(":core:data") +include(":core:telemetry") include(":feature") include(":feature:root")