From 8adff2d5272b21131407c709fa4bbeb81a01c2af Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 31 Mar 2026 19:49:17 +0200 Subject: [PATCH] feat: update TerminalUI to handle pawn promotion I/O via awaitingPromotion flag Co-Authored-By: Claude Sonnet 4.6 --- .../de/nowchess/ui/terminal/TerminalUI.scala | 33 +++++--- .../nowchess/ui/terminal/TerminalUITest.scala | 80 ++++++++++++++++++- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala index 5fc32af..e15979c 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala @@ -1,6 +1,7 @@ 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 @@ -11,6 +12,7 @@ import de.nowchess.chess.view.Renderer */ class TerminalUI(engine: GameEngine) extends Observer: private var running = true + private var awaitingPromotion = false /** Called by GameEngine whenever a game event occurs. */ override def onGameEvent(event: GameEvent): Unit = @@ -44,6 +46,10 @@ class TerminalUI(engine: GameEngine) extends Observer: print(Renderer.render(e.board)) printPrompt(e.turn) + case _: PromotionRequiredEvent => + println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") + awaitingPromotion = true + /** Start the terminal UI game loop. */ def start(): Unit = // Register as observer @@ -57,14 +63,24 @@ class TerminalUI(engine: GameEngine) extends Observer: // Game loop while running do val input = Option(StdIn.readLine()).getOrElse("quit").trim - input.toLowerCase match - case "quit" | "q" => - running = false - println("Game over. Goodbye!") - case "" => - printPrompt(engine.turn) - case _ => - engine.processUserInput(input) + if awaitingPromotion then + input.toLowerCase match + case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen) + case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook) + case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop) + case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight) + case _ => + println("Invalid choice. Enter q, r, b, or n.") + println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight") + else + input.toLowerCase match + case "quit" | "q" => + running = false + println("Game over. Goodbye!") + case "" => + printPrompt(engine.turn) + case _ => + engine.processUserInput(input) // Unsubscribe when done engine.unsubscribe(this) @@ -73,4 +89,3 @@ class TerminalUI(engine: GameEngine) extends Observer: val undoHint = if engine.canUndo then " [undo]" else "" val redoHint = if engine.canRedo then " [redo]" else "" print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ") - diff --git a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala index 16ccba4..54903c9 100644 --- a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala +++ b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala @@ -5,7 +5,7 @@ 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} +import de.nowchess.api.board.{Board, Color, File, Rank, Square} import de.nowchess.chess.logic.GameHistory class TerminalUITest extends AnyFunSuite with Matchers { @@ -186,4 +186,82 @@ class TerminalUITest extends AnyFunSuite with Matchers { // 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") + } }