refactor: NCS-22 NCS-23 reworked modules and tests (#17)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-04-06 09:07:39 +02:00
parent 51ffd7aac9
commit 8f56a82104
98 changed files with 3752 additions and 5940 deletions
+12 -1
View File
@@ -1,3 +1,6 @@
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.jvm.tasks.Jar
plugins {
id("scala")
id("org.scoverage")
@@ -21,7 +24,9 @@ scala {
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(listOf(
"de.nowchess.ui.gui"
"de.nowchess.ui.gui",
"de.nowchess.ui.terminal",
"de.nowchess.ui.Main",
))
}
@@ -38,6 +43,10 @@ tasks.named<JavaExec>("run") {
standardInput = System.`in`
}
tasks.named<Jar>("jar") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
@@ -52,7 +61,9 @@ dependencies {
}
implementation(project(":modules:core"))
implementation(project(":modules:rule"))
implementation(project(":modules:api"))
implementation(project(":modules:io"))
// ScalaFX dependencies
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
@@ -1,20 +1,22 @@
package de.nowchess.ui.gui
import scala.compiletime.uninitialized
import scalafx.Includes.*
import scalafx.application.Platform
import scalafx.geometry.{Insets, Pos}
import scalafx.scene.control.{Button, ButtonType, ChoiceDialog, Label}
import scalafx.scene.layout.{BorderPane, GridPane, HBox, VBox, StackPane}
import scalafx.scene.layout.{BorderPane, GridPane, HBox, StackPane, VBox}
import scalafx.scene.paint.Color as FXColor
import scalafx.scene.shape.Rectangle
import scalafx.scene.text.{Font, Text}
import scalafx.stage.Stage
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square, File, Rank}
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.command.{MoveCommand, MoveResult}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.logic.{CastlingRightsCalculator, EnPassantCalculator, GameHistory, GameRules, withCastle}
import de.nowchess.chess.notation.{FenExporter, FenParser, PgnExporter, PgnParser}
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.{GameContextExport, GameContextImport}
/** ScalaFX chess board view that displays the game state.
* Uses chess sprites and color palette.
@@ -36,6 +38,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized
// Initialize UI
initializeBoard()
@@ -69,15 +74,23 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
spacing = 10
alignment = Pos.Center
children = Seq(
new Button("Undo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canUndo then engine.undo()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
{
undoButton = new Button("Undo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canUndo then engine.undo()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
disable = !engine.canUndo
}
undoButton
},
new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
{
redoButton = new Button("Redo") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo()
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
disable = !engine.canRedo
}
redoButton
},
new Button("Reset") {
font = Font.font(comicSansFontFamily, 12)
@@ -164,10 +177,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
if piece.color == currentTurn then
selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = GameRules.legalMoves(currentBoard, engine.history, currentTurn)
.collect { case (`clickedSquare`, to) => to }
val legalDests = engine.ruleSet.legalMoves(engine.context, clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
}
}
@@ -216,7 +230,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
stackPane.children = children
}
updateUndoRedoButtons()
def updateUndoRedoButtons(): Unit =
if undoButton != null then undoButton.disable = !engine.canUndo
if redoButton != null then redoButton.disable = !engine.canRedo
private def highlightSquare(rank: Int, file: Int, color: String): Unit =
squareViews.get((rank, file)).foreach { stackPane =>
val bgRect = new Rectangle {
@@ -258,56 +278,32 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
private def doFenExport(): Unit =
val state = GameState(
piecePlacement = FenExporter.boardToFen(currentBoard),
activeColor = currentTurn,
castlingWhite = CastlingRightsCalculator.deriveCastlingRights(engine.history, Color.White),
castlingBlack = CastlingRightsCalculator.deriveCastlingRights(engine.history, Color.Black),
enPassantTarget = EnPassantCalculator.enPassantTarget(currentBoard, engine.history),
halfMoveClock = 0,
fullMoveNumber = engine.history.moves.size / 2 + 1,
status = GameStatus.InProgress
)
showCopyDialog("FEN Export", FenExporter.gameStateToFen(state))
doExport(FenExporter, "FEN")
private def doFenImport(): Unit =
showInputDialog("FEN Import", rows = 1).foreach { fen =>
FenParser.parseFen(fen) match
case None => showMessage("Invalid FEN")
case Some(state) =>
FenParser.parseBoard(state.piecePlacement) match
case None => showMessage("Invalid FEN board")
case Some(board) => engine.loadPosition(board, GameHistory.empty, state.activeColor)
}
doImport(FenParser, "FEN")
private def doPgnExport(): Unit =
showCopyDialog("PGN Export", PgnExporter.exportGame(Map.empty, engine.history))
doExport(PgnExporter, "PGN")
private def doPgnImport(): Unit =
showInputDialog("PGN Import", rows = 6).foreach { pgn =>
PgnParser.parsePgn(pgn) match
case None => showMessage("Invalid PGN")
case Some(pgnGame) =>
val (finalBoard, finalHistory) = pgnGame.moves.foldLeft((Board.initial, GameHistory.empty)):
case ((board, history), move) =>
val color = if history.moves.size % 2 == 0 then Color.White else Color.Black
val newBoard = move.castleSide match
case Some(side) => board.withCastle(color, side)
case None =>
val (b, _) = board.withMove(move.from, move.to)
move.promotionPiece match
case Some(pp) =>
val pt = pp match
case PromotionPiece.Queen => PieceType.Queen
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight
b.updated(move.to, Piece(color, pt))
case None => b
(newBoard, history.addMove(move))
val finalTurn = if finalHistory.moves.size % 2 == 0 then Color.White else Color.Black
engine.loadPosition(finalBoard, finalHistory, finalTurn)
doImport(PgnParser, "PGN")
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
val exported = exporter.exportGameContext(engine.context)
showCopyDialog(s"$formatName Export", exported)
}
private def doImport(importer: GameContextImport, formatName: String): Unit = {
showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
importer.importGameContext(input) match
case Right(gameContext) =>
engine.loadPosition(gameContext)
showMessage(s"$formatName loaded successfully!")
case Left(err) =>
showMessage(s"⚠️ $formatName Error: $err")
}
}
private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content)
@@ -339,3 +335,4 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.initOwner(stage.delegate)
val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
@@ -17,38 +17,57 @@ class GUIObserver(private val boardView: ChessBoardView) extends Observer:
Platform.runLater {
event match
case e: MoveExecutedEvent =>
boardView.updateBoard(e.board, e.turn)
boardView.updateBoard(e.context.board, e.context.turn)
e.capturedPiece.foreach { piece =>
boardView.showMessage(s"Captured: $piece on ${e.toSquare}")
}
case e: CheckDetectedEvent =>
boardView.updateBoard(e.board, e.turn)
boardView.showMessage(s"${e.turn.label} is in check!")
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage(s"${e.context.turn.label} is in check!")
case e: CheckmateEvent =>
boardView.updateBoard(e.board, e.turn)
boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Game Over", s"Checkmate! ${e.winner.label} wins.")
case e: StalemateEvent =>
boardView.updateBoard(e.board, e.turn)
boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Game Over", "Stalemate! The game is a draw.")
case e: InvalidMoveEvent =>
boardView.showMessage(s"⚠️ ${e.reason}")
case e: BoardResetEvent =>
boardView.updateBoard(e.board, e.turn)
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage("Board has been reset to initial position.")
case e: PromotionRequiredEvent =>
boardView.showPromotionDialog(e.from, e.to)
case e: DrawClaimedEvent =>
boardView.updateBoard(e.board, e.turn)
boardView.updateBoard(e.context.board, e.context.turn)
showAlert(AlertType.Information, "Draw Claimed", "Draw claimed! The game is a draw.")
case e: FiftyMoveRuleAvailableEvent =>
case e: FiftyMoveRuleAvailableEvent =>
boardView.showMessage("50-move rule available! The game is a draw.")
case e: MoveUndoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage(s"↶ Undo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: MoveRedoneEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
if e.capturedPiece.isDefined then
boardView.showMessage(s"↷ Redo: ${e.pgnNotation} — Captured: ${e.capturedPiece.get}")
else
boardView.showMessage(s"↷ Redo: ${e.pgnNotation}")
boardView.updateUndoRedoButtons()
case e: PgnLoadedEvent =>
boardView.updateBoard(e.context.board, e.context.turn)
boardView.showMessage("✓ PGN loaded successfully!")
boardView.updateUndoRedoButtons()
}
private def showAlert(alertType: AlertType, titleText: String, content: String): Unit =
@@ -3,8 +3,8 @@ package de.nowchess.ui.terminal
import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.chess.view.Renderer
import de.nowchess.chess.observer.*
import de.nowchess.ui.utils.Renderer
/** Terminal UI that implements Observer pattern.
* Subscribes to GameEngine and receives state change events.
@@ -19,23 +19,35 @@ class TerminalUI(engine: GameEngine) extends Observer:
event match
case e: MoveExecutedEvent =>
println()
print(Renderer.render(e.board))
print(Renderer.render(e.context.board))
e.capturedPiece.foreach: cap =>
println(s"Captured: $cap on ${e.toSquare}")
printPrompt(e.turn)
printPrompt(e.context.turn)
case e: MoveUndoneEvent =>
println(s"Undo: ${e.pgnNotation}")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case e: MoveRedoneEvent =>
println(s"Redo: ${e.pgnNotation}")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case e: CheckDetectedEvent =>
println(s"${e.turn.label} is in check!")
println(s"${e.context.turn.label} is in check!")
case e: CheckmateEvent =>
println(s"Checkmate! ${e.winner.label} wins.")
println()
print(Renderer.render(e.board))
print(Renderer.render(e.context.board))
case e: StalemateEvent =>
println("Stalemate! The game is a draw.")
println()
print(Renderer.render(e.board))
print(Renderer.render(e.context.board))
case e: InvalidMoveEvent =>
println(s"⚠️ ${e.reason}")
@@ -43,8 +55,8 @@ class TerminalUI(engine: GameEngine) extends Observer:
case e: BoardResetEvent =>
println("Board has been reset to initial position.")
println()
print(Renderer.render(e.board))
printPrompt(e.turn)
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
case _: PromotionRequiredEvent =>
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
@@ -56,6 +68,12 @@ class TerminalUI(engine: GameEngine) extends Observer:
case _: FiftyMoveRuleAvailableEvent =>
println("50-move rule available! The game is a draw.")
case e: PgnLoadedEvent =>
println("PGN loaded successfully.")
println()
print(Renderer.render(e.context.board))
printPrompt(e.context.turn)
/** Start the terminal UI game loop. */
def start(): Unit =
// Register as observer
@@ -0,0 +1,18 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.{Color, Piece, PieceType}
extension (p: Piece)
def unicode: String = (p.color, p.pieceType) match
case (Color.White, PieceType.King) => "\u2654"
case (Color.White, PieceType.Queen) => "\u2655"
case (Color.White, PieceType.Rook) => "\u2656"
case (Color.White, PieceType.Bishop) => "\u2657"
case (Color.White, PieceType.Knight) => "\u2658"
case (Color.White, PieceType.Pawn) => "\u2659"
case (Color.Black, PieceType.King) => "\u265A"
case (Color.Black, PieceType.Queen) => "\u265B"
case (Color.Black, PieceType.Rook) => "\u265C"
case (Color.Black, PieceType.Bishop) => "\u265D"
case (Color.Black, PieceType.Knight) => "\u265E"
case (Color.Black, PieceType.Pawn) => "\u265F"
@@ -0,0 +1,28 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.*
object Renderer:
private val AnsiReset = "\u001b[0m"
private val AnsiLightSquare = "\u001b[48;5;223m" // warm beige
private val AnsiDarkSquare = "\u001b[48;5;130m" // brown
private val AnsiWhitePiece = "\u001b[97m" // bright white text
private val AnsiBlackPiece = "\u001b[30m" // black text
def render(board: Board): String =
val rows = (0 until 8).reverse.map { rank =>
val cells = (0 until 8).map { file =>
val sq = Square(File.values(file), Rank.values(rank))
val isLightSq = (file + rank) % 2 != 0
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
board.pieceAt(sq) match
case Some(piece) =>
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
case None =>
s"$bgColor $AnsiReset"
}.mkString
s"${rank + 1} $cells ${rank + 1}"
}.mkString("\n")
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
@@ -1,22 +0,0 @@
package de.nowchess.ui
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
class MainTest extends AnyFunSuite with Matchers {
test("main should execute and quit immediately when fed 'quit'") {
val in = new ByteArrayInputStream("quit\n".getBytes)
val out = new ByteArrayOutputStream()
Console.withIn(in) {
Console.withOut(out) {
Main.main(Array.empty)
}
}
val output = out.toString
output should include ("Game over. Goodbye!")
}
}
@@ -1,327 +0,0 @@
package de.nowchess.ui.terminal
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
import de.nowchess.chess.logic.GameHistory
class TerminalUITest extends AnyFunSuite with Matchers {
test("TerminalUI should start, print initial state, and correctly respond to 'q'") {
val in = new ByteArrayInputStream("q\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
}
test("TerminalUI should ignore empty inputs and re-print prompt") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// Prompt appears three times: Initial, after empty, on exit.
output.split("White's turn.").length should be > 2
}
test("TerminalUI should explicitly handle empty input by re-prompting") {
val in = new ByteArrayInputStream("\n\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// With two empty inputs, prompt should appear at least 4 times:
// 1. Initial board display
// 2. After first empty input
// 3. After second empty input
// 4. Before quit
val promptCount = output.split("White's turn.").length
promptCount should be >= 4
output should include("Game over. Goodbye!")
}
test("TerminalUI printPrompt should include undo and redo hints if engine returns true") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine() {
// Stub engine to force undo/redo to true
override def canUndo: Boolean = true
override def canRedo: Boolean = true
}
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("[undo]")
output should include("[redo]")
}
test("TerminalUI onGameEvent should properly format InvalidMoveEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(InvalidMoveEvent(Board(Map.empty), GameHistory(), Color.Black, "Invalid move format"))
}
out.toString should include("⚠️")
out.toString should include("Invalid move format")
}
test("TerminalUI onGameEvent should properly format CheckDetectedEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckDetectedEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Black is in check!")
}
test("TerminalUI onGameEvent should properly format CheckmateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckmateEvent(Board(Map.empty), GameHistory(), Color.Black, Color.White))
}
val ostr = out.toString
ostr should include("Checkmate! White wins.")
}
test("TerminalUI onGameEvent should properly format StalemateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(StalemateEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Stalemate! The game is a draw.")
}
test("TerminalUI onGameEvent should properly format BoardResetEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(BoardResetEvent(Board(Map.empty), GameHistory(), Color.White))
}
out.toString should include("Board has been reset to initial position.")
}
test("TerminalUI onGameEvent should properly format MoveExecutedEvent with capturing piece") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(MoveExecutedEvent(Board(Map.empty), GameHistory(), Color.Black, "A1", "A8", Some("Knight(White)")))
}
out.toString should include("Captured: Knight(White) on A8") // Depending on how piece/coord serialize
}
test("TerminalUI processes valid move input via processUserInput") {
val in = new ByteArrayInputStream("e2e4\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
// The move should have been processed and the board displayed
engine.turn shouldBe Color.Black
}
test("TerminalUI shows promotion prompt on PromotionRequiredEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(PromotionRequiredEvent(
Board(Map.empty), GameHistory(), Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
}
out.toString should include("Promote to")
}
test("TerminalUI routes promotion choice to engine.completePromotion") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Queen))
out.toString should include("Promote to")
}
test("TerminalUI re-prompts on invalid promotion choice") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
// "x" is invalid, then "r" for rook
val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Rook))
out.toString should include("Invalid")
}
test("TerminalUI routes Bishop promotion choice to engine.completePromotion") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
val in = new ByteArrayInputStream("e7e8\nb\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Bishop))
}
test("TerminalUI routes Knight promotion choice to engine.completePromotion") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
val in = new ByteArrayInputStream("e7e8\nn\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Knight))
}
}
@@ -0,0 +1,46 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
test("unicode returns correct unicode character for all piece types"):
val pieces = Seq(
(Piece(Color.White, PieceType.King), "\u2654"),
(Piece(Color.White, PieceType.Queen), "\u2655"),
(Piece(Color.White, PieceType.Rook), "\u2656"),
(Piece(Color.White, PieceType.Bishop), "\u2657"),
(Piece(Color.White, PieceType.Knight), "\u2658"),
(Piece(Color.White, PieceType.Pawn), "\u2659"),
(Piece(Color.Black, PieceType.King), "\u265A"),
(Piece(Color.Black, PieceType.Queen), "\u265B"),
(Piece(Color.Black, PieceType.Rook), "\u265C"),
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
(Piece(Color.Black, PieceType.Knight), "\u265E"),
(Piece(Color.Black, PieceType.Pawn), "\u265F")
)
pieces.foreach { (piece, expected) =>
piece.unicode shouldBe expected
}
test("render outputs coordinates ranks ansi escapes and piece glyphs"):
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
val rendered = Renderer.render(Board(Map.empty))
val lines = rendered.trim.split("\\n").toList.map(_.trim)
lines.head shouldBe "a b c d e f g h"
lines.last shouldBe "a b c d e f g h"
rendered should include("8")
rendered should include("1")
Renderer.render(board) should include("\u2655")
Renderer.render(board) should include("\u001b[")
test("render applies black piece color for black pieces"):
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
val rendered = Renderer.render(board)
rendered should include("\u265A") // Black king unicode
rendered should include("\u001b[30m") // ANSI black text color