From 5de96a3ac98ee00455dba6f52dd113b3acc2cf40 Mon Sep 17 00:00:00 2001 From: Siyabonga Buthelezi <114085572+ElliotBadinger@users.noreply.github.com> Date: Sat, 4 Oct 2025 09:35:44 +0200 Subject: [PATCH 1/2] Agent Session [2025-03-10]: Modular recorder refactor --- data/build.gradle.kts | 42 +++---- .../siya/epistemophile/data/di/DataModule.kt | 18 --- .../epistemophile/data/di/RepositoryModule.kt | 17 --- .../data/recording/RecordingRepositoryImpl.kt | 93 ++++++++++++-- .../recording/RecordingRepositoryImplTest.kt | 114 ++++++++++++++++++ domain/build.gradle.kts | 5 +- .../domain/model/RecorderState.kt | 11 ++ .../domain/model/RecordingError.kt | 17 +++ .../domain/repository/RecordingRepository.kt | 14 ++- .../epistemophile/domain/usecase/UseCases.kt | 19 ++- .../domain/usecase/RecordingUseCasesTest.kt | 106 ++++++++++++++++ features/recorder/build.gradle.kts | 43 +++---- .../features/recorder/RecordingViewModel.kt | 100 ++++++++++++--- .../features/recorder/MainDispatcherRule.kt | 23 ++++ .../recorder/RecordingViewModelTest.kt | 96 ++++++++++++--- gradle/libs.versions.toml | 2 + 16 files changed, 576 insertions(+), 144 deletions(-) delete mode 100644 data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt delete mode 100644 data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt create mode 100644 data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt create mode 100644 domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt create mode 100644 domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt create mode 100644 domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt create mode 100644 features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt diff --git a/data/build.gradle.kts b/data/build.gradle.kts index e17d4111..2852838b 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,35 +1,23 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.hilt.android) - id("kotlin-kapt") -} - -android { - namespace = "com.siya.epistemophile.data" - compileSdk = 34 - - defaultConfig { - minSdk = 30 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } + alias(libs.plugins.kotlin.jvm) } dependencies { - implementation(project(":core")) implementation(project(":domain")) - implementation(libs.coroutines.core) - implementation(libs.coroutines.android) + implementation(libs.javax.inject) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) +} - implementation(libs.hilt.android) - kapt(libs.hilt.compiler) - annotationProcessor(libs.hilt.compiler) +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } } diff --git a/data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt b/data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt deleted file mode 100644 index d36520d1..00000000 --- a/data/src/main/kotlin/com/siya/epistemophile/data/di/DataModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.siya.epistemophile.data.di - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -object DataModule { - - @Provides - fun provideContext(@ApplicationContext context: Context): Context { - return context - } -} diff --git a/data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt b/data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt deleted file mode 100644 index c43518b4..00000000 --- a/data/src/main/kotlin/com/siya/epistemophile/data/di/RepositoryModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.siya.epistemophile.data.di - -import com.siya.epistemophile.data.recording.RecordingRepositoryImpl -import com.siya.epistemophile.domain.repository.RecordingRepository -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -abstract class RepositoryModule { - @Binds - @Singleton - abstract fun bindRecordingRepository(impl: RecordingRepositoryImpl): RecordingRepository -} diff --git a/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt b/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt index dbf3f16a..0e0dd1c3 100644 --- a/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt +++ b/data/src/main/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImpl.kt @@ -1,24 +1,91 @@ package com.siya.epistemophile.data.recording +import com.siya.epistemophile.domain.model.RecorderState +import com.siya.epistemophile.domain.model.RecordingError import com.siya.epistemophile.domain.repository.RecordingRepository -import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext -/** - * Minimal stub implementation that will later delegate to the audio pipeline / Android service. - */ -import javax.inject.Inject +class RecordingRepositoryImpl @Inject constructor( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) : RecordingRepository { + + private val mutex = Mutex() + private val _state = MutableStateFlow(RecorderState()) + override val recorderState: StateFlow = _state.asStateFlow() + + override suspend fun enableListening(): Result = withContext(dispatcher) { + mutex.withLock { + _state.value = _state.value.copy(isListening = true) + Result.success(Unit) + } + } + + override suspend fun disableListening(): Result = withContext(dispatcher) { + mutex.withLock { + val current = _state.value + _state.value = current.copy( + isListening = false, + isRecording = false + ) + Result.success(Unit) + } + } + + override suspend fun startRecording(prependedMemorySeconds: Float): Result = + withContext(dispatcher) { + mutex.withLock { + val current = _state.value + if (current.isRecording) { + return@withLock Result.failure(RecordingError.AlreadyRecording) + } + + _state.value = current.copy( + isListening = true, + isRecording = true + ) + Result.success(Unit) + } + } -class RecordingRepositoryImpl @Inject constructor() : RecordingRepository { - private val _isListening = MutableStateFlow(false) - override val isListening: Flow = _isListening.asStateFlow() + override suspend fun stopRecording(): Result = withContext(dispatcher) { + mutex.withLock { + val current = _state.value + if (!current.isRecording) { + return@withLock Result.failure(RecordingError.NotRecording) + } - override suspend fun enableListening() { _isListening.value = true } - override suspend fun disableListening() { _isListening.value = false } + _state.value = current.copy( + isRecording = false, + hasUnsavedRecording = true + ) + Result.success(Unit) + } + } - override suspend fun startRecording(prependedMemorySeconds: Float) { /* no-op stub */ } - override suspend fun stopRecording() { /* no-op stub */ } + override suspend fun dumpRecording( + memorySeconds: Float, + newFileName: String? + ): Result = withContext(dispatcher) { + mutex.withLock { + val current = _state.value + if (!current.hasUnsavedRecording) { + return@withLock Result.failure(RecordingError.NothingToSave) + } - override suspend fun dumpRecording(memorySeconds: Float, newFileName: String?): Result = Result.success(Unit) + val sanitizedName = newFileName?.takeIf { it.isNotBlank() } + _state.value = current.copy( + hasUnsavedRecording = false, + lastSavedFileName = sanitizedName + ) + Result.success(Unit) + } + } } diff --git a/data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt b/data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt new file mode 100644 index 00000000..b9d7f543 --- /dev/null +++ b/data/src/test/kotlin/com/siya/epistemophile/data/recording/RecordingRepositoryImplTest.kt @@ -0,0 +1,114 @@ +package com.siya.epistemophile.data.recording + +import com.siya.epistemophile.domain.model.RecordingError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingRepositoryImplTest { + private lateinit var dispatcher: TestDispatcher + private lateinit var scope: TestScope + private lateinit var repository: RecordingRepositoryImpl + + @Before + fun setUp() { + dispatcher = StandardTestDispatcher() + scope = TestScope(dispatcher) + repository = RecordingRepositoryImpl(dispatcher) + } + + @Test + fun `initial state is idle`() = scope.runTest { + val state = repository.recorderState.value + + assertFalse(state.isListening) + assertFalse(state.isRecording) + assertFalse(state.hasUnsavedRecording) + assertNull(state.lastSavedFileName) + } + + @Test + fun `enable listening updates state`() = scope.runTest { + val result = repository.enableListening() + + assertTrue(result.isSuccess) + assertTrue(repository.recorderState.value.isListening) + } + + @Test + fun `disable listening stops recording`() = scope.runTest { + repository.enableListening() + repository.startRecording() + + val result = repository.disableListening() + + assertTrue(result.isSuccess) + val state = repository.recorderState.value + assertFalse(state.isListening) + assertFalse(state.isRecording) + } + + @Test + fun `start recording enforces single active session`() = scope.runTest { + repository.enableListening() + val first = repository.startRecording() + val second = repository.startRecording() + + assertTrue(first.isSuccess) + assertTrue(repository.recorderState.value.isRecording) + assertTrue(second.isFailure) + assertTrue(second.exceptionOrNull() is RecordingError.AlreadyRecording) + } + + @Test + fun `stop recording requires active recording`() = scope.runTest { + val result = repository.stopRecording() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is RecordingError.NotRecording) + } + + @Test + fun `stop recording marks unsaved data`() = scope.runTest { + repository.enableListening() + repository.startRecording() + + val result = repository.stopRecording() + + assertTrue(result.isSuccess) + val state = repository.recorderState.value + assertFalse(state.isRecording) + assertTrue(state.hasUnsavedRecording) + } + + @Test + fun `dump recording clears unsaved flag`() = scope.runTest { + repository.enableListening() + repository.startRecording() + repository.stopRecording() + + val result = repository.dumpRecording(memorySeconds = 30f, newFileName = "clip") + + assertTrue(result.isSuccess) + val state = repository.recorderState.value + assertFalse(state.hasUnsavedRecording) + assertEquals("clip", state.lastSavedFileName) + } + + @Test + fun `dump recording without data fails`() = scope.runTest { + val result = repository.dumpRecording(memorySeconds = 10f, newFileName = null) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is RecordingError.NothingToSave) + } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 90b08a4e..882e8756 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -2,10 +2,11 @@ plugins { alias(libs.plugins.kotlin.jvm) } - - dependencies { implementation(libs.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } java { diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt new file mode 100644 index 00000000..33a12ad6 --- /dev/null +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecorderState.kt @@ -0,0 +1,11 @@ +package com.siya.epistemophile.domain.model + +/** + * Immutable snapshot of the recorder state used across the domain and presentation layers. + */ +data class RecorderState( + val isListening: Boolean = false, + val isRecording: Boolean = false, + val hasUnsavedRecording: Boolean = false, + val lastSavedFileName: String? = null +) diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt new file mode 100644 index 00000000..06647818 --- /dev/null +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/model/RecordingError.kt @@ -0,0 +1,17 @@ +package com.siya.epistemophile.domain.model + +/** + * Represents domain level failures that can occur while controlling the recorder. + */ +sealed class RecordingError(message: String) : Throwable(message) { + data object AlreadyRecording : RecordingError("Recording is already in progress") + data object NotRecording : RecordingError("There is no active recording to stop") + data object NothingToSave : RecordingError("No recording available to save") + data class Unknown(val original: Throwable) : RecordingError( + original.message ?: "Unknown recording error" + ) { + init { + initCause(original) + } + } +} diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt index 98d97299..dd8e7888 100644 --- a/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/repository/RecordingRepository.kt @@ -1,14 +1,16 @@ package com.siya.epistemophile.domain.repository -import kotlinx.coroutines.flow.Flow +import com.siya.epistemophile.domain.model.RecorderState +import kotlinx.coroutines.flow.StateFlow interface RecordingRepository { - val isListening: Flow - suspend fun enableListening() - suspend fun disableListening() + val recorderState: StateFlow - suspend fun startRecording(prependedMemorySeconds: Float = 0f) - suspend fun stopRecording() + suspend fun enableListening(): Result + suspend fun disableListening(): Result + + suspend fun startRecording(prependedMemorySeconds: Float = 0f): Result + suspend fun stopRecording(): Result suspend fun dumpRecording(memorySeconds: Float, newFileName: String? = null): Result } diff --git a/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt b/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt index 0c379539..152f7144 100644 --- a/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt +++ b/domain/src/main/kotlin/com/siya/epistemophile/domain/usecase/UseCases.kt @@ -3,8 +3,23 @@ package com.siya.epistemophile.domain.usecase import com.siya.epistemophile.domain.repository.RecordingRepository class StartListeningUseCase(private val repo: RecordingRepository) { - suspend operator fun invoke() = repo.enableListening() + suspend operator fun invoke(): Result = repo.enableListening() } + class StopListeningUseCase(private val repo: RecordingRepository) { - suspend operator fun invoke() = repo.disableListening() + suspend operator fun invoke(): Result = repo.disableListening() +} + +class StartRecordingUseCase(private val repo: RecordingRepository) { + suspend operator fun invoke(prependedMemorySeconds: Float = 0f): Result = + repo.startRecording(prependedMemorySeconds) +} + +class StopRecordingUseCase(private val repo: RecordingRepository) { + suspend operator fun invoke(): Result = repo.stopRecording() +} + +class DumpRecordingUseCase(private val repo: RecordingRepository) { + suspend operator fun invoke(memorySeconds: Float, newFileName: String? = null): Result = + repo.dumpRecording(memorySeconds, newFileName) } diff --git a/domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt b/domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt new file mode 100644 index 00000000..45f39aad --- /dev/null +++ b/domain/src/test/kotlin/com/siya/epistemophile/domain/usecase/RecordingUseCasesTest.kt @@ -0,0 +1,106 @@ +package com.siya.epistemophile.domain.usecase + +import com.siya.epistemophile.domain.model.RecorderState +import com.siya.epistemophile.domain.repository.RecordingRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingUseCasesTest { + private val fakeRepository = FakeRecordingRepository() + + @Test + fun `start listening delegates to repository`() = runTest { + val useCase = StartListeningUseCase(fakeRepository) + + val result = useCase() + + assertEquals(1, fakeRepository.enableInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `stop listening delegates to repository`() = runTest { + val useCase = StopListeningUseCase(fakeRepository) + + val result = useCase() + + assertEquals(1, fakeRepository.disableInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `start recording delegates to repository`() = runTest { + val useCase = StartRecordingUseCase(fakeRepository) + + val result = useCase(5f) + + assertEquals(1, fakeRepository.startRecordingInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `stop recording delegates to repository`() = runTest { + val useCase = StopRecordingUseCase(fakeRepository) + + val result = useCase() + + assertEquals(1, fakeRepository.stopRecordingInvocations) + assertTrue(result.isSuccess) + } + + @Test + fun `dump recording delegates to repository`() = runTest { + val useCase = DumpRecordingUseCase(fakeRepository) + + val result = useCase(10f, "test") + + assertEquals(1, fakeRepository.dumpRecordingInvocations) + assertTrue(result.isSuccess) + } + + private class FakeRecordingRepository : RecordingRepository { + private val state = MutableStateFlow(RecorderState()) + + var enableInvocations = 0 + private set + var disableInvocations = 0 + private set + var startRecordingInvocations = 0 + private set + var stopRecordingInvocations = 0 + private set + var dumpRecordingInvocations = 0 + private set + + override val recorderState: StateFlow = state + + override suspend fun enableListening(): Result { + enableInvocations++ + return Result.success(Unit) + } + + override suspend fun disableListening(): Result { + disableInvocations++ + return Result.success(Unit) + } + + override suspend fun startRecording(prependedMemorySeconds: Float): Result { + startRecordingInvocations++ + return Result.success(Unit) + } + + override suspend fun stopRecording(): Result { + stopRecordingInvocations++ + return Result.success(Unit) + } + + override suspend fun dumpRecording(memorySeconds: Float, newFileName: String?): Result { + dumpRecordingInvocations++ + return Result.success(Unit) + } + } +} diff --git a/features/recorder/build.gradle.kts b/features/recorder/build.gradle.kts index 15745bd0..6376797a 100644 --- a/features/recorder/build.gradle.kts +++ b/features/recorder/build.gradle.kts @@ -1,37 +1,24 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.hilt.android) - id("kotlin-kapt") -} - -android { - namespace = "com.siya.epistemophile.features.recorder" - compileSdk = 34 - - defaultConfig { minSdk = 30 } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { jvmTarget = "17" } + alias(libs.plugins.kotlin.jvm) } dependencies { implementation(project(":domain")) - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.viewmodel.ktx) - implementation(libs.coroutines.android) - - implementation(libs.hilt.android) - kapt(libs.hilt.compiler) + implementation(libs.coroutines.core) + implementation(libs.javax.inject) testImplementation(libs.junit) - testImplementation(libs.mockito.core) - testImplementation(libs.mockito.kotlin) - testImplementation(libs.coroutines.core) testImplementation(libs.coroutines.test) - testImplementation(libs.androidx.arch.core.testing) + testImplementation(project(":data")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } } diff --git a/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt b/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt index 112437a1..695950d4 100644 --- a/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt +++ b/features/recorder/src/main/java/com/siya/epistemophile/features/recorder/RecordingViewModel.kt @@ -1,28 +1,100 @@ package com.siya.epistemophile.features.recorder -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import com.siya.epistemophile.domain.model.RecordingError +import com.siya.epistemophile.domain.repository.RecordingRepository +import com.siya.epistemophile.domain.usecase.DumpRecordingUseCase import com.siya.epistemophile.domain.usecase.StartListeningUseCase +import com.siya.epistemophile.domain.usecase.StartRecordingUseCase import com.siya.epistemophile.domain.usecase.StopListeningUseCase -import com.siya.epistemophile.domain.repository.RecordingRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import com.siya.epistemophile.domain.usecase.StopRecordingUseCase import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class RecordingViewModel @Inject constructor( private val repository: RecordingRepository, private val startListeningUseCase: StartListeningUseCase, - private val stopListeningUseCase: StopListeningUseCase -) : ViewModel() { - val isListening: Flow = repository.isListening + private val stopListeningUseCase: StopListeningUseCase, + private val startRecordingUseCase: StartRecordingUseCase, + private val stopRecordingUseCase: StopRecordingUseCase, + private val dumpRecordingUseCase: DumpRecordingUseCase, + private val dispatcher: CoroutineDispatcher = Dispatchers.Default +) { - fun toggleListening(enable: Boolean) { - viewModelScope.launch { - if (enable) { - startListeningUseCase() - } else { - stopListeningUseCase() + private val job = SupervisorJob() + private val scope = CoroutineScope(job + dispatcher) + + private val _uiState = MutableStateFlow(RecordingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + scope.launch { + repository.recorderState.collectLatest { state -> + _uiState.update { current -> + current.copy( + isListening = state.isListening, + isRecording = state.isRecording, + canSaveRecording = state.hasUnsavedRecording, + lastSavedFileName = state.lastSavedFileName + ) + } } } } + + fun toggleListening(enable: Boolean) { + scope.launch { + val result = if (enable) startListeningUseCase() else stopListeningUseCase() + handleResult(result) + } + } + + fun startRecording(prependedMemorySeconds: Float = 0f) { + scope.launch { + handleResult(startRecordingUseCase(prependedMemorySeconds)) + } + } + + fun stopRecording() { + scope.launch { + handleResult(stopRecordingUseCase()) + } + } + + fun saveRecording(memorySeconds: Float, newFileName: String? = null) { + scope.launch { + handleResult(dumpRecordingUseCase(memorySeconds, newFileName)) + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + fun clear() { + job.cancel() + } + + private fun handleResult(result: Result) { + result.exceptionOrNull()?.let { throwable -> + val error = throwable as? RecordingError ?: RecordingError.Unknown(throwable) + _uiState.update { it.copy(error = error) } + } + } } + +data class RecordingUiState( + val isListening: Boolean = false, + val isRecording: Boolean = false, + val canSaveRecording: Boolean = false, + val lastSavedFileName: String? = null, + val error: RecordingError? = null +) diff --git a/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt new file mode 100644 index 00000000..3505e025 --- /dev/null +++ b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package com.siya.epistemophile.features.recorder + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt index 84abcbd4..b53cc7f7 100644 --- a/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt +++ b/features/recorder/src/test/kotlin/com/siya/epistemophile/features/recorder/RecordingViewModelTest.kt @@ -1,43 +1,105 @@ - package com.siya.epistemophile.features.recorder -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.siya.epistemophile.domain.repository.RecordingRepository +import com.siya.epistemophile.data.recording.RecordingRepositoryImpl +import com.siya.epistemophile.domain.model.RecordingError +import com.siya.epistemophile.domain.usecase.DumpRecordingUseCase import com.siya.epistemophile.domain.usecase.StartListeningUseCase +import com.siya.epistemophile.domain.usecase.StartRecordingUseCase import com.siya.epistemophile.domain.usecase.StopListeningUseCase -import kotlinx.coroutines.Dispatchers +import com.siya.epistemophile.domain.usecase.StopRecordingUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class RecordingViewModelTest { + private val dispatcher: TestDispatcher = StandardTestDispatcher() + @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() + val mainDispatcherRule = MainDispatcherRule(dispatcher) - private val testDispatcher = StandardTestDispatcher() + private lateinit var repository: RecordingRepositoryImpl + private lateinit var viewModel: RecordingViewModel @Before fun setUp() { - Dispatchers.setMain(testDispatcher) + repository = RecordingRepositoryImpl(dispatcher) + viewModel = RecordingViewModel( + repository = repository, + startListeningUseCase = StartListeningUseCase(repository), + stopListeningUseCase = StopListeningUseCase(repository), + startRecordingUseCase = StartRecordingUseCase(repository), + stopRecordingUseCase = StopRecordingUseCase(repository), + dumpRecordingUseCase = DumpRecordingUseCase(repository), + dispatcher = dispatcher + ) + } + + @Test + fun `toggling listening updates ui state`() = runTest(dispatcher) { + viewModel.toggleListening(true) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.isListening) + } + + @Test + fun `start recording exposes active state`() = runTest(dispatcher) { + viewModel.startRecording() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state.isListening) + assertTrue(state.isRecording) + } + + @Test + fun `stop recording marks clip available`() = runTest(dispatcher) { + viewModel.startRecording() + advanceUntilIdle() + + viewModel.stopRecording() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isRecording) + assertTrue(state.canSaveRecording) + } + + @Test + fun `save recording clears pending flag and records name`() = runTest(dispatcher) { + viewModel.startRecording() + advanceUntilIdle() + viewModel.stopRecording() + advanceUntilIdle() + + viewModel.saveRecording(memorySeconds = 15f, newFileName = "clip-01") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.canSaveRecording) + assertEquals("clip-01", state.lastSavedFileName) } @Test - fun togglingListening_updatesStateOnSuccess() = runTest(testDispatcher) { - val repo = Mockito.mock(RecordingRepository::class.java) - val startUseCase = Mockito.mock(StartListeningUseCase::class.java) - val stopUseCase = Mockito.mock(StopListeningUseCase::class.java) + fun `invalid stop emits error`() = runTest(dispatcher) { + viewModel.stopRecording() + advanceUntilIdle() - val vm = RecordingViewModel(repo, startUseCase, stopUseCase) + val error = viewModel.uiState.value.error + assertTrue(error is RecordingError.NotRecording) - vm.toggleListening(true) - // TODO: Add proper assertion logic with StateFlow collection + viewModel.clearError() + assertNull(viewModel.uiState.value.error) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 45f8b70a..d911cbe3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ mockito = "5.11.0" mockito-kotlin = "5.0.0" robolectric = "4.11.1" tap-target-view = "1.13.3" +javax-inject = "1" [libraries] androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } @@ -31,6 +32,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } tap-target-view = { module = "com.getkeepsafe.taptargetview:taptargetview", version.ref = "tap-target-view" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javax-inject" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 748d376adb64251a5f0ecee7dc57da1c2a11174e Mon Sep 17 00:00:00 2001 From: Siyabonga Buthelezi <114085572+ElliotBadinger@users.noreply.github.com> Date: Sat, 4 Oct 2025 09:46:55 +0200 Subject: [PATCH 2/2] Update and rename WARP.md to AGENTS.md --- WARP.md => AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename WARP.md => AGENTS.md (99%) diff --git a/WARP.md b/AGENTS.md similarity index 99% rename from WARP.md rename to AGENTS.md index 7f8e5448..1d9a340a 100644 --- a/WARP.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# WARP.md +# AGENTS.md This file provides guidance to WARP (warp.dev) when working with code in this repository.