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