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