From 13c6402e6aecd8a799b6ae33563b5977b758f4cd Mon Sep 17 00:00:00 2001 From: Michael Holloway Date: Fri, 8 Aug 2025 22:43:08 -0400 Subject: [PATCH] Implement comprehensive unit testing infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add modern testing dependencies (Mockk, Turbine, Truth, Coroutines Test) - Create AudioEngine interface to enable testable audio functionality - Implement comprehensive unit tests for core business logic: * UserUtils: IP address validation with edge cases * AudioManager: Engine lifecycle and MIDI note handling * RecentChangesRepository: Data flow and SSE integration * RecentChangesViewModel: Event processing, state management, filtering - Add AndroidLogRule for unit test Log handling - Update GitHub Actions CI to run tests and code formatting checks - Improve IP address validation regex for proper IPv4/IPv6 detection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/workflows/android.yml | 14 ++ app/build.gradle.kts | 9 + .../listentowikipedia/audio/AudioEngine.kt | 12 + .../listentowikipedia/audio/AudioManager.kt | 9 +- .../listentowikipedia/audio/DspFaustEngine.kt | 21 ++ .../di/ActivityScopedModule.kt | 6 + .../listentowikipedia/util/UserUtils.kt | 10 +- .../listentowikipedia/ExampleUnitTest.kt | 16 -- .../audio/AudioManagerTest.kt | 157 ++++++++++++ .../repository/RecentChangesRepositoryTest.kt | 118 +++++++++ .../testutil/AndroidLogRule.kt | 34 +++ .../listentowikipedia/util/UserUtilsTest.kt | 42 ++++ .../viewmodel/RecentChangesViewModelTest.kt | 228 ++++++++++++++++++ gradle/libs.versions.toml | 13 + 14 files changed, 667 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioEngine.kt create mode 100644 app/src/main/java/org/mdholloway/listentowikipedia/audio/DspFaustEngine.kt delete mode 100644 app/src/test/java/org/mdholloway/listentowikipedia/ExampleUnitTest.kt create mode 100644 app/src/test/java/org/mdholloway/listentowikipedia/audio/AudioManagerTest.kt create mode 100644 app/src/test/java/org/mdholloway/listentowikipedia/repository/RecentChangesRepositoryTest.kt create mode 100644 app/src/test/java/org/mdholloway/listentowikipedia/testutil/AndroidLogRule.kt create mode 100644 app/src/test/java/org/mdholloway/listentowikipedia/util/UserUtilsTest.kt create mode 100644 app/src/test/java/org/mdholloway/listentowikipedia/viewmodel/RecentChangesViewModelTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c5c1754..4411133 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,5 +22,19 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + + - name: Check code formatting + run: ./gradlew spotlessCheck + + - name: Run unit tests + run: ./gradlew test + - name: Build with Gradle run: ./gradlew build + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: app/build/reports/tests/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c2aaa04..65cdefc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -96,9 +96,18 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.truth) + testImplementation(libs.androidx.arch.core.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.android.compiler) } diff --git a/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioEngine.kt b/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioEngine.kt new file mode 100644 index 0000000..f3ed4f2 --- /dev/null +++ b/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioEngine.kt @@ -0,0 +1,12 @@ +package org.mdholloway.listentowikipedia.audio + +interface AudioEngine { + fun start(): Boolean + + fun stop() + + fun keyOn( + midiNote: Int, + velocity: Int, + ) +} diff --git a/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioManager.kt b/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioManager.kt index f3364b6..e777f77 100644 --- a/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioManager.kt +++ b/app/src/main/java/org/mdholloway/listentowikipedia/audio/AudioManager.kt @@ -1,7 +1,6 @@ package org.mdholloway.listentowikipedia.audio import android.util.Log -import com.DspFaust.DspFaust import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject @@ -9,7 +8,7 @@ import javax.inject.Inject class AudioManager @Inject constructor( - private val dspFaust: DspFaust, + private val audioEngine: AudioEngine, ) { companion object { private const val TAG = "AudioManager" @@ -23,7 +22,7 @@ class AudioManager */ fun start(): Boolean { if (!isStarted) { - isStarted = dspFaust.start() + isStarted = audioEngine.start() Log.i(TAG, "Audio engine started: $isStarted") } return isStarted @@ -34,7 +33,7 @@ class AudioManager */ fun stop() { if (isStarted) { - dspFaust.stop() + audioEngine.stop() isStarted = false Log.i(TAG, "Audio engine stopped") } @@ -50,7 +49,7 @@ class AudioManager velocity: Int, ) { if (isStarted) { - dspFaust.keyOn(midiNote, velocity) + audioEngine.keyOn(midiNote, velocity) } else { Log.w(TAG, "Cannot play note: audio engine not started") } diff --git a/app/src/main/java/org/mdholloway/listentowikipedia/audio/DspFaustEngine.kt b/app/src/main/java/org/mdholloway/listentowikipedia/audio/DspFaustEngine.kt new file mode 100644 index 0000000..ae41b67 --- /dev/null +++ b/app/src/main/java/org/mdholloway/listentowikipedia/audio/DspFaustEngine.kt @@ -0,0 +1,21 @@ +package org.mdholloway.listentowikipedia.audio + +import com.DspFaust.DspFaust +import javax.inject.Inject + +class DspFaustEngine + @Inject + constructor( + private val dspFaust: DspFaust, + ) : AudioEngine { + override fun start(): Boolean = dspFaust.start() + + override fun stop() = dspFaust.stop() + + override fun keyOn( + midiNote: Int, + velocity: Int, + ) { + dspFaust.keyOn(midiNote, velocity) + } + } diff --git a/app/src/main/java/org/mdholloway/listentowikipedia/di/ActivityScopedModule.kt b/app/src/main/java/org/mdholloway/listentowikipedia/di/ActivityScopedModule.kt index 47e09a2..d58050b 100644 --- a/app/src/main/java/org/mdholloway/listentowikipedia/di/ActivityScopedModule.kt +++ b/app/src/main/java/org/mdholloway/listentowikipedia/di/ActivityScopedModule.kt @@ -8,6 +8,8 @@ import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped +import org.mdholloway.listentowikipedia.audio.AudioEngine +import org.mdholloway.listentowikipedia.audio.DspFaustEngine import org.mdholloway.listentowikipedia.network.SseManager @Module @@ -17,6 +19,10 @@ object ActivityScopedModule { @ActivityScoped fun provideDspFaust(): DspFaust = DspFaust() + @Provides + @ActivityScoped + fun provideAudioEngine(dspFaust: DspFaust): AudioEngine = DspFaustEngine(dspFaust) + @Provides @ActivityScoped fun provideSseManager( diff --git a/app/src/main/java/org/mdholloway/listentowikipedia/util/UserUtils.kt b/app/src/main/java/org/mdholloway/listentowikipedia/util/UserUtils.kt index 1ec939d..0b509e2 100644 --- a/app/src/main/java/org/mdholloway/listentowikipedia/util/UserUtils.kt +++ b/app/src/main/java/org/mdholloway/listentowikipedia/util/UserUtils.kt @@ -4,7 +4,15 @@ package org.mdholloway.listentowikipedia.util * Helper function to check if a string is an IP address */ fun isIpAddress(input: String): Boolean { - val ipv4Pattern = "^([0-9]{1,3}\\.)+\\d{1,3}$".toRegex() + if (input.isEmpty()) return false + + // IPv4 pattern - exactly 4 groups of 1-3 digits, each group 0-255 + val ipv4Pattern = + "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$" + .toRegex() + + // IPv6 pattern - exactly 8 groups of 1-4 hex digits val ipv6Pattern = "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$".toRegex() + return input.matches(ipv4Pattern) || input.matches(ipv6Pattern) } diff --git a/app/src/test/java/org/mdholloway/listentowikipedia/ExampleUnitTest.kt b/app/src/test/java/org/mdholloway/listentowikipedia/ExampleUnitTest.kt deleted file mode 100644 index 1d9196a..0000000 --- a/app/src/test/java/org/mdholloway/listentowikipedia/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mdholloway.listentowikipedia - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/org/mdholloway/listentowikipedia/audio/AudioManagerTest.kt b/app/src/test/java/org/mdholloway/listentowikipedia/audio/AudioManagerTest.kt new file mode 100644 index 0000000..bffabeb --- /dev/null +++ b/app/src/test/java/org/mdholloway/listentowikipedia/audio/AudioManagerTest.kt @@ -0,0 +1,157 @@ +package org.mdholloway.listentowikipedia.audio + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mdholloway.listentowikipedia.testutil.AndroidLogRule + +class AudioManagerTest { + @get:Rule + val androidLogRule = AndroidLogRule() + + private val mockAudioEngine = mockk(relaxed = true) + private lateinit var audioManager: AudioManager + + @Before + fun setUp() { + audioManager = AudioManager(mockAudioEngine) + } + + @Test + fun `start returns true when AudioEngine starts successfully`() { + every { mockAudioEngine.start() } returns true + + val result = audioManager.start() + + assertThat(result).isTrue() + assertThat(audioManager.isRunning()).isTrue() + verify { mockAudioEngine.start() } + } + + @Test + fun `start returns false when AudioEngine fails to start`() { + every { mockAudioEngine.start() } returns false + + val result = audioManager.start() + + assertThat(result).isFalse() + assertThat(audioManager.isRunning()).isFalse() + verify { mockAudioEngine.start() } + } + + @Test + fun `start is idempotent - multiple calls don't restart engine`() { + every { mockAudioEngine.start() } returns true + + // First call should start + audioManager.start() + assertThat(audioManager.isRunning()).isTrue() + + // Second call should not call start again + audioManager.start() + + verify(exactly = 1) { mockAudioEngine.start() } + } + + @Test + fun `stop calls AudioEngine stop when engine is running`() { + every { mockAudioEngine.start() } returns true + audioManager.start() + + audioManager.stop() + + assertThat(audioManager.isRunning()).isFalse() + verify { mockAudioEngine.stop() } + } + + @Test + fun `stop is idempotent - multiple calls don't call AudioEngine stop multiple times`() { + every { mockAudioEngine.start() } returns true + audioManager.start() + + audioManager.stop() + audioManager.stop() // Second call + + verify(exactly = 1) { mockAudioEngine.stop() } + } + + @Test + fun `playNote calls AudioEngine keyOn when engine is running`() { + every { mockAudioEngine.start() } returns true + audioManager.start() + + audioManager.playNote(60, 100) + + verify { mockAudioEngine.keyOn(60, 100) } + } + + @Test + fun `playNote does not call AudioEngine keyOn when engine is not running`() { + // Engine not started + audioManager.playNote(60, 100) + + verify(exactly = 0) { mockAudioEngine.keyOn(any(), any()) } + } + + @Test + fun `playNote handles valid MIDI range`() { + every { mockAudioEngine.start() } returns true + audioManager.start() + + // Test boundary values + audioManager.playNote(0, 0) // Min values + audioManager.playNote(127, 127) // Max values + audioManager.playNote(60, 64) // Middle C, medium velocity + + verify { mockAudioEngine.keyOn(0, 0) } + verify { mockAudioEngine.keyOn(127, 127) } + verify { mockAudioEngine.keyOn(60, 64) } + } + + @Test + fun `isRunning returns correct state throughout lifecycle`() { + // Initially not running + assertThat(audioManager.isRunning()).isFalse() + + // After successful start + every { mockAudioEngine.start() } returns true + audioManager.start() + assertThat(audioManager.isRunning()).isTrue() + + // After stop + audioManager.stop() + assertThat(audioManager.isRunning()).isFalse() + } + + @Test + fun `isRunning returns false when start fails`() { + every { mockAudioEngine.start() } returns false + + audioManager.start() + + assertThat(audioManager.isRunning()).isFalse() + } + + @Test + fun `engine state is maintained correctly after multiple operations`() { + every { mockAudioEngine.start() } returns true + + // Start -> Stop -> Start cycle + audioManager.start() + assertThat(audioManager.isRunning()).isTrue() + + audioManager.stop() + assertThat(audioManager.isRunning()).isFalse() + + audioManager.start() + assertThat(audioManager.isRunning()).isTrue() + + // Should have called start twice and stop once + verify(exactly = 2) { mockAudioEngine.start() } + verify(exactly = 1) { mockAudioEngine.stop() } + } +} diff --git a/app/src/test/java/org/mdholloway/listentowikipedia/repository/RecentChangesRepositoryTest.kt b/app/src/test/java/org/mdholloway/listentowikipedia/repository/RecentChangesRepositoryTest.kt new file mode 100644 index 0000000..e4ee368 --- /dev/null +++ b/app/src/test/java/org/mdholloway/listentowikipedia/repository/RecentChangesRepositoryTest.kt @@ -0,0 +1,118 @@ +package org.mdholloway.listentowikipedia.repository + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mdholloway.listentowikipedia.model.Length +import org.mdholloway.listentowikipedia.model.RecentChangeEvent +import org.mdholloway.listentowikipedia.network.SseManager + +class RecentChangesRepositoryTest { + private val mockSseManager = mockk() + private val repository = RecentChangesRepository() + + @Test + fun `listenToRecentChanges returns empty flow when no SseManager set`() = + runTest { + repository.listenToRecentChanges().test { + awaitComplete() + } + } + + @Test + fun `listenToRecentChanges forwards events from SseManager`() = + runTest { + val testEvent = + RecentChangeEvent( + type = "edit", + namespace = 0, + title = "Test Article", + comment = "Test edit", + timestamp = System.currentTimeMillis(), + user = "TestUser", + bot = false, + length = Length(old = 100, new = 150), + wiki = "enwiki", + ) + + every { mockSseManager.recentChangeEvents } returns flowOf(testEvent) + + repository.setSseManager(mockSseManager) + + repository.listenToRecentChanges().test { + val receivedEvent = awaitItem() + assertThat(receivedEvent).isEqualTo(testEvent) + awaitComplete() + } + } + + @Test + fun `listenToRecentChanges forwards multiple events`() = + runTest { + val event1 = createTestEvent("Article1") + val event2 = createTestEvent("Article2") + val event3 = createTestEvent("Article3") + + every { mockSseManager.recentChangeEvents } returns flowOf(event1, event2, event3) + + repository.setSseManager(mockSseManager) + + repository.listenToRecentChanges().test { + assertThat(awaitItem()).isEqualTo(event1) + assertThat(awaitItem()).isEqualTo(event2) + assertThat(awaitItem()).isEqualTo(event3) + awaitComplete() + } + } + + @Test + fun `setSseManager updates the manager reference`() = + runTest { + val event = createTestEvent() + + every { mockSseManager.recentChangeEvents } returns flowOf(event) + + // Initially returns empty flow + repository.listenToRecentChanges().test { + awaitComplete() + } + + // After setting manager, should forward events + repository.setSseManager(mockSseManager) + + repository.listenToRecentChanges().test { + assertThat(awaitItem()).isEqualTo(event) + awaitComplete() + } + } + + @Test + fun `listenToRecentChanges handles empty flow from SseManager`() = + runTest { + every { mockSseManager.recentChangeEvents } returns emptyFlow() + + repository.setSseManager(mockSseManager) + + repository.listenToRecentChanges().test { + awaitComplete() + } + } + + private fun createTestEvent(title: String = "Test Article") = + RecentChangeEvent( + type = "edit", + namespace = 0, + title = title, + comment = "Test comment", + timestamp = System.currentTimeMillis(), + user = "TestUser", + bot = false, + length = Length(old = 100, new = 150), + wiki = "enwiki", + ) +} diff --git a/app/src/test/java/org/mdholloway/listentowikipedia/testutil/AndroidLogRule.kt b/app/src/test/java/org/mdholloway/listentowikipedia/testutil/AndroidLogRule.kt new file mode 100644 index 0000000..24c2293 --- /dev/null +++ b/app/src/test/java/org/mdholloway/listentowikipedia/testutil/AndroidLogRule.kt @@ -0,0 +1,34 @@ +package org.mdholloway.listentowikipedia.testutil + +import android.util.Log +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class AndroidLogRule : TestRule { + override fun apply( + base: Statement, + description: Description, + ): Statement = + object : Statement() { + override fun evaluate() { + mockkStatic(Log::class) + every { Log.v(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + + try { + base.evaluate() + } finally { + unmockkStatic(Log::class) + } + } + } +} diff --git a/app/src/test/java/org/mdholloway/listentowikipedia/util/UserUtilsTest.kt b/app/src/test/java/org/mdholloway/listentowikipedia/util/UserUtilsTest.kt new file mode 100644 index 0000000..36c0ac7 --- /dev/null +++ b/app/src/test/java/org/mdholloway/listentowikipedia/util/UserUtilsTest.kt @@ -0,0 +1,42 @@ +package org.mdholloway.listentowikipedia.util + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UserUtilsTest { + @Test + fun `isIpAddress returns true for valid IPv4 addresses`() { + assertThat(isIpAddress("192.168.1.1")).isTrue() + assertThat(isIpAddress("10.0.0.1")).isTrue() + assertThat(isIpAddress("127.0.0.1")).isTrue() + assertThat(isIpAddress("255.255.255.255")).isTrue() + } + + @Test + fun `isIpAddress returns true for valid IPv6 addresses`() { + assertThat(isIpAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).isTrue() + assertThat(isIpAddress("2001:db8:85a3:0:0:8a2e:370:7334")).isTrue() + } + + @Test + fun `isIpAddress returns false for invalid addresses`() { + assertThat(isIpAddress("300.300.300.300")).isFalse() + assertThat(isIpAddress("192.168.1")).isFalse() + assertThat(isIpAddress("not.an.ip.address")).isFalse() + assertThat(isIpAddress("")).isFalse() + } + + @Test + fun `isIpAddress returns false for usernames`() { + assertThat(isIpAddress("User123")).isFalse() + assertThat(isIpAddress("WikipediaEditor")).isFalse() + assertThat(isIpAddress("Admin")).isFalse() + } + + @Test + fun `isIpAddress handles edge cases`() { + assertThat(isIpAddress("0.0.0.0")).isTrue() + assertThat(isIpAddress("192.168.1.1.1")).isFalse() + assertThat(isIpAddress("192.168.01.1")).isFalse() // Leading zeros + } +} diff --git a/app/src/test/java/org/mdholloway/listentowikipedia/viewmodel/RecentChangesViewModelTest.kt b/app/src/test/java/org/mdholloway/listentowikipedia/viewmodel/RecentChangesViewModelTest.kt new file mode 100644 index 0000000..9201a9e --- /dev/null +++ b/app/src/test/java/org/mdholloway/listentowikipedia/viewmodel/RecentChangesViewModelTest.kt @@ -0,0 +1,228 @@ +package org.mdholloway.listentowikipedia.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mdholloway.listentowikipedia.audio.AudioManager +import org.mdholloway.listentowikipedia.model.Length +import org.mdholloway.listentowikipedia.model.RecentChangeEvent +import org.mdholloway.listentowikipedia.repository.RecentChangesRepository +import org.mdholloway.listentowikipedia.testutil.AndroidLogRule +import org.mdholloway.listentowikipedia.ui.state.CircleColors + +@OptIn(ExperimentalCoroutinesApi::class) +class RecentChangesViewModelTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val androidLogRule = AndroidLogRule() + + private val testDispatcher = StandardTestDispatcher() + private val mockRepository = mockk() + private val mockAudioManager = mockk(relaxed = true) + + private lateinit var viewModel: RecentChangesViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = RecentChangesViewModel(mockRepository) + viewModel.setAudioManager(mockAudioManager) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is empty`() { + val initialState = viewModel.uiState.value + assertThat(initialState.displayCircles).isEmpty() + assertThat(initialState.recentChangeTexts).isEmpty() + } + + @Test + fun `startListeningToRecentChanges processes valid events`() = + runTest { + val testEvent = + createTestEvent( + wiki = "enwiki", + namespace = 0, + type = "edit", + user = "TestUser", + length = Length(old = 100, new = 150), + ) + + every { mockRepository.listenToRecentChanges() } returns flowOf(testEvent) + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + val updatedState = viewModel.uiState.value + + // Verify circle was added + assertThat(updatedState.displayCircles).hasSize(1) + val circle = updatedState.displayCircles[0] + assertThat(circle.event).isEqualTo(testEvent) + assertThat(circle.radius).isEqualTo(50f) // abs(150 - 100) + assertThat(circle.color).isEqualTo(CircleColors.Registered) + + // Verify text was added + assertThat(updatedState.recentChangeTexts).hasSize(1) + assertThat(updatedState.recentChangeTexts[0]).contains("TestUser added 50 bytes") + + // Verify audio was played + verify { mockAudioManager.playNote(any(), 100) } + } + + @Test + fun `startListeningToRecentChanges handles bot user correctly`() = + runTest { + val botEvent = createTestEvent(bot = true, user = "BotUser") + + every { mockRepository.listenToRecentChanges() } returns flowOf(botEvent) + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + val updatedState = viewModel.uiState.value + val circle = updatedState.displayCircles[0] + assertThat(circle.color).isEqualTo(CircleColors.Bot) + } + + @Test + fun `startListeningToRecentChanges handles anonymous user correctly`() = + runTest { + val anonymousEvent = createTestEvent(user = "192.168.1.1") + + every { mockRepository.listenToRecentChanges() } returns flowOf(anonymousEvent) + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + val updatedState = viewModel.uiState.value + val circle = updatedState.displayCircles[0] + assertThat(circle.color).isEqualTo(CircleColors.Anonymous) + assertThat(updatedState.recentChangeTexts[0]).contains("An anonymous user") + } + + @Test + fun `startListeningToRecentChanges ignores invalid events`() = + runTest { + val invalidEvents = + listOf( + createTestEvent(wiki = "dewiki"), // Wrong wiki + createTestEvent(namespace = 1), // Wrong namespace + createTestEvent(type = "new"), // Wrong type + ) + + every { mockRepository.listenToRecentChanges() } returns flowOf(*invalidEvents.toTypedArray()) + + val initialState = viewModel.uiState.value + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + // State should remain unchanged + assertThat(viewModel.uiState.value).isEqualTo(initialState) + } + + @Test + fun `removeCircle removes correct circle from state`() = + runTest { + val testEvent = createTestEvent(user = "TestUser") + + every { mockRepository.listenToRecentChanges() } returns flowOf(testEvent) + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + val stateWithCircle = viewModel.uiState.value + assertThat(stateWithCircle.displayCircles).hasSize(1) + + val circleId = stateWithCircle.displayCircles[0].id + viewModel.removeCircle(circleId) + + val stateAfterRemoval = viewModel.uiState.value + assertThat(stateAfterRemoval.displayCircles).isEmpty() + } + + @Test + fun `radius calculation clamps values correctly`() = + runTest { + val largeEditEvent = createTestEvent(length = Length(old = 100, new = 10000)) // +9900 bytes + val smallEditEvent = createTestEvent(length = Length(old = 100, new = 105)) // +5 bytes + + every { mockRepository.listenToRecentChanges() } returns flowOf(largeEditEvent, smallEditEvent) + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + val finalState = viewModel.uiState.value + assertThat(finalState.displayCircles).hasSize(2) + + // Check the clamped values + val circles = finalState.displayCircles + assertThat(circles[0].radius).isEqualTo(240f) // Max radius for large edit + assertThat(circles[1].radius).isEqualTo(10f) // Min radius for small edit + } + + @Test + fun `text list maintains maximum size`() = + runTest { + val events = + (1..5).map { i -> + createTestEvent(title = "Article$i", length = Length(old = 100, new = 100 + i)) + } + + every { mockRepository.listenToRecentChanges() } returns flowOf(*events.toTypedArray()) + + viewModel.startListeningToRecentChanges() + advanceUntilIdle() + + val finalState = viewModel.uiState.value + + // Should only keep the latest 3 messages + assertThat(finalState.recentChangeTexts).hasSize(3) + // Most recent should be first (Article5, Article4, Article3) + assertThat(finalState.recentChangeTexts[0]).contains("Article5") + assertThat(finalState.recentChangeTexts[1]).contains("Article4") + assertThat(finalState.recentChangeTexts[2]).contains("Article3") + } + + private fun createTestEvent( + type: String = "edit", + namespace: Int = 0, + title: String = "Test Article", + user: String = "TestUser", + bot: Boolean = false, + length: Length? = Length(old = 100, new = 150), + wiki: String = "enwiki", + ) = RecentChangeEvent( + type = type, + namespace = namespace, + title = title, + comment = "Test comment", + timestamp = System.currentTimeMillis(), + user = user, + bot = bot, + length = length, + wiki = wiki, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 915fd5c..19453a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,12 @@ constraintlayout = "2.2.1" lifecycleLivedataKtx = "2.9.2" lifecycleViewmodelKtx = "2.9.2" hilt = "2.57" +mockk = "1.14.5" +turbine = "1.2.1" +coroutinesTest = "1.10.2" +truth = "1.4.4" +archCoreTesting = "2.2.0" +hiltTesting = "2.57" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -49,6 +55,13 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec oboe = { module = "com.google.oboe:oboe", version.ref = "oboe" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } +androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "archCoreTesting" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hiltTesting" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }