From 947a9349d748e8fe252fa23ada6a97fd3079d813 Mon Sep 17 00:00:00 2001 From: yet Date: Wed, 13 May 2026 18:55:43 +0400 Subject: [PATCH 1/5] refactor(di): extract Firebase telemetry into core:telemetry module Move CrashlyticsRepositoryImpl and AnalyticRepositoryImpl out of core:data into a new core:telemetry module so core:data builds and links on iOS without Firebase native frameworks at link time. TelemetryBindings is contributed to AppScope and wired into both AndroidAppGraph and NativeAppGraph, so runtime DI is unchanged. Co-Authored-By: Claude Opus 4.7 --- composeApp/build.gradle.kts | 1 + .../ge/yet3/blokblast/di/AndroidAppGraph.kt | 2 ++ .../ge/yet3/blokblast/ComposeAppCommonTest.kt | 12 --------- .../ge/yet3/blokblast/di/NativeAppGraph.kt | 2 ++ core/data/build.gradle.kts | 5 ---- .../ge/yet/blokblast/data/di/DataBindings.kt | 10 ------- core/telemetry/build.gradle.kts | 19 +++++++++++++ .../telemetry/di/TelemetryBindings.kt | 27 +++++++++++++++++++ .../repository/AnalyticRepositoryImpl.kt | 2 +- .../repository/CrashlyticsRepositoryImpl.kt | 2 +- settings.gradle.kts | 1 + 11 files changed, 54 insertions(+), 29 deletions(-) delete mode 100644 composeApp/src/commonTest/kotlin/ge/yet3/blokblast/ComposeAppCommonTest.kt create mode 100644 core/telemetry/build.gradle.kts create mode 100644 core/telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry/di/TelemetryBindings.kt rename core/{data/src/commonMain/kotlin/ge/yet/blokblast/data => telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry}/repository/AnalyticRepositoryImpl.kt (93%) rename core/{data/src/commonMain/kotlin/ge/yet/blokblast/data => telemetry/src/commonMain/kotlin/ge/yet/blokblast/telemetry}/repository/CrashlyticsRepositoryImpl.kt (96%) 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/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/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/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/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") From ab4262a1d26b3b716e9715d0fa62e3a6f5145557 Mon Sep 17 00:00:00 2001 From: yet Date: Wed, 13 May 2026 18:56:00 +0400 Subject: [PATCH 2/5] test: add coverage for engine, repositories, and store factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings total to 130 tests across core:domain, core:data, feature:home, feature:settings, and feature:game — all green on iosSimulatorArm64Test. - ScoreCalculator, Grid, ShapeGenerator: pure-logic edge cases - GameEngine: placement, clearing, combo, cross-clear, feedback tiers, game-over detection, revive caps, restore semantics, autoSave debounce - SettingsBackedGameSaveRepository: round-trip, corrupt-JSON handling, cache - SettingsBackedSettingsRepository: defaults, monotonic best, concurrent increment safety via mutex - DefaultAudioRepository: music gating across requested/foreground/sound flags - DefaultVibrationRepository: live-flag gating - HomeStoreFactory: hasSavedGame heuristic, best-score max, refresh logging - SettingsStoreFactory: settings-flow propagation and write-intent logging - GameStoreFactory: bootstrap branches, best-score persistence, SFX wiring, music start/stop, game-over countdown, review-prompt qualifier matrix, place/restart/revive intents Co-Authored-By: Claude Opus 4.7 --- .../repository/DefaultAudioRepositoryTest.kt | 166 ++++++ .../DefaultVibrationRepositoryTest.kt | 79 +++ .../SettingsBackedGameSaveRepositoryTest.kt | 119 ++++ .../SettingsBackedSettingsRepositoryTest.kt | 97 ++++ .../blokblast/domain/engine/GameEngineTest.kt | 449 ++++++++++++++- .../domain/engine/ScoreCalculatorTest.kt | 76 +++ .../domain/engine/ShapeGeneratorTest.kt | 70 +++ .../ge/yet/blokblast/domain/model/GridTest.kt | 99 ++++ .../game/store/GameStoreFactoryTest.kt | 509 ++++++++++++++++++ .../home/store/HomeStoreFactoryTest.kt | 149 +++++ .../store/SettingsStoreFactoryTest.kt | 124 +++++ 11 files changed, 1923 insertions(+), 14 deletions(-) create mode 100644 core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt create mode 100644 core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt create mode 100644 core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepositoryTest.kt create mode 100644 core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt create mode 100644 core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ScoreCalculatorTest.kt create mode 100644 core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/ShapeGeneratorTest.kt create mode 100644 core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/model/GridTest.kt create mode 100644 feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt create mode 100644 feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt create mode 100644 feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/store/SettingsStoreFactoryTest.kt 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/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/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/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() {} + } +} From 673ec14adc82e0e1749ee33a46da28a0f5573ea8 Mon Sep 17 00:00:00 2001 From: yet Date: Wed, 13 May 2026 19:18:18 +0400 Subject: [PATCH 3/5] refactor(common): move formatScore to core:common and add tests The score formatter is a pure utility; moving it out of composeApp lets it be exercised in unit tests (composeApp test-link still pulls Firebase via core:telemetry). Updates AnimatedCounter and GameOverOverlay imports. Co-Authored-By: Claude Opus 4.7 --- .../component/overlay/GameOverOverlay.kt | 2 +- .../component/score/AnimatedCounter.kt | 2 +- .../com/app/common}/utils/formatScore.kt | 2 +- .../com/app/common/utils/FormatScoreTest.kt | 51 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) rename {composeApp/src/commonMain/kotlin/ge/yet3/blokblast => core/common/src/commonMain/kotlin/com/app/common}/utils/formatScore.kt (91%) create mode 100644 core/common/src/commonTest/kotlin/com/app/common/utils/FormatScoreTest.kt 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/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()) + } +} From 73b735ec168dd45650204bd0632c4634c077229b Mon Sep 17 00:00:00 2001 From: yet Date: Wed, 13 May 2026 19:18:18 +0400 Subject: [PATCH 4/5] test: cover Default*Component classes and Home/Settings mappers Adds component-level tests for the Decompose layer using LifecycleRegistry plus light fakes for AudioRepository, SettingsRepository, AnalyticRepository, StoreReviewRepository, and SettingsComponent.Factory. Brings total to 165 tests across the runnable iosSimulatorArm64Test targets. - Home/Settings Mappers: state -> model translation - DefaultHomeComponent: initial model, click callbacks with isNewGame flag, lifecycle.resume -> Refresh + home_shown analytics - DefaultMainSettingsComponent: three toggles propagate to settings, more/back navigation callbacks - DefaultRootComponent: initial Home child, settings flow mirroring, audio onAppForeground/onAppBackground on lifecycle start/stop, Home continue/newGame -> Game(isNewGame=false/true), back navigation - DefaultGameComponent: settings sheet open/dismiss with analytics, exit callback, restart/revive intents, review prompt sheet on label, audio.stopMusic on lifecycle destroy Co-Authored-By: Claude Opus 4.7 --- .../feature/game/DefaultGameComponentTest.kt | 292 ++++++++++++++++++ .../feature/home/DefaultHomeComponentTest.kt | 155 ++++++++++ .../feature/home/integration/MappersTest.kt | 22 ++ .../feature/root/DefaultRootComponentTest.kt | 201 ++++++++++++ .../DefaultMainSettingsComponentTest.kt | 118 +++++++ .../settings/integration/MappersTest.kt | 26 ++ 6 files changed, 814 insertions(+) create mode 100644 feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt create mode 100644 feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt create mode 100644 feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/integration/MappersTest.kt create mode 100644 feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt create mode 100644 feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/DefaultMainSettingsComponentTest.kt create mode 100644 feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/integration/MappersTest.kt 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/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/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) + } +} From 4e733c98bb50bb8fed485a7cbfd2df555bcd0b1c Mon Sep 17 00:00:00 2001 From: yet Date: Wed, 13 May 2026 19:22:34 +0400 Subject: [PATCH 5/5] ci: add PR workflow for unit tests and Android assembleDebug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggers on PRs to main and pushes to main. Two jobs: - tests (macos-15): ./gradlew allTests — needs macOS for iosSimulatorArm64 test runners. Uploads test reports as an artifact on failure. - android-build (ubuntu-latest): :androidApp:assembleDebug as a smoke check on a cheaper runner. Concurrency: cancels in-progress runs on the same PR when new commits land, keeps main pushes running to completion. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/ci.yml 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