test: add scriptable infrared fakes

This commit is contained in:
2026-08-25 11:40:23 +08:00
committed by KremeCN
parent 0e82daaa23
commit 82afee3a1b
4 changed files with 506 additions and 2 deletions
@@ -82,6 +82,8 @@ class FakeInfraredGateway : InfraredGateway {
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf()) val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
val downloadCalls: MutableList<InfraredDownloadCall> = val downloadCalls: MutableList<InfraredDownloadCall> =
Collections.synchronizedList(mutableListOf()) Collections.synchronizedList(mutableListOf())
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
@Volatile @Volatile
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit) var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
@@ -98,7 +100,7 @@ class FakeInfraredGateway : InfraredGateway {
key: String, key: String,
): DomainResult<Unit> { ): DomainResult<Unit> {
sentKeys += InfraredCall(profileId, deviceName, key) sentKeys += InfraredCall(profileId, deviceName, key)
return sendResult return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult
} }
override suspend fun learnKey( override suspend fun learnKey(
@@ -107,7 +109,25 @@ class FakeInfraredGateway : InfraredGateway {
key: String, key: String,
): DomainResult<Unit> { ): DomainResult<Unit> {
learnedKeys += InfraredCall(profileId, deviceName, key) learnedKeys += InfraredCall(profileId, deviceName, key)
return learnResult return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult
}
fun setSendResult(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
result: DomainResult<Unit>,
) {
sendResults[InfraredOperationKey(profileId, deviceName, key)] = result
}
fun setLearnResult(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
result: DomainResult<Unit>,
) {
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
} }
override suspend fun downloadCodes( override suspend fun downloadCodes(
@@ -131,6 +151,12 @@ data class InfraredDownloadCall(
val codes: List<InfraredCode>, val codes: List<InfraredCode>,
) )
private data class InfraredOperationKey(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
)
private data class DeviceKey( private data class DeviceKey(
val profileId: ProviderProfileId, val profileId: ProviderProfileId,
val deviceName: DeviceName, val deviceName: DeviceName,
@@ -0,0 +1,162 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
class FakeInfraredKeyRepository : InfraredKeyRepository {
private val lock = Any()
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
DomainResult.Success(
values.filter { it.profileId == profileId && it.deviceName == deviceName },
)
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> =
synchronized(lock) {
DomainResult.Success(keys[ref])
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> = synchronized(lock) {
val ref = key.ref()
if (keys.keys.any { it.keyId == key.id }) {
return@synchronized invalidRequest("Infrared key id already exists")
}
if (keys.values.any {
it.profileId == key.profileId &&
it.deviceName == key.deviceName &&
it.sdkKey == key.sdkKey
}
) {
return@synchronized invalidRequest("Infrared key binding already exists")
}
keys[ref] = key
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> = synchronized(lock) {
val existing = keys[ref] ?: return@synchronized keyNotFound()
if (existing.status !in expectedStatuses) {
return@synchronized invalidRequest("Infrared key status changed")
}
keys[ref] = existing.copy(
status = targetStatus,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> = synchronized(lock) {
val existing = keys[ref] ?: return@synchronized keyNotFound()
keys[ref] = existing.copy(
displayName = displayName,
updatedAtEpochMillis = updatedAtEpochMillis,
)
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> =
synchronized(lock) {
val existing = keys[ref] ?: return@synchronized keyNotFound()
if (existing.status == InfraredKeyStatus.LEARNING) {
return@synchronized invalidRequest("Cannot delete a learning infrared key")
}
keys.remove(ref)
publishLocked()
DomainResult.Success(Unit)
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
updatedAtEpochMillis: Long,
): DomainResult<Int> = synchronized(lock) {
var changed = 0
keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
changed += 1
key.copy(
status = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} else {
key
}
}
if (changed > 0) publishLocked()
DomainResult.Success(changed)
}
private fun publishLocked() {
state.value = keys.values.toList()
}
private fun keyNotFound(): DomainResult.Failure =
invalidRequest("Infrared key not found")
private fun invalidRequest(detail: String): DomainResult.Failure =
DomainResult.Failure(DomainError.InvalidRequest(detail = detail))
}
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
private val lock = Any()
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
override fun observeDeviceName(
profileId: ProviderProfileId,
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
DomainResult.Success(values[profileId])
}
override suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit> = synchronized(lock) {
val value = deviceName.value
if (value.isBlank() || value != value.trim()) {
return@synchronized DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared device name must be trimmed and non-blank"),
)
}
deviceNames.value = deviceNames.value + (profileId to deviceName)
DomainResult.Success(Unit)
}
}
private fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -54,4 +54,56 @@ class FakeDeviceGatewaysTest {
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single()) assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
assertTrue(download is DomainResult.Success) assertTrue(download is DomainResult.Success)
} }
@Test
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
val gateway = FakeInfraredGateway()
val infraredDevice = DeviceName("ir-1")
gateway.setLearnResult(
profileOne,
infraredDevice,
"power",
DomainResult.Failure(DomainError.DeviceOffline("20206")),
)
assertTrue(gateway.learnKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
assertEquals(
DomainResult.Success(Unit),
gateway.learnKey(profileTwo, infraredDevice, "power"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.learnKey(profileOne, infraredDevice, "volume-up"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.learnKey(profileOne, DeviceName("ir-2"), "power"),
)
}
@Test
fun infraredSendResultsCanDifferByProfileDeviceAndKey() = runTest {
val gateway = FakeInfraredGateway()
val infraredDevice = DeviceName("ir-1")
gateway.setSendResult(
profileOne,
infraredDevice,
"power",
DomainResult.Failure(DomainError.DeviceOffline("20206")),
)
assertTrue(gateway.sendKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
assertEquals(
DomainResult.Success(Unit),
gateway.sendKey(profileTwo, infraredDevice, "power"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.sendKey(profileOne, infraredDevice, "volume-up"),
)
assertEquals(
DomainResult.Success(Unit),
gateway.sendKey(profileOne, DeviceName("ir-2"), "power"),
)
}
} }
@@ -0,0 +1,264 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeInfraredKeyRepositoryTest {
private val profileOne = ProviderProfileId("one")
private val profileTwo = ProviderProfileId("two")
private val deviceOne = DeviceName("ir-1")
private val deviceTwo = DeviceName("ir-2")
@Test
fun observeKeysEmitsChangesAndFiltersByProfileAndDevice() = runTest {
val repository = FakeInfraredKeyRepository()
val emissions = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) {
repository.observeKeys(profileOne, deviceOne).take(2).toList()
}
repository.insert(savedKey(id = "matching"))
assertEquals(
listOf(
DomainResult.Success(emptyList<SavedInfraredKey>()),
DomainResult.Success(listOf(savedKey(id = "matching"))),
),
emissions.await(),
)
repository.insert(savedKey(id = "other-profile", profileId = profileTwo))
repository.insert(savedKey(id = "other-device", deviceName = deviceTwo))
assertEquals(
DomainResult.Success(listOf(savedKey(id = "matching"))),
repository.observeKeys(profileOne, deviceOne).first(),
)
}
@Test
fun getKeyReturnsOnlyTheMatchingProfileAndId() = runTest {
val repository = FakeInfraredKeyRepository()
repository.insert(savedKey(id = "power"))
assertEquals(
DomainResult.Success(savedKey(id = "power")),
repository.getKey(InfraredKeyRef(profileOne, InfraredKeyId("power"))),
)
assertEquals(
DomainResult.Success(null),
repository.getKey(InfraredKeyRef(profileTwo, InfraredKeyId("power"))),
)
}
@Test
fun insertRejectsDuplicateIdAndDuplicateProviderDeviceSdkBinding() = runTest {
val repository = FakeInfraredKeyRepository()
assertEquals(DomainResult.Success(Unit), repository.insert(savedKey(id = "power")))
val duplicateId = repository.insert(
savedKey(
id = "power",
profileId = profileTwo,
deviceName = deviceTwo,
sdkKey = "other-sdk-key",
),
)
val duplicateBinding = repository.insert(
savedKey(id = "volume", displayName = "Volume"),
)
assertInvalidRequest(duplicateId)
assertInvalidRequest(duplicateBinding)
assertEquals(
DomainResult.Success(listOf(savedKey(id = "power"))),
repository.observeKeys(profileOne, deviceOne).first(),
)
}
@Test
fun transitionStatusRequiresExpectedStatusAndUpdatesFailureContextAtomically() = runTest {
val repository = FakeInfraredKeyRepository()
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.LEARNING))
val rejected = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.READY),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
updatedAtEpochMillis = 20L,
)
assertInvalidRequest(rejected)
assertEquals(InfraredKeyStatus.LEARNING, repository.successfulKey(ref)?.status)
assertNull(repository.successfulKey(ref)?.lastErrorCode)
assertNull(repository.successfulKey(ref)?.lastErrorDetail)
assertEquals(
DomainResult.Success(Unit),
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
updatedAtEpochMillis = 20L,
),
)
assertEquals(
savedKey(
id = "power",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
updatedAtEpochMillis = 20L,
),
repository.successfulKey(ref),
)
}
@Test
fun renameUpdatesDisplayNameAndTimestamp() = runTest {
val repository = FakeInfraredKeyRepository()
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.READY))
assertEquals(
DomainResult.Success(Unit),
repository.rename(ref, displayName = "Main Power", updatedAtEpochMillis = 30L),
)
assertEquals("Main Power", repository.successfulKey(ref)?.displayName)
assertEquals(30L, repository.successfulKey(ref)?.updatedAtEpochMillis)
}
@Test
fun deleteRejectsLearningKeysAndRemovesOtherStatuses() = runTest {
val repository = FakeInfraredKeyRepository()
val learningRef = InfraredKeyRef(profileOne, InfraredKeyId("learning"))
val readyRef = InfraredKeyRef(profileOne, InfraredKeyId("ready"))
repository.insert(savedKey(id = "learning", sdkKey = "learning", status = InfraredKeyStatus.LEARNING))
repository.insert(savedKey(id = "ready", sdkKey = "ready", status = InfraredKeyStatus.READY))
assertInvalidRequest(repository.deleteIfNotLearning(learningRef))
assertEquals(DomainResult.Success(Unit), repository.deleteIfNotLearning(readyRef))
assertTrue(repository.successfulKey(learningRef) != null)
assertNull(repository.successfulKey(readyRef))
}
@Test
fun failInterruptedLearningRecoversOnlyLearningKeysForOneProfileWithFixedDetail() = runTest {
val repository = FakeInfraredKeyRepository()
repository.insert(savedKey(id = "one-learning", sdkKey = "one-learning"))
repository.insert(
savedKey(
id = "one-ready",
sdkKey = "one-ready",
status = InfraredKeyStatus.READY,
),
)
repository.insert(
savedKey(
id = "two-learning",
profileId = profileTwo,
sdkKey = "two-learning",
),
)
assertEquals(
DomainResult.Success(1),
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 40L),
)
val recovered = repository.successfulKey(
InfraredKeyRef(profileOne, InfraredKeyId("one-learning")),
)
assertEquals(InfraredKeyStatus.FAILED, recovered?.status)
assertNull(recovered?.lastErrorCode)
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, recovered?.lastErrorDetail)
assertEquals(40L, recovered?.updatedAtEpochMillis)
assertEquals(
InfraredKeyStatus.READY,
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("one-ready")))?.status,
)
assertEquals(
InfraredKeyStatus.LEARNING,
repository.successfulKey(InfraredKeyRef(profileTwo, InfraredKeyId("two-learning")))?.status,
)
assertEquals(
DomainResult.Success(0),
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 50L),
)
}
@Test
fun deviceConfigurationIsIsolatedByProfile() = runTest {
val repository = FakeInfraredDeviceConfigurationRepository()
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
assertEquals(
DomainResult.Success(Unit),
repository.setDeviceName(profileOne, deviceOne),
)
assertEquals(DomainResult.Success(deviceOne), repository.observeDeviceName(profileOne).first())
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileTwo).first())
}
@Test
fun deviceConfigurationRejectsBlankOrSurroundingWhitespace() = runTest {
val repository = FakeInfraredDeviceConfigurationRepository()
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ")))
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ir-1")))
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName("ir-1 ")))
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
}
private fun savedKey(
id: String,
profileId: ProviderProfileId = profileOne,
deviceName: DeviceName = deviceOne,
displayName: String = "Power",
sdkKey: String = "power",
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
lastErrorCode: String? = null,
lastErrorDetail: String? = null,
updatedAtEpochMillis: Long = 10L,
) = SavedInfraredKey(
id = InfraredKeyId(id),
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
sdkKey = sdkKey,
status = status,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = 10L,
updatedAtEpochMillis = updatedAtEpochMillis,
)
private suspend fun FakeInfraredKeyRepository.successfulKey(
ref: InfraredKeyRef,
): SavedInfraredKey? = (getKey(ref) as DomainResult.Success<SavedInfraredKey?>).value
private fun assertInvalidRequest(result: DomainResult<*>) {
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
}
}