summaryrefslogtreecommitdiff
path: root/core/src/commonTest/kotlin/coffee
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/commonTest/kotlin/coffee
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/commonTest/kotlin/coffee')
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt14
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt55
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt76
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt54
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt65
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt46
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt72
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt264
8 files changed, 646 insertions, 0 deletions
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt
new file mode 100644
index 0000000..7ab195c
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/ChimeTest.kt
@@ -0,0 +1,14 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.audio.Chime
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class ChimeTest {
+ @Test fun rendersAudibleShortJingle() {
+ val pcm = Chime.render(Chime.PB)
+ val seconds = pcm.size.toDouble() / Chime.SAMPLE_RATE
+ assertTrue(seconds in 0.8..3.0, "length $seconds")
+ assertTrue(pcm.maxOf { it.toInt() } > 3000)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt
new file mode 100644
index 0000000..829049e
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/CubeTest.kt
@@ -0,0 +1,55 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.Notation
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class CubeTest {
+ @Test fun basicIdentities() {
+ for (n in 2..7) {
+ assertTrue(NxnCube(n).apply("R R R R").isSolved)
+ assertTrue(NxnCube(n).apply("R U R' U' R U R' U' R U R' U' R U R' U' R U R' U' R U R' U'").isSolved, "sexy^6 on $n")
+ assertFalse(NxnCube(n).apply("R U").isSolved)
+ }
+ }
+
+ @Test fun scrambleThenInverseSolves() {
+ val scr = "3Rw2 Uw' F 3Fw D2 Lw' B2 x y' 2R 3Uw2 z M"
+ val c = NxnCube(7).apply(scr)
+ assertFalse(c.isSolved)
+ c.apply(Notation.invert(scr))
+ assertTrue(c.isSolved)
+ }
+
+ @Test fun rMovesFrontToUp() {
+ val c = NxnCube(3).apply("R")
+ // U face right column now shows F's colour (index 2)
+ assertEquals(listOf(0, 0, 2, 0, 0, 2, 0, 0, 2), c.face(NxnCube.Face.U).toList())
+ // F face right column shows D (3)
+ assertEquals(listOf(2, 2, 3, 2, 2, 3, 2, 2, 3), c.face(NxnCube.Face.F).toList())
+ }
+
+ @Test fun uMovesFrontToLeft() {
+ val c = NxnCube(3).apply("U")
+ assertEquals(listOf(5, 5, 5), c.face(NxnCube.Face.R).take(3)) // B comes to R
+ assertEquals(listOf(2, 2, 2), c.face(NxnCube.Face.L).take(3)) // F goes to L
+ }
+
+ @Test fun wideEqualsFacePlusSlice() {
+ val a = NxnCube(5).apply("Rw")
+ val b = NxnCube(5).apply("R 2R")
+ assertContentEquals(a.stickers, b.stickers)
+ assertContentEquals(NxnCube(3).apply("x").stickers, NxnCube(3).apply("R M' L'").stickers)
+ }
+
+ @Test fun tPermIsOrderTwo() {
+ val t = "R U R' U' R' F R2 U' R' U' R U R' F'"
+ val c = NxnCube(3).apply(t)
+ assertFalse(c.isSolved)
+ assertTrue(c.apply(t).isSolved)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt
new file mode 100644
index 0000000..ad7659c
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/ImportTest.kt
@@ -0,0 +1,76 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.importer.CsTimerImport
+import coffee.liz.rookery.importer.Importer
+import coffee.liz.rookery.importer.LetterPairGymImport
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ImportTest {
+ private val csTimer = """
+ {"session1":[
+ [[0,114010],"Uw' L2 D L' R2 Dw D' R' F D Bw2 B2 Lw2 D2 Rw' Dw2 Lw' U' Dw' F2 B2 Rw D' Dw2 Lw2 R2 B2 Dw' Fw' Rw B2 Lw2 Uw' D F' Bw2 U Uw Bw2 F' R Dw2 Uw Rw' Dw D2 U Uw Lw' B2 F R' Uw2 B' R Lw2 B Lw L2 B'","",1794373390],
+ [[0,25058],"B' R' F2 U2 L2 F2 U2 B' L2 B2 U2 F' R2 U' F2 L D' L' B L2 F","",1794526179,["U'@0 L'@200","333"]],
+ [[2000,14039],"R U F","nice",1794526518],
+ [[-1,14245],"D2 F","",1794534484]],
+ "session2":[[[0,4000],"R U R' U'","",1794534500]],
+ "properties":{"sessionData":"{\"1\":{\"name\":1,\"opt\":{}},\"2\":{\"name\":\"pyra\",\"opt\":{\"scrType\":\"pyrso\"}}}","giiMacMap":"{\"null\":\"AA:BB:CC:DD:EE:FF\"}"}}
+ """.trimIndent()
+
+ @Test fun parsesCsTimer() {
+ val r = CsTimerImport.parse(csTimer)
+ assertEquals(5, r.solves.size)
+ val events = r.solves.map { it.event }
+ assertEquals(listOf(Event.E555, Event.E333, Event.E333, Event.E333, Event.PYRAM), events)
+ assertEquals(listOf(Penalty.NONE, Penalty.NONE, Penalty.PLUS2, Penalty.DNF), r.solves.take(4).map { it.penalty })
+ assertEquals("U'@0 L'@200", r.solves[1].moves)
+ assertEquals(1794373390_000, r.solves[0].at)
+ assertEquals(3, r.sessions.size)
+ assertTrue(r.sessions.any { it.name == "csTimer 1 (5x5)" })
+ assertEquals("AA:BB:CC:DD:EE:FF", r.devices.single().mac)
+ }
+
+ @Test fun importIsIdempotent() {
+ val repo = Repo(Store("t", MemoryLogStorage(), MemoryBlobStore()) { 1 })
+ Importer(repo).csTimer(csTimer)
+ Importer(repo).csTimer(csTimer)
+ assertEquals(5, repo.solves().size)
+ assertEquals(3, repo.sessions().size)
+ assertEquals(1, repo.devices().size)
+ }
+
+ private val gym = """
+ {"app":"letter-pair-gym","v":1,"at":"2026-11-13T03:28:27.903Z",
+ "settings":{"edgeScheme":"ABCDQRSTEFGHIJKLMNOPUVWX","eBuf":2},
+ "words":{"AB":"abacus","AD":"adelie","XW":""},
+ "notes":{"AD":"adelie penguin"},
+ "wordHist":[{"d":1794519658205,"from":"Peg","p":"PG","to":"Postgres"}],
+ "stats":{"AB":{"k":1,"l":[689],"m":5,"ms":61704.39,"n":11,"r":"gsmmsg","s":4}},
+ "memoHist":[{"d":1794505722395,"ms":154339,"ok":8,"p":10}],
+ "solves":[],
+ "images":{"AB":"images/AB - abacus.jpg","AD":"images/AD - missing.jpg"}}
+ """.trimIndent()
+
+ @Test fun importsLetterPairGym() {
+ val parsed = LetterPairGymImport.parse(gym)
+ assertEquals(mapOf("AB" to "abacus", "AD" to "adelie"), parsed.words)
+ assertEquals(listOf(689L), parsed.stats.getValue("AB").latenciesMs)
+
+ val repo = Repo(Store("t", MemoryLogStorage(), MemoryBlobStore()) { 1 })
+ val summary = Importer(repo).letterPairGym(gym) { path -> if (path.startsWith("images/AB")) byteArrayOf(1, 2, 3) else null }
+ val pairs = repo.letterPairs()
+ assertEquals("abacus", pairs.getValue("AB").primary)
+ assertEquals("adelie penguin", pairs.getValue("AD").note)
+ assertTrue(pairs.getValue("AB").image!!.endsWith(".jpg"))
+ assertEquals(1, summary.warnings.size)
+ assertEquals("Postgres", repo.wordHistory().single().to)
+ assertEquals(1, repo.memoSessions().size)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt
new file mode 100644
index 0000000..35fc1fc
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StatsTest.kt
@@ -0,0 +1,54 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.DNF
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.stats.Averages
+import coffee.liz.rookery.stats.StatKind
+import coffee.liz.rookery.stats.StatSummary
+import coffee.liz.rookery.stats.newPersonalBests
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class StatsTest {
+ @Test fun ao5TrimsBestAndWorst() {
+ assertEquals(12000, Averages.average(listOf(10000, 11000, 12000, 13000, 20000)))
+ assertEquals(12000, Averages.average(listOf(10000, 11000, 12000, 13000, DNF)))
+ assertEquals(DNF, Averages.average(listOf(10000, 11000, 12000, DNF, DNF)))
+ }
+
+ @Test fun trimCounts() {
+ assertEquals(1, Averages.trim(5)); assertEquals(1, Averages.trim(12))
+ assertEquals(3, Averages.trim(50)); assertEquals(5, Averages.trim(100)); assertEquals(50, Averages.trim(1000))
+ }
+
+ @Test fun mo3AnyDnfIsDnf() {
+ assertEquals(DNF, Averages.mean(listOf(1, 2, DNF)))
+ assertEquals(2, Averages.mean(listOf(1, 2, 3)))
+ }
+
+ @Test fun slidingBestMatchesBruteForce() {
+ val rng = kotlin.random.Random(42)
+ val xs = List(300) { if (rng.nextInt(20) == 0) DNF else rng.nextLong(8000, 20000) }
+ for (n in listOf(5, 12, 100)) {
+ val brute = (0..xs.size - n).minOf { Averages.average(xs.subList(it, it + n)) }
+ assertEquals(brute, Averages.best(xs, n))
+ }
+ assertEquals((0..xs.size - 3).minOf { Averages.mean(xs.subList(it, it + 3)) }, Averages.best(xs, 3, mean = true))
+ }
+
+ @Test fun personalBestsFromLatestSolve() {
+ val before = listOf(15000L, 14000, 16000, 15500, 14500)
+ val after = before + 11000L
+ val pbs = newPersonalBests(StatSummary.of(before), StatSummary.of(after)).map { it.kind }
+ assertEquals(listOf(StatKind.SINGLE, StatKind.MO3, StatKind.AO5), pbs)
+ assertNull(newPersonalBests(StatSummary.of(after), StatSummary.of(after + 30000L)).firstOrNull { it.kind == StatKind.SINGLE })
+ }
+
+ @Test fun formatting() {
+ assertEquals("9.99", formatTime(9999))
+ assertEquals("10.00", formatTime(9999, round = true))
+ assertEquals("1:02.34", formatTime(62345))
+ assertEquals("DNF", formatTime(DNF))
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt
new file mode 100644
index 0000000..e5fc945
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StoreTest.kt
@@ -0,0 +1,65 @@
+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.store.Hlc
+import coffee.liz.rookery.store.HlcClock
+import coffee.liz.rookery.store.MemoryBlobStore
+import coffee.liz.rookery.store.MemoryLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class StoreTest {
+ @Test fun hlcOrdersAndRoundTrips() {
+ var now = 1000L
+ val c = HlcClock("a") { now }
+ val a = c.tick(); val b = c.tick()
+ assertTrue(b > a)
+ now = 500 // clock went backwards
+ assertTrue(c.tick() > b)
+ c.observe(Hlc(9999, 7, "z"))
+ assertTrue(c.tick() > Hlc(9999, 7, "z"))
+ assertEquals(a, Hlc.parse(a.toString()))
+ assertTrue(Hlc(5, 0, "a").toString() < Hlc(10, 0, "a").toString())
+ }
+
+ private fun solve(id: String, ms: Long) = Solve(id, "s", Event.E333, ms, Penalty.NONE, "R U", "", 1)
+
+ @Test fun twoDevicesConvergeRegardlessOfOrder() {
+ val shared = mutableListOf<String>()
+ var t = 0L
+ val a = Repo(Store("dev-a", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ val b = Repo(Store("dev-b", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+
+ t = 10; a.addSolve(solve("x", 1000))
+ t = 11; b.addSolve(solve("y", 2000))
+ a.store.sync(); b.store.sync()
+ // concurrent edits to the same field: later stamp wins on both sides
+ t = 20; a.setPenalty("x", Penalty.PLUS2)
+ t = 21; b.setPenalty("x", Penalty.DNF)
+ t = 22; b.deleteSolve("y")
+ a.store.sync(); b.store.sync()
+
+ assertEquals(a.solves(), b.solves())
+ assertEquals(listOf("x"), a.solves().map { it.id })
+ assertEquals(Penalty.DNF, a.solves().single().penalty)
+ }
+
+ @Test fun letterPairWordsMergeAsSet() {
+ val shared = mutableListOf<String>()
+ var t = 0L
+ val a = Repo(Store("a", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ val b = Repo(Store("b", MemoryLogStorage(shared), MemoryBlobStore()) { t })
+ t = 1; a.setPrimaryWord("AB", "abacus", 1)
+ t = 2; b.setPrimaryWord("AB", "abba", 2)
+ a.store.sync(); b.store.sync()
+ val p = a.letterPairs().getValue("AB")
+ assertEquals("abba", p.primary)
+ assertEquals(setOf("abacus", "abba"), p.words)
+ assertEquals(2, a.wordHistory().size)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt
new file mode 100644
index 0000000..2ee2421
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/TimerTest.kt
@@ -0,0 +1,46 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Penalty
+import coffee.liz.rookery.timer.TimerMachine
+import coffee.liz.rookery.timer.TimerMachine.State
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+class TimerTest {
+ @Test fun inspectionFlowWithPlusTwo() {
+ val t = TimerMachine(inspection = true)
+ var result: Pair<Long, Penalty>? = null
+ t.onFinish = { ms, p -> result = ms to p }
+ t.press(0); t.tick(500); t.release(600)
+ assertIs<State.Inspecting>(t.state)
+ t.press(15_500); t.tick(15_700)
+ assertIs<State.Holding>(t.state)
+ t.tick(15_850)
+ assertIs<State.Ready>(t.state)
+ t.release(16_000)
+ assertEquals(State.Running(16_000, Penalty.PLUS2), t.state)
+ t.press(26_000)
+ assertEquals(10_000L to Penalty.PLUS2, result)
+ t.press(26_100) // still holding the stop key: nothing happens
+ assertIs<State.Stopped>(t.state)
+ t.release(26_200); t.press(27_000)
+ assertIs<State.Holding>(t.state)
+ }
+
+ @Test fun releasingEarlyDoesNotStart() {
+ val t = TimerMachine(inspection = false)
+ t.press(0); t.tick(100); t.release(150)
+ assertEquals(State.Idle, t.state)
+ t.press(200); t.tick(600); t.release(700)
+ assertIs<State.Running>(t.state)
+ }
+
+ @Test fun overInspectionIsDnf() {
+ val t = TimerMachine(inspection = true)
+ var p: Penalty? = null
+ t.onFinish = { _, pen -> p = pen }
+ t.press(0); t.release(10); t.tick(17_100)
+ assertEquals(Penalty.DNF, p)
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt
new file mode 100644
index 0000000..09a1aae
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/TwistyPuzzleTest.kt
@@ -0,0 +1,72 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.model.Puzzle
+import coffee.liz.rookery.puzzle.Notation
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.Puzzles
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class TwistyPuzzleTest {
+ @Test fun stickerCounts() {
+ for (n in 2..7) assertEquals(6 * n * n, Puzzles.cube(n).stickers.size, "${n}x$n")
+ assertEquals(36, Puzzles.pyraminx().stickers.size)
+ assertEquals(30, Puzzles.skewb().stickers.size)
+ }
+
+ @Test fun stickersFaceOutwards() {
+ for (p in listOf(Puzzles.cube(3), Puzzles.pyraminx(), Puzzles.skewb())) {
+ for (s in p.stickers) assertTrue((s.normal dot s.centroid) > 0, "${p.name} sticker at ${s.centroid} faces inward")
+ }
+ }
+
+ @Test fun cubeMatchesNxnModelFaceByFace() {
+ val alg = "R U2 F' 2L Dw B2 3Rw' x U' M2 y' S"
+ for (n in listOf(3, 5, 7)) {
+ val generic = Puzzles.cube(n).solved().apply(alg)
+ val reference = NxnCube(n).apply(alg)
+ for (f in NxnCube.Face.entries) {
+ val mine = generic.colors.filterIndexed { i, _ -> generic.puzzle.stickerFace[i] == f.ordinal }.sorted()
+ assertEquals(reference.face(f).sorted(), mine, "${n}x$n face $f")
+ }
+ assertTrue(generic.apply(Notation.invert(alg)).isSolved)
+ }
+ }
+
+ @Test fun cubeMoveOrders() {
+ val c = Puzzles.cube(3).solved()
+ assertTrue(c.apply("R R R R").isSolved)
+ assertTrue(c.apply("R U R' U' ".repeat(6)).isSolved)
+ val t = "R U R' U' R' F R2 U' R' U' R U R' F'"
+ assertFalse(c.apply(t).isSolved)
+ assertTrue(c.apply("$t $t").isSolved)
+ }
+
+ @Test fun pyraminxMoves() {
+ val p = Puzzles.pyraminx().solved()
+ assertTrue(p.apply("U U U").isSolved)
+ assertTrue(p.apply("u u'").isSolved)
+ assertFalse(p.apply("U").isSolved)
+ // a tip turn only moves the 3 tip stickers
+ assertEquals(3, p.apply("u").colors.indices.count { p.apply("u").colors[it] != p.colors[it] })
+ val scr = "U' L' R B U' L B' U r' b"
+ assertTrue(p.apply(scr).apply("b' r U' B L' U B' R' L U").isSolved)
+ }
+
+ @Test fun skewbMoves() {
+ val s = Puzzles.skewb().solved()
+ assertTrue(s.apply("R R R").isSolved)
+ assertFalse(s.apply("R").isSolved)
+ // a skewb turn moves half the puzzle: 3 faces touch the turning corner fully-ish; count changes
+ val moved = s.apply("R").colors.indices.count { s.apply("R").colors[it] != s.colors[it] }
+ assertEquals(15, moved)
+ assertTrue(s.apply("R U' L B' R' U").apply("U' R B L' U R'").isSolved)
+ }
+
+ @Test fun modelsExistForCubesPyraAndSkewb() {
+ for (p in listOf(Puzzle.CUBE2, Puzzle.CUBE7, Puzzle.PYRAMINX, Puzzle.SKEWB)) assertNotNull(Puzzles.of(p))
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
new file mode 100644
index 0000000..5e45af3
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/bt/GanProtocolTest.kt
@@ -0,0 +1,264 @@
+package coffee.liz.rookery.bt
+
+import coffee.liz.rookery.bt.SmartCubeEvent.Battery
+import coffee.liz.rookery.bt.SmartCubeEvent.Facelets
+import coffee.liz.rookery.bt.SmartCubeEvent.Gyro
+import coffee.liz.rookery.bt.SmartCubeEvent.Hardware
+import coffee.liz.rookery.bt.SmartCubeEvent.Move
+import kotlinx.coroutines.test.runTest
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/** Records commands a driver sends back to the cube. */
+class RecordingRawConnection : GanRawConnection {
+ val sent = mutableListOf<ByteArray>()
+ var disconnects = 0
+ override suspend fun sendCommandMessage(message: ByteArray) { sent += message }
+ override suspend fun disconnect() { disconnects++ }
+}
+
+/** Cube state after F R (from gan-web-bluetooth's docs). */
+val F_R_STATE = CubeState(
+ cp = listOf(0, 5, 2, 1, 7, 4, 6, 3), co = listOf(1, 2, 0, 2, 1, 1, 0, 2),
+ ep = listOf(1, 9, 2, 3, 11, 8, 6, 7, 4, 5, 10, 0), eo = listOf(1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0),
+)
+const val F_R_FACELETS = "UUFUUFLLFUUURRRRRRFFRFFDFFDRRBDDBDDBLLDLLDLLDLBBUBBUBB"
+val SOLVED_STATE = CubeState(List(8) { it }, List(8) { 0 }, List(12) { it }, List(12) { 0 })
+
+/** Writes the first 7 corners / 11 edges of [s] at the given bit offsets, as the cube does. */
+fun GanBitWriter.state(s: CubeState, cpAt: Int, coAt: Int, epAt: Int, eoAt: Int): GanBitWriter {
+ for (i in 0 until 7) { put(cpAt + i * 3, 3, s.cp[i]); put(coAt + i * 2, 2, s.co[i]) }
+ for (i in 0 until 11) { put(epAt + i * 4, 4, s.ep[i]); put(eoAt + i, 1, s.eo[i]) }
+ return this
+}
+
+object Packets {
+ fun gen2Facelets(serial: Int, s: CubeState) = GanBitWriter(20).put(0, 4, 0x4).put(4, 8, serial).state(s, 12, 33, 47, 91).bytes
+
+ /** [moves] newest first: (face 0..5 URFDLB, direction 0/1, elapsed ms since previous move). */
+ fun gen2Moves(serial: Int, moves: List<Triple<Int, Int, Int>>): ByteArray {
+ val w = GanBitWriter(20).put(0, 4, 0x2).put(4, 8, serial)
+ moves.forEachIndexed { i, (face, dir, elapsed) -> w.put(12 + 5 * i, 4, face).put(16 + 5 * i, 1, dir).put(47 + 16 * i, 16, elapsed) }
+ return w.bytes
+ }
+
+ // Live-move face codes for Gen3/Gen4, indexed by face URFDLB.
+ val LIVE = listOf(2, 32, 8, 1, 16, 4)
+ val HISTORY = listOf(1, 5, 3, 0, 4, 2)
+
+ fun gen3Facelets(serial: Int, s: CubeState) =
+ GanBitWriter(20).put(0, 8, 0x55).put(8, 8, 0x02).put(16, 8, 0x10).put(24, 16, serial, true).state(s, 40, 61, 77, 121).bytes
+
+ fun gen3Move(serial: Int, face: Int, dir: Int, cubeTs: Long) =
+ GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x01).put(16, 8, 0x08).put(24, 32, cubeTs, true)
+ .put(56, 16, serial, true).put(72, 2, dir).put(74, 6, LIVE[face]).bytes
+
+ fun gen4Facelets(serial: Int, s: CubeState) =
+ GanBitWriter(20).put(0, 8, 0xED).put(8, 8, 0x10).put(16, 16, serial, true).state(s, 32, 53, 69, 113).bytes
+
+ fun gen4Move(serial: Int, face: Int, dir: Int, cubeTs: Long) =
+ GanBitWriter(20).put(0, 8, 0x01).put(8, 8, 0x08).put(16, 32, cubeTs, true)
+ .put(48, 16, serial, true).put(64, 2, dir).put(66, 6, LIVE[face]).bytes
+
+ /** Gen4 move history: [moves] are (face, dir) for serials startSerial, startSerial-1, ... */
+ fun gen4History(startSerial: Int, moves: List<Pair<Int, Int>>): ByteArray {
+ val w = GanBitWriter(20).put(0, 8, 0xD1).put(8, 8, moves.size / 2 + 1).put(16, 8, startSerial)
+ moves.forEachIndexed { i, (face, dir) -> w.put(24 + 4 * i, 3, HISTORY[face]).put(27 + 4 * i, 1, dir) }
+ return w.bytes
+ }
+}
+
+class GanProtocolTest {
+ private var now = 1_000L
+ private val clock = { now }
+
+ @Test fun bitReaderRoundTripsAllWidths() {
+ val w = GanBitWriter(12).put(3, 5, 0b10110).put(8, 16, 0xBEEF).put(27, 16, 0x1234, true).put(43, 32, 0xDEADBEEFL, true)
+ val r = GanBitReader(w.bytes)
+ assertEquals(0b10110, r.bits(3, 5))
+ assertEquals(0xBEEF, r.bits(8, 16))
+ assertEquals(0x1234, r.bits(27, 16, true))
+ assertEquals(0xDEADBEEFL, r.u32(43, true))
+ assertEquals(0, r.bits(200, 8)) // past the end reads as zero
+ }
+
+ @Test fun kociembaFacelets() {
+ assertEquals(GanFacelets.SOLVED, GanFacelets.toKociemba(SOLVED_STATE))
+ assertEquals(F_R_FACELETS, GanFacelets.toKociemba(F_R_STATE))
+ val completed = GanFacelets.complete(F_R_STATE.cp.take(7), F_R_STATE.co.take(7), F_R_STATE.ep.take(11), F_R_STATE.eo.take(11))
+ assertEquals(F_R_STATE, completed)
+ }
+
+ @Test fun gen2Commands() {
+ val d = GanGen2ProtocolDriver(clock)
+ assertEquals(20, d.createCommandMessage(GanCommand.REQUEST_FACELETS).size)
+ assertEquals(0x04, d.createCommandMessage(GanCommand.REQUEST_FACELETS)[0].toInt())
+ assertEquals(0x05, d.createCommandMessage(GanCommand.REQUEST_HARDWARE)[0].toInt())
+ assertEquals(0x09, d.createCommandMessage(GanCommand.REQUEST_BATTERY)[0].toInt())
+ assertContentEquals(
+ byteArrayOf(0x0A, 0x05, 0x39, 0x77, 0, 0, 0x01, 0x23, 0x45, 0x67, 0x89.toByte(), 0xAB.toByte(), 0, 0, 0, 0, 0, 0, 0, 0),
+ d.createCommandMessage(GanCommand.REQUEST_RESET),
+ )
+ }
+
+ @Test fun gen2FaceletsMovesBatteryHardwareGyro() = runTest {
+ val d = GanGen2ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ // Moves before the first facelets event are ignored.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen2Moves(5, listOf(Triple(1, 0, 100)))))
+
+ val f = d.handleStateEvent(c, Packets.gen2Facelets(10, F_R_STATE)).single() as Facelets
+ assertEquals(F_R_FACELETS, f.facelets)
+ assertEquals(10, f.serial)
+ assertEquals(F_R_STATE, f.state)
+
+ now = 2_000
+ // Serial 11: one new move R (face 1, CW), 120 ms after the previous.
+ val m1 = d.handleStateEvent(c, Packets.gen2Moves(11, listOf(Triple(1, 0, 120)))).single() as Move
+ assertEquals(Move("R", 120, 2_000, 11, recovered = false), m1)
+
+ // Serial 13: two moves; the older (U', serial 12) was missed live and comes with the newer (F, serial 13).
+ now = 2_500
+ val ms = d.handleStateEvent(c, Packets.gen2Moves(13, listOf(Triple(2, 0, 200), Triple(0, 1, 150))))
+ assertEquals(listOf(Move("U'", 270, 2_500, 12, true), Move("F", 470, 2_500, 13, false)), ms)
+
+ val battery = GanBitWriter(20).put(0, 4, 0x9).put(8, 8, 87).bytes
+ assertEquals(Battery(87), d.handleStateEvent(c, battery).single())
+ assertEquals(Battery(100), d.handleStateEvent(c, GanBitWriter(20).put(0, 4, 0x9).put(8, 8, 140).bytes).single())
+
+ val hw = GanBitWriter(20).put(0, 4, 0x5).put(8, 8, 1).put(16, 8, 2).put(24, 8, 3).put(32, 8, 4).put(104, 1, 1)
+ "GAN12ui!".forEachIndexed { i, ch -> hw.put(40 + 8 * i, 8, ch.code) }
+ assertEquals(Hardware("GAN12ui!", "1.2", "3.4", null, true), d.handleStateEvent(c, hw.bytes).single())
+
+ // Gyro: w = +1, x = -0.5 (sign bit + magnitude), velocity x = -3.
+ val g = GanBitWriter(20).put(0, 4, 0x1).put(4, 16, 0x7FFF).put(20, 16, 0x8000 or 0x3FFF).put(68, 4, 0b1011)
+ val gyro = d.handleStateEvent(c, g.bytes).single() as Gyro
+ assertEquals(1.0, gyro.quaternion.w)
+ assertTrue(abs(gyro.quaternion.x + 0.5) < 1e-3)
+ assertEquals(-3, gyro.velocity!!.x)
+
+ assertEquals(emptyList(), d.handleStateEvent(c, GanBitWriter(20).put(0, 4, 0xD).bytes))
+ assertEquals(1, c.disconnects)
+ }
+
+ @Test fun gen3Commands() {
+ val d = GanGen3ProtocolDriver(clock)
+ assertContentEquals(byteArrayOf(0x68, 0x01) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_FACELETS))
+ assertContentEquals(byteArrayOf(0x68, 0x04) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_HARDWARE))
+ assertContentEquals(byteArrayOf(0x68, 0x07) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_BATTERY))
+ assertEquals(0x05, d.createCommandMessage(GanCommand.REQUEST_RESET)[2].toInt())
+ }
+
+ @Test fun gen3FaceletsMovesBatteryHardware() = runTest {
+ val d = GanGen3ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen3Move(1, 0, 0, 10)))
+ assertEquals(Facelets(GanFacelets.SOLVED, 7, SOLVED_STATE), d.handleStateEvent(c, Packets.gen3Facelets(7, SOLVED_STATE)).single())
+ assertEquals(Move("L'", 123_456, 1_000, 8), d.handleStateEvent(c, Packets.gen3Move(8, 4, 1, 123_456)).single())
+ assertEquals(Move("B", 0xFFFFFFF0L, 1_000, 9), d.handleStateEvent(c, Packets.gen3Move(9, 5, 0, 0xFFFFFFF0L)).single())
+ // Wrong magic is ignored.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen3Move(10, 0, 0, 1).also { it[0] = 0x54 }))
+
+ val battery = GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x10).put(16, 8, 1).put(24, 8, 64).bytes
+ assertEquals(Battery(64), d.handleStateEvent(c, battery).single())
+
+ val hw = GanBitWriter(16).put(0, 8, 0x55).put(8, 8, 0x07).put(16, 8, 9).put(72, 4, 2).put(76, 4, 5).put(80, 4, 1).put(84, 4, 0)
+ "GANic".forEachIndexed { i, ch -> hw.put(32 + 8 * i, 8, ch.code) }
+ assertEquals(Hardware("GANic", "1.0", "2.5", null, false), d.handleStateEvent(c, hw.bytes).single())
+ }
+
+ @Test fun gen4Commands() {
+ val d = GanGen4ProtocolDriver(clock)
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xED.toByte(), 0, 0) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_FACELETS))
+ assertContentEquals(byteArrayOf(0xDD.toByte(), 0x04, 0x00, 0xEF.toByte(), 0, 0) + ByteArray(14), d.createCommandMessage(GanCommand.REQUEST_BATTERY))
+ assertContentEquals(byteArrayOf(0xDF.toByte(), 0x03) + ByteArray(18), d.createCommandMessage(GanCommand.REQUEST_HARDWARE))
+ assertEquals(0xD2.toByte(), d.createCommandMessage(GanCommand.REQUEST_RESET)[0])
+ }
+
+ @Test fun gen4MovesRecoverMissedMovesFromHistory() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ assertEquals(F_R_FACELETS, (d.handleStateEvent(c, Packets.gen4Facelets(10, F_R_STATE)).single() as Facelets).facelets)
+ assertEquals(listOf(Move("D", 5000, 1_000, 11)), d.handleStateEvent(c, Packets.gen4Move(11, 3, 0, 5000)))
+
+ // Serial 14 arrives but 12 and 13 were lost: it is held back and history is requested.
+ assertEquals(emptyList(), d.handleStateEvent(c, Packets.gen4Move(14, 1, 1, 5600)))
+ assertContentEquals(byteArrayOf(0xD1.toByte(), 0x04, 13, 0, 4, 0) + ByteArray(14), c.sent.single())
+
+ // History: serials 13 (F'), 12 (U), 11 (D), 10 (whatever) newest first.
+ now = 1_050
+ val recovered = d.handleStateEvent(c, Packets.gen4History(13, listOf(2 to 1, 0 to 0, 3 to 0, 5 to 0)))
+ assertEquals(
+ listOf(Move("U", null, 1_050, 12, true), Move("F'", null, 1_050, 13, true), Move("R'", 5600, 1_000, 14)),
+ recovered,
+ )
+ }
+
+ @Test fun gen4FaceletsTriggerHistoryWhenIdle() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ d.handleStateEvent(c, Packets.gen4Facelets(20, SOLVED_STATE))
+ d.handleStateEvent(c, Packets.gen4Move(21, 0, 0, 100))
+ // Periodic facelets says serial 23 more than 500 ms after the last move: 22 and 23 were missed.
+ now += 600
+ d.handleStateEvent(c, Packets.gen4Facelets(23, SOLVED_STATE))
+ assertContentEquals(byteArrayOf(0xD1.toByte(), 0x04, 23, 0, 4, 0) + ByteArray(14), c.sent.single())
+ val got = d.handleStateEvent(c, Packets.gen4History(23, listOf(1 to 0, 2 to 0, 0 to 0, 0 to 0)))
+ assertEquals(listOf("F", "R"), got.map { (it as Move).move })
+ assertEquals(listOf(22, 23), got.map { (it as Move).serial })
+ }
+
+ @Test fun gen4BatteryHardwareGyro() = runTest {
+ val d = GanGen4ProtocolDriver(clock)
+ val c = RecordingRawConnection()
+ // Battery level sits right after the payload: byte 1 + dataLength.
+ val battery = GanBitWriter(20).put(0, 8, 0xEF).put(8, 8, 3).put(32, 8, 55).bytes
+ assertEquals(Battery(55), d.handleStateEvent(c, battery).single())
+
+ d.createCommandMessage(GanCommand.REQUEST_HARDWARE)
+ val date = GanBitWriter(20).put(0, 8, 0xFA).put(8, 8, 5).put(24, 16, 2023, true).put(40, 8, 7).put(48, 8, 9).bytes
+ val nameW = GanBitWriter(20).put(0, 8, 0xFC).put(8, 8, 9)
+ "GAN12uiM".forEachIndexed { i, ch -> nameW.put(24 + 8 * i, 8, ch.code) }
+ val sw = GanBitWriter(20).put(0, 8, 0xFD).put(8, 8, 2).put(24, 4, 1).put(28, 4, 6).bytes
+ val hw = GanBitWriter(20).put(0, 8, 0xFE).put(8, 8, 2).put(24, 4, 1).put(28, 4, 1).bytes
+ assertEquals(emptyList(), d.handleStateEvent(c, date))
+ assertEquals(emptyList(), d.handleStateEvent(c, nameW.bytes))
+ assertEquals(emptyList(), d.handleStateEvent(c, sw))
+ assertEquals(Hardware("GAN12uiM", "1.1", "1.6", "2023-07-09", true), d.handleStateEvent(c, hw).single())
+
+ val g = GanBitWriter(20).put(0, 8, 0xEC).put(8, 8, 0x0A).put(16, 16, 0x7FFF).put(48, 16, 0x8000 or 0x7FFF).put(88, 4, 0b0010).bytes
+ val gyro = d.handleStateEvent(c, g).single() as Gyro
+ assertEquals(Quaternion(0.0, -1.0, 0.0, 1.0), gyro.quaternion)
+ assertEquals(AngularVelocity(0, 0, 2), gyro.velocity)
+ }
+
+ @Test fun timestampLinearFit() {
+ // Cube clock runs 10% slow relative to the host.
+ val fit = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1110, 100), TimedMove("R'", 1220, 200)))
+ assertEquals(listOf(0L, 110L, 220L), fit.map { it.cubeTimestampMs })
+ assertEquals(-9.091, GanTimestampFit.skewPercent(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1110, 100), TimedMove("R'", 1220, 200))))
+
+ // A recovered move without timestamps is interpolated from its neighbours (next - 50 ms).
+ val gap = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", null, null), TimedMove("R'", 1220, 200)))
+ assertEquals(listOf(0L, 165L, 220L), gap.map { it.cubeTimestampMs })
+ val lastMissing = GanTimestampFit.linearFit(listOf(TimedMove("R", 1000, 0), TimedMove("U", 1100, 100), TimedMove("R'", null, null)))
+ assertEquals(listOf(0L, 100L, 150L), lastMissing.map { it.cubeTimestampMs })
+ assertEquals(emptyList(), GanTimestampFit.linearFit(emptyList()))
+ }
+
+ @Test fun macAndScanHelpers() {
+ assertContentEquals(byteArrayOf(0x78, 0x56, 0x34, 0x12, 0xF0.toByte(), 0x6F), GanDefinitions.saltFromMac("6F:F0:12:34:56:78"))
+ assertContentEquals(GanDefinitions.saltFromMac("6f-f0-12-34-56-78"), GanDefinitions.saltFromMac("6F:F0:12:34:56:78"))
+ // Manufacturer data (company 0x1201): 3 bytes of something, then the MAC reversed.
+ val mfr = mapOf(0x1201 to byteArrayOf(1, 2, 3, 0x78, 0x56, 0x34, 0x12, 0xF0.toByte(), 0x6F, 9, 9))
+ assertEquals("6F:F0:12:34:56:78", GanDefinitions.macFromManufacturerData(mfr))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("6F:F0:12:34:56:78", null, -60, mfr)))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", "GAN12uiM_1A2B", -60)))
+ assertTrue(GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", null, -60, serviceUuids = listOf(GanDefinitions.GEN3_SERVICE))))
+ assertTrue(!GanCubeConnector.isGanCube(BleAdvertisement("00:11:22:33:44:55", "Headphones", -60, mfr)))
+ assertEquals(GanGeneration.GEN4, GanGeneration.fromServiceUuids(listOf("0000180a-0000-1000-8000-00805f9b34fb", GanDefinitions.GEN4_SERVICE.uppercase())))
+ }
+}