diff options
Diffstat (limited to 'core/src/commonTest/kotlin/coffee/liz')
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()))) + } +} |
