summaryrefslogtreecommitdiff
path: root/core/src/jvmTest/kotlin/coffee/liz
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:11:08 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:11:08 -0800
commit3afb04de43726d71580b626e856c68b6ed79984e (patch)
tree33f7bcb227ff8cfcb046a10036d2faa69c2edd1f /core/src/jvmTest/kotlin/coffee/liz
downloadrookery-3afb04de43726d71580b626e856c68b6ed79984e.tar.gz
rookery-3afb04de43726d71580b626e856c68b6ed79984e.zip
Rookery: penguin-themed cubing toolkit, phases 0-1 and GAN BLE
Kotlin Multiplatform + Compose desktop app. - Sync: per-device append-only op logs (HLC-stamped LWW) in a plain folder, safe under Syncthing/Drive; content-addressed image blobs. - Timer: WCA inspection, +2/DNF, solve table, WCA-correct aoN/moN, per-event lifetime PBs with a pixel-penguin party and synthesized 8-bit chime. Mascot sprite from liz.coffee. - Scrambles for all WCA events + FTO via tnoodle-lib, with previews. - Importers: csTimer exports (incl. smart-cube reconstructions and cube MACs) and the Letter Pair Gym prototype (words, notes, images, history, practice stats). Idempotent; headless `--import`. - Letter-pair grid with miss heatmap, glance lookup and editor. - 3D puzzle engine (polyhedron + cut planes) and Canvas renderer: NxN 2-7, Pyraminx, Skewb, verified sticker-for-sticker against tnoodle; 3D replay of smart-cube solves. - GAN Gen2/3/4 smart-cube protocol (ported from gan-web-bluetooth, MIT) with a BlueZ D-Bus transport and `--cube scan|monitor` CLI. Not yet tested against hardware. - lizCI pipeline (.ci) building a test+package Docker target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'core/src/jvmTest/kotlin/coffee/liz')
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/JvmTest.kt55
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/TnoodleAgreementTest.kt103
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCryptoTest.kt40
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/bt/GanCubeConnectorTest.kt118
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)
+ }
+}