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); /// 3x4 board where only the far corner reads zero, so floods stay small and chording /// matters. /// /// M 1 1 M /// 2 2 2 1 /// 1 M 1 0 /// private static MinesweeperGame PuzzleBoard() => MinesweeperGame.FromMines(new BoardSize(3, 4, 3), [new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]); /// 2x3 board with a single mine, small enough to win with one chord. /// /// 0 1 M /// 0 1 1 /// 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(() => 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."); } /// 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. /// /// 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 /// 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(() => game.Reveal(new CellLocation(3, 0))); Assert.Throws(() => game.Reveal(new CellLocation(-1, 0))); Assert.Throws(() => game.ToggleFlag(new CellLocation(0, 4))); Assert.Throws(() => 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(() => game.Reveal(new CellLocation(0, 1))); Assert.Equal(GameStatus.Won, exception.Status); Assert.Throws(() => game.ToggleFlag(new CellLocation(0, 1))); Assert.Throws(() => 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(() => 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)); } /// Every revealed cell, used to assert a chord opened nothing extra. private static List RevealedCells(IEnumerable 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(() => MinesweeperGame.FromMines(new BoardSize(3, 3, 2), [new CellLocation(0, 0)])); Assert.Throws(() => MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(5, 5)])); Assert.Throws(() => 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 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); } } } }