Basic working data display in wear-app

This commit is contained in:
darken
2022-09-14 18:00:18 +02:00
committed by Matthias Urhahn
parent 2eb3f70e0c
commit 1d51c207f4
225 changed files with 606 additions and 860 deletions
@@ -1,376 +0,0 @@
package eu.darken.capod.common.flow
import eu.darken.capod.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.*
import org.junit.jupiter.api.Test
import testhelper.BaseTest
import testhelper.coroutine.runTest2
import testhelper.flow.test
import java.io.IOException
import java.lang.Thread.sleep
import kotlin.concurrent.thread
import kotlin.coroutines.EmptyCoroutineContext
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 =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
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 =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
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 {
runTest2(autoCancel = true) {
hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test"
}
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
}
@Test
fun `value updates`() {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
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(scope = 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 =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
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(scope = 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 =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { "1" },
)
val testCollector = hotData.flow.test(scope = 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`() = runTest2(autoCancel = true) {
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = this,
startValueProvider = valueProvider,
)
val sub1 = hotData.flow.test(tag = "sub1", scope = this)
val sub2 = hotData.flow.test(tag = "sub2", scope = this)
val sub3 = hotData.flow.test(tag = "sub3", scope = this)
hotData.updateAsync { "A" }
hotData.updateAsync { "B" }
hotData.updateAsync { "C" }
advanceUntilIdle()
listOf(sub1, sub2, sub3).forEach {
it.await { list, _ -> list.size == 4 }
it.latestValues shouldBe listOf("Test", "A", "B", "C")
it.cancelAndJoin()
}
hotData.flow.first() shouldBe "C"
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `value is persisted between unsubscribes`() = runTest2(autoCancel = 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", scope = 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.cancelAndJoin()
val testCollector2 = hotData.flow.test(tag = "collector2", scope = this)
testCollector2.silent = false
advanceUntilIdle()
testCollector2.cancelAndJoin()
testCollector2.latestValues shouldBe listOf(11L)
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `blocking update is actually blocking`() = runBlocking {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
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(scope = testScope)
testScope.advanceUntilIdle()
hotData.updateBlocking { this - 3 } shouldBe 0
testCollector.await { _, i -> i == 3 }
testCollector.latestValues shouldBe listOf(2, 3, 0)
testCollector.cancelAndJoin()
}
@Test
fun `blocking update rethrows error`() = runBlocking {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = testScope.coroutineContext,
startValueProvider = {
delay(2000)
2
},
)
val testCollector = hotData.flow.test(scope = 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.cancelAndJoin()
}
@Test
fun `async updates error handler`() = runTest2(expectedError = IOException::class) {
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = this,
startValueProvider = { 1 },
)
val testCollector = hotData.flow.test(scope = this)
advanceUntilIdle()
hotData.updateAsync { throw IOException("Surprise") }
advanceUntilIdle()
}
@Test
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runBlocking {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = { 1 },
)
val testCollector = hotData.flow.test(scope = 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.cancelAndJoin()
}
@Test
fun `clean up function is called when parent scope is cancelled`() = runTest {
val testScope =
createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + EmptyCoroutineContext)
var onReleaseValue: String? = null
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { "Test" },
onRelease = {
onReleaseValue = it
}
)
hotData.flow.first() shouldBe "Test"
testScope.cancel()
onReleaseValue shouldBe "Test"
}
}
@@ -1,124 +0,0 @@
package eu.darken.capod.common.preferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
import testhelper.BaseTest
import testhelper.json.toComparableJson
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceMoshiTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@JsonClass(generateAdapter = true)
data class TestGson(
val string: String = "",
val boolean: Boolean = true,
val float: Float = 1.0f,
val int: Int = 1,
val long: Long = 1L
)
@Test
fun `reading and writing using manual reader and writer`() = runBlockingTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
FlowPreference<TestGson?>(
preferences = mockPreferences,
key = "testKey",
rawReader = moshiReader(moshi, testData1),
rawWriter = moshiWriter(moshi)
).apply {
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe testData1
it!!.copy(string = "update")
}
value shouldBe testData2
flow.first() shouldBe testData2
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
{
"string":"update",
"boolean":true,
"float":1.0,
"int":1,
"long":1
}
""".toComparableJson()
update {
it shouldBe testData2
null
}
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing using autocreated reader and writer`() = runBlockingTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
mockPreferences.createFlowPreference<TestGson?>(
key = "testKey",
defaultValue = testData1,
moshi = moshi
).apply {
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe testData1
it!!.copy(string = "update")
}
value shouldBe testData2
flow.first() shouldBe testData2
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
{
"string":"update",
"boolean":true,
"float":1.0,
"int":1,
"long":1
}
""".toComparableJson()
update {
it shouldBe testData2
null
}
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `enum serialization`() = runBlockingTest {
val moshi = Moshi.Builder().build()
val monitorMode = mockPreferences.createFlowPreference(
"core.monitor.mode",
MonitorMode.AUTOMATIC,
moshi
)
monitorMode.value shouldBe MonitorMode.AUTOMATIC
monitorMode.update { MonitorMode.MANUAL }
monitorMode.value shouldBe MonitorMode.MANUAL
}
}
@@ -1,159 +0,0 @@
package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
import testhelper.BaseTest
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@Test
fun `reading and writing strings`() = runBlockingTest {
mockPreferences.createFlowPreference<String?>(
key = "testKey",
defaultValue = "default"
).apply {
value shouldBe "default"
flow.first() shouldBe "default"
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe "default"
"newvalue"
}
value shouldBe "newvalue"
flow.first() shouldBe "newvalue"
mockPreferences.dataMapPeek.values.first() shouldBe "newvalue"
update {
it shouldBe "newvalue"
null
}
value shouldBe "default"
flow.first() shouldBe "default"
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing boolean`() = runBlockingTest {
mockPreferences.createFlowPreference<Boolean?>(
key = "testKey",
defaultValue = true
).apply {
value shouldBe true
flow.first() shouldBe true
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe true
false
}
value shouldBe false
flow.first() shouldBe false
mockPreferences.dataMapPeek.values.first() shouldBe false
update {
it shouldBe false
null
}
value shouldBe true
flow.first() shouldBe true
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing long`() = runBlockingTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 9000L
).apply {
value shouldBe 9000L
flow.first() shouldBe 9000L
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 9000L
9001L
}
value shouldBe 9001L
flow.first() shouldBe 9001L
mockPreferences.dataMapPeek.values.first() shouldBe 9001L
update {
it shouldBe 9001L
null
}
value shouldBe 9000L
flow.first() shouldBe 9000L
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing integer`() = runBlockingTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 123
).apply {
value shouldBe 123
flow.first() shouldBe 123
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 123
44
}
value shouldBe 44
flow.first() shouldBe 44
mockPreferences.dataMapPeek.values.first() shouldBe 44
update {
it shouldBe 44
null
}
value shouldBe 123
flow.first() shouldBe 123
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing float`() = runBlockingTest {
mockPreferences.createFlowPreference<Float?>(
key = "testKey",
defaultValue = 3.6f
).apply {
value shouldBe 3.6f
flow.first() shouldBe 3.6f
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 3.6f
15000f
}
value shouldBe 15000f
flow.first() shouldBe 15000f
mockPreferences.dataMapPeek.values.first() shouldBe 15000f
update {
it shouldBe 15000f
null
}
value shouldBe 3.6f
flow.first() shouldBe 3.6f
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
}
@@ -1,41 +0,0 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsFactoryTest : BaseAirPodsTest() {
@Test
fun `create AirPodsGen1`() = runBlockingTest {
create<DualAirPods>("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<DualAirPods>("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
}
}
}
@@ -1,71 +0,0 @@
package eu.darken.capod.pods.core.apple
import dagger.Component
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockkObject
import org.junit.jupiter.api.BeforeEach
import testhelper.BaseTest
import javax.inject.Singleton
abstract class BaseAirPodsTest : BaseTest() {
@Singleton
@Component(modules = [AppleFactoryModule::class])
interface AppleFactoryTestComponent {
val appleFactory: AppleFactory
@Component.Factory
interface Factory {
fun create(): AppleFactoryTestComponent
}
}
private val baseBleScanResult = BleScanResult(
address = "77:49:4C:D8:25:0C",
rssi = -66,
generatedAtNanos = 136136027721826,
manufacturerSpecificData = emptyMap()
)
val factory: AppleFactory = DaggerBaseAirPodsTest_AppleFactoryTestComponent.factory().create().appleFactory
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
mockkObject(SystemClockWrap)
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
}
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()
val result = mockData(bytes)
block.invoke(factory.create(result) as T)
}
fun mockData(hex: String): BleScanResult {
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): BleScanResult = baseBleScanResult.copy(
manufacturerSpecificData = mutableMapOf<Int, ByteArray>().apply {
this[ContinuityProtocol.APPLE_COMPANY_IDENTIFIER] = data
}
)
}
@@ -1,31 +0,0 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BasicSingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `test mapping`() = runBlockingTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
rawStatus shouldBe 0x00.toUByte()
rawPodsBattery shouldBe 0xF5.toUByte()
rawFlags shouldBe 0x0.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
}
}
@Test
fun `test battery headset percent`() = runBlockingTest {
create<SingleApplePods>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
batteryHeadsetPercent shouldBe 0.5f
}
}
}
@@ -1,246 +0,0 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class DualApplePodsTest : BaseAirPodsTest() {
@Test
fun `test bit mapping`() = runBlockingTest {
create<DualAirPods>("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()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x5.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
}
}
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
create<DualAirPods>("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
// --^-----
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
}
create<DualAirPods>("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
// --^-----
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
}
}
@Test
fun `test AirPodDevice - left pod ear status`() = runBlockingTest {
// Left Pod primary
create<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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<DualAirPods>("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 DualAirPods.LidState.OPEN
}
// Lid open, left pod in case
create<DualAirPods>("07 19 01 0E 20 51 9A 93 >31< 00 00 95 D0 A5 D7 E3 F4 F1 38 38 99 61 3B 57 95 37 B7") {
// 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN
}
// Lid open, left pod in case
create<DualAirPods>("07 19 01 0E 20 71 A9 92 31 00 00 DB 48 7F 32 8C CE 80 6F D9 27 98 D6 76 45 9D 62") {
// 31 0011 0001
caseLidState shouldBe DualAirPods.LidState.OPEN
}
// Lid just closed
create<DualAirPods>("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 DualAirPods.LidState.CLOSED
}
// Lid closed
create<DualAirPods>("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 DualAirPods.LidState.CLOSED
}
// Lid closed, right pod in case
create<DualAirPods>("07 19 01 0E 20 51 9A 93 >38< 00 00 3A D8 85 76 B0 91 48 31 DA FF 6C 4A 2B C2 67 F4") {
// 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED
}
// Lid closed, left pod in case
create<DualAirPods>("07 19 01 0E 20 71 A9 92 38 00 00 44 91 C4 8B 85 98 DD 55 4E 6A CA BC B5 CA 8D 37") {
// 38 0011 1000
caseLidState shouldBe DualAirPods.LidState.CLOSED
}
}
@Test
fun `test AirPodDevice - connection state`() = runBlockingTest {
// Disconnected
create<DualAirPods>("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
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
}
// Connected idle
create<DualAirPods>("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
state shouldBe DualAirPods.ConnectionState.IDLE
}
// Connected and playing music
create<DualAirPods>("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
state shouldBe DualAirPods.ConnectionState.MUSIC
}
// Connected and call active
create<DualAirPods>("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
state shouldBe DualAirPods.ConnectionState.CALL
}
// Connected and call active
create<DualAirPods>("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
state shouldBe DualAirPods.ConnectionState.RINGING
}
// Switching?
create<DualAirPods>("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
state shouldBe DualAirPods.ConnectionState.HANGING_UP
}
}
}
@@ -1,35 +0,0 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class SingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `default bit mapping Max`() = runBlockingTest {
create<SingleApplePods>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
}
}
@Test
fun `test values based on AirPodMax`() = runBlockingTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
}
@@ -1,44 +0,0 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.HasAppleColor
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()
rawFlags shouldBe 0x5.toUShort()
rawCaseBattery shouldBe 0x6.toUShort()
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 DualAirPods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.DISCONNECTED
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}
@@ -1,43 +0,0 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.HasAppleColor
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()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
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 DualAirPods.LidState.NOT_IN_CASE
state shouldBe DualAirPods.ConnectionState.MUSIC
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}
@@ -1,65 +0,0 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualAirPods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `AirPods Gen3`() = runBlockingTest {
create<AirPodsGen3>("07 19 01 13 20 75 AA B9 31 00 04 67 9A 57 DF BE F6 90 52 B0 04 1F 8D 89 DA F4 9E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1320.toUShort()
rawStatus shouldBe 0x75.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x9.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x04.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.9f
caseLidState shouldBe DualAirPods.LidState.OPEN
state shouldBe DualAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
@Test
fun `random guy at bus stop`() = runBlockingTest {
create<AirPodsGen3>("07 19 01 13 20 2B 88 8F 01 00 08 E2 0E 84 37 C5 98 16 D4 B7 37 ED 23 8B 08 EA A1") {
batteryLeftPodPercent shouldBe 0.8f
batteryRightPodPercent shouldBe 0.8f
isCaseCharging shouldBe false
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe DualAirPods.LidState.NOT_IN_CASE
state shouldBe DualAirPods.ConnectionState.UNKNOWN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}
@@ -1,53 +0,0 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max`() = runBlockingTest {
create<AirPodsMax>("07 19 01 0A 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max flipped values`() = runBlockingTest {
create<AirPodsMax>("07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0A20.toUShort()
rawStatus shouldBe 0x02.toUByte()
rawPodsBattery shouldBe 0x05.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x04.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x44.toUByte()
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
isHeadphonesBeingWorn shouldBe true
}
}
}
@@ -1,173 +0,0 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
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()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x5.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.5f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
@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") {
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.3f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
}
// 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
}
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
create<AirPodsPro>("0719010e202b668f01000400000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
create<AirPodsPro>("0719010e200b668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
create<AirPodsPro>("0719010e2003668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
create<AirPodsPro>("0719010e2001668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
create<AirPodsPro>("0719010e2009668f01000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
create<AirPodsPro>("0719010e2053669653000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
}
create<AirPodsPro>("0719010e203366a602000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.6f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
}
create<AirPodsPro>("0719010e202b768f02000500000000000000000000000000000000") {
batteryLeftPodPercent shouldBe 0.6f
batteryRightPodPercent shouldBe 0.7f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
}
}
// 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
}
}
}
@@ -1,36 +0,0 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsFlexText : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
fun `default BeatsFlex`() = runBlockingTest {
create<BeatsFlex>("07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x1020.toUShort()
rawStatus shouldBe 0x0A.toUByte()
rawPodsBattery shouldBe 0xF4.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x00.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
@Test
fun `random neighbour`() = runBlockingTest {
create<BeatsFlex>("07 19 01 10 20 0A F6 8F 02 4F 00 95 68 94 9E 99 D6 90 F4 5E 68 3C 58 21 68 9F 0D") {
batteryHeadsetPercent shouldBe 0.6f
}
}
}
@@ -1,27 +0,0 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsSolo3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default BeatsSolo3`() = runBlockingTest {
create<BeatsSolo3>("07 19 01 06 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0620.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -1,27 +0,0 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsStudio3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default BeatsStudio3`() = runBlockingTest {
create<BeatsStudio3>("07 19 01 09 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0920.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -1,27 +0,0 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class BeatsXTest : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
fun `default BeatsX`() = runBlockingTest {
create<BeatsX>("07 19 01 05 20 00 F5 0F 01 01 00 6D CE C0 04 22 0A 85 31 2D 82 6B 42 80 01 20 1A") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0520.toUShort()
rawStatus shouldBe 0x00.toUByte()
rawPodsBattery shouldBe 0xF5.toUByte()
rawFlags shouldBe 0x0.toUShort()
rawCaseBattery shouldBe 0xF.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x01.toUByte()
rawSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.5f
}
}
}
@@ -1,27 +0,0 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class PowerBeats3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default PowerBeats3`() = runBlockingTest {
create<PowerBeats3>("07 19 01 03 20 62 04 80 01 0F 40 0D 70 50 16 F2 40 83 16 BF 10 16 34 9B 74 84 E8") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0320.toUShort()
rawStatus shouldBe 0x62.toUByte()
rawPodsBattery shouldBe 0x04.toUByte()
rawFlags shouldBe 0x8.toUShort()
rawCaseBattery shouldBe 0x0.toUShort()
rawCaseLidState shouldBe 0x01.toUByte()
rawDeviceColor shouldBe 0x0F.toUByte()
rawSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
}
}
}
@@ -1,39 +0,0 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class PowerBeatsProTest : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `test PowerBeatsPro`() = runBlockingTest {
create<PowerBeatsPro>("07 19 01 0B 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 0x0B20.toUShort()
rawStatus shouldBe 0x54.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0xB.toUShort()
rawCaseBattery shouldBe 0x5.toUShort()
rawCaseLidState shouldBe 0x31.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.5f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}
@@ -1,31 +0,0 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class Twsi99999Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runBlockingTest {
create<Twsi99999>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0220.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x32.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
}
}
}
@@ -1,31 +0,0 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
class VarunrAirPodsProTest : BaseAirPodsTest() {
@Test
fun `guessed data`() = runBlockingTest {
create<VarunrAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") {
rawPrefix shouldBe 0x01.toUByte()
rawDeviceModel shouldBe 0x0E20.toUShort()
rawStatus shouldBe 0x71.toUByte()
rawPodsBattery shouldBe 0xAA.toUByte()
rawFlags shouldBe 0x3.toUShort()
rawCaseBattery shouldBe 0x7.toUShort()
rawCaseLidState shouldBe 0x36.toUByte()
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
}
}
}
-29
View File
@@ -1,29 +0,0 @@
package testhelper
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.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()
}
}
}
@@ -1,24 +0,0 @@
package testhelper.coroutine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
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 createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + dispatcher) {
override fun beforeEach(context: ExtensionContext?) {
Dispatchers.setMain(dispatcher)
}
override fun afterEach(context: ExtensionContext?) {
cleanupTestCoroutines()
Dispatchers.resetMain()
}
}
@@ -1,23 +0,0 @@
package testhelper.coroutine
import eu.darken.capod.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)
@@ -1,38 +0,0 @@
package testhelper.coroutine
import eu.darken.capod.common.debug.logging.asLog
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.reflect.KClass
fun runTest2(
autoCancel: Boolean = false,
context: CoroutineContext = EmptyCoroutineContext,
expectedError: KClass<out Throwable>? = null,
testBody: suspend TestScope.() -> Unit
) {
try {
val scope = TestScope(context = context)
try {
scope.runTest {
testBody()
if (autoCancel) scope.cancel("autoCancel")
}
} catch (e: Throwable) {
val isExpected = expectedError?.isInstance(e) ?: false
if (!isExpected) throw e
}
} catch (e: CancellationException) {
if (e.message == "autoCancel" && autoCancel) {
io.kotest.mpp.log { "Test was auto-cancelled ${e.asLog()}" }
} else {
throw e
}
}
}
@@ -1,98 +0,0 @@
package testhelper.flow
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.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
fun <T> Flow<T>.test(
tag: String? = null,
scope: CoroutineScope
): TestCollector<T> = createTest(tag ?: "FlowTest").start(scope = scope)
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) job.cancel()
try {
job.join()
} catch (e: Exception) {
error = e
}
}
suspend fun assertNoErrors() = apply {
awaitFinal()
require(error == null) { "Error was not null: $error" }
}
suspend fun cancelAndJoin() {
if (job.isCompleted) throw IllegalStateException("Flow is already canceled.")
job.cancelAndJoin()
}
}
@@ -1,29 +0,0 @@
package testhelper.json
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import okio.Buffer
import okio.ByteString.Companion.encode
import okio.buffer
import okio.sink
import java.io.File
fun String.toComparableJson(): String {
val value = Buffer().use {
it.writeUtf8(this)
val reader = JsonReader.of(it)
reader.readJsonValue()
}
val adapter = Moshi.Builder().build().adapter(Any::class.java).indent(" ")
return adapter.toJson(value)
}
fun String.writeToFile(file: File) = encode().let { text ->
require(!file.exists())
file.parentFile?.mkdirs()
file.createNewFile()
file.sink().buffer().use { it.write(text) }
}
@@ -1,26 +0,0 @@
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)
}
}
@@ -1,22 +0,0 @@
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
}
}