summaryrefslogtreecommitdiff
path: root/core/src/commonTest/kotlin
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:41:10 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-12 21:41:10 -0800
commitf95457986531730095b00e2e894e9bf4d9df7166 (patch)
tree8be48ec41f882dba1f55407c6fc38b8bc7c3dcfc /core/src/commonTest/kotlin
parent2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9 (diff)
downloadrookery-f95457986531730095b00e2e894e9bf4d9df7166.tar.gz
rookery-f95457986531730095b00e2e894e9bf4d9df7166.zip
BLD trainers: OP tracing/checking, FSRS drilling, BLD tab
- bld: 3x3 sticker geometry in Speffz order, lettering schemes, the solver's frame (default yellow top / orange front), OP tracing with cycle breaks and twists, and memo checking by executing the typed targets as OP swaps, so any valid memo passes and errors are reported as the pieces left wrong. - srs: FSRS-4.5 memory model and a weighted drill picker that favours forgotten, hard and recently missed items. Reviews are stored one entity per answer so concurrent devices merge cleanly; Letter Pair Gym history seeds pair difficulty. - BLD tab: pair->word (three at a time), word->pair typing, target drills (name the outlined sticker / find a letter's sticker on a scrambled 3D cube), memo trainer on official 3BLD scrambles with hidden recall fields, and a setup page with orientation, buffers and a click-to-edit lettering net. - PuzzleView: tap-to-pick stickers, highlight outline, face cameras. - CubeMapping between renderer stickers and NxN slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'core/src/commonTest/kotlin')
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt96
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt73
2 files changed, 169 insertions, 0 deletions
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt
new file mode 100644
index 0000000..cae88a1
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/BldTest.kt
@@ -0,0 +1,96 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.bld.BldSetup
+import coffee.liz.rookery.bld.Cube3
+import coffee.liz.rookery.bld.PieceType
+import coffee.liz.rookery.bld.Tracer
+import coffee.liz.rookery.puzzle.NxnCube
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class BldTest {
+ private val wca = BldSetup(rotation = "")
+
+ @Test fun speffzPositionsAreTheRightStickers() {
+ assertEquals("UBL", Cube3.pieceName(PieceType.CORNER, 0)) // A
+ assertEquals("RDF", Cube3.stickerName(PieceType.CORNER, 15)) // P, the OP helper target
+ assertEquals("UFR", Cube3.pieceName(PieceType.CORNER, 2)) // C
+ assertEquals("UR", Cube3.pieceName(PieceType.EDGE, 1)) // B, edge buffer
+ assertEquals("UL", Cube3.pieceName(PieceType.EDGE, 3)) // D, T-perm target
+ assertEquals("UB", Cube3.pieceName(PieceType.EDGE, 0)) // A
+ assertEquals("UF", Cube3.pieceName(PieceType.EDGE, 2)) // C
+ // every piece has 3 (corner) / 2 (edge) stickers, 8 / 12 pieces
+ assertEquals(8, (0 until 24).map { Cube3.pieceId(PieceType.CORNER, it) }.distinct().size)
+ assertEquals(12, (0 until 24).map { Cube3.pieceId(PieceType.EDGE, it) }.distinct().size)
+ // UBL clockwise from U: U, L, B -> A, E, R in Speffz (the classic "AER" buffer)
+ assertEquals(listOf(0, 4, 17), Cube3.pieceFrom(PieceType.CORNER, 0).toList())
+ // UFR clockwise from U: U, R, F -> C, M, J
+ assertEquals(listOf(2, 12, 9), Cube3.pieceFrom(PieceType.CORNER, 2).toList())
+ }
+
+ @Test fun solvedCubeHasNoTargets() {
+ val t = Tracer(wca)
+ val s = wca.stateOf("")
+ assertTrue(t.trace(s, PieceType.CORNER).isEmpty())
+ assertTrue(t.trace(s, PieceType.EDGE).isEmpty())
+ }
+
+ @Test fun tPermIsOneEdgeTargetD() {
+ val t = Tracer(wca)
+ val s = wca.stateOf("R U R' U' R' F R2 U' R' U' R U R' F'")
+ assertEquals("D", t.letters(t.trace(s, PieceType.EDGE)))
+ }
+
+ @Test fun tracedMemoAlwaysSolvesWhenExecuted() {
+ val rng = Random(7)
+ val faces = listOf("U", "R", "F", "D", "L", "B")
+ val sfx = listOf("", "'", "2")
+ for (setup in listOf(wca, BldSetup())) { // WCA frame and the user's yellow-top/orange-front frame
+ val t = Tracer(setup)
+ repeat(200) {
+ val scr = List(22) { faces[rng.nextInt(6)] + sfx[rng.nextInt(3)] }.joinToString(" ")
+ val s = setup.stateOf(scr)
+ val corners = t.trace(s, PieceType.CORNER)
+ val edges = t.trace(s, PieceType.EDGE)
+ val after = t.execute(s, corners + edges)
+ assertTrue(t.isSolved(after, PieceType.CORNER), "corners after $scr: ${t.wrongPieces(after, PieceType.CORNER)}")
+ assertTrue(t.isSolved(after, PieceType.EDGE), "edges after $scr: ${t.wrongPieces(after, PieceType.EDGE)}")
+ // corner and edge target counts share parity on a legal cube
+ assertEquals(corners.size % 2, edges.size % 2, scr)
+ }
+ }
+ }
+
+ @Test fun wrongMemoReportsThePieces() {
+ val t = Tracer(wca)
+ val s = wca.stateOf("R U R' U' R' F R2 U' R' U' R U R' F'")
+ val (targets, errors) = t.parse("C", PieceType.EDGE) // should be D
+ assertTrue(errors.isEmpty())
+ val after = t.execute(s, targets)
+ assertEquals(setOf("UF", "UL", "UR"), t.wrongPieces(after, PieceType.EDGE).toSet())
+ assertEquals(listOf("'B' is on the buffer piece"), t.parse("B", PieceType.EDGE).second)
+ }
+
+ @Test fun userFrameHasYellowOnTopAndOrangeInFront() {
+ val solved = BldSetup().solvedColors
+ assertEquals(NxnCube.Face.D.ordinal, solved[4]) // U centre shows the yellow (WCA D) colour
+ assertEquals(NxnCube.Face.L.ordinal, solved[2 * 9 + 4]) // F centre shows orange (WCA L)
+ assertEquals(NxnCube.Face.B.ordinal, solved[1 * 9 + 4]) // R centre shows blue
+ }
+}
+
+class CubeMappingTest {
+ @Test fun genericStickersMapOntoNxnSlotsOneToOne() {
+ for (n in 2..5) {
+ val m = coffee.liz.rookery.puzzle.CubeMapping(coffee.liz.rookery.puzzle.Puzzles.cube(n), n)
+ assertEquals((0 until 6 * n * n).toSet(), m.slotOf.toSet())
+ // same scramble through both models gives the same picture
+ val alg = "R U2 F' L D B2"
+ val generic = coffee.liz.rookery.puzzle.Puzzles.cube(n).solved().apply(alg)
+ val viaNxn = m.state(NxnCube(n).apply(alg).stickers)
+ assertTrue(generic.colors.contentEquals(viaNxn.colors), "${n}x$n")
+ }
+ }
+}
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt
new file mode 100644
index 0000000..5e76286
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/SrsTest.kt
@@ -0,0 +1,73 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.srs.DrillPicker
+import coffee.liz.rookery.srs.Fsrs
+import coffee.liz.rookery.srs.Grade
+import coffee.liz.rookery.srs.Memory
+import coffee.liz.rookery.srs.Review
+import coffee.liz.rookery.srs.gradeAnswer
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SrsTest {
+ private val day = Memory.DAY_MS.toLong()
+
+ @Test fun goodReviewsGrowStabilityAndMissesShrinkIt() {
+ var m = Fsrs.first(Review("AB", 0, Grade.GOOD))
+ val s0 = m.stability
+ m = Fsrs.next(m, Review("AB", 3 * day, Grade.GOOD))
+ assertTrue(m.stability > s0)
+ val before = m.stability
+ m = Fsrs.next(m, Review("AB", 10 * day, Grade.AGAIN))
+ assertTrue(m.stability < before)
+ assertEquals(1, m.lapses)
+ assertEquals(3, m.reps)
+ }
+
+ @Test fun difficultyTracksGrades() {
+ val easy = Fsrs.replay(List(5) { Review("x", it * day, Grade.EASY) })!!
+ val hard = Fsrs.replay(List(5) { Review("x", it * day, Grade.AGAIN) })!!
+ assertTrue(easy.difficulty < hard.difficulty)
+ assertTrue(hard.difficulty <= 10.0 && easy.difficulty >= 1.0)
+ }
+
+ @Test fun replayIsOrderIndependent() {
+ val rs = listOf(Review("x", 5 * day, Grade.HARD), Review("x", 0, Grade.GOOD), Review("x", 2 * day, Grade.AGAIN))
+ assertEquals(Fsrs.replay(rs), Fsrs.replay(rs.reversed()))
+ }
+
+ @Test fun retrievabilityIsNinetyPercentAfterStabilityDays() {
+ val m = Memory(stability = 4.0, difficulty = 5.0, lastReview = 0, reps = 1, lapses = 0)
+ assertEquals(0.9, m.retrievability(4 * day), 1e-9)
+ assertEquals(1.0, m.retrievability(0), 1e-9)
+ }
+
+ @Test fun answerGrading() {
+ assertEquals(Grade.AGAIN, gradeAnswer(false, 100))
+ assertEquals(Grade.EASY, gradeAnswer(true, 1_000))
+ assertEquals(Grade.GOOD, gradeAnswer(true, 2_000))
+ assertEquals(Grade.HARD, gradeAnswer(true, 6_000))
+ }
+
+ @Test fun pickerFavoursWeakAndMissedItems() {
+ val now = 30 * day
+ val strong = Memory(100.0, 2.0, now - day, 10, 0)
+ val weak = Memory(1.0, 9.0, now - 10 * day, 10, 6)
+ val items = listOf("strong", "weak")
+ val picker = DrillPicker(Random(1))
+ val counts = items.associateWith { 0 }.toMutableMap()
+ repeat(2_000) {
+ val p = picker.pick(items, { if (it == "strong") strong else weak }, { if (it == "weak") 2 else 0 }, emptyList(), now)!!
+ counts[p] = counts.getValue(p) + 1
+ }
+ assertTrue(counts.getValue("weak") > 8 * counts.getValue("strong"), "$counts")
+ }
+
+ @Test fun pickerAvoidsRecentItemsWhenItCan() {
+ val picker = DrillPicker(Random(2))
+ repeat(50) { assertEquals("b", picker.pick(listOf("a", "b"), { null }, { 0 }, listOf("a"), 0)) }
+ assertEquals("a", picker.pick(listOf("a"), { null }, { 0 }, listOf("a"), 0)) // nothing else to pick
+ }
+}