mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
POC, decoding works.
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
package eu.darken.cap.common.flow
|
||||
|
||||
import eu.darken.cap.common.collections.mutate
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.instanceOf
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelper.BaseTest
|
||||
import testhelper.coroutine.runBlockingTest2
|
||||
import testhelper.flow.test
|
||||
import java.io.IOException
|
||||
import java.lang.Thread.sleep
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DynamicStateFlowTest : BaseTest() {
|
||||
|
||||
// Without an init value, there isn't a way to keep using the flow
|
||||
@Test
|
||||
fun `exceptions on initialization are rethrown`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
val hotData = DynamicStateFlow<String>(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
coroutineContext = Dispatchers.Unconfined,
|
||||
startValueProvider = { throw IOException() }
|
||||
)
|
||||
runBlocking {
|
||||
withTimeoutOrNull(500) {
|
||||
// This blocking scope gets the init exception as the first caller
|
||||
hotData.flow.firstOrNull()
|
||||
} shouldBe null
|
||||
}
|
||||
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
|
||||
coEvery { valueProvider.invoke(any()) } returns "Test"
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
coroutineContext = Dispatchers.Unconfined,
|
||||
startValueProvider = valueProvider,
|
||||
)
|
||||
|
||||
testScope.apply {
|
||||
runBlockingTest2(allowUncompleted = true) {
|
||||
hotData.flow.first() shouldBe "Test"
|
||||
hotData.flow.first() shouldBe "Test"
|
||||
}
|
||||
coVerify(exactly = 1) { valueProvider.invoke(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `value updates`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
|
||||
coEvery { valueProvider.invoke(any()) } returns 1
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
startValueProvider = valueProvider,
|
||||
)
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
testCollector.silent = true
|
||||
|
||||
(1..16).forEach { _ ->
|
||||
thread {
|
||||
(1..200).forEach { _ ->
|
||||
sleep(10)
|
||||
hotData.updateAsync(
|
||||
onUpdate = { this + 1L },
|
||||
onError = { throw it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
testCollector.await { list, _ -> list.size == 3201 }
|
||||
testCollector.latestValues shouldBe (1..3201).toList()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) { valueProvider.invoke(any()) }
|
||||
}
|
||||
|
||||
data class TestData(
|
||||
val number: Long = 1
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `check multi threading value updates with more complex data`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
val valueProvider = mockk<suspend CoroutineScope.() -> Map<String, TestData>>()
|
||||
coEvery { valueProvider.invoke(any()) } returns mapOf("data" to TestData())
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
startValueProvider = valueProvider,
|
||||
)
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
testCollector.silent = true
|
||||
|
||||
(1..10).forEach { _ ->
|
||||
thread {
|
||||
(1..400).forEach { _ ->
|
||||
hotData.updateAsync {
|
||||
mutate {
|
||||
this["data"] = getValue("data").copy(
|
||||
number = getValue("data").number + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
testCollector.await { list, _ -> list.size == 4001 }
|
||||
testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList()
|
||||
}
|
||||
|
||||
coVerify(exactly = 1) { valueProvider.invoke(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only emit new values if they actually changed updates`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
startValueProvider = { "1" },
|
||||
)
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
testCollector.silent = true
|
||||
|
||||
hotData.updateAsync { "1" }
|
||||
hotData.updateAsync { "2" }
|
||||
hotData.updateAsync { "2" }
|
||||
hotData.updateAsync { "1" }
|
||||
|
||||
runBlocking {
|
||||
testCollector.await { list, _ -> list.size == 3 }
|
||||
testCollector.latestValues shouldBe listOf("1", "2", "1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple subscribers share the flow`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
|
||||
coEvery { valueProvider.invoke(any()) } returns "Test"
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
startValueProvider = valueProvider,
|
||||
)
|
||||
|
||||
testScope.runBlockingTest2(allowUncompleted = true) {
|
||||
val sub1 = hotData.flow.test(tag = "sub1", startOnScope = this)
|
||||
val sub2 = hotData.flow.test(tag = "sub2", startOnScope = this)
|
||||
val sub3 = hotData.flow.test(tag = "sub3", startOnScope = this)
|
||||
|
||||
hotData.updateAsync { "A" }
|
||||
hotData.updateAsync { "B" }
|
||||
hotData.updateAsync { "C" }
|
||||
|
||||
listOf(sub1, sub2, sub3).forEach {
|
||||
it.await { list, _ -> list.size == 4 }
|
||||
it.latestValues shouldBe listOf("Test", "A", "B", "C")
|
||||
it.cancel()
|
||||
}
|
||||
|
||||
hotData.flow.first() shouldBe "C"
|
||||
}
|
||||
coVerify(exactly = 1) { valueProvider.invoke(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `value is persisted between unsubscribes`() = runBlockingTest2(allowUncompleted = true) {
|
||||
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
|
||||
coEvery { valueProvider.invoke(any()) } returns 1
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = this,
|
||||
coroutineContext = this.coroutineContext,
|
||||
startValueProvider = valueProvider,
|
||||
)
|
||||
|
||||
val testCollector1 = hotData.flow.test(tag = "collector1", startOnScope = this)
|
||||
testCollector1.silent = false
|
||||
|
||||
(1..10).forEach { _ ->
|
||||
hotData.updateAsync {
|
||||
this + 1L
|
||||
}
|
||||
}
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
testCollector1.await { list, _ -> list.size == 11 }
|
||||
testCollector1.latestValues shouldBe (1L..11L).toList()
|
||||
|
||||
testCollector1.cancel()
|
||||
testCollector1.awaitFinal()
|
||||
|
||||
val testCollector2 = hotData.flow.test(tag = "collector2", startOnScope = this)
|
||||
testCollector2.silent = false
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
testCollector2.cancel()
|
||||
testCollector2.awaitFinal()
|
||||
|
||||
testCollector2.latestValues shouldBe listOf(11L)
|
||||
|
||||
coVerify(exactly = 1) { valueProvider.invoke(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocking update is actually blocking`() = runBlocking {
|
||||
val testScope = TestCoroutineScope()
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
coroutineContext = testScope.coroutineContext,
|
||||
startValueProvider = {
|
||||
delay(2000)
|
||||
2
|
||||
},
|
||||
)
|
||||
|
||||
hotData.updateAsync {
|
||||
delay(2000)
|
||||
this + 1
|
||||
}
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
hotData.updateBlocking { this - 3 } shouldBe 0
|
||||
|
||||
testCollector.await { _, i -> i == 3 }
|
||||
testCollector.latestValues shouldBe listOf(2, 3, 0)
|
||||
|
||||
testCollector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocking update rethrows error`() = runBlocking {
|
||||
val testScope = TestCoroutineScope()
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
coroutineContext = testScope.coroutineContext,
|
||||
startValueProvider = {
|
||||
delay(2000)
|
||||
2
|
||||
},
|
||||
)
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
shouldThrow<IOException> {
|
||||
hotData.updateBlocking { throw IOException("Surprise") } shouldBe 0
|
||||
}
|
||||
hotData.flow.first() shouldBe 2
|
||||
|
||||
hotData.updateBlocking { 3 } shouldBe 3
|
||||
hotData.flow.first() shouldBe 3
|
||||
|
||||
testScope.uncaughtExceptions.singleOrNull() shouldBe null
|
||||
|
||||
testCollector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `async updates error handler`() {
|
||||
val testScope = TestCoroutineScope()
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
startValueProvider = { 1 },
|
||||
)
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
hotData.updateAsync { throw IOException("Surprise") }
|
||||
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
|
||||
|
||||
testCollector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking {
|
||||
val testScope = TestCoroutineScope()
|
||||
|
||||
val hotData = DynamicStateFlow(
|
||||
loggingTag = "tag",
|
||||
parentScope = testScope,
|
||||
startValueProvider = { 1 },
|
||||
)
|
||||
|
||||
val testCollector = hotData.flow.test(startOnScope = testScope)
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
var thrownError: Exception? = null
|
||||
|
||||
hotData.updateAsync(
|
||||
onUpdate = { throw IOException("Surprise") },
|
||||
onError = { thrownError = it }
|
||||
)
|
||||
|
||||
testScope.advanceUntilIdle()
|
||||
thrownError!!.shouldBeInstanceOf<IOException>()
|
||||
testScope.uncaughtExceptions.singleOrNull() shouldBe null
|
||||
|
||||
testCollector.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package eu.darken.cap.pods.core.airpods
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.ScanRecord
|
||||
import android.bluetooth.le.ScanResult
|
||||
import eu.darken.cap.pods.core.PodDevice
|
||||
import eu.darken.cap.pods.core.airpods.models.AirPodsGen1
|
||||
import eu.darken.cap.pods.core.airpods.models.AirPodsPro
|
||||
import eu.darken.cap.pods.core.airpods.models.UnknownAppleDevice
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.instanceOf
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelper.BaseTest
|
||||
|
||||
class AirPodsFactoryTest : BaseTest() {
|
||||
|
||||
@MockK lateinit var scanResult: ScanResult
|
||||
@MockK lateinit var scanRecord: ScanRecord
|
||||
@MockK lateinit var device: BluetoothDevice
|
||||
|
||||
val factory = AirPodsFactory(
|
||||
proximityPairingDecoder = ProximityPairing.Decoder(),
|
||||
continuityProtocolDecoder = ContinuityProtocol.Decoder(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
MockKAnnotations.init(this)
|
||||
every { scanResult.scanRecord } returns scanRecord
|
||||
every { scanResult.rssi } returns -66
|
||||
every { scanResult.timestampNanos } returns 136136027721826
|
||||
every { scanResult.device } returns device
|
||||
every { device.address } returns "77:49:4C:D8:25:0C"
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : PodDevice?> create(hex: String, block: T.() -> Unit) {
|
||||
val trimmed = hex
|
||||
.replace(" ", "")
|
||||
.replace(">", "")
|
||||
.replace("<", "")
|
||||
require(trimmed.length % 2 == 0) { "Not a HEX string" }
|
||||
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
mockData(bytes)
|
||||
block.invoke(factory.create(scanResult) as T)
|
||||
}
|
||||
|
||||
private fun mockData(hex: String) {
|
||||
val trimmed = hex
|
||||
.replace(" ", "")
|
||||
.replace(">", "")
|
||||
.replace("<", "")
|
||||
require(trimmed.length % 2 == 0) { "Not a HEX string" }
|
||||
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
return mockData(bytes)
|
||||
}
|
||||
|
||||
private fun mockData(data: ByteArray) {
|
||||
every { scanRecord.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER) } returns data
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - active microphone`() = runBlockingTest {
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00101011
|
||||
// --^-----
|
||||
microPhonePod shouldBe AirPodsDevice.Pod.LEFT
|
||||
}
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00001011
|
||||
// --^-----
|
||||
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - left pod ear status`() = runBlockingTest {
|
||||
// Left Pod primary
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00100010
|
||||
// 765432¹0
|
||||
isLeftPodInEar shouldBe true
|
||||
}
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00100000
|
||||
// 765432¹0
|
||||
isLeftPodInEar shouldBe false
|
||||
}
|
||||
|
||||
// Right Pod is primary
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00001001
|
||||
// 7654³210
|
||||
isLeftPodInEar shouldBe true
|
||||
}
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00000001
|
||||
// 7654³210
|
||||
isLeftPodInEar shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - right pod ear status`() = runBlockingTest {
|
||||
// Left Pod primary
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00101001
|
||||
// 7654³210
|
||||
isRightPodInEar shouldBe true
|
||||
}
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00100001
|
||||
// 7654³210
|
||||
isRightPodInEar shouldBe false
|
||||
}
|
||||
|
||||
// Right Pod is primary
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00000011
|
||||
// 765432¹0
|
||||
isRightPodInEar shouldBe true
|
||||
}
|
||||
create<AirPodsDevice>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
// 00000001
|
||||
// 765432¹0
|
||||
isRightPodInEar shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - battery status`() = runBlockingTest {
|
||||
// Right Pod is primary
|
||||
create<AirPodsDevice>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
|
||||
// 88 10001000
|
||||
batteryLeftPodPercent shouldBe 0.9f
|
||||
batteryRightPodPercent shouldBe 0.8f
|
||||
}
|
||||
// Left Pod primary
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
|
||||
// F8 11111000
|
||||
batteryLeftPodPercent shouldBe 0.9f
|
||||
batteryRightPodPercent shouldBe 0.8f
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - pod charging`() = runBlockingTest {
|
||||
/**
|
||||
* Right pod is charging
|
||||
*/
|
||||
// This is the left
|
||||
create<AirPodsDevice>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
|
||||
// 1001 0100
|
||||
isLeftPodCharging shouldBe false
|
||||
isRightPodCharging shouldBe true
|
||||
}
|
||||
// This is the right
|
||||
create<AirPodsDevice>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
|
||||
// 1010 0100
|
||||
isLeftPodCharging shouldBe false
|
||||
isRightPodCharging shouldBe true
|
||||
}
|
||||
|
||||
/**
|
||||
* Left pod is charging
|
||||
*/
|
||||
// This is the left
|
||||
create<AirPodsDevice>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
|
||||
// 1001 0100
|
||||
isLeftPodCharging shouldBe true
|
||||
isRightPodCharging shouldBe false
|
||||
}
|
||||
// This is the right
|
||||
create<AirPodsDevice>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
|
||||
// 1010 0100
|
||||
isLeftPodCharging shouldBe true
|
||||
isRightPodCharging shouldBe false
|
||||
}
|
||||
|
||||
// Both charging
|
||||
create<AirPodsDevice>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
|
||||
// 1011 0100
|
||||
isLeftPodCharging shouldBe true
|
||||
isRightPodCharging shouldBe true
|
||||
}
|
||||
// Both not charging
|
||||
create<AirPodsDevice>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
|
||||
// 1000 1111
|
||||
isLeftPodCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - case charging`() = runBlockingTest {
|
||||
create<AirPodsDevice>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
|
||||
// 0011 0011
|
||||
isCaseCharging shouldBe false
|
||||
}
|
||||
create<AirPodsDevice>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
|
||||
// 0101 0011
|
||||
isCaseCharging shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - case lid test`() = runBlockingTest {
|
||||
// Lid open
|
||||
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
|
||||
// 31 0011 0001
|
||||
caseLidState shouldBe AirPodsDevice.LidState.OPEN
|
||||
}
|
||||
// Lid just closed
|
||||
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
|
||||
// 39 0011 1001
|
||||
caseLidState shouldBe AirPodsDevice.LidState.UNKNOWN
|
||||
}
|
||||
// Lid closed
|
||||
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
|
||||
// 38 0011 1000
|
||||
caseLidState shouldBe AirPodsDevice.LidState.CLOSED
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPodDevice - connection state`() = runBlockingTest {
|
||||
// Disconnected
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
|
||||
// 31 0011 0001
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED
|
||||
}
|
||||
// Connected idle
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
|
||||
// 39 0011 1001
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.IDLE
|
||||
}
|
||||
// Connected and playing music
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
|
||||
// 38 0011 1000
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC
|
||||
}
|
||||
// Connected and call active
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
|
||||
// 38 0011 1000
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.CALL
|
||||
}
|
||||
// Connected and call active
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
|
||||
// 38 0011 1000
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.RINGING
|
||||
}
|
||||
// Switching?
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
|
||||
// 38 0011 1000
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.HANGING_UP
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create AirPodsGen1`() = runBlockingTest {
|
||||
create<AirPodsDevice>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
|
||||
this shouldBe instanceOf<AirPodsGen1>()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create AirPodsPro`() = runBlockingTest {
|
||||
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
|
||||
this shouldBe instanceOf<AirPodsPro>()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown AppleDevice`() = runBlockingTest {
|
||||
create<ApplePods>("07 19 01 FF FF 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
|
||||
this shouldBe instanceOf<UnknownAppleDevice>()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid data`() = runBlockingTest {
|
||||
create<PodDevice?>("abcd") {
|
||||
this shouldBe null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package eu.darken.cap.pods.core.airpods
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.ScanRecord
|
||||
import android.bluetooth.le.ScanResult
|
||||
import eu.darken.cap.pods.core.PodDevice
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import testhelper.BaseTest
|
||||
|
||||
abstract class BaseAirPodsTest : BaseTest() {
|
||||
|
||||
@MockK lateinit var scanResult: ScanResult
|
||||
@MockK lateinit var scanRecord: ScanRecord
|
||||
@MockK lateinit var device: BluetoothDevice
|
||||
|
||||
val factory = AirPodsFactory(
|
||||
proximityPairingDecoder = ProximityPairing.Decoder(),
|
||||
continuityProtocolDecoder = ContinuityProtocol.Decoder(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
MockKAnnotations.init(this)
|
||||
every { scanResult.scanRecord } returns scanRecord
|
||||
every { scanResult.rssi } returns -66
|
||||
every { scanResult.timestampNanos } returns 136136027721826
|
||||
every { scanResult.device } returns device
|
||||
every { device.address } returns "77:49:4C:D8:25:0C"
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : PodDevice?> create(hex: String, block: T.() -> Unit) {
|
||||
val trimmed = hex
|
||||
.replace(" ", "")
|
||||
.replace(">", "")
|
||||
.replace("<", "")
|
||||
require(trimmed.length % 2 == 0) { "Not a HEX string" }
|
||||
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
mockData(bytes)
|
||||
block.invoke(factory.create(scanResult) as T)
|
||||
}
|
||||
|
||||
fun mockData(hex: String) {
|
||||
val trimmed = hex
|
||||
.replace(" ", "")
|
||||
.replace(">", "")
|
||||
.replace("<", "")
|
||||
require(trimmed.length % 2 == 0) { "Not a HEX string" }
|
||||
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
return mockData(bytes)
|
||||
}
|
||||
|
||||
fun mockData(data: ByteArray) {
|
||||
every { scanRecord.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER) } returns data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package eu.darken.cap.pods.core.airpods.models
|
||||
|
||||
import eu.darken.cap.pods.core.airpods.AirPodsDevice
|
||||
import eu.darken.cap.pods.core.airpods.BaseAirPodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen1Test : BaseAirPodsTest() {
|
||||
|
||||
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
|
||||
@Test
|
||||
fun `fake airpods`() = runBlockingTest {
|
||||
create<AirPodsGen1>("07 19 01 02 20 55 AF 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
|
||||
rawPrefix shouldBe 0x01.toUByte()
|
||||
rawDeviceModel shouldBe 0x0220.toUShort()
|
||||
rawStatus shouldBe 0x55.toUByte()
|
||||
rawPodsBattery shouldBe 0xAF.toUByte()
|
||||
rawCaseBattery shouldBe 0x56.toUByte()
|
||||
rawCaseLidState shouldBe 0x31.toUByte()
|
||||
rawDeviceColor shouldBe 0x00.toUByte()
|
||||
rawSuffix shouldBe 0x00.toUByte()
|
||||
|
||||
batteryLeftPodPercent shouldBe 1.0f
|
||||
batteryRightPodPercent shouldBe null
|
||||
|
||||
isCaseCharging shouldBe true
|
||||
isLeftPodCharging shouldBe false
|
||||
isRightPodCharging shouldBe true
|
||||
|
||||
isLeftPodInEar shouldBe false
|
||||
isRightPodInEar shouldBe false
|
||||
batteryCasePercent shouldBe 0.6f
|
||||
|
||||
caseLidState shouldBe AirPodsDevice.LidState.OPEN
|
||||
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package eu.darken.cap.pods.core.airpods.models
|
||||
|
||||
import eu.darken.cap.pods.core.airpods.AirPodsDevice
|
||||
import eu.darken.cap.pods.core.airpods.BaseAirPodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen2Test : BaseAirPodsTest() {
|
||||
|
||||
@Test
|
||||
fun `random Neighbor AirPodsGen2`() = runBlockingTest {
|
||||
create<AirPodsGen2>("07 19 01 0F 20 02 F9 8F 01 00 05 F2 7E 14 E0 54 0A 53 69 5B 7D F2 15 1F D7 B1 12") {
|
||||
rawPrefix shouldBe 0x01.toUByte()
|
||||
rawDeviceModel shouldBe 0x0F20.toUShort()
|
||||
rawStatus shouldBe 0x02.toUByte()
|
||||
rawPodsBattery shouldBe 0xF9.toUByte()
|
||||
rawCaseBattery shouldBe 0x8F.toUByte()
|
||||
rawCaseLidState shouldBe 0x01.toUByte()
|
||||
rawDeviceColor shouldBe 0x00.toUByte()
|
||||
rawSuffix shouldBe 0x05.toUByte()
|
||||
|
||||
batteryLeftPodPercent shouldBe null
|
||||
batteryRightPodPercent shouldBe 0.9f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
|
||||
isLeftPodInEar shouldBe false
|
||||
isRightPodInEar shouldBe true
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
caseLidState shouldBe AirPodsDevice.LidState.NOT_IN_CASE
|
||||
|
||||
connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package eu.darken.cap.pods.core.airpods.models
|
||||
|
||||
import eu.darken.cap.pods.core.airpods.AirPodsDevice
|
||||
import eu.darken.cap.pods.core.airpods.BaseAirPodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsProTest : BaseAirPodsTest() {
|
||||
|
||||
@Test
|
||||
fun `test AirPods Pro - default changed and in case`() = runBlockingTest {
|
||||
create<AirPodsPro>("07 19 01 0E 20 54 AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
|
||||
|
||||
rawPrefix shouldBe 0x01.toUByte()
|
||||
rawDeviceModel shouldBe 0x0e20.toUShort()
|
||||
rawStatus shouldBe 0x54.toUByte()
|
||||
rawPodsBattery shouldBe 0xAA.toUByte()
|
||||
rawCaseBattery shouldBe 0xB5.toUByte()
|
||||
rawCaseLidState shouldBe 0x31.toUByte()
|
||||
rawDeviceColor shouldBe 0x00.toUByte()
|
||||
rawSuffix shouldBe 0x00.toUByte()
|
||||
|
||||
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
|
||||
|
||||
batteryLeftPodPercent shouldBe 1.0f
|
||||
batteryRightPodPercent shouldBe 1.0f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe true
|
||||
isLeftPodCharging shouldBe true
|
||||
batteryCasePercent shouldBe 0.5f
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test AirPods from my downstairs neighbour`() = runBlockingTest {
|
||||
create<AirPodsPro>("07 19 01 0E 20 00 F3 8F 02 00 04 79 C6 3F F9 C3 15 D9 11 A1 3C B1 58 66 B9 8B 67") {
|
||||
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
|
||||
|
||||
batteryLeftPodPercent shouldBe null
|
||||
batteryRightPodPercent shouldBe 0.3f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
}
|
||||
|
||||
// Test data from https://github.com/adolfintel/OpenPods/issues/34#issuecomment-565894487
|
||||
@Test
|
||||
fun `various AirPods Pro messages`() = runBlockingTest {
|
||||
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e202b668f01000400000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e200b668f01000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e2003668f01000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e2001668f01000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e2009668f01000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e2053669653000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe true
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe 0.6f
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e203366a602000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.6f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe true
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe 0.6f
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
|
||||
create<AirPodsPro>("0719010e202b768f02000500000000000000000000000000000000") {
|
||||
batteryLeftPodPercent shouldBe 0.6f
|
||||
batteryRightPodPercent shouldBe 0.7f
|
||||
|
||||
isCaseCharging shouldBe false
|
||||
isRightPodCharging shouldBe false
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe null
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
}
|
||||
|
||||
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
|
||||
@Test
|
||||
fun `fake airpods`() = runBlockingTest {
|
||||
create<AirPodsGen1>("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") {
|
||||
batteryLeftPodPercent shouldBe 1.0f
|
||||
batteryRightPodPercent shouldBe 1.0f
|
||||
|
||||
isCaseCharging shouldBe true
|
||||
isRightPodCharging shouldBe true
|
||||
isLeftPodCharging shouldBe false
|
||||
batteryCasePercent shouldBe 0.6f
|
||||
|
||||
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package testhelper
|
||||
|
||||
import eu.darken.cap.common.debug.logging.Logging
|
||||
import eu.darken.cap.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.cap.common.debug.logging.log
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import testhelpers.logging.JUnitLogger
|
||||
|
||||
|
||||
open class BaseTest {
|
||||
init {
|
||||
Logging.clearAll()
|
||||
Logging.install(JUnitLogger())
|
||||
testClassName = this.javaClass.simpleName
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var testClassName: String? = null
|
||||
|
||||
@JvmStatic
|
||||
@AfterAll
|
||||
fun onTestClassFinished() {
|
||||
unmockkAll()
|
||||
log(testClassName!!, VERBOSE) { "onTestClassFinished()" }
|
||||
Logging.clearAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package testhelper.coroutine
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.extension.AfterEachCallback
|
||||
import org.junit.jupiter.api.extension.BeforeEachCallback
|
||||
import org.junit.jupiter.api.extension.ExtensionContext
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class CoroutinesTestExtension(
|
||||
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
|
||||
) : BeforeEachCallback, AfterEachCallback, TestCoroutineScope by TestCoroutineScope(dispatcher) {
|
||||
|
||||
override fun beforeEach(context: ExtensionContext?) {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
override fun afterEach(context: ExtensionContext?) {
|
||||
cleanupTestCoroutines()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package testhelper.coroutine
|
||||
|
||||
import eu.darken.cap.common.coroutine.DispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class TestDispatcherProvider(private val context: CoroutineContext? = null) : DispatcherProvider {
|
||||
override val Default: CoroutineContext
|
||||
get() = context ?: Dispatchers.Unconfined
|
||||
override val Main: CoroutineContext
|
||||
get() = context ?: Dispatchers.Unconfined
|
||||
override val MainImmediate: CoroutineContext
|
||||
get() = context ?: Dispatchers.Unconfined
|
||||
override val Unconfined: CoroutineContext
|
||||
get() = context ?: Dispatchers.Unconfined
|
||||
override val IO: CoroutineContext
|
||||
get() = context ?: Dispatchers.Unconfined
|
||||
}
|
||||
|
||||
fun CoroutineScope.asDispatcherProvider() = this.coroutineContext.asDispatcherProvider()
|
||||
|
||||
fun CoroutineContext.asDispatcherProvider() = TestDispatcherProvider(context = this)
|
||||
@@ -0,0 +1,49 @@
|
||||
package testhelper.coroutine
|
||||
|
||||
import eu.darken.cap.common.debug.logging.log
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
import kotlinx.coroutines.test.UncompletedCoroutinesError
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
/**
|
||||
* If you have a test that uses a coroutine that never stops, you may use this.
|
||||
*/
|
||||
|
||||
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
|
||||
fun TestCoroutineScope.runBlockingTest2(
|
||||
allowUncompleted: Boolean = false,
|
||||
block: suspend TestCoroutineScope.() -> Unit
|
||||
): Unit = runBlockingTest2(
|
||||
allowUncompleted = allowUncompleted,
|
||||
context = coroutineContext,
|
||||
testBody = block
|
||||
)
|
||||
|
||||
fun runBlockingTest2(
|
||||
allowUncompleted: Boolean = false,
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
testBody: suspend TestCoroutineScope.() -> Unit
|
||||
) {
|
||||
try {
|
||||
runBlocking {
|
||||
try {
|
||||
runBlockingTest(
|
||||
context = context,
|
||||
testBody = testBody
|
||||
)
|
||||
} catch (e: UncompletedCoroutinesError) {
|
||||
if (!allowUncompleted) throw e
|
||||
else log { "Ignoring active job." }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!allowUncompleted || (e.message != "This job has not completed yet")) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package testhelper.flow
|
||||
|
||||
import eu.darken.cap.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.cap.common.debug.logging.asLog
|
||||
import eu.darken.cap.common.debug.logging.log
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
|
||||
fun <T> Flow<T>.test(
|
||||
tag: String? = null,
|
||||
startOnScope: CoroutineScope = TestCoroutineScope()
|
||||
): TestCollector<T> = createTest(tag ?: "FlowTest").start(scope = startOnScope)
|
||||
|
||||
fun <T> Flow<T>.createTest(
|
||||
tag: String? = null
|
||||
): TestCollector<T> = TestCollector(this, tag ?: "FlowTest")
|
||||
|
||||
class TestCollector<T>(
|
||||
private val flow: Flow<T>,
|
||||
private val tag: String
|
||||
|
||||
) {
|
||||
private var error: Throwable? = null
|
||||
private lateinit var job: Job
|
||||
private val cache = MutableSharedFlow<T>(
|
||||
replay = Int.MAX_VALUE,
|
||||
extraBufferCapacity = Int.MAX_VALUE,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
private var latestInternal: T? = null
|
||||
private val collectedValuesMutex = Mutex()
|
||||
private val collectedValues = mutableListOf<T>()
|
||||
|
||||
var silent = false
|
||||
|
||||
fun start(scope: CoroutineScope) = apply {
|
||||
flow
|
||||
.buffer(capacity = Int.MAX_VALUE)
|
||||
.onStart { log(tag) { "Setting up." } }
|
||||
.onCompletion { log(tag) { "Final." } }
|
||||
.onEach {
|
||||
collectedValuesMutex.withLock {
|
||||
if (!silent) log(tag) { "Collecting: $it" }
|
||||
latestInternal = it
|
||||
collectedValues.add(it)
|
||||
cache.emit(it)
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
log(tag, WARN) { "Caught error: ${e.asLog()}" }
|
||||
error = e
|
||||
}
|
||||
.launchIn(scope)
|
||||
.also { job = it }
|
||||
}
|
||||
|
||||
fun emissions(): Flow<T> = cache
|
||||
|
||||
val latestValue: T?
|
||||
get() = collectedValues.last()
|
||||
|
||||
val latestValues: List<T>
|
||||
get() = collectedValues
|
||||
|
||||
fun await(
|
||||
timeout: Long = 10_000,
|
||||
condition: (List<T>, T) -> Boolean
|
||||
): T = runBlocking {
|
||||
withTimeout(timeMillis = timeout) {
|
||||
emissions().first {
|
||||
condition(collectedValues, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitFinal(cancel: Boolean = false) = apply {
|
||||
if (cancel) cancel()
|
||||
try {
|
||||
job.join()
|
||||
} catch (e: Exception) {
|
||||
error = e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun assertNoErrors() = apply {
|
||||
awaitFinal()
|
||||
require(error == null) { "Error was not null: $error" }
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (job.isCompleted) throw IllegalStateException("Flow is already canceled.")
|
||||
|
||||
runBlocking {
|
||||
job.cancelAndJoin()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package testhelper.livedata
|
||||
|
||||
import androidx.arch.core.executor.ArchTaskExecutor
|
||||
import androidx.arch.core.executor.TaskExecutor
|
||||
import org.junit.jupiter.api.extension.AfterEachCallback
|
||||
import org.junit.jupiter.api.extension.BeforeEachCallback
|
||||
import org.junit.jupiter.api.extension.ExtensionContext
|
||||
|
||||
class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
|
||||
|
||||
override fun beforeEach(context: ExtensionContext?) {
|
||||
ArchTaskExecutor.getInstance().setDelegate(
|
||||
object : TaskExecutor() {
|
||||
override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
|
||||
|
||||
override fun postToMainThread(runnable: Runnable) = runnable.run()
|
||||
|
||||
override fun isMainThread(): Boolean = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun afterEach(context: ExtensionContext?) {
|
||||
ArchTaskExecutor.getInstance().setDelegate(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package testhelper.preferences
|
||||
|
||||
import androidx.core.content.edit
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelper.BaseTest
|
||||
import testhelpers.preferences.MockSharedPreferences
|
||||
|
||||
class MockSharedPreferencesTest : BaseTest() {
|
||||
|
||||
private fun createInstance() = MockSharedPreferences()
|
||||
|
||||
@Test
|
||||
fun `test boolean insertion`() {
|
||||
val prefs = createInstance()
|
||||
prefs.dataMapPeek shouldBe emptyMap()
|
||||
prefs.getBoolean("key", true) shouldBe true
|
||||
prefs.edit { putBoolean("key", false) }
|
||||
prefs.getBoolean("key", true) shouldBe false
|
||||
prefs.dataMapPeek["key"] shouldBe false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user