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 mines) { var session = new GameSession(); session.LoadGame(MinesweeperGame.FromMines(size, mines)); return session; } /// 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. 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; }