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,311 @@
using System.Text.RegularExpressions;
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class BoardRendererTests
{
private static readonly AnsiPalette Plain = new(enabled: false);
private static readonly AnsiPalette Colored = new(enabled: true);
/// <summary>3x4 board with a fixed layout used across the rendering assertions.
/// <code>
/// M 1 1 M
/// 2 2 2 1
/// 1 M 1 0
/// </code></summary>
private static MinesweeperGame Puzzle() =>
MinesweeperGame.FromMines(new BoardSize(3, 4, 3),
[new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]);
private static BoardRenderer Renderer(AnsiPalette palette) => new(palette);
/// <summary>Grid rows with the row-number gutter trimmed off.</summary>
private static List<string> Rows(string rendered) =>
rendered.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Skip(1)
.Select(line => line.Substring(3))
.ToList();
/// <summary>The glyph in each three-character cell of one grid row.</summary>
private static List<string> Glyphs(string rendered, int row)
{
var line = Rows(rendered)[row];
var glyphs = new List<string>();
for (var index = 2; index < line.Length; index += 3)
{
glyphs.Add(line[index].ToString());
}
return glyphs;
}
private static string StripAnsi(string text) => Regex.Replace(text, "\e\\[[0-9;]*m", string.Empty);
[Fact]
public void RenderBoard_BeforeAnyMove_ShowsEveryCellHidden()
{
var board = Renderer(Plain).RenderBoard(Puzzle());
Assert.Equal(3, Rows(board).Count);
Assert.Equal(["#", "#", "#", "#"], Glyphs(board, 0));
Assert.DoesNotContain("*", board);
Assert.DoesNotContain("·", board);
}
[Fact]
public void RenderBoard_HidesMinesUntilTheGameEnds()
{
var renderer = Renderer(Plain);
Assert.DoesNotContain("*", renderer.RenderBoard(Puzzle()));
var lost = Puzzle();
lost.Reveal(new CellLocation(0, 0));
Assert.Contains("*", renderer.RenderBoard(lost));
}
[Fact]
public void RenderBoard_RevealedCellsShowNumbersAndBlanks()
{
var game = Puzzle();
game.Reveal(new CellLocation(2, 3));
Assert.Equal(["#", "#", "1", "·"], Glyphs(Renderer(Plain).RenderBoard(game), 2));
}
[Fact]
public void RenderBoard_ShowsFlagsThenWrongFlags()
{
var renderer = Renderer(Plain);
var game = Puzzle();
game.ToggleFlag(new CellLocation(1, 1));
Assert.Equal(["#", "P", "#", "#"], Glyphs(renderer.RenderBoard(game), 1));
game.Reveal(new CellLocation(0, 0));
Assert.Contains("X", renderer.RenderBoard(game));
}
[Fact]
public void RenderBoard_AfterWinFlagsEveryMine()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
var board = Renderer(Plain).RenderBoard(game);
Assert.Equal(["·", "1", "P"], Glyphs(board, 2));
Assert.DoesNotContain("*", board);
}
[Fact]
public void RenderBoard_AfterLossExposesEveryMine()
{
var game = Puzzle();
game.Reveal(new CellLocation(0, 0));
var board = string.Concat(Rows(Renderer(Plain).RenderBoard(game)));
Assert.Equal(3, board.Count(c => c == '*'));
}
[Fact]
public void RenderBoard_UsesLetteredColumnsAndNumberedRows()
{
var lines = Renderer(Plain).RenderBoard(Puzzle()).Split('\n');
Assert.Equal(" A B C D", lines[0]);
Assert.StartsWith(" 1", lines[1]);
Assert.StartsWith(" 3", lines[3]);
}
[Fact]
public void RenderBoard_LabelsColumnsWithUpToTwoLetters()
{
Assert.Equal("A", BoardRenderer.ColumnName(0));
Assert.Equal("Z", BoardRenderer.ColumnName(25));
Assert.Equal("AA", BoardRenderer.ColumnName(26));
Assert.Equal("AB", BoardRenderer.ColumnName(27));
}
[Fact]
public void RenderBoard_KeepsEveryRowTheSameWidth()
{
var game = new MinesweeperGame(new BoardSize(9, 30, 99), seed: 3);
game.Reveal(new CellLocation(4, 15));
var lines = Renderer(Plain).RenderBoard(game).Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(10, lines.Length);
Assert.All(lines, line => Assert.Equal(93, line.Length));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void RenderBoard_ColorsDoNotDisturbAlignment(bool withCursor)
{
// 5x5 with a mine wall down column C, so half the board stays hidden while the other half
// opens into blanks, numbers, and flags.
var game = MinesweeperGame.FromMines(new BoardSize(5, 5, 4),
[new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]);
game.Reveal(new CellLocation(0, 0));
game.ToggleFlag(new CellLocation(0, 2));
game.ToggleFlag(new CellLocation(4, 4));
CellLocation? cursor = withCursor ? new CellLocation(2, 1) : null;
var plain = Renderer(Plain).RenderBoard(game, cursor);
var colored = StripAnsi(Renderer(Colored).RenderBoard(game, cursor));
Assert.Equal(GameStatus.Playing, game.Status);
Assert.Equal(plain, colored);
Assert.All(plain.Split('\n', StringSplitOptions.RemoveEmptyEntries), line => Assert.Equal(18, line.Length));
}
[Fact]
public void RenderBoard_ColorsDoNotDisturbAlignmentOnLostAndWonBoards()
{
var lost = Puzzle();
lost.Reveal(new CellLocation(0, 0));
var won = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
won.Reveal(new CellLocation(0, 0));
foreach (var game in new[] { lost, won })
{
Assert.Equal(
Renderer(Plain).RenderBoard(game),
StripAnsi(Renderer(Colored).RenderBoard(game)));
}
}
[Fact]
public void RenderBoard_MarksTheCursorEvenWithoutColor()
{
var plain = Renderer(Plain);
var game = Puzzle();
Assert.Contains("[#]", plain.RenderBoard(game, new CellLocation(0, 1)));
Assert.Equal(plain.RenderBoard(game).Length, plain.RenderBoard(game, new CellLocation(1, 2)).Length);
}
[Fact]
public void RenderBoard_CursorOffBoardDoesNotChangeOutput()
{
var renderer = Renderer(Plain);
var game = Puzzle();
Assert.Equal(renderer.RenderBoard(game), renderer.RenderBoard(game, new CellLocation(9, 9)));
}
[Fact]
public void RenderHeader_ReportsSizeMinesAndTime()
{
var header = Renderer(Plain).RenderHeader(Puzzle(), TimeSpan.FromSeconds(42));
Assert.Contains("4x3", header);
Assert.Contains("mines left 3", header);
Assert.Contains("42s", header);
}
[Fact]
public void RenderHeader_CountsDownMinesAsFlagsArePlaced()
{
var game = Puzzle();
var renderer = Renderer(Plain);
game.ToggleFlag(new CellLocation(1, 1));
Assert.Contains("mines left 2", renderer.RenderHeader(game, TimeSpan.Zero));
Assert.Contains("flags 1/3", renderer.RenderHeader(game, TimeSpan.Zero));
}
[Fact]
public void RenderHeader_HintsBeforeTheFirstMove()
{
var game = new MinesweeperGame(new BoardSize(5, 5, 3), seed: 1);
Assert.Contains("First click is always safe.", Renderer(Plain).RenderHeader(game, TimeSpan.Zero));
}
[Fact]
public void RenderHeader_CelebratesAWin()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
Assert.Contains("you win", Renderer(Plain).RenderHeader(game, TimeSpan.FromSeconds(3)));
}
[Fact]
public void RenderHeader_ExplainsALossUsingOneBasedCoordinates()
{
var game = Puzzle();
game.Reveal(new CellLocation(0, 0));
var header = Renderer(Plain).RenderHeader(game, TimeSpan.FromSeconds(1));
Assert.Contains("Boom", header);
Assert.Contains("row 1, column A", header);
}
[Fact]
public void GlyphFor_MapsEachStateToItsOwnCharacter()
{
var game = Puzzle();
var view = game.GetView();
Assert.Equal('#', BoardRenderer.GlyphFor(game, view[0, 1]));
game.Reveal(new CellLocation(2, 3));
view = game.GetView();
Assert.Equal('·', BoardRenderer.GlyphFor(game, view[2, 3]));
Assert.Equal('1', BoardRenderer.GlyphFor(game, view[2, 2]));
game.ToggleFlag(new CellLocation(0, 1));
view = game.GetView();
Assert.Equal('P', BoardRenderer.GlyphFor(game, view[0, 1]));
game.Reveal(new CellLocation(0, 0));
view = game.GetView();
Assert.Equal('*', BoardRenderer.GlyphFor(game, view[0, 3]));
Assert.Equal('X', BoardRenderer.GlyphFor(game, view[0, 1]));
}
[Fact]
public void Palette_WithoutColorEmitsNoEscapeCodes()
{
var game = Puzzle();
game.Reveal(new CellLocation(0, 1));
var rendered = Renderer(Plain).RenderBoard(game) + Renderer(Plain).RenderHeader(game, TimeSpan.Zero);
Assert.DoesNotContain("\e[", rendered);
}
[Fact]
public void Palette_GivesEachNumberItsOwnColor()
{
Assert.Equal(8, Enumerable.Range(1, 8).Select(Colored.Number).Distinct().Count());
Assert.Equal("9", Colored.Number(9));
Assert.DoesNotContain("\e[", Plain.Number(3));
}
[Fact]
public void Palette_DistinguishesEveryCellKind()
{
var styles = new[]
{
Colored.Cursor("[#]"),
Colored.Hidden(" #"),
Colored.Flag(" P"),
Colored.Mine(" *"),
Colored.WrongFlag(" X"),
Colored.ExplodedMine(" *"),
Colored.Number(1, " 1"),
};
Assert.Equal(styles.Length, styles.Distinct().Count());
}
}