diff options
Diffstat (limited to 'core/src/jvmTest')
4 files changed, 316 insertions, 0 deletions
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt new file mode 100644 index 0000000..ef76184 --- /dev/null +++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt @@ -0,0 +1,55 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.model.Event +import coffee.liz.rookery.model.Penalty +import coffee.liz.rookery.model.Solve +import coffee.liz.rookery.scramble.TnoodleScrambler +import coffee.liz.rookery.store.FolderBlobStore +import coffee.liz.rookery.store.FolderLogStorage +import coffee.liz.rookery.store.Repo +import coffee.liz.rookery.store.Store +import java.nio.file.Files +import kotlin.io.path.appendText +import kotlin.io.path.listDirectoryEntries +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class JvmTest { + @Test fun folderSyncBetweenDevicesToleratesTornLines() { + val root = Files.createTempDirectory("rookery-sync") + fun open(node: String) = Repo(Store(node, FolderLogStorage(root, node), FolderBlobStore(root)) { System.currentTimeMillis() }) + val a = open("phone") + a.addSolve(Solve("s1", "x", Event.E333, 9000, Penalty.NONE, "R", "", 1)) + + val b = open("laptop") // fresh device reads everything on startup + assertEquals(listOf("s1"), b.solves().map { it.id }) + + // simulate Syncthing delivering half of a line from phone + val seg = root.resolve("devices/phone").listDirectoryEntries().single() + val line = """{"t":"9999999999999-0000-phone","c":"solve","id":"s1","f":"pen","v":"dnf"}""" + seg.appendText(line.substring(0, 20)) + b.store.sync() + assertEquals(Penalty.NONE, b.solves().single().penalty) + seg.appendText(line.substring(20) + "\n") + b.store.sync() + assertEquals(Penalty.DNF, b.solves().single().penalty) + + // restart: state rebuilds from disk + assertEquals(Penalty.DNF, open("laptop").solves().single().penalty) + val blob = a.store.blobs.put(byteArrayOf(1, 2, 3), "jpg") + assertEquals(3, open("laptop").store.blobs.get(blob)?.size) + } + + /** Runs under the app's 256m heap cap (see core/build.gradle.kts), so this also guards memory. */ + @Test fun tnoodleScramblesAndDraws() { + val s = TnoodleScrambler() + for (e in Event.entries) { + val scr = s.next(e) + assertTrue(scr.isNotBlank(), "$e") + val svg = assertNotNull(s.previewSvg(e, scr), "$e svg") + assertTrue(svg.contains("<svg"), "$e svg: ${svg.take(80)}") + } + } +} diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt new file mode 100644 index 0000000..dd5e9a8 --- /dev/null +++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt @@ -0,0 +1,103 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.puzzle.Puzzles +import coffee.liz.rookery.puzzle.TwistyPuzzle +import coffee.liz.rookery.puzzle.V3 +import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry +import kotlin.math.atan2 +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Our 3D models must agree sticker-for-sticker with tnoodle's official 2D drawings. Each model + * sticker is mapped onto tnoodle's net through its face (an affine map between the face's 3D and + * 2D corners); the colour found there must match ours. + */ +class TnoodleAgreementTest { + private data class P2(val x: Double, val y: Double) + private data class NetSticker(val c: P2, val color: String) + + /** Sticker centroids and fills from a tnoodle SVG, applying each path's transform. */ + private fun parse(svg: String): List<NetSticker> = Regex("""<path [^>]*>""").findAll(svg).mapNotNull { m -> + fun attr(n: String) = Regex("""\b$n="([^"]*)"""").find(m.value)?.groupValues?.get(1) + val d = attr("d") ?: return@mapNotNull null + val fill = attr("fill") ?: return@mapNotNull null + val t = attr("transform")?.let { Regex("""matrix\(([^)]*)\)""").find(it)?.groupValues?.get(1)?.split(',')?.map { v -> v.trim().toDouble() } } + ?: listOf(1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + val pts = Regex("""[ML] ([-\d.]+) ([-\d.]+)""").findAll(d).map { + val x = it.groupValues[1].toDouble(); val y = it.groupValues[2].toDouble() + P2(t[0] * x + t[2] * y + t[4], t[1] * x + t[3] * y + t[5]) + }.toList().distinct() + NetSticker(P2(pts.sumOf { it.x } / pts.size, pts.sumOf { it.y } / pts.size), fill) + }.toList() + + private fun d2(a: P2, b: P2) = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + + /** + * Net face corners, in order around the face: the face's corner stickers pushed outwards from its + * centre (corner-sticker centroids sit 2/3 of the way to the corner for both puzzles here). + */ + private fun netCorners(face: List<NetSticker>, n: Int): List<P2> { + val cx = face.sumOf { it.c.x } / face.size; val cy = face.sumOf { it.c.y } / face.size + return face.sortedByDescending { d2(it.c, P2(cx, cy)) }.take(n) + .sortedBy { atan2(it.c.y - cy, it.c.x - cx) } + .map { P2(cx + (it.c.x - cx) * 1.5, cy + (it.c.y - cy) * 1.5) } + } + + /** Map a point on a 3D face to the net via the face's first three corners (affine). */ + private fun toNet(p: V3, c3: List<V3>, c2: List<P2>): P2 { + val e1 = c3[1] - c3[0]; val e2 = c3[c3.size - 1] - c3[0]; val v = p - c3[0] + val d11 = e1 dot e1; val d12 = e1 dot e2; val d22 = e2 dot e2 + val den = d11 * d22 - d12 * d12 + val s = ((v dot e1) * d22 - (v dot e2) * d12) / den + val t = ((v dot e2) * d11 - (v dot e1) * d12) / den + val a = c2[0]; val b = c2[1]; val d = c2[c2.size - 1] + return P2(a.x + s * (b.x - a.x) + t * (d.x - a.x), a.y + s * (b.y - a.y) + t * (d.y - a.y)) + } + + private fun checkAgreement(reg: PuzzleRegistry, model: TwistyPuzzle, faceCorners: List<List<V3>>, faceColors: List<String>) { + val scrambler = reg.scrambler + val solvedNet = parse(scrambler.drawScramble("", null).toString()) + val n = faceCorners[0].size + val colorIndex = faceColors.withIndex().associate { (i, c) -> c to i } + + fun mismatches(scr: String, mapping: Map<Int, List<P2>>, only: Int? = null): Int { + val net = parse(scrambler.drawScramble(scr, null).toString()) + val st = model.solved().apply(scr) + return model.stickers.indices.count { i -> + val f = model.stickerFace[i] + if (only != null && f != only) return@count false + val p = toNet(model.stickers[i].centroid, faceCorners[f], mapping.getValue(f)) + colorIndex[net.minBy { d2(it.c, p) }.color] != st.colors[i] + } + } + + // Which net corner is which 3D corner: pick the rotation/reflection that fits reference scrambles + // (several, since one scramble can leave a face symmetric enough to tie two alignments)… + val refs = List(4) { scrambler.generateScramble() } + val mapping = HashMap<Int, List<P2>>() + for ((color, face) in solvedNet.groupBy { it.color }) { + val f = colorIndex.getValue(color) + val c = netCorners(face, n) + val options = (0 until n).flatMap { r -> val rot = c.drop(r) + c.take(r); listOf(rot, rot.reversed()) } + mapping[f] = options.minBy { opt -> refs.sumOf { mismatches(it, mapping + (f to opt), f) } } + } + // …then that fixed alignment must hold for fresh scrambles. + repeat(15) { + val scr = scrambler.generateScramble() + assertEquals(0, mismatches(scr, mapping), "${model.name} disagrees with tnoodle on $scr") + } + } + + @Test fun pyraminxMatchesTnoodle() { + val m = Puzzles.pyraminx() + val v = m.axes.associate { it.name to it.normal } + val corners = listOf(listOf("U", "L", "R"), listOf("U", "L", "B"), listOf("U", "R", "B"), listOf("L", "R", "B")).map { f -> f.map(v::getValue) } + checkAgreement(PuzzleRegistry.PYRA, m, corners, listOf("#00ff00", "#ff0000", "#0000ff", "#ffff00")) + } + + @Test fun skewbMatchesTnoodle() { + val corners = Puzzles.cube(1).stickers.map { it.points } + checkAgreement(PuzzleRegistry.SKEWB, Puzzles.skewb(), corners, listOf("#ffffff", "#ff0000", "#00ff00", "#ffff00", "#ff8000", "#0000ff")) + } +} diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt new file mode 100644 index 0000000..a8ad88e --- /dev/null +++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt @@ -0,0 +1,40 @@ +package coffee.liz.rookery.bt + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith + +@OptIn(ExperimentalStdlibApi::class) +class GanCryptoTest { + // Vectors computed with a Node.js re-implementation of gan-web-bluetooth's GanGen2CubeEncrypter + // (aes-js CBC swapped for node:crypto aes-128-cbc, no padding). The MAC is made up; it is chosen so + // that key[5] + salt[5] = 0x100, exercising the firmware's `% 0xFF` (255, not 256) salting. + private val mac = "6F:F0:12:34:56:78" + private val enc = GanEncrypter(JvmAes128, GanDefinitions.GAN_KEY, mac) + + @Test fun saltedKeyAndIv() { + assertContentEquals("7958763a220116072005185442111253".hexToByteArray(), enc.key) + assertContentEquals("8959663a127076272095781432120243".hexToByteArray(), enc.iv) + } + + @Test fun encryptMatchesReference() { + val m20 = "030a11181f262d343b424950575e656c737a8188".hexToByteArray() + assertContentEquals("f8b5b4a9ac44104f5d5ce7635f869bf95bc95c02".hexToByteArray(), enc.encrypt(m20)) + val m16 = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2".hexToByteArray() + assertContentEquals("c4f8c0629684911cb703cd9a76f44ffb".hexToByteArray(), enc.encrypt(m16)) + } + + @Test fun roundTrips() { + val moyu = GanEncrypter(JvmAes128, GanDefinitions.MOYU_AI_KEY, mac) + for (size in listOf(16, 17, 19, 20, 31, 32)) { + val msg = ByteArray(size) { (it * 37 + size).toByte() } + assertContentEquals(msg, enc.decrypt(enc.encrypt(msg))) + assertContentEquals(msg, moyu.decrypt(moyu.encrypt(msg))) + } + } + + @Test fun rejectsShortMessages() { + assertFailsWith<IllegalArgumentException> { enc.encrypt(ByteArray(15)) } + assertFailsWith<IllegalArgumentException> { enc.decrypt(ByteArray(15)) } + } +} diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt new file mode 100644 index 0000000..87c5eb4 --- /dev/null +++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt @@ -0,0 +1,118 @@ +package coffee.liz.rookery.bt + +import coffee.liz.rookery.bt.SmartCubeEvent.Facelets +import coffee.liz.rookery.bt.SmartCubeEvent.Move +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +private class FakeConnection(override val address: String, override val name: String?, private val services: List<BleService>) : BleConnection { + val notifications = Channel<ByteArray>(Channel.UNLIMITED) + val writes = mutableListOf<Triple<String, String, ByteArray>>() + val subscribed = mutableListOf<Pair<String, String>>() + val gone = CompletableDeferred<Unit>() + var disconnectCalls = 0 + + override suspend fun discoverServices() = services + override suspend fun read(service: String, characteristic: String) = ByteArray(0) + override suspend fun write(service: String, characteristic: String, value: ByteArray) { writes += Triple(service, characteristic, value) } + override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> { + subscribed += service to characteristic + return notifications.receiveAsFlow() + } + override val disconnected: Flow<Unit> = flow { gone.await(); emit(Unit) } + override suspend fun disconnect() { disconnectCalls++; gone.complete(Unit) } +} + +private class FakeTransport(val conn: FakeConnection) : BleTransport { + override fun scan(): Flow<BleAdvertisement> = emptyFlow() + override suspend fun connect(address: String): BleConnection { check(address == conn.address); return conn } +} + +class GanCubeConnectorTest { + private val mac = "6F:F0:12:34:56:78" + private val enc = GanEncrypter(JvmAes128, GanDefinitions.GAN_KEY, mac) + + private fun service(g: GanGeneration) = BleService(g.service, listOf(BleCharacteristic(g.command), BleCharacteristic(g.state))) + private val deviceInfo = BleService("0000180a-0000-1000-8000-00805f9b34fb", emptyList()) + + private fun TestScope.collect(cube: SmartCube): MutableList<SmartCubeEvent> { + val got = mutableListOf<SmartCubeEvent>() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { cube.events.collect { got += it } } + return got + } + + @Test fun gen3EndToEnd() = runTest { + val conn = FakeConnection(mac, "GANicV2_1234", listOf(deviceInfo, service(GanGeneration.GEN3))) + val cube = GanCubeConnector(FakeTransport(conn), JvmAes128) { 42L }.connect(backgroundScope, mac) + assertEquals(GanGeneration.GEN3, (cube as GanSmartCube).generation) + assertEquals(listOf(GanDefinitions.GEN3_SERVICE to GanDefinitions.GEN3_STATE), conn.subscribed) + val got = collect(cube) + + cube.requestFacelets() + val (svc, chr, value) = conn.writes.single() + assertEquals(GanDefinitions.GEN3_SERVICE to GanDefinitions.GEN3_COMMAND, svc to chr) + assertContentEquals(byteArrayOf(0x68, 0x01) + ByteArray(14), enc.decrypt(value)) + + conn.notifications.send(enc.encrypt(Packets.gen3Facelets(3, F_R_STATE))) + conn.notifications.send(enc.encrypt(Packets.gen3Move(4, 1, 0, 777))) + conn.notifications.send(ByteArray(8)) // too short to decrypt: dropped + runCurrent() + assertEquals(listOf(Facelets(F_R_FACELETS, 3, F_R_STATE), Move("R", 777, 42, 4)), got) + + conn.gone.complete(Unit) // the cube went away + runCurrent() + assertEquals(SmartCubeEvent.Disconnected, got.last()) + } + + @Test fun gen4AndGen2Detection() = runTest { + val c4 = FakeConnection(mac, "GAN12uiM_ABCD", listOf(service(GanGeneration.GEN4))) + val cube4 = GanCubeConnector(FakeTransport(c4), JvmAes128) { 0L }.connect(backgroundScope, mac) as GanSmartCube + assertEquals(GanGeneration.GEN4, cube4.generation) + val got = collect(cube4) + c4.notifications.send(enc.encrypt(Packets.gen4Facelets(1, SOLVED_STATE))) + runCurrent() + assertEquals(GanFacelets.SOLVED, (got.single() as Facelets).facelets) + cube4.resetToSolved() + assertEquals(0xD2.toByte(), enc.decrypt(c4.writes.single().third)[0]) + cube4.disconnect() + runCurrent() + assertEquals(1, c4.disconnectCalls) + assertEquals(SmartCubeEvent.Disconnected, got.last()) + + val c2 = FakeConnection(mac, "GAN-1234", listOf(service(GanGeneration.GEN2))) + val cube2 = GanCubeConnector(FakeTransport(c2), JvmAes128) { 0L }.connect(backgroundScope, mac) as GanSmartCube + assertEquals(GanGeneration.GEN2, cube2.generation) + cube2.requestBattery() + assertEquals(0x09, enc.decrypt(c2.writes.single().third)[0].toInt()) + cube2.disconnect() + } + + @Test fun moyuAiUsesItsOwnKey() = runTest { + val c = FakeConnection(mac, "AiCube_0001", listOf(service(GanGeneration.GEN2))) + val cube = GanCubeConnector(FakeTransport(c), JvmAes128) { 0L }.connect(backgroundScope, mac) + cube.requestHardware() + val moyu = GanEncrypter(JvmAes128, GanDefinitions.MOYU_AI_KEY, mac) + assertEquals(0x05, moyu.decrypt(c.writes.single().third)[0].toInt()) + cube.disconnect() + } + + @Test fun unknownDeviceIsRejectedAndDisconnected() = runTest { + val c = FakeConnection(mac, "Speaker", listOf(deviceInfo)) + assertFailsWith<IllegalStateException> { GanCubeConnector(FakeTransport(c), JvmAes128).connect(backgroundScope, mac) } + assertTrue(c.disconnectCalls == 1) + } +} |
