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