Merge main and common module to simplify.

This commit is contained in:
darken
2025-09-29 13:36:43 +02:00
parent deb012600d
commit be8f4919c8
278 changed files with 142 additions and 117 deletions
@@ -0,0 +1,16 @@
package eu.darken.capod.common
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ByteArrayExtensionsTest : BaseTest() {
@Test
fun `hex - ByteArray conversion`() = runTest {
val addr = "78-73-AF-B4-85-22"
val raw = addr.fromHex()
raw.toHex() shouldBe addr
}
}
@@ -0,0 +1,377 @@
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.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import testhelpers.flow.test
import java.io.IOException
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`() = runTest2(expectedError = IOException::class) {
val testScope = this
val hotData = DynamicStateFlow<String>(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { throw IOException() }
)
// This blocking scope gets the init exception as the first caller
hotData.flow.firstOrNull()
}
@Test
fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = valueProvider,
)
hotData.flow.first() shouldBe "Test"
hotData.flow.first() shouldBe "Test"
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `value updates`() = runTest2(autoCancel = true) {
val testScope = this
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 { _ ->
hotData.updateAsync(
onUpdate = { this + 1L },
onError = { throw it }
)
}
}
}
advanceUntilIdle()
testCollector.await { list, _ -> list.size == 3201 }
testCollector.latestValues shouldBe (1..3201).toList()
advanceUntilIdle()
coVerify(exactly = 1) { valueProvider.invoke(any()) }
testCollector.cancelAndJoin()
}
data class TestData(
val number: Long = 1
)
@Test
fun `check multi threading value updates with more complex data`() = runTest2(autoCancel = true) {
val testScope = this
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
)
}
}
}
}
}
advanceUntilIdle()
testCollector.await { list, _ -> list.size == 4001 }
testCollector.latestValues.map { it.values.single().number } shouldBe (1L..4001L).toList()
advanceUntilIdle()
coVerify(exactly = 1) { valueProvider.invoke(any()) }
testCollector.cancelAndJoin()
}
@Test
fun `only emit new values if they actually changed updates`() = runTest2(autoCancel = true) {
val testScope = this
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" }
advanceUntilIdle()
testCollector.await { list, _ -> list.size == 3 }
testCollector.latestValues shouldBe listOf("1", "2", "1")
}
@Test
fun `multiple subscribers share the flow`() = runTest2(autoCancel = true) {
val testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> String>()
coEvery { valueProvider.invoke(any()) } returns "Test"
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
startValueProvider = valueProvider,
)
val sub1 = hotData.flow.test(tag = "sub1", scope = testScope)
val sub2 = hotData.flow.test(tag = "sub2", scope = testScope)
val sub3 = hotData.flow.test(tag = "sub3", scope = testScope)
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 testScope = this
val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
coEvery { valueProvider.invoke(any()) } returns 1
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = this.coroutineContext,
startValueProvider = valueProvider,
)
val testCollector1 = hotData.flow.test(tag = "collector1", scope = testScope)
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 = testScope)
testCollector2.silent = false
advanceUntilIdle()
testCollector2.cancelAndJoin()
testCollector2.latestValues shouldBe listOf(11L)
coVerify(exactly = 1) { valueProvider.invoke(any()) }
}
@Test
fun `blocking update is actually blocking`() = runTest2(autoCancel = true) {
val testScope = this
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
advanceUntilIdle()
testCollector.await { _, i -> i == 3 }
testCollector.latestValues shouldBe listOf(2, 3, 0)
testCollector.cancelAndJoin()
}
@Test
fun `blocking update rethrows error`() = runTest2(autoCancel = true) {
val testScope = this
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
advanceUntilIdle()
hotData.flow.first() shouldBe 3
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()
testCollector.cancelAndJoin()
}
@Test
fun `async updates rethrow errors on HotDataFlow scope if no error handler is set`() = runTest2(autoCancel = true) {
val testScope = this
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>()
testCollector.cancelAndJoin()
}
@Test
fun `clean up function is called when parent scope is cancelled`() {
var onReleaseValue: String? = null
runTest2(autoCancel = true) {
val testScope = this
val hotData = DynamicStateFlow(
loggingTag = "tag",
parentScope = testScope,
coroutineContext = Dispatchers.Unconfined,
startValueProvider = { "Test" },
onRelease = {
onReleaseValue = it
}
)
hotData.flow.first() shouldBe "Test"
}
onReleaseValue shouldBe "Test"
}
}
@@ -0,0 +1,124 @@
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.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.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`() = runTest {
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`() = runTest {
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`() = runTest {
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
}
}
@@ -0,0 +1,159 @@
package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@Test
fun `reading and writing strings`() = runTest {
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`() = runTest {
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`() = runTest {
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`() = runTest {
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`() = runTest {
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
}
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.fromHex
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class RPACheckerTest : BaseTest() {
@Test
fun `test check`() {
val checker = RPAChecker()
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79".fromHex(),
) shouldBe true
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-AA".fromHex(),
) shouldBe false
}
@Test
fun `bad input check`() {
val checker = RPAChecker()
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "".fromHex(),
) shouldBe false
checker.verify(
address = "",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-AA".fromHex(),
) shouldBe false
checker.verify(
address = "",
irk = "".fromHex(),
) shouldBe false
}
}
@@ -0,0 +1,42 @@
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 kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsFactoryTest : BaseAirPodsTest() {
@Test
fun `create AirPodsGen1`() = runTest {
create<DualApplePods>("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`() = runTest {
create<DualApplePods>("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`() = runTest {
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,91 @@
package eu.darken.capod.pods.core.apple
import dagger.BindsInstance
import dagger.Component
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.fromHex
import eu.darken.capod.common.serialization.SerializationModule
import eu.darken.capod.main.core.GeneralSettings
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.mockk
import io.mockk.mockkObject
import org.junit.jupiter.api.BeforeEach
import testhelpers.BaseTest
import testhelpers.preferences.mockFlowPreference
import java.time.Instant
import javax.inject.Singleton
abstract class BaseAirPodsTest : BaseTest() {
@Singleton
@Component(modules = [AppleFactoryModule::class, SerializationModule::class])
interface AppleFactoryTestComponent {
val appleFactory: AppleFactory
@Component.Factory
interface Factory {
fun create(
@BindsInstance generalSettings: GeneralSettings,
): AppleFactoryTestComponent
}
}
val generalSettings = mockk<GeneralSettings>().apply {
every { mainDeviceIdentityKey } returns mockFlowPreference(null)
every { mainDeviceEncryptionKey } returns mockFlowPreference(null)
}
private fun hexToByteArray(hex: String): ByteArray = hex
.replace(">", "")
.replace("<", "")
.fromHex()
private fun cleanKey(key: String): ByteArray = hexToByteArray(key)
.also { require(it.size == 16) { "Not a valid key: ${it.size} byte" } }
fun setKeyIRK(key: String?) {
generalSettings.apply {
every { mainDeviceIdentityKey } returns mockFlowPreference(key?.let { cleanKey(it) })
}
}
fun setKeyEnc(key: String?) {
generalSettings.apply {
every { mainDeviceEncryptionKey } returns mockFlowPreference(key?.let { cleanKey(it) })
}
}
val factory: AppleFactory = DaggerBaseAirPodsTest_AppleFactoryTestComponent.factory().create(
generalSettings = generalSettings
).appleFactory
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
mockkObject(SystemClockWrap)
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
}
internal suspend inline fun <reified T : PodDevice?> create(
hex: String,
address: String = "77:49:4C:D8:25:0C",
block: T.() -> Unit
) {
val result = BleScanResult(
receivedAt = Instant.now(),
address = address,
rssi = -66,
generatedAtNanos = 136136027721826,
manufacturerSpecificData = mutableMapOf<Int, ByteArray>().apply {
this[ContinuityProtocol.APPLE_COMPANY_IDENTIFIER] = hexToByteArray(hex)
}
)
block.invoke(factory.create(result) as T)
}
}
@@ -0,0 +1,31 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BasicSingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `test mapping`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0520.toUShort()
pubStatus shouldBe 0x00.toUByte()
pubPodsBattery shouldBe 0xF5.toUByte()
pubFlags shouldBe 0x0.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x01.toUByte()
pubSuffix shouldBe 0x00.toUByte()
}
}
@Test
fun `test battery headset percent`() = runTest {
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
}
}
}
@@ -0,0 +1,247 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class DualApplePodsTest : BaseAirPodsTest() {
@Test
fun `test bit mapping`() = runTest {
create<DualApplePods>("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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0e20.toUShort()
pubStatus shouldBe 0x54.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x5.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
}
}
@Test
fun `test AirPodDevice - active microphone`() = runTest {
create<DualApplePods>("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<DualApplePods>("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`() = runTest {
// Left Pod primary
create<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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`() = runTest {
// Left Pod primary
create<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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`() = runTest {
// Right Pod is primary
create<DualApplePods>("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<DualApplePods>("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`() = runTest {
/**
* Right pod is charging
*/
// This is the left
create<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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<DualApplePods>("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`() = runTest {
create<DualApplePods>("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<DualApplePods>("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`() = runTest {
// Lid open
create<DualApplePods>("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 DualApplePods.LidState.OPEN
}
// Lid open, left pod in case
create<DualApplePods>("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 DualApplePods.LidState.OPEN
}
// Lid open, left pod in case
create<DualApplePods>("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 DualApplePods.LidState.OPEN
}
// Lid just closed
create<DualApplePods>("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 DualApplePods.LidState.CLOSED
}
// Lid closed
create<DualApplePods>("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 DualApplePods.LidState.CLOSED
}
// Lid closed, right pod in case
create<DualApplePods>("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 DualApplePods.LidState.CLOSED
}
// Lid closed, left pod in case
create<DualApplePods>("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 DualApplePods.LidState.CLOSED
}
}
@Test
fun `test AirPodDevice - connection state`() = runTest {
// Disconnected
create<HasStateDetectionAirPods>("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 HasStateDetectionAirPods.ConnectionState.DISCONNECTED
}
// Connected idle
create<HasStateDetectionAirPods>("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 HasStateDetectionAirPods.ConnectionState.IDLE
}
// Connected and playing music
create<HasStateDetectionAirPods>("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 HasStateDetectionAirPods.ConnectionState.MUSIC
}
// Connected and call active
create<HasStateDetectionAirPods>("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 HasStateDetectionAirPods.ConnectionState.CALL
}
// Connected and call active
create<HasStateDetectionAirPods>("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 HasStateDetectionAirPods.ConnectionState.RINGING
}
// Switching?
create<HasStateDetectionAirPods>("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 HasStateDetectionAirPods.ConnectionState.HANGING_UP
}
}
}
@@ -0,0 +1,23 @@
package eu.darken.capod.pods.core.apple
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class SingleApplePodsTest : BaseAirPodsTest() {
@Test
fun `default bit mapping Max`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
}
}
}
@@ -0,0 +1,45 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
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`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0220.toUShort()
pubStatus shouldBe 0x55.toUByte()
pubPodsBattery shouldBe 0xAF.toUByte()
pubFlags shouldBe 0x5.toUShort()
pubCaseBattery shouldBe 0x6.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix 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 DualApplePods.LidState.OPEN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN1
}
}
}
@@ -0,0 +1,46 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `random Neighbor AirPodsGen2`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0F20.toUShort()
pubStatus shouldBe 0x02.toUByte()
pubPodsBattery shouldBe 0xF9.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix 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 DualApplePods.LidState.NOT_IN_CASE
state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN2
}
}
}
@@ -0,0 +1,68 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `AirPods Gen3`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1320.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x9.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix 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 DualApplePods.LidState.OPEN
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN3
}
}
@Test
fun `random guy at bus stop`() = runTest {
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 DualApplePods.LidState.NOT_IN_CASE
state shouldBe HasStateDetectionAirPods.ConnectionState.UNKNOWN
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}
@@ -0,0 +1,46 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen4AncTest : BaseAirPodsTest() {
@Test
fun `AirPods Gen4 with ANC via log from #226`() = runTest {
create<AirPodsGen4Anc>("07 19 01 1B 20 0B 9A 8F 10 00 04 43 DF EC 1D D3 F1 C3 F4 A1 9B 29 26 B9 E7 3A A0") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1B20.toUShort()
pubStatus shouldBe 0x0b.toUByte()
pubPodsBattery shouldBe 0x9A.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x10.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe DualApplePods.LidState.UNKNOWN
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN4_ANC
}
}
}
@@ -0,0 +1,46 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen4Test : BaseAirPodsTest() {
@Test
fun `AirPods Gen4 via log from #225`() = runTest {
create<AirPodsGen4>("07 19 01 19 20 2B 33 8F 11 00 04 59 D4 57 20 0F 1C 13 38 B2 00 74 E9 DD 70 D7 A5") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1920.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0x33.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x11.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryLeftPodPercent shouldBe 0.3f
batteryRightPodPercent shouldBe 0.3f
isCaseCharging shouldBe false
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe DualApplePods.LidState.UNKNOWN
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_GEN4
}
}
}
@@ -0,0 +1,93 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
isHeadsetBeingCharged shouldBe false
model shouldBe PodDevice.Model.AIRPODS_MAX
}
}
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
fun `default AirPods Max flipped values`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x02.toUByte()
pubPodsBattery shouldBe 0x05.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x04.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x44.toUByte()
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
}
}
@Test
fun `some dude at the gym`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0A20.toUShort()
pubStatus shouldBe 0x23.toUByte()
pubPodsBattery shouldBe 0x07.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x03.toUByte()
pubDeviceColor shouldBe 0x03.toUByte()
pubSuffix shouldBe 0x65.toUByte()
batteryHeadsetPercent shouldBe 0.7f
isHeadsetBeingCharged shouldBe false
pubStatus.isBitSet(5) shouldBe true
podStyle shouldBe HasAppleColor.DeviceColor.BLUE
}
}
@Test
fun `wear status`() = runTest {
create<AirPodsMax>("07 19 01 0A 20 03 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
pubStatus shouldBe 0x03.toUByte()
pubStatus.isBitSet(5) shouldBe false
isBeingWorn shouldBe false
}
create<AirPodsMax>("07 19 01 0A 20 23 07 80 03 03 65 1F 28 32 D0 D9 71 43 00 9A 40 E7 6B EA 6C 2C FB") {
pubStatus shouldBe 0x23.toUByte()
pubStatus.isBitSet(5) shouldBe true
isBeingWorn shouldBe true
}
}
}
@@ -0,0 +1,32 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsMaxUsbcTest : BaseAirPodsTest() {
// Test data from https://github.com/d4rken-org/capod/issues/236
@Test
fun `default AirPods Max`() = runTest {
create<AirPodsMaxUsbc>("07 19 01 1F 20 2B 05 80 03 12 C5 2E 8B F9 9A 7E 19 7B 63 0F 30 6E D7 3B E2 EC 32") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1F20.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0x05.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x03.toUByte()
pubDeviceColor shouldBe 0x12.toUByte()
pubSuffix shouldBe 0xC5.toUByte()
batteryHeadsetPercent shouldBe 0.5f
isHeadsetBeingCharged shouldBe false
model shouldBe PodDevice.Model.AIRPODS_MAX_USBC
}
}
}
@@ -0,0 +1,83 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class AirPodsPro2Test : BaseAirPodsTest() {
/**
* https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084
*/
@Test
fun `test AirPods Pro 2 - unknown setup from #31`() = runTest {
create<AirPodsPro2>("07 19 01 14 20 55 88 F9 51 00 04 20 50 03 CA D5 C9 AC 0F FA 84 78 94 5A 4D DF F5") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x55.toUByte()
pubPodsBattery shouldBe 0x88.toUByte()
pubFlags shouldBe 0xF.toUShort()
pubCaseBattery shouldBe 0x9.toUShort()
pubCaseLidState shouldBe 0x51.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
batteryLeftPodPercent shouldBe 0.8f
batteryRightPodPercent shouldBe 0.8f
isCaseCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.9f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2
}
}
/**
* https://old.reddit.com/message/messages/1hst12h
*/
@Test
fun `test AirPods Pro 2 - unknown setup from reddit user`() = runTest {
create<AirPodsPro2>("07 19 01 14 20 2B 9A 8F 01 00 04 0F 26 1A C4 2B FA 2F B9 B6 08 CD 60 CB DF 75 AB") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0x9A.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
}
}
}
@@ -0,0 +1,72 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class AirPodsPro2UsbcTest : BaseAirPodsTest() {
/**
* https://github.com/d4rken-org/capod/issues/164
*/
@Test
fun `AirPods Pro 2 with USB-C - via #164`() = runTest {
create<AirPodsPro2Usbc>("07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x2420.toUShort()
pubStatus shouldBe 0x0B.toUByte()
pubPodsBattery shouldBe 0x99.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x11.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC
}
}
@Test
fun `AirPods Pro 2 with USB-C - via #164 - in case`() = runTest {
create<AirPodsPro2Usbc>("07 19 01 24 20 53 AA 98 32 00 05 49 0A B8 BF 8E 29 D8 70 12 0D A7 0C CE 77 56 00") {
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe false
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.8f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO2_USBC
}
}
}
@@ -0,0 +1,94 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class AirPodsPro3Test : BaseAirPodsTest() {
/**
* Test case for AirPods Pro 3 - placeholder with correct device code
* Following the pattern of other AirPods models (0x2720)
*/
@Test
fun `AirPods Pro 3 - placeholder test`() = runTest {
// This test uses a placeholder hex string with the correct device code 0x2720
// The actual proximity pairing data will need to be captured from real AirPods Pro 3
create<AirPodsPro3>("07 19 01 27 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x2720.toUShort()
pubStatus shouldBe 0x0B.toUByte()
pubPodsBattery shouldBe 0x99.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x11.toUByte()
pubDeviceColor shouldBe 0x0.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO3
}
}
@Test
fun `AirPods Pro 3 - placeholder test - in case`() = runTest {
// This test uses a placeholder hex string with the correct device code 0x2720
// The actual proximity pairing data will need to be captured from real AirPods Pro 3
create<AirPodsPro3>("07 19 01 27 20 53 AA 98 32 00 05 49 0A B8 BF 8E 29 D8 70 12 0D A7 0C CE 77 56 00") {
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe false
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.8f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO3
}
}
@Test
fun `AirPods Pro 3`() = runTest {
create<AirPodsPro3>("07 19 01 27 20 0B 99 8F 11 00 05 43 A8 85 17 07 FF 41 62 2C FE 5E 20 08 1A 52 40") {
isLeftPodInEar shouldBe true
isRightPodInEar shouldBe true
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.WHITE.name
model shouldBe PodDevice.Model.AIRPODS_PRO3
}
}
}
@@ -0,0 +1,224 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.toHex
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class AirPodsProTest : BaseAirPodsTest() {
@Test
fun `test AirPods Pro - default changed and in case`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0e20.toUShort()
pubStatus shouldBe 0x54.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x5.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix 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
model shouldBe PodDevice.Model.AIRPODS_PRO
}
}
@Test
fun `test AirPods from my downstairs neighbour`() = runTest {
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`() = runTest {
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`() = runTest {
create<AirPodsGen1>("071901022055AA563100006FE4DF10AF106081033B76D9C7112288") {
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
}
}
@Test
fun `left pod has no data`() = runTest {
create<AirPodsPro>("07 19 01 0E 20 0B F9 8F 03 00 05 5B 59 67 4C F7 F3 EF 01 BA F4 92 1B 26 E4 90 40") {
pubPodsBattery shouldBe 0xF9.toUByte()
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.9f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
}
}
@Test
fun `decrypt data`() = runTest {
val data = "07 19 01 0E 20 51 9A 98 33 00 04 0C 14 E0 EB 43 3F 4B 22 C0 A9 ED CB 33 E7 09 71"
val address = "5A:16:2B:91:D1:CD"
create<AirPodsPro>(data, address) {
batteryLeftPodPercent shouldBe 0.9f
isLeftPodCharging shouldBe false
batteryRightPodPercent shouldBe 1.0f
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.8f
isCaseCharging shouldBe false
payload.public.data.toByteArray().toHex(" ") shouldBe "01 0E 20 51 9A 98 33 00 04"
payload.private shouldBe null
}
setKeyIRK("79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79")
setKeyEnc("3B-9C-80-57-E6-45-7F-F2-1B-8E-07-63-6C-99-E0-29")
create<AirPodsPro>(data, address) {
batteryLeftPodPercent shouldBe 0.98f
isLeftPodCharging shouldBe false
batteryRightPodPercent shouldBe 1.0f
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.86f
isCaseCharging shouldBe false
payload.public.data.toByteArray().toHex(" ") shouldBe "01 0E 20 51 9A 98 33 00 04"
payload.private!!.data.toByteArray().toHex(" ") shouldBe "44 E4 62 56 17 FA 06 31 E4 0A 01 13 31 13 4C 40"
}
}
}
@@ -0,0 +1,72 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class BeatsFitProTest : BaseAirPodsTest() {
/**
* From https://github.com/d4rken-org/capod/issues/33#issuecomment-1256235651
*/
@Test
fun `test basics`() = runTest {
create<BeatsFitPro>("07 19 01 12 20 20 FA 8F 01 11 24 9B 9B 23 52 60 5A 8C 32 1C A5 C2 81 51 82 AF C8") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1220.toUShort()
pubStatus shouldBe 0x20.toUByte()
pubPodsBattery shouldBe 0xFA.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x11.toUByte()
pubSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe null
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name
model shouldBe PodDevice.Model.BEATS_FIT_PRO
}
}
@Test
fun `extra rl test case`() = runTest {
create<BeatsFitPro>("07 19 01 12 20 04 FA 92 54 11 24 CE B1 DF 9D 8D F5 E3 37 60 B1 23 8B 90 3B 63 3F") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1220.toUShort()
pubStatus shouldBe 0x04.toUByte()
pubPodsBattery shouldBe 0xFA.toUByte()
pubFlags shouldBe 0x9.toUShort()
pubCaseBattery shouldBe 0x2.toUShort()
pubCaseLidState shouldBe 0x54.toUByte()
pubDeviceColor shouldBe 0x11.toUByte()
pubSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe true
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.2f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name
model shouldBe PodDevice.Model.BEATS_FIT_PRO
}
}
}
@@ -0,0 +1,39 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsFlexText : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
fun `default BeatsFlex`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1020.toUShort()
pubStatus shouldBe 0x0A.toUByte()
pubPodsBattery shouldBe 0xF4.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x00.toUByte()
pubDeviceColor shouldBe 0x01.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_FLEX
}
}
@Test
fun `random neighbour`() = runTest {
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
}
}
}
@@ -0,0 +1,30 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsSolo3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default BeatsSolo3`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0620.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_SOLO_3
}
}
}
@@ -0,0 +1,30 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsStudio3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default BeatsStudio3`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0920.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.BEATS_STUDIO_3
}
}
}
@@ -0,0 +1,30 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsXTest : BaseAirPodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
fun `default BeatsX`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0520.toUShort()
pubStatus shouldBe 0x00.toUByte()
pubPodsBattery shouldBe 0xF5.toUByte()
pubFlags shouldBe 0x0.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x01.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.5f
model shouldBe PodDevice.Model.BEATS_X
}
}
}
@@ -0,0 +1,30 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeats3Test : BaseAirPodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
fun `default PowerBeats3`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0320.toUShort()
pubStatus shouldBe 0x62.toUByte()
pubPodsBattery shouldBe 0x04.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x0F.toUByte()
pubSuffix shouldBe 0x40.toUByte()
batteryHeadsetPercent shouldBe 0.4f
model shouldBe PodDevice.Model.POWERBEATS_3
}
}
}
@@ -0,0 +1,75 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeats4Test : BaseAirPodsTest() {
@Test
fun `playing music`() = runTest {
create<PowerBeats4>("07 19 01 0D 20 00 02 80 01 00 05 07 C5 E1 9C BD 9A 05 0E AE 9E 56 53 2F F9 75 4A") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0D20.toUShort()
pubStatus shouldBe 0x0.toUByte()
pubPodsBattery shouldBe 0x02.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x05.toUByte()
batteryHeadsetPercent shouldBe 0.2f
model shouldBe PodDevice.Model.POWERBEATS_4
state shouldBe HasStateDetectionAirPods.ConnectionState.MUSIC
}
}
@Test
fun `extra test case`() = runTest {
create<PowerBeats4>("07 19 01 0D 20 00 02 80 02 00 04 80 90 60 77 32 C2 0C 75 7A 3D 7E D7 1C 0E B8 43") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0D20.toUShort()
pubStatus shouldBe 0x0.toUByte()
pubPodsBattery shouldBe 0x02.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x02.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x04.toUByte()
batteryHeadsetPercent shouldBe 0.2f
model shouldBe PodDevice.Model.POWERBEATS_4
state shouldBe HasStateDetectionAirPods.ConnectionState.IDLE
}
}
@Test
fun `disconnected state`() = runTest {
create<PowerBeats4>("07 19 01 0D 20 00 02 80 01 00 00 25 DF 66 40 AF 44 7B 77 95 8F D1 92 50 26 11 74") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0D20.toUShort()
pubStatus shouldBe 0x0.toUByte()
pubPodsBattery shouldBe 0x02.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0x0.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x00.toUByte()
batteryHeadsetPercent shouldBe 0.2f
model shouldBe PodDevice.Model.POWERBEATS_4
state shouldBe HasStateDetectionAirPods.ConnectionState.DISCONNECTED
}
}
}
@@ -0,0 +1,61 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class PowerBeatsPro2Test : BaseAirPodsTest() {
@Test
fun `test PowerBeatsPro2`() = runTest {
create<PowerBeatsPro2>("07 19 01 1D 20 2B AA 8F 01 02 04 30 42 6F CE 74 EE ED 56 AF 4E 31 16 9D 00 D7 DE") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1D20.toUShort()
pubStatus shouldBe 0x2B.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x01.toUByte()
pubDeviceColor shouldBe 0x02.toUByte()
pubSuffix shouldBe 0x04.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.RED.name
model shouldBe PodDevice.Model.POWERBEATS_PRO2
}
}
@Test
fun `test PowerBeatsPro2 - variant 2`() = runTest {
create<PowerBeatsPro2>("07 19 01 1D 20 73 AA 98 32 02 04 7D 47 B0 15 DE 5F B6 6B CB 46 F9 16 5C 88 4F 4E") {
isLeftPodMicrophone shouldBe false
isRightPodMicrophone shouldBe true
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.8f
podStyle.identifier shouldBe HasAppleColor.DeviceColor.RED.name
model shouldBe PodDevice.Model.POWERBEATS_PRO2
}
}
}
@@ -0,0 +1,72 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodDevice
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.runTest
import org.junit.jupiter.api.Test
class PowerBeatsProTest : BaseAirPodsTest() {
@Test
fun `test PowerBeatsPro`() = runTest {
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") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0B20.toUShort()
pubStatus shouldBe 0x54.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0xB.toUShort()
pubCaseBattery shouldBe 0x5.toUShort()
pubCaseLidState shouldBe 0x31.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix 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
model shouldBe PodDevice.Model.POWERBEATS_PRO
}
}
// Via https://github.com/d4rken-org/capod/pull/303#issuecomment-2991876052
@Test
fun `test PowerBeatsPro - variant 2`() = runTest {
create<PowerBeatsPro>("07 19 01 0B 20 21 AA 8F 02 44 24 6D 3E CD 38 A6 F9 6C 6A EC 95 65 AF 97 08 95 49") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0B20.toUShort()
pubStatus shouldBe 0x21.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x8.toUShort()
pubCaseBattery shouldBe 0xF.toUShort()
pubCaseLidState shouldBe 0x02.toUByte()
pubDeviceColor shouldBe 0x44.toUByte()
pubSuffix shouldBe 0x24.toUByte()
isLeftPodMicrophone shouldBe true
isRightPodMicrophone shouldBe false
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe false
isRightPodCharging shouldBe false
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
podStyle.identifier shouldBe HasAppleColor.DeviceColor.UNKNOWN.name
model shouldBe PodDevice.Model.POWERBEATS_PRO
}
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen1Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runTest {
create<FakeAirPodsGen2>("07 13 01 0F 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0F20.toUShort()
pubStatus shouldBe 0x71.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x32.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN2
}
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen2Test : BaseAirPodsTest() {
@Test
fun `charging in box`() = runTest {
create<FakeAirPodsGen1>("07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0220.toUShort()
pubStatus shouldBe 0x71.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x32.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN1
}
}
}
@@ -0,0 +1,39 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen3Test : BaseAirPodsTest() {
@Test
fun `charging in case`() = runTest {
create<FakeAirPodsGen3>("07 13 01 13 20 75 AA 37 34 00 10 00 E4 E4 64 00 00 00 00 00 00") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1320.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x34.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1f
batteryRightPodPercent shouldBe 1f
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isCaseCharging shouldBe false
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_GEN3
}
}
}
@@ -0,0 +1,61 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsPro2Test : BaseAirPodsTest() {
@Test
fun `guessed data`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 58 35 00 10 00 E4 E4 26 00 00 00 00 00 00") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x5.toUShort()
pubCaseBattery shouldBe 0x8.toUShort()
pubCaseLidState shouldBe 0x35.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
batteryCasePercent shouldBe 0.8f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2
}
}
/**
* https://discord.com/channels/548521543039189022/927235844127993866/1063027552765087754
*/
@Test
fun `user supplied`() = runTest {
create<FakeAirPodsPro2>("07 13 01 14 20 75 AA 72 39 00 00 6F E4 E4 93 30 00 30 30 30 30") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x1420.toUShort()
pubStatus shouldBe 0x75.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x7.toUShort()
pubCaseBattery shouldBe 0x2.toUShort()
pubCaseLidState shouldBe 0x39.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x0.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isCaseCharging shouldBe true
batteryCasePercent shouldBe 0.2f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO2
}
}
}
@@ -0,0 +1,40 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsProTest : BaseAirPodsTest() {
@Test
fun `guessed data`() = runTest {
create<FakeAirPodsPro>("07 13 01 0E 20 71 AA 37 36 00 10 00 FF 64 FF 00 00 00 00 00 00") {
pubPrefix shouldBe 0x01.toUByte()
pubDeviceModel shouldBe 0x0E20.toUShort()
pubStatus shouldBe 0x71.toUByte()
pubPodsBattery shouldBe 0xAA.toUByte()
pubFlags shouldBe 0x3.toUShort()
pubCaseBattery shouldBe 0x7.toUShort()
pubCaseLidState shouldBe 0x36.toUByte()
pubDeviceColor shouldBe 0x00.toUByte()
pubSuffix shouldBe 0x10.toUByte()
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
isLeftPodInEar shouldBe false
isRightPodInEar shouldBe false
isCaseCharging shouldBe false
batteryCasePercent shouldBe 0.7f
model shouldBe PodDevice.Model.FAKE_AIRPODS_PRO
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package testhelpers
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()
}
}
}
@@ -0,0 +1,3 @@
package testhelpers
class IsAUnitTest
@@ -0,0 +1,23 @@
package testhelpers.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)
@@ -0,0 +1,38 @@
package testhelpers.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
}
}
}
@@ -0,0 +1,110 @@
package testhelpers.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.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
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()
}
}
@@ -0,0 +1,29 @@
package testhelpers.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) }
}
@@ -0,0 +1,26 @@
package testhelpers.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,13 @@
package testhelpers.logging
import eu.darken.capod.common.debug.logging.Logging
class JUnitLogger(private val minLogLevel: Logging.Priority = Logging.Priority.VERBOSE) : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean = priority.intValue >= minLogLevel.intValue
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
println("${System.currentTimeMillis()} ${priority.shortLabel}/$tag: $message")
}
}
@@ -0,0 +1,21 @@
package testhelpers.preferences
import eu.darken.capod.common.preferences.FlowPreference
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
fun <T> mockFlowPreference(
defaultValue: T
): FlowPreference<T> {
val instance = mockk<FlowPreference<T>>()
val flow = MutableStateFlow(defaultValue)
every { instance.flow } answers { flow }
every { instance.value } answers { flow.value }
every { instance.update(any()) } answers {
val updateCall = arg<(T) -> T>(0)
flow.value = updateCall(flow.value)
}
return instance
}
@@ -0,0 +1,99 @@
package testhelpers.preferences
import android.content.SharedPreferences
class MockSharedPreferences : SharedPreferences {
private val listeners = mutableListOf<SharedPreferences.OnSharedPreferenceChangeListener>()
private val dataMap = mutableMapOf<String, Any>()
val dataMapPeek: Map<String, Any>
get() = dataMap.toMap()
override fun getAll(): MutableMap<String, *> = dataMap
override fun getString(key: String, defValue: String?): String? =
dataMap[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String> {
throw NotImplementedError()
}
override fun getInt(key: String, defValue: Int): Int =
dataMap[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
dataMap[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float {
throw NotImplementedError()
}
override fun getBoolean(key: String, defValue: Boolean): Boolean =
dataMap[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = dataMap.contains(key)
override fun edit(): SharedPreferences.Editor = createEditor(dataMap.toMap()) { newData ->
dataMap.clear()
dataMap.putAll(newData)
}
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.add(listener)
}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.remove(listener)
}
private fun createEditor(
toEdit: Map<String, Any>,
onSave: (Map<String, Any>) -> Unit
): SharedPreferences.Editor {
return object : SharedPreferences.Editor {
private val editorData = toEdit.toMutableMap()
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
value?.let { editorData[key] = it } ?: editorData.remove(key)
}
override fun putStringSet(
key: String?,
values: MutableSet<String>?
): SharedPreferences.Editor {
throw NotImplementedError()
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun remove(key: String): SharedPreferences.Editor = apply {
editorData.remove(key)
}
override fun clear(): SharedPreferences.Editor = apply {
editorData.clear()
}
override fun commit(): Boolean {
onSave(editorData)
return true
}
override fun apply() {
onSave(editorData)
}
}
}
}
@@ -0,0 +1,21 @@
package testhelpers.preferences
import androidx.core.content.edit
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
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
}
}