using System.Text.RegularExpressions; using Minesweeper.Cli; using Minesweeper.Core; namespace Minesweeper.Core.Tests; public class BoardRendererTests { private static readonly AnsiPalette Plain = new(enabled: false); private static readonly AnsiPalette Colored = new(enabled: true); /// 3x4 board with a fixed layout used across the rendering assertions. /// /// M 1 1 M /// 2 2 2 1 /// 1 M 1 0 /// private static MinesweeperGame Puzzle() => MinesweeperGame.FromMines(new BoardSize(3, 4, 3), [new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]); private static BoardRenderer Renderer(AnsiPalette palette) => new(palette); /// Grid rows with the row-number gutter trimmed off. private static List Rows(string rendered) => rendered.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Skip(1) .Select(line => line.Substring(3)) .ToList(); /// The glyph in each three-character cell of one grid row. private static List Glyphs(string rendered, int row) { var line = Rows(rendered)[row]; var glyphs = new List(); for (var index = 2; index < line.Length; index += 3) { glyphs.Add(line[index].ToString()); } return glyphs; } private static string StripAnsi(string text) => Regex.Replace(text, "\e\\[[0-9;]*m", string.Empty); [Fact] public void RenderBoard_BeforeAnyMove_ShowsEveryCellHidden() { var board = Renderer(Plain).RenderBoard(Puzzle()); Assert.Equal(3, Rows(board).Count); Assert.Equal(["#", "#", "#", "#"], Glyphs(board, 0)); Assert.DoesNotContain("*", board); Assert.DoesNotContain("·", board); } [Fact] public void RenderBoard_HidesMinesUntilTheGameEnds() { var renderer = Renderer(Plain); Assert.DoesNotContain("*", renderer.RenderBoard(Puzzle())); var lost = Puzzle(); lost.Reveal(new CellLocation(0, 0)); Assert.Contains("*", renderer.RenderBoard(lost)); } [Fact] public void RenderBoard_RevealedCellsShowNumbersAndBlanks() { var game = Puzzle(); game.Reveal(new CellLocation(2, 3)); Assert.Equal(["#", "#", "1", "·"], Glyphs(Renderer(Plain).RenderBoard(game), 2)); } [Fact] public void RenderBoard_ShowsFlagsThenWrongFlags() { var renderer = Renderer(Plain); var game = Puzzle(); game.ToggleFlag(new CellLocation(1, 1)); Assert.Equal(["#", "P", "#", "#"], Glyphs(renderer.RenderBoard(game), 1)); game.Reveal(new CellLocation(0, 0)); Assert.Contains("X", renderer.RenderBoard(game)); } [Fact] public void RenderBoard_AfterWinFlagsEveryMine() { var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]); game.Reveal(new CellLocation(0, 0)); var board = Renderer(Plain).RenderBoard(game); Assert.Equal(["·", "1", "P"], Glyphs(board, 2)); Assert.DoesNotContain("*", board); } [Fact] public void RenderBoard_AfterLossExposesEveryMine() { var game = Puzzle(); game.Reveal(new CellLocation(0, 0)); var board = string.Concat(Rows(Renderer(Plain).RenderBoard(game))); Assert.Equal(3, board.Count(c => c == '*')); } [Fact] public void RenderBoard_UsesLetteredColumnsAndNumberedRows() { var lines = Renderer(Plain).RenderBoard(Puzzle()).Split('\n'); Assert.Equal(" A B C D", lines[0]); Assert.StartsWith(" 1", lines[1]); Assert.StartsWith(" 3", lines[3]); } [Fact] public void RenderBoard_LabelsColumnsWithUpToTwoLetters() { Assert.Equal("A", BoardRenderer.ColumnName(0)); Assert.Equal("Z", BoardRenderer.ColumnName(25)); Assert.Equal("AA", BoardRenderer.ColumnName(26)); Assert.Equal("AB", BoardRenderer.ColumnName(27)); } [Fact] public void RenderBoard_KeepsEveryRowTheSameWidth() { var game = new MinesweeperGame(new BoardSize(9, 30, 99), seed: 3); game.Reveal(new CellLocation(4, 15)); var lines = Renderer(Plain).RenderBoard(game).Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Equal(10, lines.Length); Assert.All(lines, line => Assert.Equal(93, line.Length)); } [Theory] [InlineData(true)] [InlineData(false)] public void RenderBoard_ColorsDoNotDisturbAlignment(bool withCursor) { // 5x5 with a mine wall down column C, so half the board stays hidden while the other half // opens into blanks, numbers, and flags. var game = MinesweeperGame.FromMines(new BoardSize(5, 5, 4), [new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]); game.Reveal(new CellLocation(0, 0)); game.ToggleFlag(new CellLocation(0, 2)); game.ToggleFlag(new CellLocation(4, 4)); CellLocation? cursor = withCursor ? new CellLocation(2, 1) : null; var plain = Renderer(Plain).RenderBoard(game, cursor); var colored = StripAnsi(Renderer(Colored).RenderBoard(game, cursor)); Assert.Equal(GameStatus.Playing, game.Status); Assert.Equal(plain, colored); Assert.All(plain.Split('\n', StringSplitOptions.RemoveEmptyEntries), line => Assert.Equal(18, line.Length)); } [Fact] public void RenderBoard_ColorsDoNotDisturbAlignmentOnLostAndWonBoards() { var lost = Puzzle(); lost.Reveal(new CellLocation(0, 0)); var won = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]); won.Reveal(new CellLocation(0, 0)); foreach (var game in new[] { lost, won }) { Assert.Equal( Renderer(Plain).RenderBoard(game), StripAnsi(Renderer(Colored).RenderBoard(game))); } } [Fact] public void RenderBoard_MarksTheCursorEvenWithoutColor() { var plain = Renderer(Plain); var game = Puzzle(); Assert.Contains("[#]", plain.RenderBoard(game, new CellLocation(0, 1))); Assert.Equal(plain.RenderBoard(game).Length, plain.RenderBoard(game, new CellLocation(1, 2)).Length); } [Fact] public void RenderBoard_CursorOffBoardDoesNotChangeOutput() { var renderer = Renderer(Plain); var game = Puzzle(); Assert.Equal(renderer.RenderBoard(game), renderer.RenderBoard(game, new CellLocation(9, 9))); } [Fact] public void RenderHeader_ReportsSizeMinesAndTime() { var header = Renderer(Plain).RenderHeader(Puzzle(), TimeSpan.FromSeconds(42)); Assert.Contains("4x3", header); Assert.Contains("mines left 3", header); Assert.Contains("42s", header); } [Fact] public void RenderHeader_CountsDownMinesAsFlagsArePlaced() { var game = Puzzle(); var renderer = Renderer(Plain); game.ToggleFlag(new CellLocation(1, 1)); Assert.Contains("mines left 2", renderer.RenderHeader(game, TimeSpan.Zero)); Assert.Contains("flags 1/3", renderer.RenderHeader(game, TimeSpan.Zero)); } [Fact] public void RenderHeader_HintsBeforeTheFirstMove() { var game = new MinesweeperGame(new BoardSize(5, 5, 3), seed: 1); Assert.Contains("First click is always safe.", Renderer(Plain).RenderHeader(game, TimeSpan.Zero)); } [Fact] public void RenderHeader_CelebratesAWin() { var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]); game.Reveal(new CellLocation(0, 0)); Assert.Contains("you win", Renderer(Plain).RenderHeader(game, TimeSpan.FromSeconds(3))); } [Fact] public void RenderHeader_ExplainsALossUsingOneBasedCoordinates() { var game = Puzzle(); game.Reveal(new CellLocation(0, 0)); var header = Renderer(Plain).RenderHeader(game, TimeSpan.FromSeconds(1)); Assert.Contains("Boom", header); Assert.Contains("row 1, column A", header); } [Fact] public void GlyphFor_MapsEachStateToItsOwnCharacter() { var game = Puzzle(); var view = game.GetView(); Assert.Equal('#', BoardRenderer.GlyphFor(game, view[0, 1])); game.Reveal(new CellLocation(2, 3)); view = game.GetView(); Assert.Equal('·', BoardRenderer.GlyphFor(game, view[2, 3])); Assert.Equal('1', BoardRenderer.GlyphFor(game, view[2, 2])); game.ToggleFlag(new CellLocation(0, 1)); view = game.GetView(); Assert.Equal('P', BoardRenderer.GlyphFor(game, view[0, 1])); game.Reveal(new CellLocation(0, 0)); view = game.GetView(); Assert.Equal('*', BoardRenderer.GlyphFor(game, view[0, 3])); Assert.Equal('X', BoardRenderer.GlyphFor(game, view[0, 1])); } [Fact] public void Palette_WithoutColorEmitsNoEscapeCodes() { var game = Puzzle(); game.Reveal(new CellLocation(0, 1)); var rendered = Renderer(Plain).RenderBoard(game) + Renderer(Plain).RenderHeader(game, TimeSpan.Zero); Assert.DoesNotContain("\e[", rendered); } [Fact] public void Palette_GivesEachNumberItsOwnColor() { Assert.Equal(8, Enumerable.Range(1, 8).Select(Colored.Number).Distinct().Count()); Assert.Equal("9", Colored.Number(9)); Assert.DoesNotContain("\e[", Plain.Number(3)); } [Fact] public void Palette_DistinguishesEveryCellKind() { var styles = new[] { Colored.Cursor("[#]"), Colored.Hidden(" #"), Colored.Flag(" P"), Colored.Mine(" *"), Colored.WrongFlag(" X"), Colored.ExplodedMine(" *"), Colored.Number(1, " 1"), }; Assert.Equal(styles.Length, styles.Distinct().Count()); } }