Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
9 changes: 9 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.mdholloway.listentowikipedia.audio

interface AudioEngine {
fun start(): Boolean

fun stop()

fun keyOn(
midiNote: Int,
velocity: Int,
)
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package org.mdholloway.listentowikipedia.audio

import android.util.Log
import com.DspFaust.DspFaust
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject

@ActivityScoped
class AudioManager
@Inject
constructor(
private val dspFaust: DspFaust,
private val audioEngine: AudioEngine,
) {
companion object {
private const val TAG = "AudioManager"
Expand All @@ -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
Expand All @@ -34,7 +33,7 @@ class AudioManager
*/
fun stop() {
if (isStarted) {
dspFaust.stop()
audioEngine.stop()
isStarted = false
Log.i(TAG, "Audio engine stopped")
}
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +19,10 @@ object ActivityScopedModule {
@ActivityScoped
fun provideDspFaust(): DspFaust = DspFaust()

@Provides
@ActivityScoped
fun provideAudioEngine(dspFaust: DspFaust): AudioEngine = DspFaustEngine(dspFaust)

@Provides
@ActivityScoped
fun provideSseManager(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<AudioEngine>(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() }
}
}
Loading