package coffee.liz.rookery import coffee.liz.rookery.stackmat.StackmatDecoder import coffee.liz.rookery.stackmat.StackmatPacket import coffee.liz.rookery.stackmat.StackmatSession import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class StackmatTest { private fun packetBytes(status: Char, digits: String): List = listOf(status.code) + digits.map { it.code } + (64 + digits.sumOf { it - '0' }) + listOf('\n'.code, '\r'.code) /** Audio for a list of packets, the way a sound card hears it: optional inversion, AC coupling, noise. */ private fun audio(packets: List>, rate: Int, invert: Boolean, noise: Float, rng: Random = Random(1)): FloatArray { val bits = ArrayList() repeat(40) { bits += true } for (p in packets) { for (b in p) { bits += false; for (k in 0 until 8) bits += (b shr k) and 1 == 1; bits += true } repeat(30) { bits += true } // rest between packets } val spb = rate / 1200.0 val n = (bits.size * spb).toInt() val out = FloatArray(n) var hp = 0f; var prev = 0f val alpha = 1f - 1f / (0.004f * rate) // ~4 ms high-pass for (i in 0 until n) { val raw = (if (bits[(i / spb).toInt()] != invert) 0.5f else -0.5f) hp = alpha * (hp + raw - prev); prev = raw out[i] = hp + (rng.nextFloat() - 0.5f) * noise } return out } private fun decode(samples: FloatArray, rate: Int, chunk: Int = 512): List { val got = mutableListOf() val d = StackmatDecoder(rate) { got += it } var at = 0 while (at < samples.size) { val n = minOf(chunk, samples.size - at); d.feed(samples.copyOfRange(at, at + n)); at += n } return got } private val sequence = listOf(packetBytes('I', "000000"), packetBytes('A', "000000"), packetBytes(' ', "001234"), packetBytes(' ', "012345"), packetBytes('S', "112345"), packetBytes('S', "112345")) private val expected = listOf( StackmatPacket('I', 0, 6), StackmatPacket('A', 0, 6), StackmatPacket(' ', 1_234, 6), StackmatPacket(' ', 12_345, 6), StackmatPacket('S', 72_345, 6), StackmatPacket('S', 72_345, 6), ) @Test fun decodesCleanSignalAtCommonRates() { for (rate in listOf(44_100, 48_000, 16_000)) assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0f), rate), "at $rate") } @Test fun decodesInvertedNoisySignal() { for (rate in listOf(44_100, 48_000)) { assertEquals(expected, decode(audio(sequence, rate, invert = true, noise = 0.08f), rate), "inverted at $rate") assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0.08f), rate, chunk = 97), "noisy at $rate") } } @Test fun fiveDigitTimersAndBadChecksums() { val gen3 = packetBytes('S', "12345") // 1:23.45 val corrupt = packetBytes('S', "112345").toMutableList().also { it[3] = '9'.code } val got = decode(audio(listOf(gen3, corrupt, gen3), 44_100, invert = false, noise = 0f), 44_100) assertEquals(listOf(StackmatPacket('S', 83_450, 5), StackmatPacket('S', 83_450, 5)), got) } @Test fun silenceAndNoiseDecodeToNothing() { val rng = Random(4) assertTrue(decode(FloatArray(48_000) { (rng.nextFloat() - 0.5f) * 0.01f }, 48_000).isEmpty()) assertTrue(decode(FloatArray(48_000) { rng.nextFloat() - 0.5f }, 48_000).isEmpty()) } @Test fun sessionRecordsEachSolveOnce() { val solves = mutableListOf() val s = StackmatSession { solves += it } fun p(c: Char, ms: Long) = s.packet(StackmatPacket(c, ms, 6)) p('S', 9_990) // a time already on the display when we connect: not ours p('I', 0); p('A', 0); p(' ', 100); p(' ', 5_000); p('S', 12_340); p('S', 12_340); p('C', 12_340) p('I', 0); p('S', 8_000) // the running packets were lost entirely assertEquals(listOf(12_340L, 8_000L), solves) } }