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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user