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());
}
}
@@ -0,0 +1,149 @@
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class CommandLineTests
{
[Fact]
public void TryParse_WithoutArguments_UsesBeginnerDefaults()
{
Assert.True(CommandLine.TryParse([], out var options, out var error));
Assert.Empty(error);
Assert.Equal(GameDifficulty.Beginner, options.Difficulty);
Assert.Null(options.Seed);
Assert.False(options.UseKeys);
Assert.False(options.ShowHelp);
}
[Theory]
[InlineData("-d", "expert", GameDifficulty.Expert)]
[InlineData("--difficulty", "EXPERT", GameDifficulty.Expert)]
[InlineData("-d", "hard", GameDifficulty.Expert)]
[InlineData("-d", "3", GameDifficulty.Expert)]
[InlineData("-d", "e", GameDifficulty.Expert)]
[InlineData("-d", "beginner", GameDifficulty.Beginner)]
[InlineData("-d", "easy", GameDifficulty.Beginner)]
[InlineData("-d", "1", GameDifficulty.Beginner)]
[InlineData("-d", "intermediate", GameDifficulty.Intermediate)]
[InlineData("-d", "medium", GameDifficulty.Intermediate)]
[InlineData("-d", "2", GameDifficulty.Intermediate)]
public void TryParse_DifficultySwitch(string flag, string value, GameDifficulty expected)
{
Assert.True(CommandLine.TryParse([flag, value], out var options, out _));
Assert.Equal(expected, options.Difficulty);
}
[Theory]
[InlineData(new[] { "-b" }, GameDifficulty.Beginner)]
[InlineData(new[] { "-i" }, GameDifficulty.Intermediate)]
[InlineData(new[] { "-e" }, GameDifficulty.Expert)]
public void TryParse_DifficultyShorthands(string[] args, GameDifficulty expected)
{
Assert.True(CommandLine.TryParse(args, out var options, out _));
Assert.Equal(expected, options.Difficulty);
}
[Fact]
public void TryParse_CombinesOptions()
{
Assert.True(CommandLine.TryParse(["-e", "--seed", "42", "-k"], out var options, out _));
Assert.Equal(GameDifficulty.Expert, options.Difficulty);
Assert.Equal(42, options.Seed);
Assert.True(options.UseKeys);
}
[Theory]
[InlineData("0")]
[InlineData("-7")]
[InlineData("999999999")]
public void TryParse_AcceptsAnyIntegerSeed(string seed)
{
Assert.True(CommandLine.TryParse(["--seed", seed], out var options, out _));
Assert.Equal(int.Parse(seed), options.Seed);
}
[Fact]
public void TryParse_Help()
{
Assert.True(CommandLine.TryParse(["--help"], out var options, out _));
Assert.True(options.ShowHelp);
Assert.True(CommandLine.TryParse(["-h"], out options, out _));
Assert.True(options.ShowHelp);
}
[Fact]
public void TryParse_Keys()
{
Assert.True(CommandLine.TryParse(["--keys"], out var options, out _));
Assert.True(options.UseKeys);
Assert.True(CommandLine.TryParse(["-k"], out options, out _));
Assert.True(options.UseKeys);
}
[Theory]
[InlineData("--difficulty")]
[InlineData("-d")]
public void TryParse_DifficultyWithoutValue_Fails(string flag)
{
Assert.False(CommandLine.TryParse([flag], out var options, out var error));
Assert.Null(options);
Assert.Contains("difficulty", error);
}
[Theory]
[InlineData("impossible")]
[InlineData("")]
public void TryParse_UnknownDifficulty_Fails(string value)
{
Assert.False(CommandLine.TryParse(["-d", value], out var options, out var error));
Assert.Null(options);
Assert.Contains("difficulty", error);
}
[Theory]
[InlineData("--seed")]
[InlineData("--seed", "abc")]
[InlineData("--seed", "1.5")]
public void TryParse_BadSeed_Fails(params string[] args)
{
Assert.False(CommandLine.TryParse(args, out var options, out var error));
Assert.Null(options);
Assert.Contains("seed", error);
}
[Fact]
public void TryParse_UnknownOption_FailsAndNamesIt()
{
Assert.False(CommandLine.TryParse(["--turbo"], out var options, out var error));
Assert.Null(options);
Assert.Contains("--turbo", error);
}
[Fact]
public void Usage_MentionsEveryOption()
{
Assert.Contains("--difficulty", CommandLine.Usage);
Assert.Contains("--seed", CommandLine.Usage);
Assert.Contains("--keys", CommandLine.Usage);
Assert.Contains("--help", CommandLine.Usage);
}
[Fact]
public void HelpText_ListsEveryCommandVerb()
{
foreach (var verb in new[] { "reveal", "flag", "chord", "go", "difficulty", "new", "help", "quit" })
{
Assert.Contains(verb, GameSession.HelpText);
}
}
}
@@ -0,0 +1,187 @@
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class CommandParserTests
{
[Theory]
[InlineData("q")]
[InlineData("quit")]
[InlineData("EXIT")]
public void Parse_QuitVerbs(string input) =>
Assert.IsType<Command.Quit>(CommandParser.Parse(input));
[Theory]
[InlineData("n")]
[InlineData("new")]
public void Parse_NewGame(string input) =>
Assert.IsType<Command.NewGame>(CommandParser.Parse(input));
[Theory]
[InlineData("h")]
[InlineData("help")]
[InlineData("?")]
[InlineData("HELP")]
public void Parse_Help(string input) =>
Assert.IsType<Command.ShowHelp>(CommandParser.Parse(input));
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Parse_BlankInput_IsANoOp(string input) =>
Assert.IsType<Command.Noop>(CommandParser.Parse(input));
[Fact]
public void Parse_RevealVerb_IsZeroBased()
{
var command = Assert.IsType<Command.Reveal>(CommandParser.Parse("reveal 3 4"));
Assert.Equal(new CellLocation(2, 3), command.Cell);
}
[Theory]
[InlineData("2 5")]
[InlineData("2,5")]
[InlineData(" 2 ; 5 ")]
[InlineData("2 5")]
public void Parse_BareCoordinates_Reveal(string input)
{
var command = Assert.IsType<Command.Reveal>(CommandParser.Parse(input));
Assert.Equal(new CellLocation(1, 4), command.Cell);
}
[Theory]
[InlineData("r 1 1")]
[InlineData("open 1 1")]
public void Parse_RevealAliases(string input) =>
Assert.IsType<Command.Reveal>(CommandParser.Parse(input));
[Theory]
[InlineData("f 2 3")]
[InlineData("flag 2 3")]
[InlineData("mark 2 3")]
public void Parse_FlagAliases(string input)
{
var command = Assert.IsType<Command.Flag>(CommandParser.Parse(input));
Assert.Equal(new CellLocation(1, 2), command.Cell);
}
[Theory]
[InlineData("c 4 5")]
[InlineData("chord 4 5")]
public void Parse_ChordAliases(string input)
{
var command = Assert.IsType<Command.Chord>(CommandParser.Parse(input));
Assert.Equal(new CellLocation(3, 4), command.Cell);
}
[Fact]
public void Parse_GotoMovesTheCursor()
{
var command = Assert.IsType<Command.SetCursor>(CommandParser.Parse("go 6 7"));
Assert.Equal(new CellLocation(5, 6), command.Cell);
}
[Theory]
[InlineData("d 1", GameDifficulty.Beginner)]
[InlineData("difficulty beginner", GameDifficulty.Beginner)]
[InlineData("d EASY", GameDifficulty.Beginner)]
[InlineData("d 2", GameDifficulty.Intermediate)]
[InlineData("difficulty INTERMEDIATE", GameDifficulty.Intermediate)]
[InlineData("d medium", GameDifficulty.Intermediate)]
[InlineData("d 3", GameDifficulty.Expert)]
[InlineData("difficulty expert", GameDifficulty.Expert)]
[InlineData("d HARD", GameDifficulty.Expert)]
public void Parse_DifficultyAliases(string input, GameDifficulty expected)
{
var command = Assert.IsType<Command.ChangeDifficulty>(CommandParser.Parse(input));
Assert.Equal(expected, command.Difficulty);
}
[Fact]
public void Parse_DifficultyWithoutValue_ShowsHelp() =>
Assert.IsType<Command.ShowHelp>(CommandParser.Parse("difficulty"));
[Theory]
[InlineData("banana")]
[InlineData("reveal 3")]
[InlineData("reveal x y")]
[InlineData("flag -1 4")]
[InlineData("flag 0 4")]
[InlineData("reveal 1 0")]
[InlineData("d nonsense")]
[InlineData(" reveal")]
public void Parse_InvalidInput_ReportsUnknown(string input)
{
var command = Assert.IsType<Command.Unknown>(CommandParser.Parse(input));
Assert.NotEmpty(command.Input);
}
[Fact]
public void Parse_KeepsUnknownTextForEchoing()
{
var command = Assert.IsType<Command.Unknown>(CommandParser.Parse(" frobnicate 9 "));
Assert.Equal("frobnicate 9", command.Input);
}
[Theory]
[InlineData(ConsoleKey.UpArrow, -1, 0)]
[InlineData(ConsoleKey.DownArrow, 1, 0)]
[InlineData(ConsoleKey.LeftArrow, 0, -1)]
[InlineData(ConsoleKey.RightArrow, 0, 1)]
public void FromKey_ArrowsMoveTheCursor(ConsoleKey key, int row, int column)
{
var command = Assert.IsType<Command.MoveCursor>(CommandParser.FromKey(Key(key)));
Assert.Equal(row, command.RowDelta);
Assert.Equal(column, command.ColumnDelta);
}
[Fact]
public void FromKey_EnterAndSpaceRevealAtCursor()
{
Assert.IsType<Command.RevealAtCursor>(CommandParser.FromKey(Key(ConsoleKey.Enter)));
Assert.IsType<Command.RevealAtCursor>(CommandParser.FromKey(Key(ConsoleKey.Spacebar)));
}
[Fact]
public void FromKey_FlagAndChordActOnCursor()
{
Assert.IsType<Command.FlagAtCursor>(CommandParser.FromKey(Key(ConsoleKey.F, 'f')));
Assert.IsType<Command.ChordAtCursor>(CommandParser.FromKey(Key(ConsoleKey.C, 'c')));
}
[Fact]
public void FromKey_DifficultyShortcuts()
{
Assert.Equal(GameDifficulty.Beginner,
Assert.IsType<Command.ChangeDifficulty>(CommandParser.FromKey(Key(ConsoleKey.D1, '1'))).Difficulty);
Assert.Equal(GameDifficulty.Intermediate,
Assert.IsType<Command.ChangeDifficulty>(CommandParser.FromKey(Key(ConsoleKey.D2, '2'))).Difficulty);
Assert.Equal(GameDifficulty.Expert,
Assert.IsType<Command.ChangeDifficulty>(CommandParser.FromKey(Key(ConsoleKey.D3, '3'))).Difficulty);
}
[Fact]
public void FromKey_NewAndQuit()
{
Assert.IsType<Command.NewGame>(CommandParser.FromKey(Key(ConsoleKey.N, 'n')));
Assert.IsType<Command.Quit>(CommandParser.FromKey(Key(ConsoleKey.Escape)));
Assert.IsType<Command.Quit>(CommandParser.FromKey(Key(ConsoleKey.Q, 'q')));
}
[Fact]
public void FromKey_UnmappedKey_IsANoOp() =>
Assert.IsType<Command.Noop>(CommandParser.FromKey(Key(ConsoleKey.B, 'b')));
private static ConsoleKeyInfo Key(ConsoleKey key, char character = '\0') =>
new(character, key, shift: false, alt: false, control: false);
}
@@ -0,0 +1,361 @@
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class GameSessionTests
{
[Fact]
public void NewSession_StartsAtBeginnerWithCentredCursor()
{
var session = new GameSession();
Assert.Equal(GameDifficulty.Beginner, session.Difficulty);
Assert.Equal(9, session.Game.BoardSize.Rows);
Assert.Equal(10, session.Game.MineCount);
Assert.Equal(new CellLocation(4, 4), session.Cursor);
Assert.False(session.IsOver);
}
[Fact]
public void NewSession_UsesRequestedDifficulty()
{
var session = new GameSession(GameDifficulty.Expert);
Assert.Equal(16, session.Game.BoardSize.Rows);
Assert.Equal(30, session.Game.BoardSize.Columns);
Assert.Equal(99, session.Game.MineCount);
Assert.Equal(new CellLocation(8, 15), session.Cursor);
}
[Fact]
public void Apply_RevealByCoordinates_OpensTheChosenCell()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
session.Apply(new Command.Reveal(target));
Assert.Equal(CellState.Revealed, session.Game.GetState(target));
Assert.Equal(target, session.Cursor);
}
[Fact]
public void Apply_RevealAtCursor_UsesCurrentCursor()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
session.Apply(new Command.SetCursor(target));
session.Apply(new Command.RevealAtCursor());
Assert.Equal(CellState.Revealed, session.Game.GetState(target));
}
[Fact]
public void Apply_OffBoardCoordinate_ReportsWithoutThrowing()
{
var session = SeededSession(seed: 5);
var message = session.Apply(new Command.Reveal(new CellLocation(99, 99)));
Assert.NotNull(message);
Assert.Contains("off the board", message);
Assert.Equal(GameStatus.Ready, session.Game.Status);
}
[Fact]
public void Apply_FlagTogglesAndReportsMinesLeft()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
Assert.Contains("Mines left: 9", session.Apply(new Command.Flag(target)));
Assert.Equal(CellState.Flagged, session.Game.GetState(target));
Assert.Contains("Mines left: 10", session.Apply(new Command.Flag(target)));
Assert.Equal(CellState.Hidden, session.Game.GetState(target));
}
[Fact]
public void Apply_FlagOnRevealedCell_IsExplained()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
session.Apply(new Command.Reveal(target));
var message = session.Apply(new Command.Flag(target));
Assert.Contains("cannot be flagged", message);
}
[Fact]
public void Apply_ChordOnHiddenCell_IsExplained()
{
var session = SeededSession(seed: 5);
var hidden = HiddenCell(session);
var message = session.Apply(new Command.Chord(hidden));
Assert.Contains("already open", message);
}
[Fact]
public void Apply_ChordWithMismatchedFlags_IsExplained()
{
var session = SessionWithLayout(new BoardSize(3, 4, 3),
[new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]);
session.Apply(new Command.Reveal(new CellLocation(0, 1)));
var message = session.Apply(new Command.Chord(new CellLocation(0, 1)));
Assert.Contains("does not match", message);
Assert.False(session.IsOver);
}
[Theory]
[InlineData(0, 0, -1, 0, 8, 0)]
[InlineData(0, 0, 1, 0, 1, 0)]
[InlineData(0, 0, 0, -1, 0, 8)]
[InlineData(0, 0, 0, 1, 0, 1)]
[InlineData(8, 8, 1, 0, 0, 8)]
[InlineData(8, 8, 0, 1, 8, 0)]
public void Apply_MoveCursor_WrapsAroundEdges(int startRow, int startColumn, int rowDelta, int columnDelta,
int expectedRow, int expectedColumn)
{
var session = new GameSession();
session.Apply(new Command.SetCursor(new CellLocation(startRow, startColumn)));
session.Apply(new Command.MoveCursor(rowDelta, columnDelta));
Assert.Equal(new CellLocation(expectedRow, expectedColumn), session.Cursor);
}
[Fact]
public void Apply_SetCursorOffBoard_LeavesCursorUnchanged()
{
var session = new GameSession();
var message = session.Apply(new Command.SetCursor(new CellLocation(20, 20)));
Assert.Contains("off the board", message);
Assert.Equal(new CellLocation(4, 4), session.Cursor);
}
[Fact]
public void Apply_NewGame_ResetsBoardAndClock()
{
var session = SeededSession(seed: 5);
session.Apply(new Command.Reveal(SafeCell(session)));
var revealed = session.Game.RevealedCount;
var message = session.Apply(new Command.NewGame());
Assert.Equal("New board.", message);
Assert.Equal(0, session.Game.RevealedCount);
Assert.True(revealed > 0);
Assert.Equal(GameStatus.Ready, session.Game.Status);
}
[Fact]
public void Apply_ChangeDifficulty_SwitchesBoardSize()
{
var session = new GameSession();
var message = session.Apply(new Command.ChangeDifficulty(GameDifficulty.Expert));
Assert.Contains("30x16", message);
Assert.Equal(GameDifficulty.Expert, session.Difficulty);
Assert.Equal(99, session.Game.MineCount);
}
[Fact]
public void Apply_ChangeDifficulty_KeepsSeedForReproducibleBoards()
{
var first = new GameSession(seed: 42);
first.Apply(new Command.ChangeDifficulty(GameDifficulty.Intermediate));
var layout = first.Game.GetView();
var second = new GameSession(seed: 42);
second.Apply(new Command.ChangeDifficulty(GameDifficulty.Intermediate));
for (var row = 0; row < 16; row++)
{
for (var column = 0; column < 16; column++)
{
Assert.Equal(layout[row, column].IsMine, second.Game.GetView()[row, column].IsMine);
}
}
}
[Fact]
public void Apply_Help_ReturnsTheCommandList()
{
var session = new GameSession();
var message = session.Apply(new Command.ShowHelp());
Assert.Equal(GameSession.HelpText, message);
}
[Fact]
public void Apply_Unknown_EchoesWhatWasTyped()
{
var session = new GameSession();
var message = session.Apply(new Command.Unknown("xyzzy"));
Assert.Contains("xyzzy", message);
Assert.Contains("for help", message);
}
[Fact]
public void Apply_Noop_SaysNothing()
{
var session = new GameSession();
Assert.Null(session.Apply(new Command.Noop()));
Assert.Null(session.Apply(new Command.MoveCursor(0, 0)));
}
[Fact]
public void Apply_Quit_SignalsExit() =>
Assert.Null(new GameSession().Apply(new Command.Quit()));
[Fact]
public void AfterWin_FurtherMovesAreRefusedWithAGuide()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.True(session.IsOver);
Assert.Contains("won", session.Apply(new Command.Reveal(new CellLocation(0, 1))));
Assert.Contains("new", session.Apply(new Command.Reveal(new CellLocation(0, 1))));
Assert.Contains("new", session.Apply(new Command.Flag(new CellLocation(0, 1))));
Assert.Contains("new", session.Apply(new Command.Chord(new CellLocation(0, 1))));
}
[Fact]
public void AfterLoss_FurtherMovesPointToNewGame()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(0, 0)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.True(session.IsOver);
Assert.Contains("new", session.Apply(new Command.Reveal(new CellLocation(2, 2))));
}
[Fact]
public void LosingThenStartingNewGame_ClearsGameOverState()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(0, 0)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
session.Apply(new Command.NewGame());
Assert.False(session.IsOver);
Assert.Null(session.Game.LostOn);
Assert.Equal(CellState.Hidden, session.Game.GetState(new CellLocation(0, 0)));
}
[Fact]
public void Clock_KeepsTimeAcrossReveals()
{
var session = SeededSession(seed: 5);
session.Apply(new Command.Reveal(SafeCell(session)));
Assert.True(session.Elapsed >= TimeSpan.Zero);
}
[Fact]
public void WinningMove_ReportsTheTime()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
var message = session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.Contains("Cleared in", message);
Assert.False(session.IsTiming);
}
[Fact]
public void FirstReveal_StartsTheClock()
{
var session = SeededSession(seed: 5);
Assert.False(session.IsTiming);
session.Apply(new Command.Reveal(SafeCell(session)));
Assert.True(session.IsTiming);
}
[Fact]
public void LosingMove_StopsTheClock()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(0, 0)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.False(session.IsTiming);
}
[Fact]
public void RevealThatOpensACascade_ReportsHowManyCellsOpened()
{
var session = SessionWithLayout(new BoardSize(5, 5, 4),
[new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]);
var message = session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.Contains("Opened 10 cells", message);
}
private static GameSession SeededSession(int seed) => new(GameDifficulty.Beginner, seed);
private static GameSession SessionWithLayout(BoardSize size, IReadOnlyList<CellLocation> mines)
{
var session = new GameSession();
session.LoadGame(MinesweeperGame.FromMines(size, mines));
return session;
}
/// <summary>A cell that opens safely on a fresh board of the session's seed and leaves the game
/// still in play, so tests can make a first move without risking an instant win or loss.</summary>
private static CellLocation SafeCell(GameSession session)
{
for (var row = 0; row < session.Game.BoardSize.Rows; row++)
{
for (var column = 0; column < session.Game.BoardSize.Columns; column++)
{
var candidate = new CellLocation(row, column);
var trial = new MinesweeperGame(session.Game.BoardSize, session.Seed);
trial.Reveal(candidate);
if (!trial.IsMine(candidate) && trial.Status == GameStatus.Playing)
{
return candidate;
}
}
}
throw new InvalidOperationException("No safe opening cell found.");
}
private static CellLocation HiddenCell(GameSession session)
{
for (var row = 0; row < session.Game.BoardSize.Rows; row++)
{
for (var column = 0; column < session.Game.BoardSize.Columns; column++)
{
var candidate = new CellLocation(row, column);
if (session.Game.GetState(candidate) == CellState.Hidden)
{
return candidate;
}
}
}
throw new InvalidOperationException("No hidden cell found.");
}
private static int SeedOf(GameSession session) => session.Seed ?? 0;
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Minesweeper.Core\Minesweeper.Core.csproj" />
<ProjectReference Include="..\..\src\Minesweeper.Cli\Minesweeper.Cli.csproj" />
</ItemGroup>
</Project>
@@ -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);
}
}
}
}