From 3fa57392b0fbc6e00faba2b9b51a4451bc995a9e Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Sat, 29 Aug 2026 14:37:03 -0300 Subject: [PATCH] Prepare RubikSolverRobot for archival publication --- .github/workflows/ci.yml | 25 ++++ .gitignore | 12 ++ LICENSE | 21 +++ README.md | 121 +++++++++++++++++- docs/ARCHITECTURE.md | 25 ++++ docs/HARDWARE.md | 32 +++++ docs/LEGACY_NOTES.md | 33 +++++ Avance 1.png => docs/images/progress-1.png | Bin Avance 2.png => docs/images/progress-2.png | Bin Avance 3.png => docs/images/progress-3.png | Bin firmware/rubik_robot/rubik_robot.ino | 38 ++++++ .../arduino_moves.ino | 0 .../camera_gui_kociemba.py | 0 pyproject.toml | 28 ++++ src/rubik_solver_robot/__init__.py | 16 +++ src/rubik_solver_robot/__main__.py | 3 + src/rubik_solver_robot/cli.py | 73 +++++++++++ src/rubik_solver_robot/controller.py | 37 ++++++ src/rubik_solver_robot/cube.py | 63 +++++++++ src/rubik_solver_robot/protocol.py | 48 +++++++ tests/test_cli.py | 54 ++++++++ tests/test_cube.py | 41 ++++++ tests/test_protocol.py | 23 ++++ 23 files changed, 690 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/HARDWARE.md create mode 100644 docs/LEGACY_NOTES.md rename Avance 1.png => docs/images/progress-1.png (100%) rename Avance 2.png => docs/images/progress-2.png (100%) rename Avance 3.png => docs/images/progress-3.png (100%) create mode 100644 firmware/rubik_robot/rubik_robot.ino rename arduinoMoves29-11.ino => legacy/arduino_moves.ino (100%) rename CAMARA-GUi Kociemba 2.0 sin delays.py => legacy/camera_gui_kociemba.py (100%) create mode 100644 pyproject.toml create mode 100644 src/rubik_solver_robot/__init__.py create mode 100644 src/rubik_solver_robot/__main__.py create mode 100644 src/rubik_solver_robot/cli.py create mode 100644 src/rubik_solver_robot/controller.py create mode 100644 src/rubik_solver_robot/cube.py create mode 100644 src/rubik_solver_robot/protocol.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_cube.py create mode 100644 tests/test_protocol.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da62dfe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install package + run: python -m pip install -e . + - name: Compile Python source + run: python -m compileall -q src tests + - name: Run unit tests + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02232a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.coverage +.pytest_cache/ +.venv/ +venv/ +dist/ +build/ +*.egg-info/ +.DS_Store +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8678dcc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RubikSolverRobot contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6c528c6..12f136a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,119 @@ +# RubikSolverRobot -![](https://github.com/contursofran/RubikSolverRobot/blob/master/Avance%201.png) -![](https://github.com/contursofran/RubikSolverRobot/blob/master/Avance%202.png) -![](https://github.com/contursofran/RubikSolverRobot/blob/master/Avance%203.png) +An archived robotics project that detects a Rubik's Cube, calculates a solution, +and sends face-turn commands to an Arduino-driven mechanism. + +The original prototype is preserved in [`legacy/`](legacy/). A small Python 3 +command-line interface now documents and tests the core solver-to-robot protocol +without starting a camera or moving hardware by default. + +> [!IMPORTANT] +> This is a historical showcase project, not a maintained hardware product. +> Servo geometry, camera thresholds, and timing values depend on the original +> prototype. Test all motion with the mechanism disconnected or unloaded first. + +## What the prototype did + +1. Captured the six cube faces with a webcam. +2. Classified sticker colors with OpenCV thresholds. +3. Used the Kociemba algorithm to calculate a solution. +4. Converted standard cube moves to a one-byte serial protocol. +5. Used an Arduino and two servos to reposition and turn the cube. + +```mermaid +flowchart LR + Camera[Webcam] --> Vision[Color detection] + Vision --> State[54-sticker cube state] + State --> Solver[Kociemba solver] + Solver --> Protocol[Move encoder] + Protocol --> Serial[USB serial] + Serial --> Arduino[Arduino and servos] +``` + +## Repository map + +| Path | Purpose | +| --- | --- | +| `src/rubik_solver_robot/` | Safe Python 3 state validation, solving, and command encoding | +| `firmware/rubik_robot/` | Serial protocol test sketch; it does not move servos | +| `legacy/` | Unmodified original Python GUI and Arduino sketch | +| `docs/` | Architecture, hardware notes, and legacy limitations | +| `tests/` | Unit tests for cube-state and protocol rules | + +## Try it without hardware + +Python 3.10 or later is required. + +```bash +python -m pip install -e . +rubik-robot --solution "R U R' U'" +``` + +The command prints the encoded bytes and does not open a serial port: + +```text +Moves: R U R' U' +Commands: R U r u +Mode: dry run (no serial data sent) +``` + +You can also solve a 54-character cube state in `URFDLB` face order: + +```bash +python -m pip install -e '.[solver]' +rubik-robot --cube-state UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB +``` + +Hardware execution is an explicit action: + +```bash +python -m pip install -e '.[hardware]' +rubik-robot --solution "R U R' U'" --execute --port /dev/ttyACM0 +``` + +Use a Windows port such as `COM5` when applicable. Read +[`docs/HARDWARE.md`](docs/HARDWARE.md) before you send commands. + +## Protocol + +The modern interface accepts standard moves for the six faces: `U`, `R`, `F`, +`D`, `L`, and `B`. A prime suffix means a counter-clockwise turn. A `2` suffix +means a half turn. + +| Move | Serial byte | Move | Serial byte | Move | Serial byte | +| --- | --- | --- | --- | --- | --- | +| `F` | `F` | `F'` | `f` | `F2` | `1` | +| `B` | `B` | `B'` | `b` | `B2` | `2` | +| `U` | `U` | `U'` | `u` | `U2` | `3` | +| `D` | `D` | `D'` | `d` | `D2` | `4` | +| `L` | `L` | `L'` | `l` | `L2` | `5` | +| `R` | `R` | `R'` | `r` | `R2` | `6` | + +## Historical screenshots + +![Cube detection interface](docs/images/progress-1.png) + +![Detected face and controls](docs/images/progress-2.png) + +![Multi-face capture](docs/images/progress-3.png) + +## Current limits + +- The Python 3 interface does not replace the old camera GUI. +- The protocol test firmware does not drive the servo mechanism. +- The legacy Arduino sketch has known syntax and dispatch defects. +- The original color thresholds and motion values need hardware calibration. +- Exact compatibility with the original prototype is not a project goal. + +See [`docs/LEGACY_NOTES.md`](docs/LEGACY_NOTES.md) for the audit details. + +## Development + +```bash +python -m unittest discover -s tests -v +python -m compileall -q src tests +``` + +## License + +This repository is available under the [MIT License](LICENSE). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..497b1e9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,25 @@ +# Architecture + +## Modern archival interface + +The Python 3 code separates the stable parts of the project into small modules: + +- `cube.py` validates a 54-sticker state and calls the optional Kociemba solver. +- `protocol.py` validates face-turn notation and converts each move to one byte. +- `controller.py` opens a serial port only after the caller requests execution. +- `cli.py` combines these parts and uses dry-run mode by default. + +The interface has no camera dependency. This makes the protocol easy to review, +test, and demonstrate on a computer that has no robot attached. + +## Safety boundary + +Parsing and dry runs cannot open a serial port. Hardware access requires both +`--execute` and `--port`. This boundary prevents an accidental servo movement +during normal examples and automated tests. + +## Legacy application + +The original Python program contains the full camera pipeline and Tkinter UI. +It also creates global camera and UI resources at module load time. It remains +in `legacy/` as source material. It is not imported by the modern package. diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md new file mode 100644 index 0000000..a0274ed --- /dev/null +++ b/docs/HARDWARE.md @@ -0,0 +1,32 @@ +# Hardware notes + +## Historical setup + +The source indicates that the prototype used: + +- an Arduino-compatible board; +- two hobby servos on pins 3 and 4; +- a USB webcam; +- a serial connection at 9600 baud; +- a mechanical arm that pushes and holds the cube; +- a rotating base that changes cube orientation. + +The repository does not include a bill of materials, wiring diagram, CAD files, +or servo specifications. Do not infer electrical limits from the source code. + +## Safe test sequence + +1. Disconnect servo power. +2. Upload the protocol test sketch from `firmware/rubik_robot/`. +3. Run the Python CLI without `--execute` and inspect its command output. +4. Connect to the test sketch and confirm the `ACK` lines in the serial monitor. +5. Review the original motion code in `legacy/arduino_moves.ino`. +6. Restore motion one operation at a time and calibrate mechanical limits. +7. Keep the cube and hands clear during powered tests. + +## Firmware status + +The modern sketch validates the serial protocol only. It does not attach servos +or move the mechanism. The old sketch contains the historical motion sequences, +but it has known compile and command-dispatch defects. It needs repair and +hardware-specific calibration before use. diff --git a/docs/LEGACY_NOTES.md b/docs/LEGACY_NOTES.md new file mode 100644 index 0000000..9239af6 --- /dev/null +++ b/docs/LEGACY_NOTES.md @@ -0,0 +1,33 @@ +# Legacy audit notes + +The `legacy/` directory preserves the original uploaded files without a code +rewrite. They show the complete prototype idea, but they are not a supported +runtime. + +## Desktop application + +- The file uses Python 2 `print` statements while importing Python 3 modules. +- Camera, GUI, solver, detection, and serial control share global state. +- The program creates a Tk root window and opens camera index 0 during import. +- The serial port is fixed to `COM5` at 9600 baud. +- Long hardware waits run in the same workflow as UI operations. +- Color detection uses fixed pixel regions, area limits, and HSV thresholds. +- The solve action refers to names that are not consistently defined. +- Dependencies and supported versions were not recorded. + +## Arduino sketch + +- At least one statement is missing a semicolon in the uploaded source. +- A function block near the end is not closed correctly. +- A lowercase `r` command calls the clockwise `R()` function. +- A one-byte variable is compared with the multi-character values `L2` and `R2`. +- The desktop code sends `5` and `6` for these half turns, but the sketch does + not handle those bytes. +- The loop prints a value even when no new serial byte is available. +- Servo angles and delays are fixed for one physical assembly. + +## Publication decision + +The modern package gives the repository a safe demonstration path. The old files +remain useful as a historical record. Exact restoration of the camera UI and +mechanical behavior is outside the archive scope. diff --git a/Avance 1.png b/docs/images/progress-1.png similarity index 100% rename from Avance 1.png rename to docs/images/progress-1.png diff --git a/Avance 2.png b/docs/images/progress-2.png similarity index 100% rename from Avance 2.png rename to docs/images/progress-2.png diff --git a/Avance 3.png b/docs/images/progress-3.png similarity index 100% rename from Avance 3.png rename to docs/images/progress-3.png diff --git a/firmware/rubik_robot/rubik_robot.ino b/firmware/rubik_robot/rubik_robot.ino new file mode 100644 index 0000000..3278a38 --- /dev/null +++ b/firmware/rubik_robot/rubik_robot.ino @@ -0,0 +1,38 @@ +// RubikSolverRobot serial protocol test firmware. +// +// This sketch does not attach servos or move hardware. It lets you verify the +// desktop command stream before you restore and calibrate the legacy motion +// code. + +const long SERIAL_BAUD_RATE = 9600; +const char VALID_COMMANDS[] = "Ff1Bb2Uu3Dd4Ll5Rr6S"; + +bool isValidCommand(char command) { + for (unsigned int index = 0; index < sizeof(VALID_COMMANDS) - 1; ++index) { + if (VALID_COMMANDS[index] == command) { + return true; + } + } + return false; +} + +void setup() { + Serial.begin(SERIAL_BAUD_RATE); + Serial.println("READY RubikSolverRobot protocol test"); +} + +void loop() { + if (Serial.available() <= 0) { + return; + } + + const char command = Serial.read(); + if (!isValidCommand(command)) { + Serial.print("ERR "); + Serial.println(command); + return; + } + + Serial.print("ACK "); + Serial.println(command); +} diff --git a/arduinoMoves29-11.ino b/legacy/arduino_moves.ino similarity index 100% rename from arduinoMoves29-11.ino rename to legacy/arduino_moves.ino diff --git a/CAMARA-GUi Kociemba 2.0 sin delays.py b/legacy/camera_gui_kociemba.py similarity index 100% rename from CAMARA-GUi Kociemba 2.0 sin delays.py rename to legacy/camera_gui_kociemba.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1f8c864 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "rubik-solver-robot" +version = "1.0.0" +description = "Archived Rubik's Cube solver robot protocol tools" +readme = "README.md" +requires-python = ">=3.10" +license = { file = "LICENSE" } +keywords = ["arduino", "robotics", "rubiks-cube", "serial"] +classifiers = [ + "Development Status :: 7 - Inactive", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] + +[project.optional-dependencies] +solver = ["kociemba>=1.2.1,<2"] +hardware = ["pyserial>=3.5,<4"] +all = ["kociemba>=1.2.1,<2", "pyserial>=3.5,<4"] + +[project.scripts] +rubik-robot = "rubik_solver_robot.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/rubik_solver_robot/__init__.py b/src/rubik_solver_robot/__init__.py new file mode 100644 index 0000000..36ec8ab --- /dev/null +++ b/src/rubik_solver_robot/__init__.py @@ -0,0 +1,16 @@ +"""Safe protocol tools for the archived RubikSolverRobot project.""" + +from .cube import SOLVED_STATE, CubeStateError, solve_cube, validate_cube_state +from .protocol import MoveError, encode_solution, parse_solution + +__all__ = [ + "SOLVED_STATE", + "CubeStateError", + "MoveError", + "encode_solution", + "parse_solution", + "solve_cube", + "validate_cube_state", +] + +__version__ = "1.0.0" diff --git a/src/rubik_solver_robot/__main__.py b/src/rubik_solver_robot/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/rubik_solver_robot/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/rubik_solver_robot/cli.py b/src/rubik_solver_robot/cli.py new file mode 100644 index 0000000..5e1e1e9 --- /dev/null +++ b/src/rubik_solver_robot/cli.py @@ -0,0 +1,73 @@ +"""Command-line entry point for dry runs and explicit serial execution.""" + +import argparse +import sys + +from .controller import SerialSettings, send_commands +from .cube import CubeStateError, solve_cube +from .protocol import MoveError, encode_solution, parse_solution + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Solve a cube or encode moves for the archived robot." + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--solution", help="moves such as: R U R' U'") + source.add_argument( + "--cube-state", + help="54 stickers in URFDLB order; whitespace is ignored", + ) + parser.add_argument( + "--execute", + action="store_true", + help="send commands to hardware; the default is a dry run", + ) + parser.add_argument("--port", help="serial port, required with --execute") + parser.add_argument("--baud", type=int, default=9600, help="serial baud rate") + parser.add_argument( + "--command-delay", + type=float, + default=0.05, + help="seconds between serial bytes", + ) + return parser + + +def run(args: argparse.Namespace) -> int: + solution = args.solution if args.solution is not None else solve_cube(args.cube_state) + moves = parse_solution(solution) + commands = encode_solution(moves) + + print("Moves:", " ".join(moves) or "already solved") + print("Commands:", " ".join(chr(command) for command in commands) or "none") + + if not args.execute: + print("Mode: dry run (no serial data sent)") + return 0 + + if not args.port: + raise ValueError("--port is required with --execute") + if args.command_delay < 0: + raise ValueError("--command-delay cannot be negative") + + settings = SerialSettings( + port=args.port, + baud_rate=args.baud, + command_delay=args.command_delay, + ) + send_commands(commands, settings) + print(f"Mode: sent {len(commands)} command(s) to {args.port}") + return 0 + + +def main() -> int: + try: + return run(build_parser().parse_args()) + except (CubeStateError, MoveError, RuntimeError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/rubik_solver_robot/controller.py b/src/rubik_solver_robot/controller.py new file mode 100644 index 0000000..659888c --- /dev/null +++ b/src/rubik_solver_robot/controller.py @@ -0,0 +1,37 @@ +"""Explicit serial execution for robot commands.""" + +from dataclasses import dataclass +from time import sleep + + +@dataclass(frozen=True) +class SerialSettings: + port: str + baud_rate: int = 9600 + timeout: float = 2.0 + command_delay: float = 0.05 + + +def send_commands(commands: bytes, settings: SerialSettings) -> None: + """Send commands to the robot after the caller explicitly selects a port.""" + + if not settings.port.strip(): + raise ValueError("a serial port is required for hardware execution") + + try: + import serial + except ImportError as error: + raise RuntimeError( + "the serial dependency is missing; install with: " + "python -m pip install -e '.[hardware]'" + ) from error + + with serial.Serial( + settings.port, + settings.baud_rate, + timeout=settings.timeout, + ) as connection: + for command in commands: + connection.write(bytes((command,))) + connection.flush() + sleep(settings.command_delay) diff --git a/src/rubik_solver_robot/cube.py b/src/rubik_solver_robot/cube.py new file mode 100644 index 0000000..ef4c133 --- /dev/null +++ b/src/rubik_solver_robot/cube.py @@ -0,0 +1,63 @@ +"""Cube-state validation and optional Kociemba solving.""" + +from collections import Counter + +FACE_ORDER = "URFDLB" +SOLVED_STATE = "".join(face * 9 for face in FACE_ORDER) + + +class CubeStateError(ValueError): + """The supplied sticker state is not valid for the solver.""" + + +def validate_cube_state(state: str) -> str: + """Return a normalized 54-sticker state or raise ``CubeStateError``. + + This validates the format and color counts. The Kociemba solver performs + the remaining cubie-orientation and permutation checks. + """ + + normalized = "".join(state.split()).upper() + if len(normalized) != 54: + raise CubeStateError("cube state must contain exactly 54 stickers") + + invalid = sorted(set(normalized) - set(FACE_ORDER)) + if invalid: + raise CubeStateError( + "cube state contains invalid face symbols: " + ", ".join(invalid) + ) + + counts = Counter(normalized) + wrong_counts = [face for face in FACE_ORDER if counts[face] != 9] + if wrong_counts: + details = ", ".join(f"{face}={counts[face]}" for face in wrong_counts) + raise CubeStateError(f"each face symbol must occur 9 times ({details})") + + centers = "".join(normalized[index] for index in (4, 13, 22, 31, 40, 49)) + if centers != FACE_ORDER: + raise CubeStateError( + f"center stickers must follow URFDLB order (received {centers})" + ) + + return normalized + + +def solve_cube(state: str) -> str: + """Solve a validated cube state with the optional ``kociemba`` package.""" + + normalized = validate_cube_state(state) + if normalized == SOLVED_STATE: + return "" + + try: + import kociemba + except ImportError as error: + raise RuntimeError( + "the solver dependency is missing; install with: " + "python -m pip install -e '.[solver]'" + ) from error + + try: + return str(kociemba.solve(normalized)) + except ValueError as error: + raise CubeStateError(f"the cube state is not solvable: {error}") from error diff --git a/src/rubik_solver_robot/protocol.py b/src/rubik_solver_robot/protocol.py new file mode 100644 index 0000000..4d63da6 --- /dev/null +++ b/src/rubik_solver_robot/protocol.py @@ -0,0 +1,48 @@ +"""Conversion from cube notation to the prototype's serial protocol.""" + +import re +from collections.abc import Iterable + +MOVE_PATTERN = re.compile(r"^[URFDLB](?:2|')?$") + +COMMANDS = { + "F": "F", + "F'": "f", + "F2": "1", + "B": "B", + "B'": "b", + "B2": "2", + "U": "U", + "U'": "u", + "U2": "3", + "D": "D", + "D'": "d", + "D2": "4", + "L": "L", + "L'": "l", + "L2": "5", + "R": "R", + "R'": "r", + "R2": "6", +} + + +class MoveError(ValueError): + """The solution contains notation that the robot cannot execute.""" + + +def parse_solution(solution: str | Iterable[str]) -> list[str]: + """Normalize and validate a solution in standard face-turn notation.""" + + raw_moves = solution.split() if isinstance(solution, str) else list(solution) + moves = [move.strip().upper().replace("’", "'") for move in raw_moves] + invalid = [move for move in moves if not MOVE_PATTERN.fullmatch(move)] + if invalid: + raise MoveError("unsupported cube move(s): " + ", ".join(invalid)) + return moves + + +def encode_solution(solution: str | Iterable[str]) -> bytes: + """Encode a solution as the one-byte commands used by the prototype.""" + + return "".join(COMMANDS[move] for move in parse_solution(solution)).encode("ascii") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5e64ed0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,54 @@ +import argparse +import contextlib +import io +import unittest +from unittest.mock import patch + +from rubik_solver_robot.cli import run + + +def arguments(**overrides: object) -> argparse.Namespace: + values: dict[str, object] = { + "solution": "R U' F2", + "cube_state": None, + "execute": False, + "port": None, + "baud": 9600, + "command_delay": 0.05, + } + values.update(overrides) + return argparse.Namespace(**values) + + +class CliTests(unittest.TestCase): + @patch("rubik_solver_robot.cli.send_commands") + def test_dry_run_does_not_send_serial_data(self, send_commands) -> None: + output = io.StringIO() + with contextlib.redirect_stdout(output): + result = run(arguments()) + + self.assertEqual(result, 0) + send_commands.assert_not_called() + self.assertIn("Mode: dry run", output.getvalue()) + + @patch("rubik_solver_robot.cli.send_commands") + def test_execute_sends_encoded_commands(self, send_commands) -> None: + output = io.StringIO() + with contextlib.redirect_stdout(output): + result = run(arguments(execute=True, port="TEST_PORT")) + + self.assertEqual(result, 0) + sent_commands, settings = send_commands.call_args.args + self.assertEqual(sent_commands, b"Ru1") + self.assertEqual(settings.port, "TEST_PORT") + + @patch("rubik_solver_robot.cli.send_commands") + def test_execute_requires_a_port(self, send_commands) -> None: + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(ValueError, "--port"): + run(arguments(execute=True)) + send_commands.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cube.py b/tests/test_cube.py new file mode 100644 index 0000000..d09987d --- /dev/null +++ b/tests/test_cube.py @@ -0,0 +1,41 @@ +import unittest + +from rubik_solver_robot.cube import ( + SOLVED_STATE, + CubeStateError, + solve_cube, + validate_cube_state, +) + + +class CubeStateTests(unittest.TestCase): + def test_accepts_solved_state(self) -> None: + self.assertEqual(validate_cube_state(SOLVED_STATE), SOLVED_STATE) + + def test_solved_state_needs_no_moves_or_optional_dependency(self) -> None: + self.assertEqual(solve_cube(SOLVED_STATE), "") + + def test_removes_whitespace_and_normalizes_case(self) -> None: + spaced = "\n".join( + SOLVED_STATE[index : index + 9].lower() + for index in range(0, 54, 9) + ) + self.assertEqual(validate_cube_state(spaced), SOLVED_STATE) + + def test_rejects_wrong_length(self) -> None: + with self.assertRaisesRegex(CubeStateError, "exactly 54"): + validate_cube_state(SOLVED_STATE[:-1]) + + def test_rejects_wrong_symbol_count(self) -> None: + with self.assertRaisesRegex(CubeStateError, "must occur 9 times"): + validate_cube_state("R" + SOLVED_STATE[1:]) + + def test_rejects_wrong_center_order(self) -> None: + stickers = list(SOLVED_STATE) + stickers[4], stickers[13] = stickers[13], stickers[4] + with self.assertRaisesRegex(CubeStateError, "center stickers"): + validate_cube_state("".join(stickers)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..6d5f8ae --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,23 @@ +import unittest + +from rubik_solver_robot.protocol import MoveError, encode_solution, parse_solution + + +class ProtocolTests(unittest.TestCase): + def test_encodes_all_move_forms(self) -> None: + solution = "F F' F2 B B' B2 U U' U2 D D' D2 L L' L2 R R' R2" + self.assertEqual(encode_solution(solution), b"Ff1Bb2Uu3Dd4Ll5Rr6") + + def test_accepts_curly_prime_and_lowercase(self) -> None: + self.assertEqual(parse_solution("r u’"), ["R", "U'"]) + + def test_rejects_unsupported_notation(self) -> None: + with self.assertRaisesRegex(MoveError, "unsupported"): + encode_solution("R M U") + + def test_accepts_an_empty_solution(self) -> None: + self.assertEqual(encode_solution(""), b"") + + +if __name__ == "__main__": + unittest.main()