feat(watch): vertical PoC — record, upload, playback end-to-end
Completes the 5-second-record-and-upload flow: - RecordingViewModel orchestrates recorder, countdown, and upload - MediaRecorder wrapper produces 16kHz/AAC-LC m4a (Whisper-compatible) - Compose RecordingScreen renders the state pyramid (Idle / RequestingPermission / Recording / Uploading / Success / Error) - UiState + pure reducer with full JVM unit-test coverage - run.sh "test" subcommand wraps the unit and instrumented tiers (set SKIP_INSTRUMENTED=1 to bypass the on-device tier) Verified end-to-end: emulator captures "Hallo, hallo", lands at tmpdata/<user>/<case>/*.m4a, Whisper transcribes, Ollama classifies, document.md is written — full pipeline in ~5s. Known: MockWebServer-based UploadClientTest hangs in setUp on the Wear OS 34 emulator (likely SELinux socket policy). Deferred — the real-server end-to-end already validates the wire protocol.
This commit is contained in:
@@ -81,9 +81,12 @@ class UploadClientTest {
|
||||
}
|
||||
|
||||
private fun copyAssetToTempFile(assetName: String): File {
|
||||
val ctx = InstrumentationRegistry.getInstrumentation().context
|
||||
val tmp = File.createTempFile("upload-test-", ".m4a", ctx.cacheDir)
|
||||
ctx.assets.open(assetName).use { input ->
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
// Assets are packaged with the test APK; cache dir lives under the app APK.
|
||||
val testCtx = instrumentation.context
|
||||
val appCtx = instrumentation.targetContext
|
||||
val tmp = File.createTempFile("upload-test-", ".m4a", appCtx.cacheDir)
|
||||
testCtx.assets.open(assetName).use { input ->
|
||||
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
return tmp
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.doctate.watch.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Thin wrapper around [MediaRecorder] producing an MPEG-4/AAC file on disk.
|
||||
* Not thread-safe: call start()/stop()/cancel() from a single coroutine scope.
|
||||
*
|
||||
* Format: 16 kHz mono AAC-LC at 64 kbps — matches Whisper's native sample rate
|
||||
* so the server-side remux can skip a resample stage.
|
||||
*/
|
||||
class AudioRecorder(private val context: Context) {
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var currentFile: File? = null
|
||||
|
||||
fun start(): File {
|
||||
require(recorder == null) { "recorder already active" }
|
||||
val outFile = File(context.cacheDir, "rec-${System.currentTimeMillis()}.m4a")
|
||||
val r = newRecorderInstance().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(16_000)
|
||||
setAudioEncodingBitRate(64_000)
|
||||
setAudioChannels(1)
|
||||
setOutputFile(outFile.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
recorder = r
|
||||
currentFile = outFile
|
||||
return outFile
|
||||
}
|
||||
|
||||
fun stop(): File {
|
||||
val r = requireNotNull(recorder) { "recorder not started" }
|
||||
val f = requireNotNull(currentFile)
|
||||
try { r.stop() } finally {
|
||||
r.release()
|
||||
recorder = null
|
||||
currentFile = null
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
val r = recorder ?: return
|
||||
val f = currentFile
|
||||
try { r.stop() } catch (_: RuntimeException) { /* not started or too short — ignore */ }
|
||||
r.release()
|
||||
recorder = null
|
||||
currentFile = null
|
||||
f?.delete()
|
||||
}
|
||||
|
||||
private fun newRecorderInstance(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,12 @@
|
||||
/* While this template provides a good starting point for using Wear Compose, you can always
|
||||
* take a look at https://github.com/android/wear-os-samples/tree/main/ComposeStarter to find the
|
||||
* most up to date changes to the libraries and their usages.
|
||||
*/
|
||||
|
||||
package com.doctate.watch.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
|
||||
import androidx.wear.compose.material3.AppScaffold
|
||||
import androidx.wear.compose.material3.Button
|
||||
import androidx.wear.compose.material3.ButtonDefaults
|
||||
import androidx.wear.compose.material3.EdgeButton
|
||||
import androidx.wear.compose.material3.ListHeader
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
import androidx.wear.compose.material3.SurfaceTransformation
|
||||
import androidx.wear.compose.material3.Text
|
||||
import androidx.wear.compose.material3.lazy.rememberTransformationSpec
|
||||
import androidx.wear.compose.material3.lazy.transformedHeight
|
||||
import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
|
||||
import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales
|
||||
import com.doctate.watch.R
|
||||
import com.doctate.watch.presentation.theme.DoctateWatchTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
WearApp("Android")
|
||||
setContent { RecordingScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WearApp(greetingName: String) {
|
||||
DoctateWatchTheme {
|
||||
AppScaffold {
|
||||
val listState = rememberTransformingLazyColumnState()
|
||||
val transformationSpec = rememberTransformationSpec()
|
||||
ScreenScaffold(
|
||||
scrollState = listState,
|
||||
edgeButton = {
|
||||
EdgeButton(
|
||||
onClick = { /*TODO*/ },
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
) {
|
||||
Text("More")
|
||||
}
|
||||
},
|
||||
) { contentPadding -> // ScreenScaffold provides default padding; adjust as needed
|
||||
TransformingLazyColumn(contentPadding = contentPadding, state = listState) {
|
||||
item {
|
||||
ListHeader(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().transformedHeight(this, transformationSpec),
|
||||
transformation = SurfaceTransformation(transformationSpec),
|
||||
) {
|
||||
Text(text = stringResource(R.string.hello_world, greetingName))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.transformedHeight(this, transformationSpec),
|
||||
transformation = SurfaceTransformation(transformationSpec),
|
||||
) {
|
||||
Text("Button A")
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.transformedHeight(this, transformationSpec),
|
||||
transformation = SurfaceTransformation(transformationSpec),
|
||||
) {
|
||||
Text("Button B")
|
||||
}
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.transformedHeight(this, transformationSpec),
|
||||
transformation = SurfaceTransformation(transformationSpec),
|
||||
) {
|
||||
Text("Button C")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WearPreviewDevices
|
||||
@WearPreviewFontScales
|
||||
@Composable
|
||||
fun DefaultPreview() {
|
||||
WearApp("Preview Android")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.doctate.watch.presentation
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.wear.compose.material3.AppScaffold
|
||||
import androidx.wear.compose.material3.Button
|
||||
import androidx.wear.compose.material3.CircularProgressIndicator
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
import androidx.wear.compose.material3.Text
|
||||
import com.doctate.watch.presentation.theme.DoctateWatchTheme
|
||||
|
||||
@Composable
|
||||
fun RecordingScreen(
|
||||
viewModel: RecordingViewModel = viewModel(factory = RecordingViewModel.Factory),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> viewModel.onPermissionResult(granted) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state is UiState.RequestingPermission) {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
DoctateWatchTheme {
|
||||
AppScaffold {
|
||||
ScreenScaffold {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when (val s = state) {
|
||||
UiState.Idle -> IdleContent(onRecord = viewModel::onRecordTap)
|
||||
UiState.RequestingPermission -> StatusText("Asking for mic…")
|
||||
is UiState.Recording -> StatusText("Recording: ${s.secondsLeft}s")
|
||||
UiState.Uploading -> UploadingContent()
|
||||
is UiState.Success -> SuccessContent(s.caseId, onReset = viewModel::onResetTap)
|
||||
is UiState.Error -> ErrorContent(s, onReset = viewModel::onResetTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdleContent(onRecord: () -> Unit) {
|
||||
Button(onClick = onRecord, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Record")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusText(message: String) {
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadingContent() {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
"Uploading…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessContent(caseId: String, onReset: () -> Unit) {
|
||||
Text(
|
||||
"OK",
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
caseId.take(8),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Button(onClick = onReset, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(state: UiState.Error, onReset: () -> Unit) {
|
||||
Text(
|
||||
if (state.transient) "Try again" else "Error",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
state.message.take(60),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Button(onClick = onReset, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.doctate.watch.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.doctate.watch.DoctateApp
|
||||
import com.doctate.watch.audio.AudioRecorder
|
||||
import com.doctate.watch.domain.CaseId
|
||||
import com.doctate.watch.net.UploadClient
|
||||
import com.doctate.watch.net.UploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
|
||||
class RecordingViewModel(
|
||||
private val recorder: AudioRecorder,
|
||||
private val uploadClient: UploadClient,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState>(UiState.Idle)
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
fun onRecordTap() = dispatch(RecordingEvent.OnRecordTap)
|
||||
|
||||
fun onPermissionResult(granted: Boolean) {
|
||||
if (!granted) {
|
||||
dispatch(RecordingEvent.PermissionDenied)
|
||||
return
|
||||
}
|
||||
dispatch(RecordingEvent.PermissionGranted)
|
||||
startRecordingFlow()
|
||||
}
|
||||
|
||||
fun onResetTap() = dispatch(RecordingEvent.OnResetTap)
|
||||
|
||||
private fun startRecordingFlow() {
|
||||
viewModelScope.launch {
|
||||
val file = try {
|
||||
withContext(Dispatchers.IO) { recorder.start() }
|
||||
} catch (e: Exception) {
|
||||
dispatch(RecordingEvent.UploadFailed("Recorder failed: ${e.message}", transient = false))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Initial state already Recording(5); tick 4..0 over 5 seconds.
|
||||
for (i in 4 downTo 0) {
|
||||
delay(1_000)
|
||||
dispatch(RecordingEvent.RecordingTick(i))
|
||||
}
|
||||
|
||||
val finalFile = try {
|
||||
withContext(Dispatchers.IO) { recorder.stop() }
|
||||
} catch (e: Exception) {
|
||||
recorder.cancel()
|
||||
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
|
||||
return@launch
|
||||
}
|
||||
dispatch(RecordingEvent.RecordingFinished)
|
||||
|
||||
val caseId = CaseId.new()
|
||||
val recordedAt = Instant.now().toString()
|
||||
try {
|
||||
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
|
||||
is UploadResult.Success ->
|
||||
dispatch(RecordingEvent.UploadSucceeded(result.caseId))
|
||||
is UploadResult.Transient ->
|
||||
dispatch(RecordingEvent.UploadFailed(result.reason, transient = true))
|
||||
is UploadResult.Terminal ->
|
||||
dispatch(RecordingEvent.UploadFailed(result.reason, transient = false))
|
||||
}
|
||||
} finally {
|
||||
finalFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatch(event: RecordingEvent) {
|
||||
_state.update { current -> reduce(current, event) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as DoctateApp
|
||||
RecordingViewModel(
|
||||
recorder = AudioRecorder(app),
|
||||
uploadClient = app.uploadClient,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.doctate.watch.presentation
|
||||
|
||||
/** What the recording screen currently shows. */
|
||||
sealed interface UiState {
|
||||
data object Idle : UiState
|
||||
data object RequestingPermission : UiState
|
||||
data class Recording(val secondsLeft: Int) : UiState
|
||||
data object Uploading : UiState
|
||||
data class Success(val caseId: String) : UiState
|
||||
data class Error(val message: String, val transient: Boolean) : UiState
|
||||
}
|
||||
|
||||
/** Events that drive state transitions. Side effects live in the ViewModel. */
|
||||
sealed interface RecordingEvent {
|
||||
data object OnRecordTap : RecordingEvent
|
||||
data object PermissionGranted : RecordingEvent
|
||||
data object PermissionDenied : RecordingEvent
|
||||
data class RecordingTick(val secondsLeft: Int) : RecordingEvent
|
||||
data object RecordingFinished : RecordingEvent
|
||||
data class UploadSucceeded(val caseId: String) : RecordingEvent
|
||||
data class UploadFailed(val message: String, val transient: Boolean) : RecordingEvent
|
||||
data object OnResetTap : RecordingEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure state transition. Unknown (state, event) combinations are no-ops: e.g. a late
|
||||
* UploadSucceeded after the user already tapped reset stays Idle.
|
||||
*/
|
||||
internal fun reduce(state: UiState, event: RecordingEvent): UiState = when (state) {
|
||||
is UiState.Idle -> when (event) {
|
||||
RecordingEvent.OnRecordTap -> UiState.RequestingPermission
|
||||
else -> state
|
||||
}
|
||||
is UiState.RequestingPermission -> when (event) {
|
||||
RecordingEvent.PermissionGranted -> UiState.Recording(secondsLeft = 5)
|
||||
RecordingEvent.PermissionDenied ->
|
||||
UiState.Error(message = "Microphone permission denied", transient = false)
|
||||
else -> state
|
||||
}
|
||||
is UiState.Recording -> when (event) {
|
||||
is RecordingEvent.RecordingTick -> UiState.Recording(secondsLeft = event.secondsLeft)
|
||||
RecordingEvent.RecordingFinished -> UiState.Uploading
|
||||
is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient)
|
||||
else -> state
|
||||
}
|
||||
is UiState.Uploading -> when (event) {
|
||||
is RecordingEvent.UploadSucceeded -> UiState.Success(caseId = event.caseId)
|
||||
is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient)
|
||||
else -> state
|
||||
}
|
||||
is UiState.Success, is UiState.Error -> when (event) {
|
||||
RecordingEvent.OnResetTap -> UiState.Idle
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.doctate.watch.presentation
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class UiStateReducerTest {
|
||||
|
||||
@Test fun idle_to_requesting_permission_on_record_tap() {
|
||||
val next = reduce(UiState.Idle, RecordingEvent.OnRecordTap)
|
||||
assertThat(next).isEqualTo(UiState.RequestingPermission)
|
||||
}
|
||||
|
||||
@Test fun requesting_permission_to_recording_on_grant() {
|
||||
val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionGranted)
|
||||
assertThat(next).isEqualTo(UiState.Recording(secondsLeft = 5))
|
||||
}
|
||||
|
||||
@Test fun requesting_permission_to_error_on_denial() {
|
||||
val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionDenied)
|
||||
assertThat(next).isInstanceOf(UiState.Error::class.java)
|
||||
assertThat((next as UiState.Error).transient).isFalse()
|
||||
}
|
||||
|
||||
@Test fun recording_updates_seconds_left_on_tick() {
|
||||
val next = reduce(UiState.Recording(secondsLeft = 5), RecordingEvent.RecordingTick(3))
|
||||
assertThat(next).isEqualTo(UiState.Recording(secondsLeft = 3))
|
||||
}
|
||||
|
||||
@Test fun recording_moves_to_uploading_when_finished() {
|
||||
val next = reduce(UiState.Recording(secondsLeft = 0), RecordingEvent.RecordingFinished)
|
||||
assertThat(next).isEqualTo(UiState.Uploading)
|
||||
}
|
||||
|
||||
@Test fun recording_to_error_on_recorder_failure() {
|
||||
val next = reduce(
|
||||
UiState.Recording(secondsLeft = 3),
|
||||
RecordingEvent.UploadFailed("mic busy", transient = false),
|
||||
)
|
||||
val err = next as UiState.Error
|
||||
assertThat(err.message).isEqualTo("mic busy")
|
||||
assertThat(err.transient).isFalse()
|
||||
}
|
||||
|
||||
@Test fun uploading_to_success_on_ack() {
|
||||
val next = reduce(UiState.Uploading, RecordingEvent.UploadSucceeded("abc-123"))
|
||||
assertThat(next).isEqualTo(UiState.Success("abc-123"))
|
||||
}
|
||||
|
||||
@Test fun uploading_to_error_on_transient_failure() {
|
||||
val next = reduce(UiState.Uploading, RecordingEvent.UploadFailed("timeout", transient = true))
|
||||
val err = next as UiState.Error
|
||||
assertThat(err.message).isEqualTo("timeout")
|
||||
assertThat(err.transient).isTrue()
|
||||
}
|
||||
|
||||
@Test fun success_resets_to_idle_on_tap() {
|
||||
val next = reduce(UiState.Success("abc-123"), RecordingEvent.OnResetTap)
|
||||
assertThat(next).isEqualTo(UiState.Idle)
|
||||
}
|
||||
|
||||
@Test fun error_resets_to_idle_on_tap() {
|
||||
val next = reduce(UiState.Error("boom", true), RecordingEvent.OnResetTap)
|
||||
assertThat(next).isEqualTo(UiState.Idle)
|
||||
}
|
||||
|
||||
@Test fun unknown_event_is_noop() {
|
||||
val next = reduce(UiState.Idle, RecordingEvent.UploadSucceeded("late ack"))
|
||||
assertThat(next).isEqualTo(UiState.Idle)
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,15 @@
|
||||
# stop adb shell am force-stop ...
|
||||
# logcat adb logcat, filtered to the app's PID
|
||||
# shot [path] screencap to PNG (default /tmp/wear_screen.png)
|
||||
# test run JVM unit tests, plus instrumented tests on emulator
|
||||
# (set SKIP_INSTRUMENTED=1 to skip the on-device tier)
|
||||
# clean ./gradlew clean
|
||||
# help show this help
|
||||
#
|
||||
# Env vars:
|
||||
# WEAR_AVD AVD name to auto-boot when no device is attached
|
||||
# (skips the interactive menu; useful for CI / scripted runs)
|
||||
# SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no emulator needed)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -205,6 +208,21 @@ cmd_clean() {
|
||||
ok "Clean."
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
detect_java
|
||||
info "Running JVM unit tests..."
|
||||
(cd "$SCRIPT_DIR" && ./gradlew :app:testDebugUnitTest)
|
||||
ok "Unit tests passed."
|
||||
if [[ "${SKIP_INSTRUMENTED:-0}" == "1" ]]; then
|
||||
info "SKIP_INSTRUMENTED=1 set; skipping connectedAndroidTest."
|
||||
return
|
||||
fi
|
||||
ensure_device
|
||||
info "Running instrumented tests..."
|
||||
(cd "$SCRIPT_DIR" && ./gradlew :app:connectedDebugAndroidTest)
|
||||
ok "Instrumented tests passed."
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
# Print the leading comment block (lines 2..first non-comment line).
|
||||
awk 'NR==1 { next } /^[^[:space:]#]/ { exit } { print }' "$0" \
|
||||
@@ -220,6 +238,7 @@ case "${1:-}" in
|
||||
stop) cmd_stop ;;
|
||||
logcat) cmd_logcat ;;
|
||||
shot) shift; cmd_shot "$@" ;;
|
||||
test) cmd_test ;;
|
||||
clean) cmd_clean ;;
|
||||
-h|--help|help) cmd_help ;;
|
||||
*) die "Unknown command: $1 (try './run.sh help')" ;;
|
||||
|
||||
Reference in New Issue
Block a user