Add console Minesweeper with a UI-free engine

Rules and board state live in Minesweeper.Core with no console calls, so the same board drives both the CLI and the tests.

Engine: seeded or random boards with classic presets; first click always safe (and its ring too when the board has room); cascading reveal that stops at numbers; flagging and chording; win by clearing every safe cell; FromMines() for deterministic layouts.

CLI: typed commands ("3 4", "f 2 5", "chord 6 7") plus optional arrow-key mode. Rows numbered, columns lettered, player input 1-based while engine coordinates stay 0-based, with CommandParser the only place that converts. ANSI colour when writing to a terminal, plain when redirected or NO_COLOR is set.

Tests: 186 covering flood-fill bounds, first-click safety, chording, win/loss, command parsing, session behaviour, and renderer alignment with colour on and off.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-09-05 21:50:11 +00:00
commit fd270095df
25 changed files with 2932 additions and 0 deletions
@@ -0,0 +1,575 @@
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class MinesweeperGameTests
{
private static readonly CellLocation Mine = new(0, 0);
private static readonly CellLocation Numbered = new(0, 1);
private static readonly CellLocation SecondNumbered = new(1, 1);
private static readonly CellLocation FarCorner = new(2, 3);
/// <summary>3x4 board where only the far corner reads zero, so floods stay small and chording
/// matters.
/// <code>
/// M 1 1 M
/// 2 2 2 1
/// 1 M 1 0
/// </code></summary>
private static MinesweeperGame PuzzleBoard() =>
MinesweeperGame.FromMines(new BoardSize(3, 4, 3), [new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]);
/// <summary>2x3 board with a single mine, small enough to win with one chord.
/// <code>
/// 0 1 M
/// 0 1 1
/// </code></summary>
private static MinesweeperGame ChordBoard() =>
MinesweeperGame.FromMines(new BoardSize(2, 3, 1), [new CellLocation(0, 2)]);
[Theory]
[InlineData(0, 5, 3)]
[InlineData(5, 0, 3)]
[InlineData(2, 2, 4)]
[InlineData(2, 2, 0)]
[InlineData(3, 3, -1)]
public void Constructor_RejectsImpossibleBoards(int rows, int columns, int mines) =>
Assert.Throws<ArgumentOutOfRangeException>(() => new MinesweeperGame(new BoardSize(rows, columns, mines)));
[Theory]
[InlineData(GameDifficulty.Beginner, 9, 9, 10)]
[InlineData(GameDifficulty.Intermediate, 16, 16, 40)]
[InlineData(GameDifficulty.Expert, 16, 30, 99)]
public void BoardSize_For_HasClassicDimensions(GameDifficulty difficulty, int rows, int columns, int mines)
{
var size = BoardSize.For(difficulty);
Assert.Equal(rows, size.Rows);
Assert.Equal(columns, size.Columns);
Assert.Equal(mines, size.Mines);
Assert.Equal(rows * columns, size.CellCount);
}
[Fact]
public void NewGame_StartsWithEverythingHiddenAndNoMinesPlaced()
{
var game = new MinesweeperGame(new BoardSize(5, 5, 4), seed: 1);
Assert.Equal(GameStatus.Ready, game.Status);
Assert.True(game.IsFirstMove);
Assert.Equal(0, game.RevealedCount);
Assert.Equal(25, game.UnrevealedCount);
Assert.Equal(0, game.FlagsUsed);
Assert.Empty(game.Mines);
Assert.Null(game.LostOn);
Assert.All(AllLocations(5, 5), l => Assert.Equal(CellState.Hidden, game.GetState(l)));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(37)]
[InlineData(9999)]
public void FirstClick_IsNeverAMine(int seed)
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed);
var clicked = new CellLocation(4, 4);
game.Reveal(clicked);
Assert.False(game.IsMine(clicked));
Assert.Equal(CellState.Revealed, game.GetState(clicked));
Assert.DoesNotContain(clicked, game.Mines);
Assert.False(game.IsFirstMove);
}
[Fact]
public void FirstClick_KeepsNeighboursSafeWhenBoardHasRoom()
{
var game = new MinesweeperGame(new BoardSize(5, 5, 4), seed: 3);
var clicked = new CellLocation(2, 2);
game.Reveal(clicked);
Assert.All(game.Neighbours(clicked), neighbour => Assert.False(game.IsMine(neighbour)));
Assert.Equal(0, game.GetAdjacentMineCount(clicked));
}
[Fact]
public void FirstClick_RelaxesNeighbourSafetyOnDenseBoards()
{
// Every cell touches the centre of a 3x3 board, so only the clicked cell can stay safe.
var game = new MinesweeperGame(new BoardSize(3, 3, 8), seed: 5);
var clicked = new CellLocation(1, 1);
game.Reveal(clicked);
Assert.Equal(8, game.Mines.Count);
Assert.False(game.IsMine(clicked));
Assert.Equal(GameStatus.Won, game.Status);
}
[Fact]
public void FirstClick_SeedsExactlyTheRequestedMineCount()
{
var game = new MinesweeperGame(new BoardSize(16, 30, 99), seed: 12);
game.Reveal(new CellLocation(8, 15));
Assert.Equal(99, game.Mines.Count);
Assert.Equal(99, game.Mines.Distinct().Count());
Assert.All(game.Mines, mine => Assert.True(game.IsMine(mine)));
}
[Fact]
public void AdjacentMineCount_MatchesNeighbouringMines()
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed: 21);
game.Reveal(new CellLocation(4, 4));
foreach (var location in AllLocations(9, 9))
{
Assert.Equal(game.Neighbours(location).Count(game.IsMine), game.GetAdjacentMineCount(location));
}
}
[Fact]
public void SameSeed_ProducesIdenticalBoard()
{
var first = new MinesweeperGame(new BoardSize(9, 9, 10), seed: 77);
var second = new MinesweeperGame(new BoardSize(9, 9, 10), seed: 77);
first.Reveal(new CellLocation(0, 0));
second.Reveal(new CellLocation(0, 0));
Assert.Equal(first.Mines, second.Mines);
}
[Fact]
public void DifferentSeeds_ProduceDifferentBoards()
{
var layouts = Enumerable.Range(0, 12)
.Select(seed =>
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed);
game.Reveal(new CellLocation(4, 4));
return game.Mines;
})
.Distinct()
.Count();
Assert.True(layouts > 1, "Expected distinct layouts across twelve seeds.");
}
/// <summary>5x5 board whose mines form a partial wall down column 2, so a click in the corner
/// opens exactly the ten cells to the left of it and nothing beyond.
/// <code>
/// 0 1 M 1 0
/// 0 2 M 2 1
/// 0 2 M 2 1
/// 0 2 M 2 1
/// 0 1 1 1 0
/// </code></summary>
private static MinesweeperGame WalledBoard() => MinesweeperGame.FromMines(new BoardSize(5, 5, 4),
[new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]);
[Fact]
public void Reveal_EmptyRegion_FloodsUntilNumbers()
{
var game = WalledBoard();
var revealed = game.Reveal(new CellLocation(0, 0));
Assert.Equal(10, revealed.Count);
Assert.All(revealed, l => Assert.Equal(CellState.Revealed, game.GetState(l)));
Assert.All(revealed, l => Assert.False(game.IsMine(l)));
Assert.Equal(GameStatus.Playing, game.Status);
// The numbers along the wall open, but the flood does not continue past them.
Assert.Equal(CellState.Revealed, game.GetState(new CellLocation(4, 1)));
Assert.Equal(1, game.GetAdjacentMineCount(new CellLocation(4, 1)));
Assert.All(AllLocations(5, 5).Where(l => l.Column >= 3), l => Assert.Equal(CellState.Hidden, game.GetState(l)));
Assert.All(game.Mines, mine => Assert.Equal(CellState.Hidden, game.GetState(mine)));
}
[Fact]
public void Reveal_FloodStopsAtNumbers()
{
var game = MinesweeperGame.FromMines(new BoardSize(1, 5, 1), [new CellLocation(0, 4)]);
var revealed = game.Reveal(new CellLocation(0, 0));
Assert.Equal(
[new CellLocation(0, 0), new CellLocation(0, 1), new CellLocation(0, 2), new CellLocation(0, 3)],
revealed.OrderBy(l => l.Column));
Assert.Equal(1, game.GetAdjacentMineCount(new CellLocation(0, 3)));
}
[Fact]
public void Reveal_FloodDoesNotOpenFlaggedCells()
{
var game = MinesweeperGame.FromMines(new BoardSize(5, 5, 1), [new CellLocation(4, 4)]);
game.ToggleFlag(new CellLocation(2, 2));
var revealed = game.Reveal(new CellLocation(0, 0));
Assert.DoesNotContain(new CellLocation(2, 2), revealed);
Assert.Equal(CellState.Flagged, game.GetState(new CellLocation(2, 2)));
Assert.Equal(1, game.FlagsUsed);
}
[Fact]
public void Reveal_OnNumberedCell_RevealsOnlyItself()
{
var game = PuzzleBoard();
var revealed = game.Reveal(Numbered);
Assert.Equal([Numbered], revealed);
Assert.Equal(1, game.GetAdjacentMineCount(Numbered));
}
[Fact]
public void Reveal_AlreadyRevealedCell_IsANoOp()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
Assert.Empty(game.Reveal(Numbered));
Assert.Equal(GameStatus.Playing, game.Status);
}
[Fact]
public void Reveal_FlaggedCell_IsANoOp()
{
var game = PuzzleBoard();
game.ToggleFlag(Numbered);
Assert.Empty(game.Reveal(Numbered));
Assert.Equal(CellState.Flagged, game.GetState(Numbered));
}
[Fact]
public void Reveal_OutsideBoard_Throws()
{
var game = PuzzleBoard();
Assert.Throws<ArgumentOutOfRangeException>(() => game.Reveal(new CellLocation(3, 0)));
Assert.Throws<ArgumentOutOfRangeException>(() => game.Reveal(new CellLocation(-1, 0)));
Assert.Throws<ArgumentOutOfRangeException>(() => game.ToggleFlag(new CellLocation(0, 4)));
Assert.Throws<ArgumentOutOfRangeException>(() => game.Chord(new CellLocation(0, -3)));
}
[Fact]
public void Reveal_OnMine_LosesAndExposesEveryMine()
{
var mines = new[] { new CellLocation(0, 0), new CellLocation(2, 2) };
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 2), mines);
var revealed = game.Reveal(new CellLocation(2, 2));
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(new CellLocation(2, 2), game.LostOn);
Assert.Equal([new CellLocation(2, 2)], revealed);
Assert.All(mines, l => Assert.Equal(CellState.Revealed, game.GetState(l)));
}
[Fact]
public void Reveal_OnMine_MarksIncorrectFlagsAsWrong()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [Mine]);
game.ToggleFlag(new CellLocation(2, 2));
game.Reveal(Mine);
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(CellState.WrongFlag, game.GetState(new CellLocation(2, 2)));
}
[Fact]
public void Reveal_LastSafeCell_WinsAndAutoFlagsMines()
{
var game = PuzzleBoard();
var mines = game.Mines.ToHashSet();
var safeCells = AllLocations(3, 4).Where(l => !mines.Contains(l)).ToList();
foreach (var cell in safeCells.Take(safeCells.Count - 1))
{
if (game.GetState(cell) == CellState.Hidden)
{
game.Reveal(cell);
}
}
Assert.Equal(GameStatus.Playing, game.Status);
game.Reveal(safeCells[^1]);
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(9, game.RevealedCount);
Assert.Equal(3, game.FlagsUsed);
Assert.All(mines, mine => Assert.Equal(CellState.Flagged, game.GetState(mine)));
}
[Fact]
public void Reveal_FloodCoveringAllSafeCells_Wins()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(8, game.RevealedCount);
}
[Fact]
public void Move_AfterWin_ThrowsGameOver()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
var exception = Assert.Throws<GameOverException>(() => game.Reveal(new CellLocation(0, 1)));
Assert.Equal(GameStatus.Won, exception.Status);
Assert.Throws<GameOverException>(() => game.ToggleFlag(new CellLocation(0, 1)));
Assert.Throws<GameOverException>(() => game.Chord(new CellLocation(0, 1)));
}
[Fact]
public void Move_AfterLoss_ThrowsGameOver()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [Mine]);
game.Reveal(Mine);
var exception = Assert.Throws<GameOverException>(() => game.Reveal(new CellLocation(2, 2)));
Assert.Equal(GameStatus.Lost, exception.Status);
}
[Fact]
public void ToggleFlag_RoundTripsThroughHidden()
{
var game = PuzzleBoard();
Assert.True(game.ToggleFlag(Numbered));
Assert.Equal(CellState.Flagged, game.GetState(Numbered));
Assert.Equal(1, game.FlagsUsed);
Assert.True(game.ToggleFlag(Numbered));
Assert.Equal(CellState.Hidden, game.GetState(Numbered));
Assert.Equal(0, game.FlagsUsed);
}
[Fact]
public void ToggleFlag_OnRevealedCell_IsRejected()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
Assert.False(game.ToggleFlag(Numbered));
Assert.Equal(CellState.Revealed, game.GetState(Numbered));
Assert.Equal(0, game.FlagsUsed);
}
[Fact]
public void ToggleFlag_DoesNotChangeRevealedCount()
{
var game = PuzzleBoard();
game.Reveal(FarCorner);
var before = game.RevealedCount;
game.ToggleFlag(Mine);
Assert.Equal(before, game.RevealedCount);
Assert.Equal(1, game.FlagsUsed);
}
[Fact]
public void Chord_OnHiddenCell_IsANoOp()
{
var game = PuzzleBoard();
Assert.Empty(game.Chord(Numbered));
}
[Fact]
public void Chord_WithTooFewFlags_IsANoOp()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
Assert.Equal(1, game.GetAdjacentMineCount(Numbered));
Assert.Empty(game.Chord(Numbered));
Assert.Equal(GameStatus.Playing, game.Status);
Assert.Equal([Numbered], RevealedCells(AllLocations(3, 4), game));
}
/// <summary>Every revealed cell, used to assert a chord opened nothing extra.</summary>
private static List<CellLocation> RevealedCells(IEnumerable<CellLocation> locations, MinesweeperGame game) =>
locations.Where(l => game.GetState(l) == CellState.Revealed).ToList();
[Fact]
public void Chord_WithCorrectFlags_OpensRemainingNeighbours()
{
var game = ChordBoard();
game.Reveal(new CellLocation(1, 0));
game.ToggleFlag(new CellLocation(0, 2));
var opened = game.Chord(new CellLocation(0, 1));
Assert.Equal([new CellLocation(1, 2)], opened);
Assert.Equal(GameStatus.Won, game.Status);
}
[Fact]
public void Chord_OnMistakenFlag_LosesGame()
{
var game = ChordBoard();
game.Reveal(new CellLocation(1, 0));
game.ToggleFlag(new CellLocation(1, 2));
game.Chord(new CellLocation(0, 1));
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(new CellLocation(0, 2), game.LostOn);
Assert.Equal(CellState.WrongFlag, game.GetState(new CellLocation(1, 2)));
}
[Fact]
public void Chord_OnlyCountsItsOwnAdjacentFlags()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
game.ToggleFlag(new CellLocation(0, 3));
// The flag sits on the far mine, which does not touch (0,1), so the counts still mismatch.
Assert.DoesNotContain(new CellLocation(0, 3), game.Neighbours(Numbered));
Assert.Empty(game.Chord(Numbered));
}
[Fact]
public void Chord_OnZeroCell_OpensNothingNewAfterItsFlood()
{
var game = PuzzleBoard();
game.Reveal(FarCorner);
Assert.Equal(0, game.GetAdjacentMineCount(FarCorner));
Assert.Empty(game.Chord(FarCorner));
}
[Fact]
public void Neighbours_RespectBoardEdges()
{
var game = PuzzleBoard();
Assert.Equal(3, game.Neighbours(new CellLocation(0, 0)).Count);
Assert.Equal(5, game.Neighbours(new CellLocation(0, 1)).Count);
Assert.Equal(8, game.Neighbours(new CellLocation(1, 1)).Count);
Assert.All(game.Neighbours(new CellLocation(0, 0)), n => Assert.True(game.IsWithinBounds(n)));
}
[Fact]
public void Neighbours_NeverIncludeTheCellItself()
{
var game = PuzzleBoard();
Assert.DoesNotContain(SecondNumbered, game.Neighbours(SecondNumbered));
}
[Fact]
public void IsWithinBounds_CoversExactlyTheGrid()
{
var game = new MinesweeperGame(new BoardSize(2, 4, 1), seed: 1);
Assert.True(game.IsWithinBounds(new CellLocation(1, 3)));
Assert.False(game.IsWithinBounds(new CellLocation(2, 0)));
Assert.False(game.IsWithinBounds(new CellLocation(1, 4)));
Assert.False(game.IsWithinBounds(new CellLocation(-1, 0)));
}
[Fact]
public void GetView_ExposesPerCellData()
{
var game = MinesweeperGame.FromMines(new BoardSize(4, 5, 3),
[new CellLocation(0, 0), new CellLocation(2, 2), new CellLocation(3, 4)]);
game.Reveal(new CellLocation(0, 4));
game.ToggleFlag(new CellLocation(1, 0));
var view = game.GetView();
Assert.Equal(4, view.GetLength(0));
Assert.Equal(5, view.GetLength(1));
foreach (var location in AllLocations(4, 5))
{
var cell = view[location.Row, location.Column];
Assert.Equal(location, cell.Location);
Assert.Equal(game.IsMine(location), cell.IsMine);
Assert.Equal(game.GetState(location), cell.State);
Assert.Equal(game.GetAdjacentMineCount(location), cell.AdjacentMines);
}
}
[Fact]
public void GetView_ReturnsAFreshSnapshotEachCall()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
var view = game.GetView();
view[0, 0] = default;
Assert.Equal(CellState.Hidden, game.GetState(new CellLocation(0, 0)));
Assert.Equal(new CellLocation(0, 0), game.GetView()[0, 0].Location);
}
[Fact]
public void FromMines_RejectsWrongMineCountOrBadLayout()
{
Assert.Throws<ArgumentException>(() =>
MinesweeperGame.FromMines(new BoardSize(3, 3, 2), [new CellLocation(0, 0)]));
Assert.Throws<ArgumentException>(() =>
MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(5, 5)]));
Assert.Throws<ArgumentException>(() =>
MinesweeperGame.FromMines(new BoardSize(3, 3, 2), [new CellLocation(0, 0), new CellLocation(0, 0)]));
}
[Fact]
public void FromMines_StartsInPlayingStateWithNumbersComputed()
{
var game = PuzzleBoard();
Assert.Equal(GameStatus.Playing, game.Status);
Assert.False(game.IsFirstMove);
Assert.Equal(2, game.GetAdjacentMineCount(SecondNumbered));
Assert.Equal(0, game.GetAdjacentMineCount(FarCorner));
}
[Fact]
public void SolvingRandomBeginnerBoards_AlwaysWins()
{
for (var seed = 0; seed < 40; seed++)
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed);
game.Reveal(new CellLocation(4, 4));
var mines = game.Mines.ToHashSet();
foreach (var cell in AllLocations(9, 9).Where(l => !mines.Contains(l)))
{
if (game.GetState(cell) == CellState.Hidden)
{
game.Reveal(cell);
}
}
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(71, game.RevealedCount);
}
}
private static IEnumerable<CellLocation> AllLocations(int rows, int columns)
{
for (var row = 0; row < rows; row++)
{
for (var column = 0; column < columns; column++)
{
yield return new CellLocation(row, column);
}
}
}
}