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:
openhands
2026-09-05 21:50:11 +00:00
commit fd270095df
25 changed files with 2932 additions and 0 deletions
@@ -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);
}
}
}