From 52eac5911862e25df0ffbf9ca791d1c1786a5fe2 Mon Sep 17 00:00:00 2001 From: Ty Tremblay Date: Sat, 1 Aug 2026 15:21:21 -0400 Subject: [PATCH] Add the interactive maze API and implement the solver Promote the interactive robot API into the library for real, so the lesson site no longer needs its temporary maze-engine shim: - Direction becomes an enum { UP, DOWN, LEFT, RIGHT } (same N/S/E/W bit values) with bit()/opposite()/clockwise()/counterClockwise(). - Add the Robot/Maze/Cell interfaces and concrete GridMaze/GridRobot/GridCell. The robot API is sensor/drivetrain-shaped: readWallSensor(Direction) (true = wall), drive(Direction) (no-op into a wall), and facing(), which the robot auto-updates on each successful drive. - Implement DefaultMazeSolver.solve() as a breadth-first search (shortest path). - Update CONTRACT.md with the interactive API and the UP/DOWN/LEFT/RIGHT naming. - Tests: 11 green under --release 8. Co-Authored-By: Claude Opus 4.8 --- CONTRACT.md | 62 ++++++++--- .../java/com/frc2713/mazesolver/Cell.java | 24 ++++ .../frc2713/mazesolver/DefaultMazeSolver.java | 94 +++++++++++++++- .../com/frc2713/mazesolver/Direction.java | 77 +++++++++++-- .../java/com/frc2713/mazesolver/GridCell.java | 45 ++++++++ .../java/com/frc2713/mazesolver/GridMaze.java | 58 ++++++++++ .../com/frc2713/mazesolver/GridRobot.java | 90 +++++++++++++++ .../java/com/frc2713/mazesolver/Maze.java | 24 ++++ .../com/frc2713/mazesolver/MazeSolver.java | 19 ++-- .../java/com/frc2713/mazesolver/Robot.java | 55 +++++++++ .../com/frc2713/mazesolver/DirectionTest.java | 31 +++++- .../com/frc2713/mazesolver/GridMazeTest.java | 105 ++++++++++++++++++ 12 files changed, 642 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/frc2713/mazesolver/Cell.java create mode 100644 src/main/java/com/frc2713/mazesolver/GridCell.java create mode 100644 src/main/java/com/frc2713/mazesolver/GridMaze.java create mode 100644 src/main/java/com/frc2713/mazesolver/GridRobot.java create mode 100644 src/main/java/com/frc2713/mazesolver/Maze.java create mode 100644 src/main/java/com/frc2713/mazesolver/Robot.java create mode 100644 src/test/java/com/frc2713/mazesolver/GridMazeTest.java diff --git a/CONTRACT.md b/CONTRACT.md index 09fe681..4e13571 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -8,22 +8,25 @@ library speak the same maze format. A maze is `int[][] maze`, indexed `maze[row][col]`. Every row has the same length. Each cell's value is a bitmask of which sides are **open** (carved -through, i.e. passable), using the same convention as the `maze-generator` -npm package the lesson site already uses to build mazes: - -| Direction | Bit | Value | -|-----------|------|-------| -| NORTH | 0001 | 1 | -| SOUTH | 0010 | 2 | -| EAST | 0100 | 4 | -| WEST | 1000 | 8 | - -A cell open to the north and east only is `NORTH \| EAST == 5`. A cell open -on all four sides is `15`. These constants are exposed as -`com.frc2713.mazesolver.Direction.{NORTH,SOUTH,EAST,WEST}`. - -The maze is assumed internally consistent: if `maze[r][c]` has `EAST` set, -then `maze[r][c + 1]` has `WEST` set, and so on. `solve` is not required to +through, i.e. passable), using the same bit values as the `maze-generator` +npm package the lesson site already uses to build mazes. The bits are named +the way they read on screen (`UP`/`DOWN`/`LEFT`/`RIGHT`), which correspond to +the generator's compass directions: + +| Direction | Compass | Bit | Value | +|-----------|---------|------|-------| +| UP | north | 0001 | 1 | +| DOWN | south | 0010 | 2 | +| RIGHT | east | 0100 | 4 | +| LEFT | west | 1000 | 8 | + +A cell open upward and to the right only is `UP.bit() \| RIGHT.bit() == 5`. A +cell open on all four sides is `15`. `com.frc2713.mazesolver.Direction` is an +`enum { UP, DOWN, LEFT, RIGHT }`; each constant's `bit()` returns its value +above. + +The maze is assumed internally consistent: if `maze[r][c]` has `RIGHT` set, +then `maze[r][c + 1]` has `LEFT` set, and so on. `solve` is not required to validate this beyond basic shape checks. Start is always the top-left cell (`[0][0]`); the goal is always the @@ -42,9 +45,32 @@ public interface MazeSolver { `solve` returns the path from start to goal as an ordered array of `{row, col}` pairs, starting with `{0, 0}` and ending at the goal cell, or `new int[0][]` if no path exists. It throws `IllegalArgumentException` for a -null, empty, or non-rectangular `maze`. +null, empty, or non-rectangular `maze`. `com.frc2713.mazesolver.DefaultMazeSolver` +is the shipped implementation (a breadth-first search, so the path is shortest). + +### Interactive API (used by the lesson playground) + +Lessons don't call `solve` — students write the algorithm themselves against a +robot they drive one cell at a time. That surface is also part of this contract: + +```java +Maze maze = new GridMaze(grid); // GridMaze is the concrete Maze +Robot robot = maze.robot(); // a robot on the start cell + +robot.readWallSensor(Direction dir); // true if that side is a wall +robot.drive(Direction dir); // move one cell if open; sets facing() +robot.facing(); // Direction it last drove (starts RIGHT) +robot.atGoal(); // on the bottom-right cell? +robot.row(); robot.col(); robot.cell(); +robot.trail(); // int[][] of [row,col] stood on, start first +``` -`com.frc2713.mazesolver.DefaultMazeSolver` is the shipped implementation. +`Direction` also offers `opposite()`, `clockwise()`, and `counterClockwise()` +so a wall-follower can be written relative to `facing()`. A `drive` into a wall +is a no-op (no move, no trail entry, `facing()` unchanged). The lesson site +interpolates a maze into a harness, splices in the student's method, drives the +robot, and animates `trail()` back — so the shapes of `GridMaze`, `Robot`, +`Cell`, and `Direction` are shared surface, not internal detail. ## Changing this contract diff --git a/src/main/java/com/frc2713/mazesolver/Cell.java b/src/main/java/com/frc2713/mazesolver/Cell.java new file mode 100644 index 0000000..2a8aadb --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/Cell.java @@ -0,0 +1,24 @@ +package com.frc2713.mazesolver; + +/** + * One square of a {@link Maze}. A cell knows where it sits and which of its four + * sides are walls — the same questions a robot standing on it can ask, without + * any bitmask arithmetic. + */ +public interface Cell { + + /** This cell's row (0 is the top row). */ + int row(); + + /** This cell's column (0 is the left column). */ + int col(); + + /** Is the given side of this cell a wall (i.e. blocked, not carved open)? */ + boolean wall(Direction dir); + + /** Is this the start cell (top-left)? */ + boolean isStart(); + + /** Is this the goal cell (bottom-right)? */ + boolean isGoal(); +} diff --git a/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java b/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java index d562b83..8f4f967 100644 --- a/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java +++ b/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java @@ -1,12 +1,102 @@ package com.frc2713.mazesolver; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; + /** - * The library's {@link MazeSolver} implementation. Solving logic goes here. + * The library's {@link MazeSolver} implementation. Finds the shortest path from + * the top-left start to the bottom-right goal with a breadth-first search over + * the cells reachable through carved (open) sides. */ public final class DefaultMazeSolver implements MazeSolver { @Override public int[][] solve(int[][] maze) { - throw new UnsupportedOperationException("not yet implemented"); + if (maze == null || maze.length == 0 || maze[0] == null || maze[0].length == 0) { + throw new IllegalArgumentException("maze must be a non-empty rectangular grid"); + } + int rows = maze.length; + int cols = maze[0].length; + for (int[] row : maze) { + if (row == null || row.length != cols) { + throw new IllegalArgumentException("maze must be rectangular: every row the same length"); + } + } + + int goalR = rows - 1; + int goalC = cols - 1; + + // parent[r][c] encodes the cell we arrived from as r*cols + c; -1 means + // unvisited, -2 marks the start (which has no parent). + int[][] parent = new int[rows][cols]; + for (int[] row : parent) { + Arrays.fill(row, -1); + } + + ArrayDeque queue = new ArrayDeque<>(); + queue.add(new int[] { 0, 0 }); + parent[0][0] = -2; + + while (!queue.isEmpty()) { + int[] cur = queue.poll(); + int r = cur[0]; + int c = cur[1]; + if (r == goalR && c == goalC) { + return reconstruct(parent, cols, goalR, goalC); + } + for (Direction dir : Direction.values()) { + if ((maze[r][c] & dir.bit()) == 0) { + continue; // that side is walled off + } + int nr = r; + int nc = c; + switch (dir) { + case UP: + nr -= 1; + break; + case DOWN: + nr += 1; + break; + case LEFT: + nc -= 1; + break; + case RIGHT: + nc += 1; + break; + } + if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) { + continue; + } + if (parent[nr][nc] != -1) { + continue; // already reached by an equal-or-shorter path + } + parent[nr][nc] = r * cols + c; + queue.add(new int[] { nr, nc }); + } + } + + return new int[0][]; // goal unreachable + } + + // Walk the parent pointers back from the goal to the start, then reverse. + private static int[][] reconstruct(int[][] parent, int cols, int goalR, int goalC) { + ArrayList reversed = new ArrayList<>(); + int r = goalR; + int c = goalC; + while (parent[r][c] != -2) { + reversed.add(new int[] { r, c }); + int p = parent[r][c]; + r = p / cols; + c = p % cols; + } + reversed.add(new int[] { 0, 0 }); + + int n = reversed.size(); + int[][] path = new int[n][]; + for (int i = 0; i < n; i++) { + path[i] = reversed.get(n - 1 - i); + } + return path; } } diff --git a/src/main/java/com/frc2713/mazesolver/Direction.java b/src/main/java/com/frc2713/mazesolver/Direction.java index 12f5b30..937578a 100644 --- a/src/main/java/com/frc2713/mazesolver/Direction.java +++ b/src/main/java/com/frc2713/mazesolver/Direction.java @@ -1,16 +1,77 @@ package com.frc2713.mazesolver; /** - * The N/S/E/W bitmask constants used by {@link MazeSolver}. Values match the - * {@code maze-generator} npm package's convention: N=1, S=2, E=4, W=8. + * One of the four ways a {@link Robot} can face or move, named the way a student + * looking at the maze on screen would name them: {@code UP}, {@code DOWN}, + * {@code LEFT}, {@code RIGHT}. + * + *

Each direction carries the single bit it occupies in a cell's N/S/E/W + * bitmask (the maze wire format shared with the {@code maze-generator} npm + * package): {@code UP == 1}, {@code DOWN == 2}, {@code RIGHT == 4}, + * {@code LEFT == 8}. On screen those are north/south/east/west respectively — a + * cell open to the north and east only is {@code UP.bit() | RIGHT.bit() == 5}. + * See {@link MazeSolver} for the maze format and {@code CONTRACT.md}. */ -public final class Direction { +public enum Direction { - public static final int NORTH = 1; - public static final int SOUTH = 2; - public static final int EAST = 4; - public static final int WEST = 8; + UP(1), + DOWN(2), + RIGHT(4), + LEFT(8); - private Direction() { + private final int bit; + + Direction(int bit) { + this.bit = bit; + } + + /** + * This direction's bit in a cell's open-sides bitmask + * ({@code UP=1, DOWN=2, RIGHT=4, LEFT=8}). + */ + public int bit() { + return bit; + } + + /** The opposite heading — where you'd point after turning all the way around. */ + public Direction opposite() { + switch (this) { + case UP: + return DOWN; + case DOWN: + return UP; + case LEFT: + return RIGHT; + default: + return LEFT; + } + } + + /** The heading 90° clockwise from this one (UP→RIGHT→DOWN→LEFT→UP). */ + public Direction clockwise() { + switch (this) { + case UP: + return RIGHT; + case RIGHT: + return DOWN; + case DOWN: + return LEFT; + default: + return UP; + } + } + + /** The heading 90° counter-clockwise from this one (UP→LEFT→DOWN→RIGHT→UP). */ + public Direction counterClockwise() { + switch (this) { + case UP: + return LEFT; + case LEFT: + return DOWN; + case DOWN: + return RIGHT; + default: + return UP; + } } } diff --git a/src/main/java/com/frc2713/mazesolver/GridCell.java b/src/main/java/com/frc2713/mazesolver/GridCell.java new file mode 100644 index 0000000..f51a51d --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/GridCell.java @@ -0,0 +1,45 @@ +package com.frc2713.mazesolver; + +/** One square of a {@link GridMaze}. Wall flags come from the cell's bitmask. */ +final class GridCell implements Cell { + + private final GridMaze maze; + private final int row; + private final int col; + + GridCell(GridMaze maze, int row, int col) { + this.maze = maze; + this.row = row; + this.col = col; + } + + private int mask() { + return maze.mask(row, col); + } + + @Override + public int row() { + return row; + } + + @Override + public int col() { + return col; + } + + @Override + public boolean wall(Direction dir) { + // A set bit means that side is OPEN, so a wall is the bit being clear. + return (mask() & dir.bit()) == 0; + } + + @Override + public boolean isStart() { + return row == 0 && col == 0; + } + + @Override + public boolean isGoal() { + return maze.isGoalCell(row, col); + } +} diff --git a/src/main/java/com/frc2713/mazesolver/GridMaze.java b/src/main/java/com/frc2713/mazesolver/GridMaze.java new file mode 100644 index 0000000..6d54621 --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/GridMaze.java @@ -0,0 +1,58 @@ +package com.frc2713.mazesolver; + +/** + * A concrete {@link Maze} backed by an N/S/E/W bitmask grid (a set bit means + * that side of the cell is OPEN). Start is the top-left cell {@code (0, 0)}; + * goal is the bottom-right cell {@code (rows - 1, cols - 1)} — the convention + * the whole maze module uses. + * + *

This is the entry point lesson snippets use: {@code new GridMaze(grid)} + * turns a raw {@code int[][]} into a maze you can hand a {@link Robot}. + */ +public class GridMaze implements Maze { + + private final int[][] grid; + private final int rows; + private final int cols; + private final int goalRow; + private final int goalCol; + private final GridRobot bot; + + public GridMaze(int[][] grid) { + this.grid = grid; + this.rows = grid.length; + this.cols = grid.length == 0 ? 0 : grid[0].length; + this.goalRow = rows - 1; + this.goalCol = cols - 1; + this.bot = new GridRobot(this); + } + + int mask(int row, int col) { + return grid[row][col]; + } + + @Override + public int rows() { + return rows; + } + + @Override + public int cols() { + return cols; + } + + @Override + public Cell cellAt(int row, int col) { + return new GridCell(this, row, col); + } + + @Override + public boolean isGoalCell(int row, int col) { + return row == goalRow && col == goalCol; + } + + @Override + public Robot robot() { + return bot; + } +} diff --git a/src/main/java/com/frc2713/mazesolver/GridRobot.java b/src/main/java/com/frc2713/mazesolver/GridRobot.java new file mode 100644 index 0000000..2d9b121 --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/GridRobot.java @@ -0,0 +1,90 @@ +package com.frc2713.mazesolver; + +import java.util.ArrayList; + +/** + * A {@link Robot} that walks a {@link GridMaze}. It records a trail: every cell + * it has stood on, start first. A drive blocked by a wall does nothing and adds + * no trail entry, so an algorithm that drives into a wall leaves a trail that + * simply stays put there. + * + *

The robot remembers one fact about itself — {@link #facing()} — and updates + * it every time it actually moves, because a robot that just drove up is now + * facing up. It starts facing {@link Direction#RIGHT}. + */ +final class GridRobot implements Robot { + + private final GridMaze maze; + private int row = 0; + private int col = 0; + private Direction facing = Direction.RIGHT; + private final ArrayList path = new ArrayList<>(); + + GridRobot(GridMaze maze) { + this.maze = maze; + path.add(new int[] { row, col }); + } + + private int mask() { + return maze.mask(row, col); + } + + @Override + public boolean readWallSensor(Direction dir) { + // A set bit means that side is OPEN, so a wall is the bit being clear. + return (mask() & dir.bit()) == 0; + } + + @Override + public void drive(Direction dir) { + if (readWallSensor(dir)) { + return; // wall ahead — the drivetrain can't push through it + } + switch (dir) { + case UP: + row -= 1; + break; + case DOWN: + row += 1; + break; + case LEFT: + col -= 1; + break; + case RIGHT: + col += 1; + break; + } + facing = dir; + path.add(new int[] { row, col }); + } + + @Override + public Direction facing() { + return facing; + } + + @Override + public boolean atGoal() { + return maze.isGoalCell(row, col); + } + + @Override + public int row() { + return row; + } + + @Override + public int col() { + return col; + } + + @Override + public Cell cell() { + return maze.cellAt(row, col); + } + + @Override + public int[][] trail() { + return path.toArray(new int[0][]); + } +} diff --git a/src/main/java/com/frc2713/mazesolver/Maze.java b/src/main/java/com/frc2713/mazesolver/Maze.java new file mode 100644 index 0000000..973f0f5 --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/Maze.java @@ -0,0 +1,24 @@ +package com.frc2713.mazesolver; + +/** + * A rectangular grid maze. Build one from an N/S/E/W bitmask grid with + * {@link GridMaze}. The start is always the top-left cell {@code (0, 0)} and the + * goal the bottom-right cell {@code (rows - 1, cols - 1)}. + */ +public interface Maze { + + /** Number of rows. */ + int rows(); + + /** Number of columns. */ + int cols(); + + /** The cell at the given row and column. */ + Cell cellAt(int row, int col); + + /** Is the given row/column the goal cell (bottom-right)? */ + boolean isGoalCell(int row, int col); + + /** A fresh {@link Robot} standing on the start cell of this maze. */ + Robot robot(); +} diff --git a/src/main/java/com/frc2713/mazesolver/MazeSolver.java b/src/main/java/com/frc2713/mazesolver/MazeSolver.java index 488e4de..e270b7c 100644 --- a/src/main/java/com/frc2713/mazesolver/MazeSolver.java +++ b/src/main/java/com/frc2713/mazesolver/MazeSolver.java @@ -6,15 +6,16 @@ * *

Bitmask convention (matches the {@code maze-generator} npm package used * to author lesson content, so a maze can round-trip between the site and - * this library unchanged): + * this library unchanged). The bits are named the way they read on screen — + * see {@link Direction}: *

- *   NORTH = 1  (0b0001)
- *   SOUTH = 2  (0b0010)
- *   EAST  = 4  (0b0100)
- *   WEST  = 8  (0b1000)
+ *   UP    = 1  (0b0001)   (north)
+ *   DOWN  = 2  (0b0010)   (south)
+ *   RIGHT = 4  (0b0100)   (east)
+ *   LEFT  = 8  (0b1000)   (west)
  * 
- * A cell's value is the bitwise OR of every open side, e.g. a cell open to - * the north and east only is {@code NORTH | EAST == 5}. See {@link Direction}. + * A cell's value is the bitwise OR of every open side, e.g. a cell open + * upward and to the right only is {@code UP.bit() | RIGHT.bit() == 5}. * *

Implementations must be pure: no networking, no threads, no filesystem, * no AWT/Swing. This library runs inside a browser JVM (CheerpJ, OpenJDK 8), @@ -30,8 +31,8 @@ public interface MazeSolver { * describing which sides of that cell are open. The maze is * assumed to be a valid grid: every row the same length, and * every "open" side agreed upon by both adjacent cells (e.g. - * if {@code maze[r][c]} has {@code EAST} set, then - * {@code maze[r][c + 1]} has {@code WEST} set). + * if {@code maze[r][c]} has {@code RIGHT} set, then + * {@code maze[r][c + 1]} has {@code LEFT} set). * @return the path from the top-left cell ({@code [0][0]}) to the * bottom-right cell ({@code [rows - 1][cols - 1]}), as an ordered * list of {@code [row, col]} coordinate pairs starting with diff --git a/src/main/java/com/frc2713/mazesolver/Robot.java b/src/main/java/com/frc2713/mazesolver/Robot.java new file mode 100644 index 0000000..bc32f5e --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/Robot.java @@ -0,0 +1,55 @@ +package com.frc2713.mazesolver; + +/** + * A robot that walks a {@link Maze} one cell at a time. It starts on the maze's + * start cell and knows only what's around it — never a bird's-eye view of the + * whole maze. + * + *

The robot has two abilities, mirroring a real one: it can sense a + * wall on any side ({@link #readWallSensor}) and it can drive one cell + * in a direction ({@link #drive}). It also remembers one fact about itself — + * which way it last drove, its {@link #facing()} — because a robot that just + * moved up is now facing up. + */ +public interface Robot { + + /** + * Reads the wall sensor on one side of the robot's current cell. + * + * @return {@code true} if that side is a wall (the robot cannot drive that + * way), {@code false} if it is open + */ + boolean readWallSensor(Direction dir); + + /** + * Drives one cell in the given direction. If that side is a wall the robot + * stays put and nothing happens; otherwise it moves and its + * {@link #facing()} becomes {@code dir}. + */ + void drive(Direction dir); + + /** + * The direction the robot last drove — the one fact it remembers about + * itself. Before it has moved, this is its initial heading. + */ + Direction facing(); + + /** Is the robot standing on the goal cell? */ + boolean atGoal(); + + /** The robot's current row (0 is the top row). */ + int row(); + + /** The robot's current column (0 is the left column). */ + int col(); + + /** The {@link Cell} the robot is currently standing on. */ + Cell cell(); + + /** + * The trail of every cell the robot has stood on, in order, as + * {@code [row, col]} pairs starting with the start cell. A drive blocked by + * a wall adds no entry. + */ + int[][] trail(); +} diff --git a/src/test/java/com/frc2713/mazesolver/DirectionTest.java b/src/test/java/com/frc2713/mazesolver/DirectionTest.java index c6af0fa..895a259 100644 --- a/src/test/java/com/frc2713/mazesolver/DirectionTest.java +++ b/src/test/java/com/frc2713/mazesolver/DirectionTest.java @@ -8,15 +8,36 @@ class DirectionTest { @Test void bitmaskValuesMatchMazeGeneratorConvention() { - assertEquals(1, Direction.NORTH); - assertEquals(2, Direction.SOUTH); - assertEquals(4, Direction.EAST); - assertEquals(8, Direction.WEST); + assertEquals(1, Direction.UP.bit()); + assertEquals(2, Direction.DOWN.bit()); + assertEquals(4, Direction.RIGHT.bit()); + assertEquals(8, Direction.LEFT.bit()); } @Test void directionsAreDistinctBits() { - int all = Direction.NORTH | Direction.SOUTH | Direction.EAST | Direction.WEST; + int all = Direction.UP.bit() | Direction.DOWN.bit() + | Direction.RIGHT.bit() | Direction.LEFT.bit(); assertEquals(15, all); } + + @Test + void oppositeFlipsTheHeading() { + assertEquals(Direction.DOWN, Direction.UP.opposite()); + assertEquals(Direction.UP, Direction.DOWN.opposite()); + assertEquals(Direction.RIGHT, Direction.LEFT.opposite()); + assertEquals(Direction.LEFT, Direction.RIGHT.opposite()); + } + + @Test + void turnsRotateThroughAllFour() { + assertEquals(Direction.RIGHT, Direction.UP.clockwise()); + assertEquals(Direction.DOWN, Direction.RIGHT.clockwise()); + assertEquals(Direction.LEFT, Direction.DOWN.clockwise()); + assertEquals(Direction.UP, Direction.LEFT.clockwise()); + + for (Direction d : Direction.values()) { + assertEquals(d, d.clockwise().counterClockwise()); + } + } } diff --git a/src/test/java/com/frc2713/mazesolver/GridMazeTest.java b/src/test/java/com/frc2713/mazesolver/GridMazeTest.java new file mode 100644 index 0000000..8f3c065 --- /dev/null +++ b/src/test/java/com/frc2713/mazesolver/GridMazeTest.java @@ -0,0 +1,105 @@ +package com.frc2713.mazesolver; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GridMazeTest { + + // A 3x3 maze with every interior wall carved open (fully connected). Bits: + // UP=1, DOWN=2, RIGHT=4, LEFT=8. Each cell is open toward every neighbor it + // has, so adjacent cells always agree. + private static int[][] openGrid() { + return new int[][] { + { 6, 14, 10 }, + { 7, 15, 11 }, + { 5, 13, 9 }, + }; + } + + @Test + void robotSensesWallsAndOpenings() { + Robot robot = new GridMaze(openGrid()).robot(); + // Top-left corner: walls up and left, openings down and right. + assertTrue(robot.readWallSensor(Direction.UP)); + assertTrue(robot.readWallSensor(Direction.LEFT)); + assertFalse(robot.readWallSensor(Direction.DOWN)); + assertFalse(robot.readWallSensor(Direction.RIGHT)); + } + + @Test + void driveMovesAndUpdatesFacing() { + Robot robot = new GridMaze(openGrid()).robot(); + assertEquals(Direction.RIGHT, robot.facing()); // initial heading + + robot.drive(Direction.DOWN); + assertEquals(1, robot.row()); + assertEquals(0, robot.col()); + assertEquals(Direction.DOWN, robot.facing()); + + robot.drive(Direction.RIGHT); + assertEquals(1, robot.row()); + assertEquals(1, robot.col()); + assertEquals(Direction.RIGHT, robot.facing()); + } + + @Test + void driveIntoWallDoesNothing() { + Robot robot = new GridMaze(openGrid()).robot(); + robot.drive(Direction.UP); // walled — should be a no-op + assertEquals(0, robot.row()); + assertEquals(0, robot.col()); + assertEquals(1, robot.trail().length); // no new trail entry + assertEquals(Direction.RIGHT, robot.facing()); // heading unchanged + } + + @Test + void robotReachesGoalAndRecordsTrail() { + Robot robot = new GridMaze(openGrid()).robot(); + robot.drive(Direction.DOWN); + robot.drive(Direction.DOWN); + robot.drive(Direction.RIGHT); + robot.drive(Direction.RIGHT); + assertTrue(robot.atGoal()); + assertEquals(5, robot.trail().length); + assertArrayEquals(new int[] { 0, 0 }, robot.trail()[0]); + assertArrayEquals(new int[] { 2, 2 }, robot.trail()[4]); + } + + @Test + void solverFindsShortestPath() { + int[][] path = new DefaultMazeSolver().solve(openGrid()); + assertArrayEquals(new int[] { 0, 0 }, path[0]); + assertArrayEquals(new int[] { 2, 2 }, path[path.length - 1]); + assertEquals(5, path.length); // 4 steps is the shortest across a 3x3 + // Every consecutive pair is an adjacent, carved move. + int[][] grid = openGrid(); + for (int i = 1; i < path.length; i++) { + int dr = path[i][0] - path[i - 1][0]; + int dc = path[i][1] - path[i - 1][1]; + assertEquals(1, Math.abs(dr) + Math.abs(dc), "steps must be to an adjacent cell"); + } + } + + @Test + void solverReturnsEmptyWhenGoalUnreachable() { + // Start cell sealed off (no open sides); goal can't be reached. + int[][] sealed = { + { 0, 8 }, + { 0, 1 }, + }; + assertEquals(0, new DefaultMazeSolver().solve(sealed).length); + } + + @Test + void solverRejectsMalformedMazes() { + assertThrows(IllegalArgumentException.class, () -> new DefaultMazeSolver().solve(null)); + assertThrows(IllegalArgumentException.class, () -> new DefaultMazeSolver().solve(new int[0][])); + assertThrows(IllegalArgumentException.class, + () -> new DefaultMazeSolver().solve(new int[][] { { 1, 2 }, { 3 } })); + } +}