Initial commit: terminal Minesweeper in C# for .NET 10

A playable console Minesweeper (arrow keys, chording, flags, question marks,
pause, difficulty presets) plus a deduction-and-guess solver that can play the
game headlessly. Game logic lives in src/Minesweeper/Game and has no console
dependencies, so it is covered by 65 unit tests.

Includes publishing helpers for self-contained, trimmed single-file binaries
(publish.sh) and an .editorconfig that the sources conform to: UTF-8 BOM and
4-space indents for C#, 2-space indents for project files, LF endings and
2-space indents for shell scripts, System-first usings, no redundant `this.`,
and s_/ _ prefixed private static and instance fields.
This commit is contained in:
drobor-ai
2026-09-08 07:12:51 +00:00
commit 2ba405b19c
28 changed files with 3212 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
using Minesweeper.Game;
using Xunit;
namespace Minesweeper.Tests;
public class BoardTests
{
private static readonly string[] s_floodLayout =
[
".....",
".....",
".....",
".....",
"...x.",
];
private static readonly string[] s_twinMineLayout =
[
"x.x",
"...",
"...",
];
[Theory]
[InlineData(0, 5, 1)]
[InlineData(5, 0, 1)]
[InlineData(3, 3, -1)]
[InlineData(3, 3, 9)]
public void ConstructorRejectsImpossibleBoards(int width, int height, int mines) =>
Assert.ThrowsAny<ArgumentOutOfRangeException>(() => new Board(width, height, mines));
[Fact]
public void FirstClickKeepsTheChosenSquareClear()
{
for (var seed = 0; seed < 50; seed++)
{
var board = new Board(9, 9, 10);
board.PlantMines(new CellRef(4, 4), reserveSafeZone: false, new Random(seed));
Assert.False(board.At(4, 4).IsMine);
Assert.Equal(10, board.MineCount);
Assert.Equal(10, board.MineCells().Count());
}
}
[Fact]
public void FirstClickCanAlsoKeepItsNeighborhoodClear()
{
for (var seed = 0; seed < 50; seed++)
{
var board = new Board(9, 9, 10);
board.PlantMines(new CellRef(4, 4), reserveSafeZone: true, new Random(seed));
Assert.Equal(0, board.MineCells().Count(m => m == new CellRef(4, 4) || board.NeighborsOf(4, 4).Contains(m)));
}
}
[Fact]
public void PlantingTwiceThrows()
{
var board = TestBoards.Create(s_floodLayout);
Assert.Throws<InvalidOperationException>(() => board.PlantMines([]));
Assert.Throws<InvalidOperationException>(() => board.PlantMines(new CellRef(0, 0), true, new Random(1)));
}
[Fact]
public void NumbersComeFromTheMineLayout()
{
var board = TestBoards.Create(s_floodLayout);
Assert.True(board.At(3, 4).IsMine);
Assert.Equal(0, board.At(0, 0).AdjacentMines);
Assert.Equal(1, board.At(2, 3).AdjacentMines);
Assert.Equal(1, board.At(4, 4).AdjacentMines);
Assert.Equal(1, board.MineCount);
}
[Theory]
[InlineData(0, 0, 3)]
[InlineData(2, 0, 5)]
[InlineData(2, 2, 8)]
public void NeighborCountsRespectTheEdges(int x, int y, int expected)
{
var board = new Board(5, 5, 0);
Assert.Equal(expected, board.NeighborsOf(x, y).Count());
}
[Fact]
public void OpeningABlankSquareFloodsUntilTheNumbers()
{
var board = TestBoards.Create(s_floodLayout);
var result = board.Open(new CellRef(0, 0));
Assert.False(result.HitMine);
Assert.Equal(23, result.Opened.Count);
Assert.False(board.At(3, 4).IsRevealed);
Assert.True(board.At(2, 3).IsRevealed);
Assert.Equal(23, Revealed(board));
// (4,4) only touches other numbers and the mine, so a flood can never reach it.
Assert.False(board.At(4, 4).IsRevealed);
}
[Fact]
public void FlagsHoldBackTheFloodFill()
{
var board = TestBoards.Create(s_floodLayout);
board.At(0, 2).State = CellState.Flagged;
var result = board.Open(new CellRef(0, 0));
Assert.Equal(22, result.Opened.Count);
Assert.True(board.At(0, 2).IsFlagged);
}
[Fact]
public void OpeningANumberedSquareRevealsOnlyIt()
{
var board = TestBoards.Create("x..", "...", "...");
var result = board.Open(new CellRef(1, 0));
Assert.Single(result.Opened);
Assert.True(board.At(1, 0).IsRevealed);
Assert.False(board.At(2, 2).IsRevealed);
}
[Fact]
public void OpeningAMineReportsTheBoom()
{
var board = TestBoards.Create(s_floodLayout);
var result = board.Open(new CellRef(3, 4));
Assert.True(result.HitMine);
Assert.Equal(new CellRef(3, 4), result.Boom!.Value);
Assert.True(board.At(3, 4).IsRevealed);
}
[Fact]
public void ChordOpensTheRestWhenTheFlagsMatch()
{
var board = TestBoards.Create(s_twinMineLayout);
board.Open(new CellRef(1, 0));
board.At(0, 0).State = CellState.Flagged;
board.At(2, 0).State = CellState.Flagged;
var result = board.Chord(new CellRef(1, 0));
Assert.False(result.HitMine);
Assert.Equal(3, result.Opened.Count);
Assert.True(board.At(0, 1).IsRevealed);
Assert.True(board.At(1, 1).IsRevealed);
Assert.True(board.At(2, 1).IsRevealed);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void ChordWithTheWrongFlagCountJustPops(int flags)
{
var board = TestBoards.Create(s_twinMineLayout);
board.Open(new CellRef(1, 0));
board.At(0, 0).State = CellState.Flagged;
if (flags > 1)
board.At(0, 1).State = CellState.Flagged;
if (flags > 2)
board.At(1, 1).State = CellState.Flagged;
var result = board.Chord(new CellRef(1, 0));
Assert.False(result.AnyOpened);
Assert.False(result.HitMine);
Assert.Equal(1, Revealed(board));
}
[Fact]
public void ChordCanStepOnAnUnflaggedMine()
{
var board = TestBoards.Create(s_twinMineLayout);
board.Open(new CellRef(1, 0));
board.At(0, 0).State = CellState.Flagged;
board.At(0, 1).State = CellState.Flagged;
var result = board.Chord(new CellRef(1, 0));
Assert.True(result.HitMine);
Assert.Equal(new CellRef(2, 0), result.Boom!.Value);
}
[Fact]
public void ChordIgnoresHiddenSquares()
{
var board = TestBoards.Create(s_twinMineLayout);
Assert.False(board.Chord(new CellRef(1, 1)).AnyOpened);
}
[Fact]
public void TinyBoardsLoseMinesTheyCannotFit()
{
var board = new Board(2, 2, 3);
board.PlantMines(new CellRef(0, 0), reserveSafeZone: true, new Random(3));
Assert.Equal(0, board.MineCount);
Assert.Empty(board.MineCells());
}
[Fact]
public void OutOfRangeSquaresThrow()
{
var board = new Board(3, 3, 1);
Assert.Throws<ArgumentOutOfRangeException>(() => board.At(3, 0));
Assert.False(board.InBounds(-1, 2));
Assert.True(board.InBounds(2, 2));
}
private static int Revealed(Board board) => board.AllCells().Count(reference => board.At(reference).IsRevealed);
}