From c21ae4e0d90380da0fb4ca34d9b76322a86789b8 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 02:33:55 +0800 Subject: [PATCH] test: add scriptable infrared fakes --- .../testing/fakes/FakeDeviceGateways.kt | 30 +- .../fakes/FakeInfraredKeyRepository.kt | 162 +++++++++++ .../testing/fakes/FakeDeviceGatewaysTest.kt | 52 ++++ .../fakes/FakeInfraredKeyRepositoryTest.kt | 264 ++++++++++++++++++ 4 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index cbd527b..89323d3 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -82,6 +82,8 @@ class FakeInfraredGateway : InfraredGateway { val learnedKeys: MutableList = Collections.synchronizedList(mutableListOf()) val downloadCalls: MutableList = Collections.synchronizedList(mutableListOf()) + private val sendResults = ConcurrentHashMap>() + private val learnResults = ConcurrentHashMap>() @Volatile var sendResult: DomainResult = DomainResult.Success(Unit) @@ -98,7 +100,7 @@ class FakeInfraredGateway : InfraredGateway { key: String, ): DomainResult { sentKeys += InfraredCall(profileId, deviceName, key) - return sendResult + return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult } override suspend fun learnKey( @@ -107,7 +109,25 @@ class FakeInfraredGateway : InfraredGateway { key: String, ): DomainResult { learnedKeys += InfraredCall(profileId, deviceName, key) - return learnResult + return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult + } + + fun setSendResult( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + result: DomainResult, + ) { + sendResults[InfraredOperationKey(profileId, deviceName, key)] = result + } + + fun setLearnResult( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + result: DomainResult, + ) { + learnResults[InfraredOperationKey(profileId, deviceName, key)] = result } override suspend fun downloadCodes( @@ -131,6 +151,12 @@ data class InfraredDownloadCall( val codes: List, ) +private data class InfraredOperationKey( + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val key: String, +) + private data class DeviceKey( val profileId: ProviderProfileId, val deviceName: DeviceName, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt new file mode 100644 index 0000000..f08509c --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt @@ -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() + private val state = MutableStateFlow>(emptyList()) + + override fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = state.map { values -> + DomainResult.Success( + values.filter { it.profileId == profileId && it.deviceName == deviceName }, + ) + } + + override suspend fun getKey(ref: InfraredKeyRef): DomainResult = + synchronized(lock) { + DomainResult.Success(keys[ref]) + } + + override suspend fun insert(key: SavedInfraredKey): DomainResult = 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, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + lastErrorDetail: String?, + updatedAtEpochMillis: Long, + ): DomainResult = 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 = 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 = + 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 = 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>(emptyMap()) + + override fun observeDeviceName( + profileId: ProviderProfileId, + ): Flow> = deviceNames.map { values -> + DomainResult.Success(values[profileId]) + } + + override suspend fun setDeviceName( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = 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, +) diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt index 8b46b3a..b162310 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGatewaysTest.kt @@ -54,4 +54,56 @@ class FakeDeviceGatewaysTest { assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single()) 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"), + ) + } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt new file mode 100644 index 0000000..df699a1 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt @@ -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()), + 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).value + + private fun assertInvalidRequest(result: DomainResult<*>) { + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + } +}