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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions core/data/src/main/java/kurou/androidpods/core/data/DataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import kurou.androidpods.core.domain.CompatibleDeviceRepository
import kurou.androidpods.core.domain.FirstLaunchRepository
import kurou.androidpods.core.domain.OverlayPositionRepository
import kurou.androidpods.core.domain.OverlaySettingsRepository
import kurou.androidpods.core.domain.RssiThresholdRepository
import kurou.androidpods.core.domain.ThemeSettingsRepository
import kurou.androidpods.core.domain.UnknownDeviceRepository
import kurou.androidpods.core.domain.UpdateRepository
Expand Down Expand Up @@ -53,6 +54,13 @@ abstract class DataModule {
fun provideWidgetBatteryDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.widgetBatteryDataStore

@Provides
@Singleton
@Named("rssi_threshold")
fun provideRssiThresholdDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.rssiThresholdDataStore
}

@Binds
Expand Down Expand Up @@ -88,4 +96,7 @@ abstract class DataModule {

@Binds
internal abstract fun bindWidgetBatteryRepository(impl: WidgetBatteryRepositoryImpl): WidgetBatteryRepository

@Binds
internal abstract fun bindRssiThresholdRepository(impl: RssiThresholdRepositoryImpl): RssiThresholdRepository
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kurou.androidpods.core.data

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kurou.androidpods.core.domain.RssiThreshold
import kurou.androidpods.core.domain.RssiThresholdRepository
import java.io.IOException
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

internal val Context.rssiThresholdDataStore by preferencesDataStore(name = "rssi_threshold")

@Singleton
internal class RssiThresholdRepositoryImpl @Inject constructor(
@param:Named("rssi_threshold") private val dataStore: DataStore<Preferences>,
) : RssiThresholdRepository {
private val thresholdKey = stringPreferencesKey("rssi_threshold")

override fun observe(): Flow<RssiThreshold> =
dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { preferences ->
preferences[thresholdKey]
?.let { try { RssiThreshold.valueOf(it) } catch (_: IllegalArgumentException) { null } }
?: RssiThreshold.VERY_NEAR
}

override suspend fun update(threshold: RssiThreshold) {
dataStore.edit { it[thresholdKey] = threshold.name }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package kurou.androidpods.core.data

import android.app.Application
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import kurou.androidpods.core.domain.RssiThreshold
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.io.IOException
import java.util.UUID

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class RssiThresholdRepositoryImplTest {
private lateinit var repository: RssiThresholdRepositoryImpl

@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Application>()
val dataStore = PreferenceDataStoreFactory.create {
File(context.filesDir, "datastore/test_rssi_${UUID.randomUUID()}.preferences_pb")
}
repository = RssiThresholdRepositoryImpl(dataStore)
}

@Test
fun `デフォルト値はVERY_NEAR`() =
runTest {
val result = repository.observe().first()

assertEquals(RssiThreshold.VERY_NEAR, result)
}

@Test
fun `updateで保存した値が取得できる`() =
runTest {
repository.update(RssiThreshold.NEAR)

val result = repository.observe().first()

assertEquals(RssiThreshold.NEAR, result)
}

@Test
fun `IOExceptionが発生した場合はデフォルト値VERY_NEARを返す`() =
runTest {
val ioExceptionDataStore = object : DataStore<Preferences> {
override val data: Flow<Preferences> = flow { throw IOException("Test") }
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
throw IOException("Test")
}
val repo = RssiThresholdRepositoryImpl(ioExceptionDataStore)

val result = repo.observe().first()

assertEquals(RssiThreshold.VERY_NEAR, result)
}

@Test
fun `IOException以外の例外は伝播する`() =
runTest {
val runtimeExceptionDataStore = object : DataStore<Preferences> {
override val data: Flow<Preferences> = flow { throw RuntimeException("Test") }
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
throw RuntimeException("Test")
}
val repo = RssiThresholdRepositoryImpl(runtimeExceptionDataStore)

var thrownException: Throwable? = null
try {
repo.observe().first()
} catch (e: RuntimeException) {
thrownException = e
}
assertNotNull(thrownException)
assertTrue(thrownException is RuntimeException)
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package kurou.androidpods.core.domain

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject

class GetAppleDevicesUseCase @Inject constructor(
private val repository: AppleDeviceRepository,
private val rssiThresholdRepository: RssiThresholdRepository,
) {
fun observe(): Flow<Map<String, AppleDevice>> = repository.observeDevices()
fun observe(): Flow<Map<String, AppleDevice>> =
combine(repository.observeDevices(), rssiThresholdRepository.observe()) { devices, threshold ->
devices.filter { (_, device) -> device.rssi >= threshold.minRssi }
}

fun startScan() = repository.startScan()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kurou.androidpods.core.domain

enum class RssiThreshold(val minRssi: Int) {
ALL(Int.MIN_VALUE),
MEDIUM(-75),
NEAR(-65),
VERY_NEAR(-55),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kurou.androidpods.core.domain

import kotlinx.coroutines.flow.Flow

interface RssiThresholdRepository {
fun observe(): Flow<RssiThreshold>

suspend fun update(threshold: RssiThreshold)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kurou.androidpods.core.domain

import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

class RssiThresholdUseCase @Inject constructor(
private val repository: RssiThresholdRepository,
) {
fun observe(): Flow<RssiThreshold> = repository.observe()

suspend fun update(threshold: RssiThreshold) = repository.update(threshold)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,21 @@ import org.junit.Test
class GetAppleDevicesUseCaseTest {
private lateinit var useCase: GetAppleDevicesUseCase
private val repository = mockk<AppleDeviceRepository>(relaxUnitFun = true)
private val rssiThresholdRepository = mockk<RssiThresholdRepository>()

private fun device(rssi: Int) = AppleDevice(
address = "AA:BB:CC:DD:EE:FF",
modelName = "AirPods Pro",
modelCode = 0x2002,
rssi = rssi,
leftBattery = 80,
rightBattery = 75,
caseBattery = 90,
)

@Before
fun setUp() {
useCase = GetAppleDevicesUseCase(repository)
useCase = GetAppleDevicesUseCase(repository, rssiThresholdRepository)
}

@After
Expand All @@ -28,26 +39,50 @@ class GetAppleDevicesUseCaseTest {
}

@Test
fun `observeがrepositoryのobserveDevicesのFlowを返す`() =
fun `ALLの場合はRSSIに関わらず全デバイスを返す`() =
runTest {
val devices = mapOf("key" to device(rssi = -90))
every { repository.observeDevices() } returns MutableStateFlow(devices)
every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.ALL)

val result = useCase.observe().first()

assertEquals(devices, result)
verify(exactly = 1) { repository.observeDevices() }
verify(exactly = 1) { rssiThresholdRepository.observe() }
confirmVerified(repository, rssiThresholdRepository)
}

@Test
fun `閾値以上のRSSIを持つデバイスのみ返す`() =
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

境界値テストが不足しています。

現在のテストは「閾値より上(rssi=-60)」と「閾値より下(rssi=-80)」のみ検証しており、「閾値にぴったり等しい値(rssi=-75)」のケースがありません。フィルター条件が >= から誤って > に変更された場合、境界値テストがなければ気づけません。

CLAUDE.md の方針(「境界値の軸を意識する」)に沿って1ケース追加することを推奨します。

@Test
fun `閾値と等しいRSSIを持つデバイスは含まれる`() =
    runTest {
        val boundaryDevice = device(rssi = -75)  // MEDIUM.minRssi と同値
        every { repository.observeDevices() } returns MutableStateFlow(mapOf("boundary" to boundaryDevice))
        every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.MEDIUM)

        val result = useCase.observe().first()

        assertEquals(mapOf("boundary" to boundaryDevice), result)
    }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正済み

runTest {
val nearDevice = device(rssi = -60)
val farDevice = device(rssi = -80).copy(modelCode = 0x2003)
val devices = mapOf("near" to nearDevice, "far" to farDevice)
every { repository.observeDevices() } returns MutableStateFlow(devices)
every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.MEDIUM)

val result = useCase.observe().first()

assertEquals(mapOf("near" to nearDevice), result)
verify(exactly = 1) { repository.observeDevices() }
verify(exactly = 1) { rssiThresholdRepository.observe() }
confirmVerified(repository, rssiThresholdRepository)
}

@Test
fun `閾値と等しいRSSIを持つデバイスは含まれる`() =
runTest {
val device =
AppleDevice(
address = "AA:BB:CC:DD:EE:FF",
modelName = "AirPods Pro",
modelCode = 0x2002,
rssi = -60,
leftBattery = 80,
rightBattery = 75,
caseBattery = 90,
)
val fakeFlow = MutableStateFlow(mapOf("AA:BB:CC:DD:EE:FF" to device))
every { repository.observeDevices() } returns fakeFlow
val boundaryDevice = device(rssi = -75)
every { repository.observeDevices() } returns MutableStateFlow(mapOf("boundary" to boundaryDevice))
every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.MEDIUM)

val result = useCase.observe().first()

assertEquals(mapOf("AA:BB:CC:DD:EE:FF" to device), result)
assertEquals(mapOf("boundary" to boundaryDevice), result)
verify(exactly = 1) { repository.observeDevices() }
confirmVerified(repository)
verify(exactly = 1) { rssiThresholdRepository.observe() }
confirmVerified(repository, rssiThresholdRepository)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
package kurou.androidpods.feature.settings

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kurou.androidpods.core.domain.OverlayPosition
Expand All @@ -34,12 +36,17 @@ internal fun OverlayPositionDialog(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPositionSelected(position) }
.selectable(
selected = position == currentPosition,
onClick = { onPositionSelected(position) },
role = Role.RadioButton,
)
.padding(vertical = 4.dp),
) {
RadioButton(
selected = position == currentPosition,
onClick = { onPositionSelected(position) },
onClick = null,
modifier = Modifier.minimumInteractiveComponentSize(),
)
Text(stringResource(position.toStringRes()))
}
Expand Down
Loading
Loading