diff --git a/README.md b/README.md index a3753a5..290a6d3 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,31 @@ This repository now includes an additive scan pipeline and guided solve flow wit - `CubeUI`: - `CubeScanSolveFlowViewModel` - `ScanWizardView`, `FaceConfirmView`, `CubeManualEditView`, `SolveStepsView` + - `SolveModeView`, `SolveModeViewModel`, `SolveModeEngine` + +### Solve Mode (Guided) + +`SolveModeView` provides guided move-by-move solving with: +- single-step controls (`Back`, `Next`, `Play/Pause`, speed, scrub slider) +- deterministic state progression from `initialState + moves` +- primary 3D visualization (`CubeRenderer3DView`) plus 2D fallback (`CubeRenderer2DView`) +- human-readable move instructions and progress tracking + +Orientation assumptions: +- Scan Wizard uses fixed orientation from capture order: `U, R, F, D, L, B`. +- In this flow, `Front` maps to scanned `F`, `Up` maps to scanned `U`. +- When orientation is not guaranteed (for example manual input), enable `requireOrientationConfirmation` in `SolveModeView` to show an Orientation Lock confirmation step before playback. + +### Extending Renderers + +Solve Mode rendering and animation are injected via protocols: +- `CubeRenderer`: `setState`, `highlight(move:)`, `clearHighlight()` +- `CubeMoveAnimator`: move animation contract with completion callback + +To add a renderer: +1. Implement `CubeRenderer` for your view-backed renderer. +2. Provide a `CubeMoveAnimator` implementation (or reuse `TimedCubeMoveAnimator`). +3. Inject both into `SolveModeViewModel` for headless-testable behavior. ### Running The Flow @@ -216,6 +241,17 @@ swift test --filter ScanSolvePipelineTests swift test --filter ScanSolveFlowIntegrationTests ``` +Run Solve Mode focused tests: + +```bash +swift test --filter CubeMoveTests +swift test --filter CubeReducerTests +swift test --filter SolveModeEngineTests +swift test --filter MoveInstructionFormatterTests +swift test --filter SolveModeViewModelTests +swift test --filter SolveModeRenderingTests +``` + ### Known Limitations - `KociembaCompatibleCubeSolver` currently delegates to the built-in search solver (clean swap point for a true two-phase backend). diff --git a/Sources/CubeUI/AnimatedCube3DView.swift b/Sources/CubeUI/AnimatedCube3DView.swift index 4d7c77b..94dea97 100644 --- a/Sources/CubeUI/AnimatedCube3DView.swift +++ b/Sources/CubeUI/AnimatedCube3DView.swift @@ -219,14 +219,7 @@ private func createAnimatedCubeNode() -> SCNNode { } let cubie = createAnimatedCubie(size: cubieSize) - let xPos = CGFloat(x - 1) * totalSize - let yPos = CGFloat(y - 1) * totalSize - let zPos = CGFloat(z - 1) * totalSize -#if os(macOS) - cubie.position = SCNVector3(x: xPos, y: yPos, z: zPos) -#else - cubie.position = SCNVector3(x: Float(xPos), y: Float(yPos), z: Float(zPos)) -#endif + cubie.position = canonicalCubiePosition(x: x, y: y, z: z, totalSize: totalSize) cubie.name = "cubie_\(x)_\(y)_\(z)" containerNode.addChildNode(cubie) @@ -266,7 +259,7 @@ private func updateCubeState(in sceneView: SCNView, cube: RubiksCube, currentMov isAnimatingLocal = value } var currentMoveLocal = currentMove - func clearCurrentMove() { + func consumeCurrentMove() { currentMoveLocal = nil } @@ -276,14 +269,19 @@ private func updateCubeState(in sceneView: SCNView, cube: RubiksCube, currentMov // Animate move if needed if let move = currentMoveLocal, !isAnimatingLocal { setIsAnimating(true) + consumeCurrentMove() animateMove(in: scene, move: move, coordinator: coordinator) { setIsAnimating(false) } } - // Write back any changes from locals to inout parameters - currentMove = currentMoveLocal - isAnimating = isAnimatingLocal + // Avoid rebinding unchanged values to prevent update feedback loops. + if currentMove != currentMoveLocal { + currentMove = currentMoveLocal + } + if isAnimating != isAnimatingLocal { + isAnimating = isAnimatingLocal + } } private func updateCubeColors(in scene: SCNScene, with cube: RubiksCube) { @@ -357,133 +355,100 @@ private func animateMove(in scene: SCNScene, move: Move, coordinator: AnimationC } // Determine which layer to rotate based on the move - let (axis, _, angle) = getMoveAnimation(for: move) + let (axis, angle) = getMoveAnimation(for: move) // Get cubies to rotate let cubiesToRotate = getCubiesForMove(containerNode, move: move) + guard !cubiesToRotate.isEmpty else { + completion() + return + } // Create a temporary parent node for the layer being rotated let layerNode = SCNNode() layerNode.name = "tempLayer" + layerNode.position = SCNVector3Zero scene.rootNode.addChildNode(layerNode) - - // Store original parents - var originalParents: [SCNNode: SCNNode] = [:] - + // Reparent cubies to the layer node, preserving world position for cubie in cubiesToRotate { - guard let parent = cubie.parent else { + guard cubie.parent != nil else { // Skip cubies without a parent to avoid inconsistent state continue } - - originalParents[cubie] = parent - - // Convert position to layer node's coordinate system - let worldPosition = parent.convertPosition(cubie.position, to: nil) + + let worldTransform = cubie.presentation.worldTransform cubie.removeFromParentNode() layerNode.addChildNode(cubie) - cubie.position = layerNode.convertPosition(worldPosition, from: nil) + cubie.transform = layerNode.convertTransform(worldTransform, from: nil) } - + // Create rotation animation for the entire layer let duration: TimeInterval = 0.5 - - SCNTransaction.begin() - SCNTransaction.animationDuration = duration - SCNTransaction.completionBlock = { - // Reparent cubies back to container with updated transforms - // Note: Only cubies that were successfully reparented to layerNode will be in childNodes, - // and all of those will have entries in originalParents due to the guard in reparenting loop + let rotation = SCNAction.rotate(by: angle, around: axis, duration: duration) + rotation.timingMode = .easeInEaseOut + + // Rotate the layer node (which rotates all cubies as a group) + layerNode.runAction(rotation) { + // Reparent cubies back to container with updated transforms. for cubie in layerNode.childNodes { - guard let originalParent = originalParents[cubie] else { - continue - } - - let worldPosition = layerNode.convertPosition(cubie.position, to: nil) - let worldTransform = cubie.worldTransform + let worldTransform = cubie.presentation.worldTransform cubie.removeFromParentNode() - originalParent.addChildNode(cubie) - cubie.position = originalParent.convertPosition(worldPosition, from: nil) - cubie.transform = originalParent.convertTransform(worldTransform, from: nil) + containerNode.addChildNode(cubie) + cubie.transform = containerNode.convertTransform(worldTransform, from: nil) } - - // Remove temporary layer node + + // Snap back to canonical grid/orientation so each move starts from an exact baseline. + snapCubiesToCanonicalGrid(in: containerNode) + layerNode.removeFromParentNode() - coordinator.animationDidStop(CAAnimation(), finished: true) completion() } - - // Rotate the layer node (which rotates all cubies as a group) - let rotation = SCNAction.rotate(by: CGFloat(angle), around: axis, duration: duration) - layerNode.runAction(rotation) - - SCNTransaction.commit() } -private func getMoveAnimation(for move: Move) -> (SCNVector3, CGFloat, CGFloat) { - // Parse from textual description to avoid relying on specific API - let text = String(describing: move).uppercased() - - // Determine face letter - let faceChar: Character? = ["R","L","U","D","F","B"].first { text.contains(String($0)) } - - // Determine amount (single or double) - let isDouble = text.contains("2") - let baseAngle: CGFloat = isDouble ? .pi : (.pi / 2) - - // Determine direction (prime/counterclockwise) - let isPrime = text.contains("'") || text.contains("CCW") || text.contains("COUNTER") || text.contains("PRIME") - let sign: CGFloat = isPrime ? -1 : 1 - - // Map face to axis; L/D/B invert direction relative to R/U/F - switch faceChar { - case "R": - return (SCNVector3(1, 0, 0), sign, sign * baseAngle) - case "L": - return (SCNVector3(1, 0, 0), -sign, -sign * baseAngle) - case "U": - return (SCNVector3(0, 1, 0), sign, sign * baseAngle) - case "D": - return (SCNVector3(0, 1, 0), -sign, -sign * baseAngle) - case "F": - return (SCNVector3(0, 0, 1), sign, sign * baseAngle) - case "B": - return (SCNVector3(0, 0, 1), -sign, -sign * baseAngle) - default: - // Fallback: rotate front layer CW quarter-turn - return (SCNVector3(0, 0, 1), 1, .pi / 2) +private func getMoveAnimation(for move: Move) -> (SCNVector3, CGFloat) { + let baseAngle: CGFloat = move.amount == .double ? .pi : (.pi / 2) + let sign: CGFloat = move.amount == .counter ? -1 : 1 + + switch move.turn { + case .R: + return (SCNVector3(1, 0, 0), sign * baseAngle) + case .L: + return (SCNVector3(1, 0, 0), -sign * baseAngle) + case .U: + return (SCNVector3(0, 1, 0), sign * baseAngle) + case .D: + return (SCNVector3(0, 1, 0), -sign * baseAngle) + case .F: + return (SCNVector3(0, 0, 1), sign * baseAngle) + case .B: + return (SCNVector3(0, 0, 1), -sign * baseAngle) } } private func getCubiesForMove(_ containerNode: SCNNode, move: Move) -> [SCNNode] { var cubies: [SCNNode] = [] - let text = String(describing: move).uppercased() - let faceChar: Character? = ["R","L","U","D","F","B"].first { text.contains(String($0)) } - for x in 0..<3 { for y in 0..<3 { for z in 0..<3 { if x == 1 && y == 1 && z == 1 { continue } let shouldInclude: Bool - switch faceChar { - case "R": + switch move.turn { + case .R: shouldInclude = x == 2 - case "L": + case .L: shouldInclude = x == 0 - case "U": + case .U: shouldInclude = y == 2 - case "D": + case .D: shouldInclude = y == 0 - case "F": + case .F: shouldInclude = z == 2 - case "B": + case .B: shouldInclude = z == 0 - default: - shouldInclude = false } if shouldInclude, @@ -497,6 +462,47 @@ private func getCubiesForMove(_ containerNode: SCNNode, move: Move) -> [SCNNode] return cubies } +private func snapCubiesToCanonicalGrid(in containerNode: SCNNode) { + let totalSize: CGFloat = 1.0 + 0.05 + for cubie in containerNode.childNodes { + guard let name = cubie.name, + let coordinates = cubieCoordinates(from: name) else { + continue + } + + cubie.eulerAngles = SCNVector3Zero + cubie.position = canonicalCubiePosition( + x: coordinates.x, + y: coordinates.y, + z: coordinates.z, + totalSize: totalSize + ) + } +} + +private func cubieCoordinates(from name: String) -> (x: Int, y: Int, z: Int)? { + let parts = name.split(separator: "_") + guard parts.count == 4, + let x = Int(parts[1]), + let y = Int(parts[2]), + let z = Int(parts[3]) else { + return nil + } + return (x, y, z) +} + +private func canonicalCubiePosition(x: Int, y: Int, z: Int, totalSize: CGFloat) -> SCNVector3 { + let xPos = CGFloat(x - 1) * totalSize + let yPos = CGFloat(y - 1) * totalSize + let zPos = CGFloat(z - 1) * totalSize + +#if os(macOS) + return SCNVector3(x: xPos, y: yPos, z: zPos) +#else + return SCNVector3(x: Float(xPos), y: Float(yPos), z: Float(zPos)) +#endif +} + // MARK: - Platform Compatibility #if os(macOS) @@ -525,4 +531,3 @@ struct AnimatedCube3DView_Previews: PreviewProvider { #endif // canImport(SceneKit) #endif // canImport(SwiftUI) - diff --git a/Sources/CubeUI/HomeView.swift b/Sources/CubeUI/HomeView.swift index 338d1be..a85335e 100644 --- a/Sources/CubeUI/HomeView.swift +++ b/Sources/CubeUI/HomeView.swift @@ -18,6 +18,10 @@ import CubeCore /// Home view showing recent solves and main navigation public struct HomeView: View { + #if DEBUG + private static let solveModeDebugFixture = SolveModeDebugFixture.stress() + #endif + @Environment(\.colorScheme) private var colorScheme @StateObject private var historyManager = SolveHistoryManager() @@ -148,9 +152,29 @@ public struct HomeView: View { color: .green ) } + + #if DEBUG + NavigationLink(destination: solveModeDebugDestination) { + ActionCard( + icon: "ladybug.fill", + title: "Solve Mode (Debug)", + subtitle: "Launch guided solve without solver", + color: .orange + ) + } + #endif } .padding(.horizontal) } + + #if DEBUG + private var solveModeDebugDestination: some View { + SolveModeView( + state: Self.solveModeDebugFixture.initialState, + solution: Self.solveModeDebugFixture.solution + ) + } + #endif @ViewBuilder private var recentSolvesSection: some View { diff --git a/Sources/CubeUI/ManualInputView.swift b/Sources/CubeUI/ManualInputView.swift index acd99b3..a8c0264 100644 --- a/Sources/CubeUI/ManualInputView.swift +++ b/Sources/CubeUI/ManualInputView.swift @@ -18,7 +18,7 @@ public struct ManualInputView: View { @State private var selectedFace: CubeFaceType = .front @State private var selectedColor: FaceColor = .red - @State private var showingSolutionPlayback = false + @State private var showingSolveMode = false @State private var validationError: String? @State private var isSolving = false @@ -179,9 +179,11 @@ public struct ManualInputView: View { .accessibilityIdentifier("closeButton") } } - .sheet(isPresented: $showingSolutionPlayback) { - SolutionPlaybackView( - initialState: CubeState(from: cubeViewModel.cube) + .sheet(isPresented: $showingSolveMode) { + SolveModeView( + state: CubeState(from: cubeViewModel.cube), + solution: cubeViewModel.solution, + requireOrientationConfirmation: true ) } } @@ -201,10 +203,14 @@ public struct ManualInputView: View { // Try to solve await cubeViewModel.solveAsync() - // If we got here, solving succeeded await MainActor.run { + if let error = cubeViewModel.errorMessage { + validationError = error + isSolving = false + return + } isSolving = false - showingSolutionPlayback = true + showingSolveMode = true } } catch { await MainActor.run { @@ -397,4 +403,3 @@ public struct EditableCubeFaceView: View { ManualInputView(cubeViewModel: CubeViewModel()) } #endif - diff --git a/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift index 96ccae5..df5622d 100644 --- a/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift +++ b/Sources/CubeUI/ScanSolveFlow/CubeScanSolveFlowViewModel.swift @@ -22,6 +22,7 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { @Published public private(set) var pendingFace: ScannedFaceData? @Published public private(set) var validationError: ValidationError? @Published public private(set) var solvedMoves: [Move] = [] + @Published public private(set) var solvedInitialState: CubeState? @Published public private(set) var currentMoveIndex: Int = 0 @Published public private(set) var isBusy = false @@ -103,6 +104,9 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { scannedFaces[pendingFace.id] = pendingFace self.pendingFace = nil + solvedMoves = [] + solvedInitialState = nil + currentMoveIndex = 0 _ = revalidateIfComplete() @@ -134,6 +138,9 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { scanned.grid[index] = color scannedFaces[face] = scanned + solvedMoves = [] + solvedInitialState = nil + currentMoveIndex = 0 _ = revalidateIfComplete() } @@ -141,6 +148,9 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { guard let center = defaultCenterColor(for: face) else { return } let replacement = CubeFaceGrid(repeating: center) scannedFaces[face] = ScannedFaceData(id: face, grid: replacement, confidence: 1) + solvedMoves = [] + solvedInitialState = nil + currentMoveIndex = 0 _ = revalidateIfComplete() } @@ -149,6 +159,7 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { pendingFace = nil validationError = nil solvedMoves = [] + solvedInitialState = nil currentMoveIndex = 0 state = .scanning } @@ -166,16 +177,19 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { switch validator.validate(state: cubeState) { case .failure(let validationError): self.validationError = validationError + solvedInitialState = nil state = .editing return case .success: break } + solvedInitialState = cubeState solvedMoves = try await solver.solve(state: cubeState) currentMoveIndex = solvedMoves.isEmpty ? 0 : 1 state = .solved } catch { + solvedInitialState = nil state = .failed(error.localizedDescription) } } @@ -186,6 +200,7 @@ public final class CubeScanSolveFlowViewModel: ObservableObject { pendingFace = nil validationError = nil solvedMoves = [] + solvedInitialState = nil currentMoveIndex = 0 } diff --git a/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift b/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift index e79f717..4c6c463 100644 --- a/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift +++ b/Sources/CubeUI/ScanSolveFlow/ScanWizardView.swift @@ -7,7 +7,7 @@ public struct ScanWizardView: View { @StateObject private var viewModel: CubeScanSolveFlowViewModel private let cameraPreview: AnyView? @State private var showingManualEdit = false - @State private var showingSteps = false + @State private var showingSolveMode = false @State private var manualEditInitialFace: FaceId = .up public init(viewModel: CubeScanSolveFlowViewModel, cameraPreview: AnyView? = nil) { @@ -67,7 +67,7 @@ public struct ScanWizardView: View { Button { Task { await viewModel.solve() - showingSteps = viewModel.state == .solved + showingSolveMode = viewModel.state == .solved } } label: { Label(viewModel.isBusy ? "Solving..." : "Solve Cube", systemImage: "wand.and.stars") @@ -84,8 +84,8 @@ public struct ScanWizardView: View { } if !viewModel.solvedMoves.isEmpty { - Button("View Step-by-step", systemImage: "list.number") { - showingSteps = true + Button("Open Solve Mode", systemImage: "list.number") { + showingSolveMode = true } .buttonStyle(.bordered) .accessibilityIdentifier("viewStepByStepButton") @@ -103,12 +103,24 @@ public struct ScanWizardView: View { }) { CubeManualEditView(viewModel: viewModel, initialFace: manualEditInitialFace) } - .navigationDestination(isPresented: $showingSteps) { - SolveStepsView(viewModel: viewModel) + .navigationDestination(isPresented: $showingSolveMode) { + if let solvedInitialState = viewModel.solvedInitialState { + SolveModeView( + state: solvedInitialState, + solution: viewModel.solvedMoves, + requireOrientationConfirmation: false + ) + } else { + ContentUnavailableView( + "Solve data unavailable", + systemImage: "exclamationmark.triangle", + description: Text("Re-run solve to open guided solve mode.") + ) + } } .onChange(of: viewModel.state) { _, newState in if newState == .solved { - showingSteps = true + showingSolveMode = true } } } diff --git a/Sources/CubeUI/SolveMode/Application/CubeMoveAnimator.swift b/Sources/CubeUI/SolveMode/Application/CubeMoveAnimator.swift new file mode 100644 index 0000000..c16ef69 --- /dev/null +++ b/Sources/CubeUI/SolveMode/Application/CubeMoveAnimator.swift @@ -0,0 +1,44 @@ +import Foundation +import CubeCore + +@MainActor +public protocol CubeRenderer: AnyObject { + func setState(_ state: CubeState) + func highlight(move: Move) + func clearHighlight() +} + +@MainActor +public protocol CubeMoveAnimator { + func animate(move: Move, on renderer: CubeRenderer, completion: @escaping @Sendable () -> Void) +} + +public protocol ConfigurableCubeMoveAnimator: CubeMoveAnimator, AnyObject { + var speedMultiplier: Double { get set } +} + +@MainActor +public final class TimedCubeMoveAnimator: ConfigurableCubeMoveAnimator { + public var speedMultiplier: Double = 1.0 + + private let baseDuration: TimeInterval + private let queue: DispatchQueue + + public init(baseDuration: TimeInterval = 0.5, queue: DispatchQueue = .main) { + self.baseDuration = max(0.05, baseDuration) + self.queue = queue + } + + public func animate(move: Move, on renderer: CubeRenderer, completion: @escaping @Sendable () -> Void) { + renderer.highlight(move: move) + + let multiplier = max(0.1, speedMultiplier) + let delay = baseDuration / multiplier + + queue.asyncAfter(deadline: .now() + delay) { + Task { @MainActor in + completion() + } + } + } +} diff --git a/Sources/CubeUI/SolveMode/Application/MoveInstructionFormatter.swift b/Sources/CubeUI/SolveMode/Application/MoveInstructionFormatter.swift new file mode 100644 index 0000000..cdf0a4c --- /dev/null +++ b/Sources/CubeUI/SolveMode/Application/MoveInstructionFormatter.swift @@ -0,0 +1,103 @@ +import Foundation +import CubeCore + +public struct SolveOrientation: Equatable, Sendable { + public let upColor: CubeColor + public let frontColor: CubeColor + + public init(upColor: CubeColor, frontColor: CubeColor) { + self.upColor = upColor + self.frontColor = frontColor + } + + public static func from(state: CubeState) -> SolveOrientation? { + guard let up = state.centerColor(of: .up), + let front = state.centerColor(of: .front) else { + return nil + } + return SolveOrientation(upColor: up, frontColor: front) + } +} + +public struct MoveInstruction: Equatable, Sendable { + public let title: String + public let spokenInstruction: String + public let hint: String? + + public init(title: String, spokenInstruction: String, hint: String?) { + self.title = title + self.spokenInstruction = spokenInstruction + self.hint = hint + } +} + +public struct MoveInstructionFormatter: Sendable { + public let orientation: SolveOrientation? + + public init(orientation: SolveOrientation?) { + self.orientation = orientation + } + + public func instruction(for move: Move) -> MoveInstruction { + let faceName = move.affectedFace.spokenName.uppercased() + let spokenInstruction: String + + switch move.direction { + case .clockwise: + spokenInstruction = "Turn the \(faceName) face clockwise 90 degrees." + case .counterClockwise: + spokenInstruction = "Turn the \(faceName) face counter-clockwise 90 degrees." + case .doubleTurn: + spokenInstruction = "Turn the \(faceName) face 180 degrees." + } + + return MoveInstruction( + title: move.notation, + spokenInstruction: spokenInstruction, + hint: orientationHint() + ) + } + + private func orientationHint() -> String? { + guard let orientation else { return nil } + return "Hold the cube with the \(orientation.frontColor.name.uppercased()) center facing you and \(orientation.upColor.name.uppercased()) on top." + } +} + +private extension Face { + var spokenName: String { + switch self { + case .up: + return "up" + case .down: + return "down" + case .left: + return "left" + case .right: + return "right" + case .front: + return "front" + case .back: + return "back" + } + } +} + +private extension CubeColor { + var name: String { + switch self { + case .white: + return "white" + case .yellow: + return "yellow" + case .red: + return "red" + case .orange: + return "orange" + case .blue: + return "blue" + case .green: + return "green" + } + } +} diff --git a/Sources/CubeUI/SolveMode/Application/SolveModeDebugFixture.swift b/Sources/CubeUI/SolveMode/Application/SolveModeDebugFixture.swift new file mode 100644 index 0000000..b57a60a --- /dev/null +++ b/Sources/CubeUI/SolveMode/Application/SolveModeDebugFixture.swift @@ -0,0 +1,52 @@ +import CubeCore + +public struct SolveModeDebugFixture: Sendable { + public let initialState: CubeState + public let solution: [Move] + + public init(initialState: CubeState, solution: [Move]) { + self.initialState = initialState + self.solution = solution + } + + public static func medium() -> SolveModeDebugFixture { + fromScramble(baseScramble) + } + + public static func stress() -> SolveModeDebugFixture { + fromScramble(baseScramble + baseScramble + baseScramble) + } + + private static func fromScramble(_ scramble: [Move]) -> SolveModeDebugFixture { + let initial = CubeReducer.apply(scramble, to: CubeState()) + let solveMoves = scramble.reversed().map(\.inverse) + return SolveModeDebugFixture(initialState: initial, solution: solveMoves) + } + + private static let baseScramble: [Move] = [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double), + Move(turn: .L, amount: .clockwise), + Move(turn: .B, amount: .counter), + Move(turn: .D, amount: .double), + Move(turn: .R, amount: .double), + Move(turn: .F, amount: .clockwise), + Move(turn: .U, amount: .clockwise), + Move(turn: .L, amount: .counter), + Move(turn: .B, amount: .double), + Move(turn: .D, amount: .clockwise), + Move(turn: .R, amount: .counter), + Move(turn: .U, amount: .double), + Move(turn: .F, amount: .counter), + Move(turn: .L, amount: .double), + Move(turn: .B, amount: .clockwise), + Move(turn: .D, amount: .counter), + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .clockwise), + Move(turn: .F, amount: .clockwise), + Move(turn: .L, amount: .clockwise), + Move(turn: .B, amount: .clockwise), + Move(turn: .D, amount: .clockwise) + ] +} diff --git a/Sources/CubeUI/SolveMode/Application/SolveModeEngine.swift b/Sources/CubeUI/SolveMode/Application/SolveModeEngine.swift new file mode 100644 index 0000000..a826b34 --- /dev/null +++ b/Sources/CubeUI/SolveMode/Application/SolveModeEngine.swift @@ -0,0 +1,113 @@ +import Foundation +import CubeCore + +/// Deterministic step navigator for solve playback. +public struct SolveModeEngine: Sendable { + public let initialState: CubeState + public let solution: [Move] + + public private(set) var stepIndex: Int + + private let checkpointInterval: Int + private var checkpoints: [Int: CubeState] + + public init( + initialState: CubeState, + solution: [Move], + stepIndex: Int = 0, + checkpointInterval: Int = 12 + ) { + self.initialState = initialState + self.solution = solution + self.stepIndex = max(0, min(stepIndex, solution.count)) + self.checkpointInterval = max(1, checkpointInterval) + self.checkpoints = [0: initialState] + } + + public var totalSteps: Int { + solution.count + } + + public func state(at step: Int) -> CubeState { + let clampedStep = clampStep(step) + if let checkpoint = checkpoints[clampedStep] { + return checkpoint + } + + let nearestStep = bestCheckpointStep(atOrBefore: clampedStep) + let nearestState = checkpoints[nearestStep] ?? initialState + if clampedStep == nearestStep { + return nearestState + } + + let moves = Array(solution[nearestStep.. CubeState { + state(at: stepIndex) + } + + public func currentMove() -> Move? { + guard stepIndex < solution.count else { return nil } + return solution[stepIndex] + } + + @discardableResult + public mutating func next() -> CubeState { + guard stepIndex < solution.count else { + return currentState() + } + + stepIndex += 1 + let state = state(at: stepIndex) + cacheCheckpointIfNeeded(step: stepIndex, state: state) + return state + } + + @discardableResult + public mutating func back() -> CubeState { + guard stepIndex > 0 else { + return currentState() + } + + stepIndex -= 1 + return state(at: stepIndex) + } + + @discardableResult + public mutating func jump(to step: Int) -> CubeState { + stepIndex = clampStep(step) + let state = state(at: stepIndex) + cacheCheckpointIfNeeded(step: stepIndex, state: state) + return state + } + + public func progressText() -> String { + "\(stepIndex)/\(solution.count)" + } + + public func isSolvedStep() -> Bool { + stepIndex >= solution.count + } + + public mutating func restart() -> CubeState { + stepIndex = 0 + return initialState + } + + private func clampStep(_ step: Int) -> Int { + max(0, min(step, solution.count)) + } + + private func bestCheckpointStep(atOrBefore step: Int) -> Int { + checkpoints.keys + .filter { $0 <= step } + .max() ?? 0 + } + + private mutating func cacheCheckpointIfNeeded(step: Int, state: CubeState) { + guard step == solution.count || step.isMultiple(of: checkpointInterval) else { return } + checkpoints[step] = state + } +} diff --git a/Sources/CubeUI/SolveMode/Domain/CubeMove.swift b/Sources/CubeUI/SolveMode/Domain/CubeMove.swift new file mode 100644 index 0000000..88928b9 --- /dev/null +++ b/Sources/CubeUI/SolveMode/Domain/CubeMove.swift @@ -0,0 +1,64 @@ +import Foundation +import CubeCore + +public enum CubeMoveParseError: Error, LocalizedError, Equatable, Sendable { + case invalidTokens([String]) + + public var errorDescription: String? { + switch self { + case .invalidTokens(let tokens): + "Invalid move notation: \(tokens.joined(separator: ", "))" + } + } +} + +public struct CubeMoveParser: Sendable { + public init() {} + + public func parse(_ notations: [String]) -> Result<[Move], CubeMoveParseError> { + var moves: [Move] = [] + var invalidTokens: [String] = [] + + moves.reserveCapacity(notations.count) + + for token in notations { + let normalized = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { continue } + + guard let move = Move(notation: normalized) else { + invalidTokens.append(normalized) + continue + } + moves.append(move) + } + + if invalidTokens.isEmpty { + return .success(moves) + } + + return .failure(.invalidTokens(invalidTokens)) + } +} + +public enum MoveDirection: Equatable, Sendable { + case clockwise + case counterClockwise + case doubleTurn +} + +public extension Move { + var direction: MoveDirection { + switch amount { + case .clockwise: + return .clockwise + case .counter: + return .counterClockwise + case .double: + return .doubleTurn + } + } + + var affectedFace: Face { + turn.face + } +} diff --git a/Sources/CubeUI/SolveMode/Domain/CubeReducer.swift b/Sources/CubeUI/SolveMode/Domain/CubeReducer.swift new file mode 100644 index 0000000..40ce3de --- /dev/null +++ b/Sources/CubeUI/SolveMode/Domain/CubeReducer.swift @@ -0,0 +1,35 @@ +import Foundation +import CubeCore + +/// Pure reducer helpers for deterministic cube state transitions in Solve Mode. +public enum CubeReducer { + public static func apply(_ move: Move, to state: CubeState) -> CubeState { + CubeState.apply(move: move, to: state) + } + + public static func apply(_ moves: [Move], to state: CubeState) -> CubeState { + moves.reduce(state) { partialState, move in + apply(move, to: partialState) + } + } + + public static func invert(_ move: Move) -> Move { + move.inverse + } +} + +public extension Move { + var inverse: Move { + let inverseAmount: Amount + switch amount { + case .clockwise: + inverseAmount = .counter + case .counter: + inverseAmount = .clockwise + case .double: + inverseAmount = .double + } + + return Move(turn: turn, amount: inverseAmount) + } +} diff --git a/Sources/CubeUI/SolveMode/UI/ControlsBarView.swift b/Sources/CubeUI/SolveMode/UI/ControlsBarView.swift new file mode 100644 index 0000000..96f1154 --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/ControlsBarView.swift @@ -0,0 +1,112 @@ +#if canImport(SwiftUI) + +import SwiftUI + +public struct ControlsBarView: View { + let stepIndex: Int + let totalSteps: Int + let progressText: String + let isAnimating: Bool + let isPlaying: Bool + @Binding var speed: SolvePlaybackSpeed + let onBack: () -> Void + let onNext: () -> Void + let onPlayPause: () -> Void + let onRestart: () -> Void + let onJump: (Int) -> Void + + public init( + stepIndex: Int, + totalSteps: Int, + progressText: String, + isAnimating: Bool, + isPlaying: Bool, + speed: Binding, + onBack: @escaping () -> Void, + onNext: @escaping () -> Void, + onPlayPause: @escaping () -> Void, + onRestart: @escaping () -> Void, + onJump: @escaping (Int) -> Void + ) { + self.stepIndex = stepIndex + self.totalSteps = totalSteps + self.progressText = progressText + self.isAnimating = isAnimating + self.isPlaying = isPlaying + _speed = speed + self.onBack = onBack + self.onNext = onNext + self.onPlayPause = onPlayPause + self.onRestart = onRestart + self.onJump = onJump + } + + public var body: some View { + VStack(spacing: 12) { + HStack { + Text("Progress") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Text(progressText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Slider( + value: Binding( + get: { Double(stepIndex) }, + set: { onJump(Int($0.rounded())) } + ), + in: 0...Double(max(totalSteps, 1)), + step: 1 + ) + .disabled(isAnimating) + .accessibilityLabel("Step slider") + .accessibilityValue("Step \(stepIndex) of \(totalSteps)") + + HStack(spacing: 10) { + Button(action: onRestart) { + Image(systemName: "arrow.counterclockwise") + .frame(width: 44, height: 44) + } + .buttonStyle(.bordered) + .disabled(stepIndex == 0 || isAnimating) + + Button(action: onBack) { + Image(systemName: "chevron.left") + .frame(width: 44, height: 44) + } + .buttonStyle(.bordered) + .disabled(stepIndex == 0 || isAnimating) + + Button(action: onPlayPause) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .frame(width: 48, height: 44) + } + .buttonStyle(.borderedProminent) + .disabled(stepIndex >= totalSteps || isAnimating) + + Button(action: onNext) { + Image(systemName: "chevron.right") + .frame(width: 44, height: 44) + } + .buttonStyle(.bordered) + .disabled(stepIndex >= totalSteps || isAnimating) + + Picker("Speed", selection: $speed) { + ForEach(SolvePlaybackSpeed.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 90) + .disabled(isAnimating) + } + } + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } +} + +#endif diff --git a/Sources/CubeUI/SolveMode/UI/CubeRenderer2DView.swift b/Sources/CubeUI/SolveMode/UI/CubeRenderer2DView.swift new file mode 100644 index 0000000..5b3f2a7 --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/CubeRenderer2DView.swift @@ -0,0 +1,122 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +public struct CubeRenderer2DView: View { + let state: CubeState + let highlightedMove: Move? + + public init(state: CubeState, highlightedMove: Move?) { + self.state = state + self.highlightedMove = highlightedMove + } + + public var body: some View { + GeometryReader { geometry in + let faceSize = min(geometry.size.width / 4.6, geometry.size.height / 3.6) + let overlay = highlightedMove.map(MoveOverlayDescriptor.init(move:)) + + VStack(spacing: faceSize * 0.08) { + HStack(spacing: faceSize * 0.08) { + Spacer() + .frame(width: faceSize) + faceView(.up, faceSize: faceSize, overlay: overlay) + Spacer() + .frame(width: faceSize * 2 + faceSize * 0.08) + } + + HStack(spacing: faceSize * 0.08) { + faceView(.left, faceSize: faceSize, overlay: overlay) + faceView(.front, faceSize: faceSize, overlay: overlay) + faceView(.right, faceSize: faceSize, overlay: overlay) + faceView(.back, faceSize: faceSize, overlay: overlay) + } + + HStack(spacing: faceSize * 0.08) { + Spacer() + .frame(width: faceSize) + faceView(.down, faceSize: faceSize, overlay: overlay) + Spacer() + .frame(width: faceSize * 2 + faceSize * 0.08) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func faceView(_ face: Face, faceSize: CGFloat, overlay: MoveOverlayDescriptor?) -> some View { + let stickers = state.faces[face] ?? Array(repeating: .white, count: 9) + let isHighlighted = overlay?.face == face + + return ZStack(alignment: .topTrailing) { + VStack(spacing: 2) { + ForEach(0..<3, id: \.self) { row in + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { column in + let index = row * 3 + column + RoundedRectangle(cornerRadius: 2) + .fill(swiftUIColor(for: stickers[safe: index] ?? .white)) + .frame(width: faceSize / 3.4, height: faceSize / 3.4) + } + } + } + } + .padding(4) + .frame(width: faceSize, height: faceSize) + .background(Color.black.opacity(0.32), in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isHighlighted ? Color.accentColor : Color.white.opacity(0.2), lineWidth: isHighlighted ? 3 : 1) + ) + + if isHighlighted, let overlay { + moveOverlayBadge(overlay) + .offset(x: 6, y: -6) + } + } + } + + private func moveOverlayBadge(_ overlay: MoveOverlayDescriptor) -> some View { + HStack(spacing: 4) { + Image(systemName: overlay.iconName) + .font(.caption.weight(.bold)) + if overlay.isDoubleTurn { + Text("2x") + .font(.caption2.bold()) + } + } + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.9), in: Capsule()) + } +} + +private struct MoveOverlayDescriptor { + let face: Face + let iconName: String + let isDoubleTurn: Bool + + init(move: Move) { + self.face = move.affectedFace + self.isDoubleTurn = move.direction == .doubleTurn + switch move.direction { + case .clockwise: + iconName = "arrow.clockwise" + case .counterClockwise: + iconName = "arrow.counterclockwise" + case .doubleTurn: + iconName = "arrow.triangle.2.circlepath" + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + guard indices.contains(index) else { return nil } + return self[index] + } +} + +#endif diff --git a/Sources/CubeUI/SolveMode/UI/CubeRenderer3DView.swift b/Sources/CubeUI/SolveMode/UI/CubeRenderer3DView.swift new file mode 100644 index 0000000..49cb59d --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/CubeRenderer3DView.swift @@ -0,0 +1,77 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore + +public struct CubeRenderer3DView: View { + @ObservedObject private var bridge: SolveModeRendererBridge + private let allowInteraction: Bool + + public init(bridge: SolveModeRendererBridge, allowInteraction: Bool = true) { + self.bridge = bridge + self.allowInteraction = allowInteraction + } + + public var body: some View { + #if canImport(SceneKit) + ZStack(alignment: .topTrailing) { + AnimatedCube3DView( + cube: bridge.state.toRubiksCube(), + currentMove: Binding( + get: { bridge.activeAnimationMove }, + set: { newValue in + guard bridge.activeAnimationMove != newValue else { return } + bridge.activeAnimationMove = newValue + } + ) + ) + .allowsHitTesting(allowInteraction) + .accessibilityLabel("3D cube visualization") + + if let move = bridge.highlightedMove { + faceBadge(for: move) + .padding(12) + .transition(.opacity.combined(with: .scale)) + } + } + #else + CubeRenderer2DView(state: bridge.state, highlightedMove: bridge.highlightedMove) + #endif + } + + private func faceBadge(for move: Move) -> some View { + VStack(alignment: .trailing, spacing: 4) { + Text("Turning \(move.affectedFace.displayName)") + .font(.caption.weight(.semibold)) + Text(move.notation) + .font(.title3.monospaced().weight(.bold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .foregroundStyle(.white) + .background(Color.accentColor.opacity(0.92), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Current move \(move.notation)") + } +} + +private extension Face { + var displayName: String { + switch self { + case .up: + return "UP" + case .down: + return "DOWN" + case .left: + return "LEFT" + case .right: + return "RIGHT" + case .front: + return "FRONT" + case .back: + return "BACK" + } + } +} + +#endif diff --git a/Sources/CubeUI/SolveMode/UI/MoveCardView.swift b/Sources/CubeUI/SolveMode/UI/MoveCardView.swift new file mode 100644 index 0000000..eee950b --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/MoveCardView.swift @@ -0,0 +1,53 @@ +#if canImport(SwiftUI) + +import SwiftUI + +public struct MoveCardView: View { + let instruction: MoveInstruction? + let isSolved: Bool + + public init(instruction: MoveInstruction?, isSolved: Bool) { + self.instruction = instruction + self.isSolved = isSolved + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + if isSolved { + Label("Solved!", systemImage: "checkmark.seal.fill") + .font(.title2.weight(.bold)) + .foregroundStyle(.green) + Text("All moves applied. Nice work.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else if let instruction { + Text(instruction.title) + .font(.system(size: 52, weight: .bold, design: .rounded)) + .frame(maxWidth: .infinity, alignment: .leading) + .monospacedDigit() + + Text(instruction.spokenInstruction) + .font(.headline) + .foregroundStyle(.primary) + + if let hint = instruction.hint { + Text(hint) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Text("Already solved") + .font(.title2.weight(.bold)) + Text("No moves are required for this cube state.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .accessibilityElement(children: .combine) + } +} + +#endif diff --git a/Sources/CubeUI/SolveMode/UI/SolveModeRendererBridge.swift b/Sources/CubeUI/SolveMode/UI/SolveModeRendererBridge.swift new file mode 100644 index 0000000..0f48280 --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/SolveModeRendererBridge.swift @@ -0,0 +1,32 @@ +#if canImport(SwiftUI) + +import Foundation +import SwiftUI +import CubeCore + +@MainActor +public final class SolveModeRendererBridge: ObservableObject, CubeRenderer { + @Published public private(set) var state: CubeState + @Published public private(set) var highlightedMove: Move? + @Published public var activeAnimationMove: Move? + + public init(initialState: CubeState) { + self.state = initialState + } + + public func setState(_ state: CubeState) { + self.state = state + } + + public func highlight(move: Move) { + highlightedMove = move + activeAnimationMove = move + } + + public func clearHighlight() { + highlightedMove = nil + activeAnimationMove = nil + } +} + +#endif diff --git a/Sources/CubeUI/SolveMode/UI/SolveModeView.swift b/Sources/CubeUI/SolveMode/UI/SolveModeView.swift new file mode 100644 index 0000000..50fccf1 --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/SolveModeView.swift @@ -0,0 +1,344 @@ +#if canImport(SwiftUI) + +import SwiftUI +import CubeCore +#if canImport(OSLog) +import OSLog +#endif + +public enum SolveRendererMode: String, CaseIterable, Identifiable, Sendable { + case flat2D = "2D" + case scene3D = "3D" + + public var id: String { rawValue } +} + +public struct SolveModeView: View { + #if canImport(OSLog) + private static let logger = Logger(subsystem: "com.cubesolver.ui", category: "SolveMode") + #endif + + @Environment(\.scenePhase) private var scenePhase + + @StateObject private var rendererBridge: SolveModeRendererBridge + @StateObject private var viewModel: SolveModeViewModel + + @SceneStorage("solve_mode_step_index") private var persistedStepIndex = 0 + @State private var didRestoreState = false + @State private var showAllMoves = false + @State private var rendererMode: SolveRendererMode + + private let solution: [Move] + + public init( + state: CubeState, + solution: [Move], + requireOrientationConfirmation: Bool = false, + initialSpeed: SolvePlaybackSpeed = .normal, + startIn3D: Bool = false + ) { + let bridge = SolveModeRendererBridge(initialState: state) + _rendererBridge = StateObject(wrappedValue: bridge) + _viewModel = StateObject( + wrappedValue: SolveModeViewModel( + initialState: state, + solution: solution, + rendererBridge: bridge, + animator: TimedCubeMoveAnimator(), + requireOrientationConfirmation: requireOrientationConfirmation, + initialSpeed: initialSpeed + ) + ) + _rendererMode = State(initialValue: startIn3D ? .scene3D : .flat2D) + self.solution = solution + } + + public init( + state: CubeState, + solutionNotation: [String], + requireOrientationConfirmation: Bool = false, + initialSpeed: SolvePlaybackSpeed = .normal, + startIn3D: Bool = false, + parser: CubeMoveParser = CubeMoveParser() + ) { + let parsedMoves: [Move] + let initialErrorMessage: String? + + switch parser.parse(solutionNotation) { + case .success(let moves): + parsedMoves = moves + initialErrorMessage = nil + case .failure(let error): + parsedMoves = [] + initialErrorMessage = error.localizedDescription + #if canImport(OSLog) + Self.logger.error("Solve mode move parse failed: \(error.localizedDescription, privacy: .public)") + #endif + } + + let bridge = SolveModeRendererBridge(initialState: state) + _rendererBridge = StateObject(wrappedValue: bridge) + _viewModel = StateObject( + wrappedValue: SolveModeViewModel( + initialState: state, + solution: parsedMoves, + rendererBridge: bridge, + animator: TimedCubeMoveAnimator(), + requireOrientationConfirmation: requireOrientationConfirmation, + initialSpeed: initialSpeed, + initialErrorMessage: initialErrorMessage + ) + ) + _rendererMode = State(initialValue: startIn3D ? .scene3D : .flat2D) + solution = parsedMoves + } + + public var body: some View { + ScrollView { + VStack(spacing: 16) { + visualizationCard + moveCard + + if let errorMessage = viewModel.errorMessage { + errorCard(errorMessage) + } + + if requiresOrientationLock { + orientationLockCard + } + + ControlsBarView( + stepIndex: viewModel.stepIndex, + totalSteps: viewModel.totalSteps, + progressText: viewModel.progressText, + isAnimating: viewModel.isAnimating, + isPlaying: viewModel.isPlaying, + speed: $viewModel.playbackSpeed, + onBack: viewModel.previousStep, + onNext: viewModel.nextStep, + onPlayPause: viewModel.togglePlayPause, + onRestart: viewModel.restart, + onJump: viewModel.jump + ) + .disabled(requiresOrientationLock) + + if !solution.isEmpty { + movesList + } + } + .padding() + } + .navigationTitle("Solve Mode") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .onAppear { + restoreStateIfNeeded() + } + .onDisappear { + viewModel.stopPlayback() + } + .onChange(of: viewModel.stepIndex) { _, newStep in + persistedStepIndex = newStep + } + .onChange(of: scenePhase) { _, phase in + guard phase != .active else { return } + viewModel.stopPlayback() + persistedStepIndex = viewModel.stepIndex + } + } + + private var visualizationCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Cube") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + + #if canImport(SceneKit) + Picker("Renderer", selection: $rendererMode) { + ForEach(SolveRendererMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 120) + .accessibilityLabel("Renderer mode") + #endif + + if viewModel.isSolved { + Label("Solved", systemImage: "sparkles") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + } + } + + Group { + if rendererMode == .scene3D { + CubeRenderer3DView(bridge: rendererBridge) + } else { + CubeRenderer2DView( + state: rendererBridge.state, + highlightedMove: rendererBridge.highlightedMove + ) + } + } + .frame(height: 290) + .background(Color.black.opacity(0.12), in: RoundedRectangle(cornerRadius: 16)) + } + .padding() + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + private var moveCard: some View { + MoveCardView( + instruction: viewModel.currentInstruction, + isSolved: viewModel.isSolved + ) + } + + private var requiresOrientationLock: Bool { + viewModel.requiresOrientationConfirmation && !viewModel.orientationConfirmed + } + + private var orientationLockCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Orientation Lock") + .font(.headline) + + Text("Confirm your holding orientation before starting. This keeps face instructions consistent.") + .font(.subheadline) + .foregroundStyle(.secondary) + + if let orientation = viewModel.orientation { + HStack(spacing: 12) { + orientationChip(title: "Front", color: orientation.frontColor) + orientationChip(title: "Up", color: orientation.upColor) + } + } + + Button("Confirm Orientation") { + viewModel.confirmOrientation() + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 16)) + .accessibilityElement(children: .contain) + } + + private var movesList: some View { + DisclosureGroup(isExpanded: $showAllMoves) { + LazyVStack(spacing: 6) { + ForEach(Array(solution.enumerated()), id: \.offset) { item in + let index = item.offset + let move = item.element + let isCompleted = index < viewModel.stepIndex + let isCurrent = index == viewModel.stepIndex && !viewModel.isSolved + + Button { + viewModel.jump(to: index) + } label: { + HStack(spacing: 10) { + Text("\(index + 1).") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + + Text(move.notation) + .font(.body.monospaced().weight(.semibold)) + + Spacer() + + if isCompleted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else if isCurrent { + Image(systemName: "arrowtriangle.left.fill") + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isCurrent ? Color.accentColor.opacity(0.14) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + .disabled(viewModel.isAnimating) + .accessibilityLabel("Move \(index + 1), \(move.notation)") + } + } + .padding(.top, 8) + } label: { + Text("All Moves") + .font(.headline) + } + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + } + + private func orientationChip(title: String, color: CubeColor) -> some View { + HStack(spacing: 8) { + Circle() + .fill(swiftUIColor(for: color)) + .frame(width: 18, height: 18) + Text("\(title): \(color.name.uppercased())") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.black.opacity(0.08), in: Capsule()) + } + + private func errorCard(_ message: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 4) { + Text("Move Data Error") + .font(.subheadline.weight(.semibold)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 12)) + } + + private func restoreStateIfNeeded() { + guard !didRestoreState else { return } + didRestoreState = true + + guard persistedStepIndex > 0 else { return } + viewModel.jump(to: persistedStepIndex) + } +} + +private extension CubeColor { + var name: String { + switch self { + case .white: + return "white" + case .yellow: + return "yellow" + case .red: + return "red" + case .orange: + return "orange" + case .blue: + return "blue" + case .green: + return "green" + } + } +} + +#endif diff --git a/Sources/CubeUI/SolveMode/UI/SolveModeViewModel.swift b/Sources/CubeUI/SolveMode/UI/SolveModeViewModel.swift new file mode 100644 index 0000000..28fb0be --- /dev/null +++ b/Sources/CubeUI/SolveMode/UI/SolveModeViewModel.swift @@ -0,0 +1,223 @@ +#if canImport(SwiftUI) + +import Foundation +import SwiftUI +import CubeCore + +public enum SolvePlaybackSpeed: Double, CaseIterable, Identifiable, Sendable { + case half = 0.5 + case normal = 1.0 + case double = 2.0 + + public var id: Double { rawValue } + + public var label: String { + switch self { + case .half: + return "0.5x" + case .normal: + return "1x" + case .double: + return "2x" + } + } +} + +@MainActor +public final class SolveModeViewModel: ObservableObject { + @Published public private(set) var displayedState: CubeState + @Published public private(set) var stepIndex: Int + @Published public private(set) var totalSteps: Int + @Published public private(set) var currentMove: Move? + @Published public private(set) var currentInstruction: MoveInstruction? + @Published public private(set) var progressText: String + @Published public private(set) var isAnimating = false + @Published public private(set) var isPlaying = false + @Published public private(set) var isSolved = false + @Published public private(set) var errorMessage: String? + @Published public private(set) var requiresOrientationConfirmation: Bool + @Published public private(set) var orientationConfirmed: Bool + @Published public var playbackSpeed: SolvePlaybackSpeed { + didSet { + if let configurableAnimator = animator as? ConfigurableCubeMoveAnimator { + configurableAnimator.speedMultiplier = playbackSpeed.rawValue + } + } + } + + public let solution: [Move] + public let orientation: SolveOrientation? + + private var engine: SolveModeEngine + private let renderer: CubeRenderer + private var animator: CubeMoveAnimator + private let formatter: MoveInstructionFormatter + private var playbackTask: Task? + + public var rendererBridge: SolveModeRendererBridge? { + renderer as? SolveModeRendererBridge + } + + public init( + initialState: CubeState, + solution: [Move], + renderer: CubeRenderer? = nil, + rendererBridge: SolveModeRendererBridge? = nil, + animator: CubeMoveAnimator = TimedCubeMoveAnimator(), + requireOrientationConfirmation: Bool = false, + initialSpeed: SolvePlaybackSpeed = .normal, + initialErrorMessage: String? = nil + ) { + self.solution = solution + self.orientation = SolveOrientation.from(state: initialState) + self.formatter = MoveInstructionFormatter(orientation: orientation) + self.engine = SolveModeEngine(initialState: initialState, solution: solution) + self.renderer = renderer ?? rendererBridge ?? SolveModeRendererBridge(initialState: initialState) + self.animator = animator + self.requiresOrientationConfirmation = requireOrientationConfirmation + self.orientationConfirmed = !requireOrientationConfirmation + self.playbackSpeed = initialSpeed + + displayedState = initialState + stepIndex = 0 + totalSteps = solution.count + currentMove = solution.first + currentInstruction = solution.first.map(formatter.instruction(for:)) + progressText = "0/\(solution.count)" + isSolved = solution.isEmpty + errorMessage = initialErrorMessage + + if let configurableAnimator = self.animator as? ConfigurableCubeMoveAnimator { + configurableAnimator.speedMultiplier = initialSpeed.rawValue + } + + self.renderer.setState(initialState) + } + + deinit { + playbackTask?.cancel() + } + + public func confirmOrientation() { + orientationConfirmed = true + } + + public func nextStep() { + guard orientationConfirmed else { return } + guard !isAnimating else { return } + guard let move = engine.currentMove() else { + isSolved = true + isPlaying = false + return + } + + isAnimating = true + animator.animate(move: move, on: renderer) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + let newState = self.engine.next() + self.finishTransition(newState: newState) + } + } + } + + public func previousStep() { + guard orientationConfirmed else { return } + guard !isAnimating else { return } + guard stepIndex > 0 else { return } + + isAnimating = true + let inverseMove = solution[stepIndex - 1].inverse + animator.animate(move: inverseMove, on: renderer) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + let newState = self.engine.back() + self.finishTransition(newState: newState) + } + } + } + + public func jump(to step: Int) { + guard orientationConfirmed else { return } + guard !isAnimating else { return } + stopPlayback() + + let newState = engine.jump(to: step) + renderer.setState(newState) + renderer.clearHighlight() + syncFromEngine(newState: newState) + } + + public func restart() { + guard !isAnimating else { return } + stopPlayback() + + let state = engine.restart() + renderer.setState(state) + renderer.clearHighlight() + syncFromEngine(newState: state) + } + + public func togglePlayPause() { + guard orientationConfirmed else { return } + + if isPlaying { + stopPlayback() + return + } + + guard !engine.isSolvedStep() else { return } + isPlaying = true + startPlaybackLoop() + } + + public func stopPlayback() { + isPlaying = false + playbackTask?.cancel() + playbackTask = nil + } + + private func startPlaybackLoop() { + playbackTask?.cancel() + playbackTask = Task { [weak self] in + while let self, !Task.isCancelled, self.isPlaying { + if self.engine.isSolvedStep() { + self.isPlaying = false + self.isSolved = true + return + } + + if !self.isAnimating { + self.nextStep() + } + + let delay = UInt64((0.78 / self.playbackSpeed.rawValue) * 1_000_000_000) + try? await Task.sleep(nanoseconds: max(80_000_000, delay)) + } + } + } + + private func finishTransition(newState: CubeState) { + renderer.setState(newState) + renderer.clearHighlight() + syncFromEngine(newState: newState) + isAnimating = false + + if engine.isSolvedStep() { + isSolved = true + isPlaying = false + } + } + + private func syncFromEngine(newState: CubeState) { + displayedState = newState + stepIndex = engine.stepIndex + totalSteps = engine.totalSteps + progressText = engine.progressText() + currentMove = engine.currentMove() + currentInstruction = currentMove.map(formatter.instruction(for:)) + isSolved = engine.isSolvedStep() + } +} + +#endif diff --git a/Sources/CubeUI/ValidatedManualInputView.swift b/Sources/CubeUI/ValidatedManualInputView.swift index fe40ff9..827569e 100644 --- a/Sources/CubeUI/ValidatedManualInputView.swift +++ b/Sources/CubeUI/ValidatedManualInputView.swift @@ -21,7 +21,7 @@ public struct ValidatedManualInputView: View { @State private var validationError: String? @State private var showValidationAlert = false @State private var isValid = true - @State private var showingSolutionPlayback = false + @State private var showingSolveMode = false @State private var isSolving = false public var body: some View { @@ -195,9 +195,11 @@ public struct ValidatedManualInputView: View { } message: { Text(isValid ? "Cube configuration is valid!" : (validationError ?? "Unknown error")) } - .sheet(isPresented: $showingSolutionPlayback) { - SolutionPlaybackView( - initialState: CubeState(from: cubeViewModel.cube) + .sheet(isPresented: $showingSolveMode) { + SolveModeView( + state: CubeState(from: cubeViewModel.cube), + solution: cubeViewModel.solution, + requireOrientationConfirmation: true ) } } @@ -220,10 +222,17 @@ public struct ValidatedManualInputView: View { // Solve the cube await cubeViewModel.solveAsync() - // Show solution playback + // Show guided solve mode await MainActor.run { + if let solveError = cubeViewModel.errorMessage { + validationError = solveError + isValid = false + isSolving = false + showValidationAlert = true + return + } isSolving = false - showingSolutionPlayback = true + showingSolveMode = true } } diff --git a/Tests/CubeUITests/CubeMoveTests.swift b/Tests/CubeUITests/CubeMoveTests.swift new file mode 100644 index 0000000..a3e5e6d --- /dev/null +++ b/Tests/CubeUITests/CubeMoveTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import CubeCore +@testable import CubeUI + +final class CubeMoveTests: XCTestCase { + func testParseValidTokens() { + let parser = CubeMoveParser() + + let result = parser.parse(["R", "U'", "F2"]) + + guard case .success(let moves) = result else { + return XCTFail("Expected successful parse") + } + + XCTAssertEqual(moves, [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double) + ]) + } + + func testParseInvalidTokensFailsSafely() { + let parser = CubeMoveParser() + + let result = parser.parse(["R", "X", "Z2", "U'"]) + + guard case .failure(let error) = result else { + return XCTFail("Expected parser failure for invalid tokens") + } + + XCTAssertEqual(error, .invalidTokens(["X", "Z2"])) + } + + func testDirectionAndAffectedFaceMapping() { + let clockwise = Move(turn: .R, amount: .clockwise) + let counter = Move(turn: .U, amount: .counter) + let doubleTurn = Move(turn: .F, amount: .double) + + XCTAssertEqual(clockwise.direction, .clockwise) + XCTAssertEqual(counter.direction, .counterClockwise) + XCTAssertEqual(doubleTurn.direction, .doubleTurn) + + XCTAssertEqual(clockwise.affectedFace, .right) + XCTAssertEqual(counter.affectedFace, .up) + XCTAssertEqual(doubleTurn.affectedFace, .front) + } +} diff --git a/Tests/CubeUITests/CubeReducerTests.swift b/Tests/CubeUITests/CubeReducerTests.swift new file mode 100644 index 0000000..da39895 --- /dev/null +++ b/Tests/CubeUITests/CubeReducerTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import CubeCore +@testable import CubeUI + +final class CubeReducerTests: XCTestCase { + func testApplyingMovesIsDeterministic() { + let initial = CubeState() + let moves = [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double), + Move(turn: .L, amount: .clockwise) + ] + + let first = CubeReducer.apply(moves, to: initial) + let second = CubeReducer.apply(moves, to: initial) + + XCTAssertEqual(first, second) + } + + func testMoveThenInverseReturnsPriorState() { + let allTurns = Turn.allCases + let amounts: [Amount] = [.clockwise, .counter, .double] + + for turn in allTurns { + for amount in amounts { + let move = Move(turn: turn, amount: amount) + let initial = CubeReducer.apply(Move(turn: .R, amount: .clockwise), to: CubeState()) + let moved = CubeReducer.apply(move, to: initial) + let restored = CubeReducer.apply(CubeReducer.invert(move), to: moved) + + XCTAssertEqual(restored, initial, "Failed inverse check for \(move.notation)") + } + } + } +} diff --git a/Tests/CubeUITests/MoveInstructionFormatterTests.swift b/Tests/CubeUITests/MoveInstructionFormatterTests.swift new file mode 100644 index 0000000..e903e4f --- /dev/null +++ b/Tests/CubeUITests/MoveInstructionFormatterTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import CubeCore +@testable import CubeUI + +final class MoveInstructionFormatterTests: XCTestCase { + func testInstructionFormattingForAllFaces() { + let formatter = MoveInstructionFormatter(orientation: nil) + let expectedFaces: [Turn: String] = [ + .U: "UP", + .D: "DOWN", + .L: "LEFT", + .R: "RIGHT", + .F: "FRONT", + .B: "BACK" + ] + + for turn in Turn.allCases { + let instruction = formatter.instruction(for: Move(turn: turn, amount: .clockwise)) + let expectedFace = expectedFaces[turn] ?? "" + XCTAssertEqual( + instruction.spokenInstruction, + "Turn the \(expectedFace) face clockwise 90 degrees." + ) + } + } + + func testClockwiseInstructionFormatting() { + let formatter = MoveInstructionFormatter(orientation: nil) + + let instruction = formatter.instruction(for: Move(turn: .R, amount: .clockwise)) + + XCTAssertEqual(instruction.title, "R") + XCTAssertEqual(instruction.spokenInstruction, "Turn the RIGHT face clockwise 90 degrees.") + } + + func testCounterClockwiseInstructionFormatting() { + let formatter = MoveInstructionFormatter(orientation: nil) + + let instruction = formatter.instruction(for: Move(turn: .U, amount: .counter)) + + XCTAssertEqual(instruction.title, "U'") + XCTAssertEqual(instruction.spokenInstruction, "Turn the UP face counter-clockwise 90 degrees.") + } + + func testDoubleTurnInstructionFormatting() { + let formatter = MoveInstructionFormatter(orientation: nil) + + let instruction = formatter.instruction(for: Move(turn: .F, amount: .double)) + + XCTAssertEqual(instruction.title, "F2") + XCTAssertEqual(instruction.spokenInstruction, "Turn the FRONT face 180 degrees.") + } + + func testOrientationHintIsIncludedWhenAvailable() { + let orientation = SolveOrientation(upColor: .white, frontColor: .green) + let formatter = MoveInstructionFormatter(orientation: orientation) + + let instruction = formatter.instruction(for: Move(turn: .L, amount: .clockwise)) + + XCTAssertEqual( + instruction.hint, + "Hold the cube with the GREEN center facing you and WHITE on top." + ) + } +} diff --git a/Tests/CubeUITests/SolveModeEngineTests.swift b/Tests/CubeUITests/SolveModeEngineTests.swift new file mode 100644 index 0000000..4198bb4 --- /dev/null +++ b/Tests/CubeUITests/SolveModeEngineTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import CubeCore +@testable import CubeUI + +final class SolveModeEngineTests: XCTestCase { + func testNextIncrementsIndexAndUpdatesState() { + var engine = makeEngine() + + let nextState = engine.next() + + XCTAssertEqual(engine.stepIndex, 1) + XCTAssertEqual(nextState, CubeReducer.apply(sampleSolution[0], to: initialState)) + XCTAssertEqual(engine.progressText(), "1/4") + } + + func testBackDecrementsIndexAndUpdatesState() { + var engine = makeEngine() + _ = engine.jump(to: 3) + + let stateAfterBack = engine.back() + + XCTAssertEqual(engine.stepIndex, 2) + let expected = CubeReducer.apply(Array(sampleSolution.prefix(2)), to: initialState) + XCTAssertEqual(stateAfterBack, expected) + } + + func testJumpToStepProducesDeterministicState() { + var engine = makeEngine() + + let jumped = engine.jump(to: 2) + let expected = CubeReducer.apply(Array(sampleSolution.prefix(2)), to: initialState) + + XCTAssertEqual(jumped, expected) + XCTAssertEqual(engine.currentState(), expected) + XCTAssertEqual(engine.currentMove(), sampleSolution[2]) + } + + func testSolvedStepAndProgressAtEnd() { + var engine = makeEngine() + + _ = engine.jump(to: sampleSolution.count) + + XCTAssertTrue(engine.isSolvedStep()) + XCTAssertNil(engine.currentMove()) + XCTAssertEqual(engine.progressText(), "4/4") + } + + func testRestartResetsToInitialState() { + var engine = makeEngine() + _ = engine.jump(to: 3) + + let restarted = engine.restart() + + XCTAssertEqual(engine.stepIndex, 0) + XCTAssertEqual(restarted, initialState) + XCTAssertEqual(engine.currentState(), initialState) + } + + private let initialState = CubeState() + + private let sampleSolution: [Move] = [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double), + Move(turn: .L, amount: .clockwise) + ] + + private func makeEngine() -> SolveModeEngine { + SolveModeEngine(initialState: initialState, solution: sampleSolution, checkpointInterval: 2) + } +} diff --git a/Tests/CubeUITests/SolveModeRenderingTests.swift b/Tests/CubeUITests/SolveModeRenderingTests.swift new file mode 100644 index 0000000..dedc0e4 --- /dev/null +++ b/Tests/CubeUITests/SolveModeRenderingTests.swift @@ -0,0 +1,72 @@ +#if canImport(SwiftUI) + +import SwiftUI +import XCTest +@testable import CubeCore +@testable import CubeUI + +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +final class SolveModeRenderingTests: XCTestCase { + func testSolveModeViewRendersCurrentMoveAndProgress() { + let view = SolveModeView( + state: CubeState(), + solution: [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double) + ] + ) + .frame(width: 390, height: 820) + + let imageData = pngData(for: view) + + XCTAssertNotNil(imageData) + XCTAssertGreaterThan(imageData?.count ?? 0, 5_000) + } + + func testCubeRenderer2DHighlightProducesDifferentSnapshot() { + let state = CubeState() + let plain = CubeRenderer2DView(state: state, highlightedMove: nil) + .frame(width: 360, height: 260) + + let highlighted = CubeRenderer2DView( + state: state, + highlightedMove: Move(turn: .R, amount: .clockwise) + ) + .frame(width: 360, height: 260) + + let plainData = pngData(for: plain) + let highlightedData = pngData(for: highlighted) + + XCTAssertNotNil(plainData) + XCTAssertNotNil(highlightedData) + XCTAssertNotEqual(plainData, highlightedData) + } + + private func pngData(for view: V) -> Data? { + let renderer = ImageRenderer(content: view) + + #if canImport(UIKit) + return renderer.uiImage?.pngData() + #elseif canImport(AppKit) + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) else { + return nil + } + return bitmap.representation(using: .png, properties: [:]) + #else + return nil + #endif + } +} + +#endif diff --git a/Tests/CubeUITests/SolveModeViewModelTests.swift b/Tests/CubeUITests/SolveModeViewModelTests.swift new file mode 100644 index 0000000..90fa072 --- /dev/null +++ b/Tests/CubeUITests/SolveModeViewModelTests.swift @@ -0,0 +1,117 @@ +#if canImport(SwiftUI) + +import XCTest +@testable import CubeCore +@testable import CubeUI + +@MainActor +final class SolveModeViewModelTests: XCTestCase { + func testNextStepIncrementsIndexAndUpdatesState() async { + let renderer = MockRenderer(initial: CubeState()) + let viewModel = SolveModeViewModel( + initialState: CubeState(), + solution: sampleMoves, + renderer: renderer, + animator: ImmediateAnimator() + ) + + viewModel.nextStep() + await Task.yield() + + XCTAssertEqual(viewModel.stepIndex, 1) + XCTAssertEqual(viewModel.progressText, "1/3") + XCTAssertEqual(viewModel.displayedState, CubeReducer.apply(sampleMoves[0], to: CubeState())) + } + + func testBackDecrementsIndexAndUpdatesState() async { + let renderer = MockRenderer(initial: CubeState()) + let viewModel = SolveModeViewModel( + initialState: CubeState(), + solution: sampleMoves, + renderer: renderer, + animator: ImmediateAnimator() + ) + + viewModel.jump(to: 2) + viewModel.previousStep() + await Task.yield() + + XCTAssertEqual(viewModel.stepIndex, 1) + XCTAssertEqual(viewModel.displayedState, CubeReducer.apply(sampleMoves[0], to: CubeState())) + } + + func testJumpToStepProducesExpectedState() { + let renderer = MockRenderer(initial: CubeState()) + let viewModel = SolveModeViewModel( + initialState: CubeState(), + solution: sampleMoves, + renderer: renderer, + animator: ImmediateAnimator() + ) + + viewModel.jump(to: 3) + + XCTAssertEqual(viewModel.stepIndex, 3) + XCTAssertTrue(viewModel.isSolved) + let expected = CubeReducer.apply(sampleMoves, to: CubeState()) + XCTAssertEqual(viewModel.displayedState, expected) + } + + func testOrientationLockBlocksInputUntilConfirmed() async { + let renderer = MockRenderer(initial: CubeState()) + let viewModel = SolveModeViewModel( + initialState: CubeState(), + solution: sampleMoves, + renderer: renderer, + animator: ImmediateAnimator(), + requireOrientationConfirmation: true + ) + + viewModel.nextStep() + await Task.yield() + XCTAssertEqual(viewModel.stepIndex, 0) + + viewModel.confirmOrientation() + viewModel.nextStep() + await Task.yield() + XCTAssertEqual(viewModel.stepIndex, 1) + } + + private let sampleMoves: [Move] = [ + Move(turn: .R, amount: .clockwise), + Move(turn: .U, amount: .counter), + Move(turn: .F, amount: .double) + ] +} + +@MainActor +private final class MockRenderer: CubeRenderer { + private(set) var state: CubeState + private(set) var highlightedMove: Move? + + init(initial: CubeState) { + state = initial + } + + func setState(_ state: CubeState) { + self.state = state + } + + func highlight(move: Move) { + highlightedMove = move + } + + func clearHighlight() { + highlightedMove = nil + } +} + +@MainActor +private final class ImmediateAnimator: CubeMoveAnimator { + func animate(move: Move, on renderer: CubeRenderer, completion: @escaping @Sendable () -> Void) { + renderer.highlight(move: move) + completion() + } +} + +#endif