From 5e0cb4b99da54853dc6ac1be0c0f050d00a3f86e Mon Sep 17 00:00:00 2001 From: aaylward <846647+aaylward@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:12:30 +0000 Subject: [PATCH 1/2] Rebased and moved PGN library stubs and tests to domains/games/libs/pgn as requested. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- domains/games/libs/pgn/BUILD.bazel | 19 + .../main/java/com/muchq/pgn/PgnReader.java | 47 ++ .../com/muchq/pgn/lexer/LexerException.java | 23 + .../java/com/muchq/pgn/lexer/PgnLexer.java | 29 ++ .../main/java/com/muchq/pgn/lexer/Token.java | 17 + .../java/com/muchq/pgn/lexer/TokenType.java | 28 + .../main/java/com/muchq/pgn/model/File.java | 13 + .../java/com/muchq/pgn/model/GameResult.java | 22 + .../main/java/com/muchq/pgn/model/Move.java | 27 + .../main/java/com/muchq/pgn/model/Nag.java | 23 + .../java/com/muchq/pgn/model/PgnGame.java | 23 + .../main/java/com/muchq/pgn/model/Piece.java | 24 + .../main/java/com/muchq/pgn/model/Rank.java | 27 + .../main/java/com/muchq/pgn/model/Square.java | 13 + .../java/com/muchq/pgn/model/TagPair.java | 7 + .../com/muchq/pgn/parser/ParseException.java | 25 + .../java/com/muchq/pgn/parser/PgnParser.java | 44 ++ .../java/com/muchq/pgn/PgnReaderTest.java | 156 ++++++ .../com/muchq/pgn/lexer/PgnLexerTest.java | 490 ++++++++++++++++++ .../com/muchq/pgn/model/GameResultTest.java | 42 ++ .../java/com/muchq/pgn/model/SquareTest.java | 182 +++++++ .../com/muchq/pgn/parser/PgnParserTest.java | 468 +++++++++++++++++ 22 files changed, 1749 insertions(+) create mode 100644 domains/games/libs/pgn/BUILD.bazel create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java create mode 100644 domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java create mode 100644 domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java create mode 100644 domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java create mode 100644 domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java create mode 100644 domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java create mode 100644 domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java diff --git a/domains/games/libs/pgn/BUILD.bazel b/domains/games/libs/pgn/BUILD.bazel new file mode 100644 index 00000000..4fd91142 --- /dev/null +++ b/domains/games/libs/pgn/BUILD.bazel @@ -0,0 +1,19 @@ +load("//bazel/rules:java.bzl", "artifact", "java_library", "java_test_suite") + +java_library( + name = "pgn", + srcs = glob(["src/main/java/**/*.java"]), + visibility = ["//visibility:public"], +) + +java_test_suite( + name = "pgn_tests", + size = "small", + srcs = glob(["src/test/java/**/*.java"]), + tags = ["manual"], + deps = [ + ":pgn", + artifact("junit:junit"), + artifact("org.assertj:assertj-core"), + ], +) diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java new file mode 100644 index 00000000..7e78e00a --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java @@ -0,0 +1,47 @@ +package com.muchq.pgn; + +import com.muchq.pgn.lexer.PgnLexer; +import com.muchq.pgn.model.PgnGame; +import com.muchq.pgn.parser.PgnParser; + +import java.util.List; + +/** + * High-level API for parsing PGN strings. + * + * Usage: + * PgnGame game = PgnReader.parseGame(pgnString); + * List games = PgnReader.parseAll(pgnString); + */ +public final class PgnReader { + + private PgnReader() { + // Utility class + } + + /** + * Parse a single game from PGN text. + * + * @param pgn The PGN string + * @return The parsed game + */ + public static PgnGame parseGame(String pgn) { + var lexer = new PgnLexer(pgn); + var tokens = lexer.tokenize(); + var parser = new PgnParser(tokens); + return parser.parseGame(); + } + + /** + * Parse all games from PGN text. + * + * @param pgn The PGN string (may contain multiple games) + * @return List of parsed games + */ + public static List parseAll(String pgn) { + var lexer = new PgnLexer(pgn); + var tokens = lexer.tokenize(); + var parser = new PgnParser(tokens); + return parser.parseAll(); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java new file mode 100644 index 00000000..6722f451 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java @@ -0,0 +1,23 @@ +package com.muchq.pgn.lexer; + +/** + * Exception thrown when the lexer encounters invalid input. + */ +public class LexerException extends RuntimeException { + private final int line; + private final int column; + + public LexerException(String message, int line, int column) { + super(String.format("%s at line %d, column %d", message, line, column)); + this.line = line; + this.column = column; + } + + public int getLine() { + return line; + } + + public int getColumn() { + return column; + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java new file mode 100644 index 00000000..5db69cee --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java @@ -0,0 +1,29 @@ +package com.muchq.pgn.lexer; + +import java.util.List; + +/** + * Tokenizes PGN input into a list of tokens. + * + * Usage: + * PgnLexer lexer = new PgnLexer(pgnString); + * List tokens = lexer.tokenize(); + */ +public class PgnLexer { + private final String input; + + public PgnLexer(String input) { + this.input = input; + } + + /** + * Tokenize the input and return all tokens. + * The last token will always be EOF. + * + * @return List of tokens + * @throws LexerException if invalid input is encountered + */ + public List tokenize() { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java new file mode 100644 index 00000000..bae5b07b --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java @@ -0,0 +1,17 @@ +package com.muchq.pgn.lexer; + +/** + * A token produced by the lexer. + * + * @param type The type of token + * @param value The string value of the token + * @param line The line number (1-indexed) + * @param column The column number (1-indexed) + */ +public record Token(TokenType type, String value, int line, int column) { + + @Override + public String toString() { + return String.format("%s('%s') at %d:%d", type, value, line, column); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java new file mode 100644 index 00000000..dd76b3ee --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java @@ -0,0 +1,28 @@ +package com.muchq.pgn.lexer; + +public enum TokenType { + // Delimiters + LEFT_BRACKET, // [ + RIGHT_BRACKET, // ] + LEFT_PAREN, // ( + RIGHT_PAREN, // ) + + // Literals + STRING, // "quoted string" + INTEGER, // 1, 2, 15, etc. + SYMBOL, // Tag names, moves (e4, Nf3, O-O, O-O-O) + + // Move notation + PERIOD, // . + ELLIPSIS, // ... + + // Annotations + NAG, // $1, $2, etc. + COMMENT, // {comment text} + + // Game results + RESULT, // 1-0, 0-1, 1/2-1/2, * + + // End of file + EOF +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java new file mode 100644 index 00000000..9f95d143 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java @@ -0,0 +1,13 @@ +package com.muchq.pgn.model; + +public enum File { + A, B, C, D, E, F, G, H; + + public static File fromChar(char c) { + throw new UnsupportedOperationException("TODO: implement"); + } + + public char toChar() { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java new file mode 100644 index 00000000..a3f1a880 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java @@ -0,0 +1,22 @@ +package com.muchq.pgn.model; + +public enum GameResult { + WHITE_WINS("1-0"), + BLACK_WINS("0-1"), + DRAW("1/2-1/2"), + ONGOING("*"); + + private final String notation; + + GameResult(String notation) { + this.notation = notation; + } + + public String notation() { + return notation; + } + + public static GameResult fromNotation(String s) { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java new file mode 100644 index 00000000..e073bedd --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java @@ -0,0 +1,27 @@ +package com.muchq.pgn.model; + +import java.util.List; +import java.util.Optional; + +/** + * Represents a single move in PGN notation with optional annotations. + * + * @param san The Standard Algebraic Notation for the move (e.g., "e4", "Nf3", "O-O") + * @param comment Optional comment in curly braces + * @param nags Numeric Annotation Glyphs ($1, $2, etc.) + * @param variations Alternative lines (recursive) + */ +public record Move( + String san, + Optional comment, + List nags, + List> variations +) { + public Move(String san) { + this(san, Optional.empty(), List.of(), List.of()); + } + + public Move(String san, String comment) { + this(san, Optional.of(comment), List.of(), List.of()); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java new file mode 100644 index 00000000..0d1a6224 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java @@ -0,0 +1,23 @@ +package com.muchq.pgn.model; + +/** + * Numeric Annotation Glyph - standard annotations like $1 (good move), $2 (poor move), etc. + * Common NAGs: + * $1 = ! (good move) + * $2 = ? (poor move) + * $3 = !! (very good move) + * $4 = ?? (blunder) + * $5 = !? (interesting move) + * $6 = ?! (dubious move) + */ +public record Nag(int value) { + + public static Nag parse(String s) { + throw new UnsupportedOperationException("TODO: implement"); + } + + @Override + public String toString() { + return "$" + value; + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java new file mode 100644 index 00000000..9ad6a206 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java @@ -0,0 +1,23 @@ +package com.muchq.pgn.model; + +import java.util.List; +import java.util.Optional; + +/** + * A complete parsed PGN game. + */ +public record PgnGame( + List tags, + List moves, + GameResult result +) { + /** + * Get a tag value by name. + */ + public Optional getTag(String name) { + return tags.stream() + .filter(t -> t.name().equals(name)) + .map(TagPair::value) + .findFirst(); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java new file mode 100644 index 00000000..763cca19 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java @@ -0,0 +1,24 @@ +package com.muchq.pgn.model; + +public enum Piece { + KING('K'), + QUEEN('Q'), + ROOK('R'), + BISHOP('B'), + KNIGHT('N'), + PAWN('\0'); + + private final char symbol; + + Piece(char symbol) { + this.symbol = symbol; + } + + public char symbol() { + return symbol; + } + + public static Piece fromSymbol(char c) { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java new file mode 100644 index 00000000..01329a6f --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java @@ -0,0 +1,27 @@ +package com.muchq.pgn.model; + +public enum Rank { + R1(1), R2(2), R3(3), R4(4), R5(5), R6(6), R7(7), R8(8); + + private final int number; + + Rank(int number) { + this.number = number; + } + + public int number() { + return number; + } + + public static Rank fromNumber(int n) { + throw new UnsupportedOperationException("TODO: implement"); + } + + public static Rank fromChar(char c) { + throw new UnsupportedOperationException("TODO: implement"); + } + + public char toChar() { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java new file mode 100644 index 00000000..54889659 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java @@ -0,0 +1,13 @@ +package com.muchq.pgn.model; + +public record Square(File file, Rank rank) { + + public static Square parse(String s) { + throw new UnsupportedOperationException("TODO: implement"); + } + + @Override + public String toString() { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java new file mode 100644 index 00000000..52b935f2 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java @@ -0,0 +1,7 @@ +package com.muchq.pgn.model; + +/** + * A PGN tag pair like [Event "World Championship"] + */ +public record TagPair(String name, String value) { +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java new file mode 100644 index 00000000..ca2ad0d8 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java @@ -0,0 +1,25 @@ +package com.muchq.pgn.parser; + +import com.muchq.pgn.lexer.Token; + +/** + * Exception thrown when the parser encounters invalid input. + */ +public class ParseException extends RuntimeException { + private final Token token; + + public ParseException(String message, Token token) { + super(String.format("%s at line %d, column %d (token: %s)", + message, token.line(), token.column(), token.value())); + this.token = token; + } + + public ParseException(String message) { + super(message); + this.token = null; + } + + public Token getToken() { + return token; + } +} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java new file mode 100644 index 00000000..fa6f14d5 --- /dev/null +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java @@ -0,0 +1,44 @@ +package com.muchq.pgn.parser; + +import com.muchq.pgn.lexer.Token; +import com.muchq.pgn.model.PgnGame; + +import java.util.List; + +/** + * Parses a list of tokens into PgnGame objects. + * + * Usage: + * PgnParser parser = new PgnParser(tokens); + * PgnGame game = parser.parseGame(); + * + * Or for multiple games: + * List games = parser.parseAll(); + */ +public class PgnParser { + private final List tokens; + + public PgnParser(List tokens) { + this.tokens = tokens; + } + + /** + * Parse a single game from the token stream. + * + * @return The parsed game + * @throws ParseException if the input is malformed + */ + public PgnGame parseGame() { + throw new UnsupportedOperationException("TODO: implement"); + } + + /** + * Parse all games from the token stream. + * + * @return List of parsed games + * @throws ParseException if the input is malformed + */ + public List parseAll() { + throw new UnsupportedOperationException("TODO: implement"); + } +} diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java new file mode 100644 index 00000000..e2ef13a5 --- /dev/null +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java @@ -0,0 +1,156 @@ +package com.muchq.pgn; + +import com.muchq.pgn.model.GameResult; +import com.muchq.pgn.model.PgnGame; +import org.junit.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PgnReaderTest { + + @Test + public void parseGame_minimalGame() { + PgnGame game = PgnReader.parseGame("[Result \"*\"] *"); + assertThat(game.result()).isEqualTo(GameResult.ONGOING); + assertThat(game.moves()).isEmpty(); + } + + @Test + public void parseGame_simpleGame() { + String pgn = """ + [Event "Test"] + [Result "1-0"] + + 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0 + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.getTag("Event")).hasValue("Test"); + assertThat(game.moves()).hasSize(7); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parseGame_withAllAnnotations() { + String pgn = """ + [Event "Annotated Game"] + [Result "*"] + + 1. e4 $1 {The king's pawn opening} e5 + 2. Nf3 (2. f4 {King's Gambit}) Nc6 $2 + 3. Bb5 {Ruy Lopez} * + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.moves()).hasSize(5); + assertThat(game.moves().get(0).nags()).isNotEmpty(); + assertThat(game.moves().get(0).comment()).isPresent(); + assertThat(game.moves().get(2).variations()).hasSize(1); + } + + @Test + public void parseAll_multipleGames() { + String pgn = """ + [Event "Game 1"] + [Result "1-0"] + 1. e4 1-0 + + [Event "Game 2"] + [Result "0-1"] + 1. d4 0-1 + + [Event "Game 3"] + [Result "1/2-1/2"] + 1. c4 1/2-1/2 + """; + List games = PgnReader.parseAll(pgn); + + assertThat(games).hasSize(3); + assertThat(games.get(0).getTag("Event")).hasValue("Game 1"); + assertThat(games.get(0).moves().get(0).san()).isEqualTo("e4"); + assertThat(games.get(1).getTag("Event")).hasValue("Game 2"); + assertThat(games.get(1).moves().get(0).san()).isEqualTo("d4"); + assertThat(games.get(2).getTag("Event")).hasValue("Game 3"); + assertThat(games.get(2).moves().get(0).san()).isEqualTo("c4"); + } + + @Test + public void parseAll_empty() { + List games = PgnReader.parseAll(""); + assertThat(games).isEmpty(); + } + + @Test + public void parseGame_realWorldPgn_fischerSpassky() { + String pgn = """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 {This opening is called the Ruy Lopez.} + a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7 + 11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5 + Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6 + 23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5 + hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5 + 35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6 + Nf2 42. g4 Bd3 43. Re6 1/2-1/2 + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.getTag("Event")).hasValue("F/S Return Match"); + assertThat(game.getTag("White")).hasValue("Fischer, Robert J."); + assertThat(game.getTag("Black")).hasValue("Spassky, Boris V."); + assertThat(game.result()).isEqualTo(GameResult.DRAW); + assertThat(game.moves()).hasSizeGreaterThan(80); + } + + @Test + public void parseGame_deeplyNestedVariations() { + String pgn = """ + [Result "*"] + + 1. e4 (1. d4 (1. c4 (1. Nf3))) * + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + + // First level variation + assertThat(game.moves().get(0).variations()).hasSize(1); + var d4 = game.moves().get(0).variations().get(0).get(0); + assertThat(d4.san()).isEqualTo("d4"); + + // Second level variation + assertThat(d4.variations()).hasSize(1); + var c4 = d4.variations().get(0).get(0); + assertThat(c4.san()).isEqualTo("c4"); + + // Third level variation + assertThat(c4.variations()).hasSize(1); + var nf3 = c4.variations().get(0).get(0); + assertThat(nf3.san()).isEqualTo("Nf3"); + } + + @Test + public void parseGame_longVariation() { + String pgn = """ + [Result "*"] + + 1. e4 c5 (1... e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O) 2. Nf3 * + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.moves()).hasSize(3); // e4, c5, Nf3 in main line + var c5 = game.moves().get(1); + assertThat(c5.variations()).hasSize(1); + assertThat(c5.variations().get(0)).hasSize(9); // e5, Nf3, Nc6, Bb5, a6, Ba4, Nf6, O-O + } +} diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java new file mode 100644 index 00000000..5759b697 --- /dev/null +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java @@ -0,0 +1,490 @@ +package com.muchq.pgn.lexer; + +import org.junit.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class PgnLexerTest { + + // === Basic Token Tests === + + @Test + public void tokenize_empty() { + List tokens = new PgnLexer("").tokenize(); + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.EOF); + } + + @Test + public void tokenize_whitespaceOnly() { + List tokens = new PgnLexer(" \n\t ").tokenize(); + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.EOF); + } + + // === Delimiter Tests === + + @Test + public void tokenize_brackets() { + List tokens = new PgnLexer("[]").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.RIGHT_BRACKET); + assertThat(tokens.get(2).type()).isEqualTo(TokenType.EOF); + } + + @Test + public void tokenize_parens() { + List tokens = new PgnLexer("()").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_PAREN); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.RIGHT_PAREN); + } + + // === String Tests === + + @Test + public void tokenize_simpleString() { + List tokens = new PgnLexer("\"hello\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(0).value()).isEqualTo("hello"); + } + + @Test + public void tokenize_stringWithSpaces() { + List tokens = new PgnLexer("\"World Championship\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(0).value()).isEqualTo("World Championship"); + } + + @Test + public void tokenize_stringWithEscapedQuote() { + List tokens = new PgnLexer("\"say \\\"hi\\\"\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(0).value()).isEqualTo("say \"hi\""); + } + + @Test + public void tokenize_stringWithBackslash() { + List tokens = new PgnLexer("\"path\\\\file\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).value()).isEqualTo("path\\file"); + } + + @Test + public void tokenize_unterminatedString() { + assertThatThrownBy(() -> new PgnLexer("\"unterminated").tokenize()) + .isInstanceOf(LexerException.class) + .hasMessageContaining("Unterminated string"); + } + + // === Integer Tests === + + @Test + public void tokenize_integer() { + List tokens = new PgnLexer("42").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("42"); + } + + @Test + public void tokenize_moveNumber() { + List tokens = new PgnLexer("1.").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("1"); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.PERIOD); + } + + @Test + public void tokenize_multiDigitMoveNumber() { + List tokens = new PgnLexer("15.").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("15"); + } + + // === Period and Ellipsis Tests === + + @Test + public void tokenize_period() { + List tokens = new PgnLexer(".").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.PERIOD); + } + + @Test + public void tokenize_ellipsis() { + List tokens = new PgnLexer("...").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.ELLIPSIS); + assertThat(tokens.get(0).value()).isEqualTo("..."); + } + + @Test + public void tokenize_blackMoveNumber() { + List tokens = new PgnLexer("1...").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("1"); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.ELLIPSIS); + } + + // === Symbol Tests (moves and tag names) === + + @Test + public void tokenize_pawnMove() { + List tokens = new PgnLexer("e4").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("e4"); + } + + @Test + public void tokenize_pieceMove() { + List tokens = new PgnLexer("Nf3").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Nf3"); + } + + @Test + public void tokenize_capture() { + List tokens = new PgnLexer("Bxe5").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Bxe5"); + } + + @Test + public void tokenize_pawnCapture() { + List tokens = new PgnLexer("exd5").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("exd5"); + } + + @Test + public void tokenize_castleKingside() { + List tokens = new PgnLexer("O-O").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("O-O"); + } + + @Test + public void tokenize_castleQueenside() { + List tokens = new PgnLexer("O-O-O").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("O-O-O"); + } + + @Test + public void tokenize_check() { + List tokens = new PgnLexer("Qh7+").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Qh7+"); + } + + @Test + public void tokenize_checkmate() { + List tokens = new PgnLexer("Qxf7#").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Qxf7#"); + } + + @Test + public void tokenize_promotion() { + List tokens = new PgnLexer("e8=Q").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("e8=Q"); + } + + @Test + public void tokenize_promotionWithCheck() { + List tokens = new PgnLexer("e8=Q+").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("e8=Q+"); + } + + @Test + public void tokenize_disambiguatedMove_file() { + List tokens = new PgnLexer("Rae1").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Rae1"); + } + + @Test + public void tokenize_disambiguatedMove_rank() { + List tokens = new PgnLexer("R1e4").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("R1e4"); + } + + @Test + public void tokenize_disambiguatedMove_full() { + List tokens = new PgnLexer("Qd1e2").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Qd1e2"); + } + + @Test + public void tokenize_tagName() { + List tokens = new PgnLexer("Event").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Event"); + } + + // === Comment Tests === + + @Test + public void tokenize_comment() { + List tokens = new PgnLexer("{this is a comment}").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(0).value()).isEqualTo("this is a comment"); + } + + @Test + public void tokenize_emptyComment() { + List tokens = new PgnLexer("{}").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(0).value()).isEqualTo(""); + } + + @Test + public void tokenize_multilineComment() { + List tokens = new PgnLexer("{line one\nline two}").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(0).value()).isEqualTo("line one\nline two"); + } + + @Test + public void tokenize_unterminatedComment() { + assertThatThrownBy(() -> new PgnLexer("{unclosed").tokenize()) + .isInstanceOf(LexerException.class) + .hasMessageContaining("Unterminated comment"); + } + + // === NAG Tests === + + @Test + public void tokenize_nag() { + List tokens = new PgnLexer("$1").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.NAG); + assertThat(tokens.get(0).value()).isEqualTo("$1"); + } + + @Test + public void tokenize_multiDigitNag() { + List tokens = new PgnLexer("$142").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.NAG); + assertThat(tokens.get(0).value()).isEqualTo("$142"); + } + + @Test + public void tokenize_nagWithoutNumber() { + assertThatThrownBy(() -> new PgnLexer("$").tokenize()) + .isInstanceOf(LexerException.class); + } + + // === Result Tests === + + @Test + public void tokenize_whiteWins() { + List tokens = new PgnLexer("1-0").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("1-0"); + } + + @Test + public void tokenize_blackWins() { + List tokens = new PgnLexer("0-1").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("0-1"); + } + + @Test + public void tokenize_draw() { + List tokens = new PgnLexer("1/2-1/2").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("1/2-1/2"); + } + + @Test + public void tokenize_ongoing() { + List tokens = new PgnLexer("*").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("*"); + } + + // === Tag Pair Tests === + + @Test + public void tokenize_tagPair() { + List tokens = new PgnLexer("[Event \"Test\"]").tokenize(); + assertThat(tokens).hasSize(5); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(1).value()).isEqualTo("Event"); + assertThat(tokens.get(2).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(2).value()).isEqualTo("Test"); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.RIGHT_BRACKET); + assertThat(tokens.get(4).type()).isEqualTo(TokenType.EOF); + } + + // === Movetext Tests === + + @Test + public void tokenize_simpleMovetext() { + List tokens = new PgnLexer("1. e4 e5 2. Nf3").tokenize(); + assertThat(tokens).hasSize(9); + // 1 + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("1"); + // . + assertThat(tokens.get(1).type()).isEqualTo(TokenType.PERIOD); + // e4 + assertThat(tokens.get(2).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(2).value()).isEqualTo("e4"); + // e5 + assertThat(tokens.get(3).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(3).value()).isEqualTo("e5"); + // 2 + assertThat(tokens.get(4).type()).isEqualTo(TokenType.INTEGER); + // . + assertThat(tokens.get(5).type()).isEqualTo(TokenType.PERIOD); + // Nf3 + assertThat(tokens.get(6).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(6).value()).isEqualTo("Nf3"); + } + + @Test + public void tokenize_movetextWithComment() { + List tokens = new PgnLexer("1. e4 {King's pawn} e5").tokenize(); + assertThat(tokens).hasSize(6); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(3).value()).isEqualTo("King's pawn"); + assertThat(tokens.get(4).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_movetextWithNag() { + List tokens = new PgnLexer("1. e4 $1 e5").tokenize(); + assertThat(tokens).hasSize(6); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.NAG); + assertThat(tokens.get(3).value()).isEqualTo("$1"); + } + + @Test + public void tokenize_movetextWithVariation() { + List tokens = new PgnLexer("1. e4 (1. d4) e5").tokenize(); + assertThat(tokens).hasSize(10); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.LEFT_PAREN); + assertThat(tokens.get(7).type()).isEqualTo(TokenType.RIGHT_PAREN); + } + + // === Position Tracking Tests === + + @Test + public void tokenize_trackLineNumber() { + List tokens = new PgnLexer("a\nb\nc").tokenize(); + assertThat(tokens.get(0).line()).isEqualTo(1); + assertThat(tokens.get(1).line()).isEqualTo(2); + assertThat(tokens.get(2).line()).isEqualTo(3); + } + + @Test + public void tokenize_trackColumn() { + List tokens = new PgnLexer("abc def").tokenize(); + assertThat(tokens.get(0).column()).isEqualTo(1); + assertThat(tokens.get(1).column()).isEqualTo(5); + } + + // === Full Game Tokenization === + + @Test + public void tokenize_completeGame() { + String pgn = """ + [Event "Test"] + [Site "Home"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 Nc6 1-0 + """; + List tokens = new PgnLexer(pgn).tokenize(); + + // Should have: 3 tags (each: [ SYMBOL STRING ]) + movetext + result + EOF + assertThat(tokens).isNotEmpty(); + assertThat(tokens.get(tokens.size() - 1).type()).isEqualTo(TokenType.EOF); + + // Verify tag structure + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); + assertThat(tokens.get(1).value()).isEqualTo("Event"); + assertThat(tokens.get(2).value()).isEqualTo("Test"); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.RIGHT_BRACKET); + } + + // === Line Comment Tests (semicolon) === + + @Test + public void tokenize_lineComment() { + List tokens = new PgnLexer("e4 ; this is a line comment\ne5").tokenize(); + // Line comments should be skipped (or tokenized as COMMENT depending on implementation) + // For this test, we expect comments to be ignored + assertThat(tokens.stream().filter(t -> t.type() == TokenType.SYMBOL).count()).isEqualTo(2); + } + + // === Edge Cases === + + @Test + public void tokenize_moveWithInlineAnnotation() { + // Some PGN files use ! and ? directly after moves + // These could be parsed as part of the symbol or as separate NAGs + List tokens = new PgnLexer("e4!").tokenize(); + // Accept either: symbol "e4!" or symbol "e4" + something + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_moveWithDoubleAnnotation() { + List tokens = new PgnLexer("e4!!").tokenize(); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_moveWithQuestionMark() { + List tokens = new PgnLexer("Qxf7??").tokenize(); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_moveWithMixedAnnotation() { + List tokens = new PgnLexer("Nc3!?").tokenize(); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } +} diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java new file mode 100644 index 00000000..c8078606 --- /dev/null +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java @@ -0,0 +1,42 @@ +package com.muchq.pgn.model; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class GameResultTest { + + @Test + public void fromNotation_whiteWins() { + assertThat(GameResult.fromNotation("1-0")).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void fromNotation_blackWins() { + assertThat(GameResult.fromNotation("0-1")).isEqualTo(GameResult.BLACK_WINS); + } + + @Test + public void fromNotation_draw() { + assertThat(GameResult.fromNotation("1/2-1/2")).isEqualTo(GameResult.DRAW); + } + + @Test + public void fromNotation_ongoing() { + assertThat(GameResult.fromNotation("*")).isEqualTo(GameResult.ONGOING); + } + + @Test + public void fromNotation_invalidThrows() { + assertThatThrownBy(() -> GameResult.fromNotation("invalid")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void notation_roundTrip() { + for (GameResult result : GameResult.values()) { + assertThat(GameResult.fromNotation(result.notation())).isEqualTo(result); + } + } +} diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java new file mode 100644 index 00000000..a0c7e6d8 --- /dev/null +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java @@ -0,0 +1,182 @@ +package com.muchq.pgn.model; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class SquareTest { + + @Test + public void parse_e4() { + Square square = Square.parse("e4"); + assertThat(square.file()).isEqualTo(File.E); + assertThat(square.rank()).isEqualTo(Rank.R4); + } + + @Test + public void parse_a1() { + Square square = Square.parse("a1"); + assertThat(square.file()).isEqualTo(File.A); + assertThat(square.rank()).isEqualTo(Rank.R1); + } + + @Test + public void parse_h8() { + Square square = Square.parse("h8"); + assertThat(square.file()).isEqualTo(File.H); + assertThat(square.rank()).isEqualTo(Rank.R8); + } + + @Test + public void parse_uppercase() { + Square square = Square.parse("E4"); + assertThat(square.file()).isEqualTo(File.E); + assertThat(square.rank()).isEqualTo(Rank.R4); + } + + @Test + public void parse_invalidFile() { + assertThatThrownBy(() -> Square.parse("z4")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_invalidRank() { + assertThatThrownBy(() -> Square.parse("e9")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_tooShort() { + assertThatThrownBy(() -> Square.parse("e")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_tooLong() { + assertThatThrownBy(() -> Square.parse("e44")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void toString_e4() { + Square square = new Square(File.E, Rank.R4); + assertThat(square.toString()).isEqualTo("e4"); + } + + @Test + public void toString_a1() { + Square square = new Square(File.A, Rank.R1); + assertThat(square.toString()).isEqualTo("a1"); + } + + @Test + public void toString_h8() { + Square square = new Square(File.H, Rank.R8); + assertThat(square.toString()).isEqualTo("h8"); + } + + // File enum tests + + @Test + public void file_fromChar() { + assertThat(File.fromChar('a')).isEqualTo(File.A); + assertThat(File.fromChar('h')).isEqualTo(File.H); + assertThat(File.fromChar('E')).isEqualTo(File.E); + } + + @Test + public void file_fromChar_invalid() { + assertThatThrownBy(() -> File.fromChar('z')) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void file_toChar() { + assertThat(File.A.toChar()).isEqualTo('a'); + assertThat(File.H.toChar()).isEqualTo('h'); + } + + // Rank enum tests + + @Test + public void rank_fromNumber() { + assertThat(Rank.fromNumber(1)).isEqualTo(Rank.R1); + assertThat(Rank.fromNumber(8)).isEqualTo(Rank.R8); + } + + @Test + public void rank_fromNumber_invalid() { + assertThatThrownBy(() -> Rank.fromNumber(0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Rank.fromNumber(9)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void rank_fromChar() { + assertThat(Rank.fromChar('1')).isEqualTo(Rank.R1); + assertThat(Rank.fromChar('8')).isEqualTo(Rank.R8); + } + + @Test + public void rank_toChar() { + assertThat(Rank.R1.toChar()).isEqualTo('1'); + assertThat(Rank.R8.toChar()).isEqualTo('8'); + } + + @Test + public void rank_number() { + assertThat(Rank.R1.number()).isEqualTo(1); + assertThat(Rank.R8.number()).isEqualTo(8); + } + + // Piece enum tests + + @Test + public void piece_fromSymbol() { + assertThat(Piece.fromSymbol('K')).isEqualTo(Piece.KING); + assertThat(Piece.fromSymbol('Q')).isEqualTo(Piece.QUEEN); + assertThat(Piece.fromSymbol('R')).isEqualTo(Piece.ROOK); + assertThat(Piece.fromSymbol('B')).isEqualTo(Piece.BISHOP); + assertThat(Piece.fromSymbol('N')).isEqualTo(Piece.KNIGHT); + } + + @Test + public void piece_fromSymbol_invalid() { + assertThatThrownBy(() -> Piece.fromSymbol('X')) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void piece_symbol() { + assertThat(Piece.KING.symbol()).isEqualTo('K'); + assertThat(Piece.PAWN.symbol()).isEqualTo('\0'); + } + + // Nag tests + + @Test + public void nag_parse() { + assertThat(Nag.parse("$1").value()).isEqualTo(1); + assertThat(Nag.parse("$6").value()).isEqualTo(6); + assertThat(Nag.parse("$142").value()).isEqualTo(142); + } + + @Test + public void nag_parse_invalid() { + assertThatThrownBy(() -> Nag.parse("1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Nag.parse("$")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Nag.parse("$abc")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void nag_toString() { + assertThat(new Nag(1).toString()).isEqualTo("$1"); + assertThat(new Nag(142).toString()).isEqualTo("$142"); + } +} diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java new file mode 100644 index 00000000..a8e47e57 --- /dev/null +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java @@ -0,0 +1,468 @@ +package com.muchq.pgn.parser; + +import com.muchq.pgn.lexer.PgnLexer; +import com.muchq.pgn.lexer.Token; +import com.muchq.pgn.model.GameResult; +import com.muchq.pgn.model.Move; +import com.muchq.pgn.model.PgnGame; +import org.junit.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class PgnParserTest { + + private PgnGame parse(String pgn) { + List tokens = new PgnLexer(pgn).tokenize(); + return new PgnParser(tokens).parseGame(); + } + + private List parseAll(String pgn) { + List tokens = new PgnLexer(pgn).tokenize(); + return new PgnParser(tokens).parseAll(); + } + + // === Tag Parsing Tests === + + @Test + public void parse_singleTag() { + PgnGame game = parse("[Event \"Test\"] *"); + assertThat(game.tags()).hasSize(1); + assertThat(game.tags().get(0).name()).isEqualTo("Event"); + assertThat(game.tags().get(0).value()).isEqualTo("Test"); + } + + @Test + public void parse_sevenTagRoster() { + String pgn = """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1/2-1/2 + """; + PgnGame game = parse(pgn); + assertThat(game.tags()).hasSize(7); + assertThat(game.getTag("Event")).hasValue("F/S Return Match"); + assertThat(game.getTag("Site")).hasValue("Belgrade, Serbia JUG"); + assertThat(game.getTag("Date")).hasValue("1992.11.04"); + assertThat(game.getTag("Round")).hasValue("29"); + assertThat(game.getTag("White")).hasValue("Fischer, Robert J."); + assertThat(game.getTag("Black")).hasValue("Spassky, Boris V."); + assertThat(game.getTag("Result")).hasValue("1/2-1/2"); + } + + @Test + public void parse_tagWithSpecialCharacters() { + PgnGame game = parse("[White \"O'Brien, John\"] *"); + assertThat(game.getTag("White")).hasValue("O'Brien, John"); + } + + @Test + public void parse_tagWithEscapedQuote() { + PgnGame game = parse("[Event \"The \\\"Big\\\" Game\"] *"); + assertThat(game.getTag("Event")).hasValue("The \"Big\" Game"); + } + + // === Simple Movetext Tests === + + @Test + public void parse_singleMove() { + PgnGame game = parse("[Result \"*\"] 1. e4 *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + } + + @Test + public void parse_twoMoves() { + PgnGame game = parse("[Result \"*\"] 1. e4 e5 *"); + assertThat(game.moves()).hasSize(2); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(1).san()).isEqualTo("e5"); + } + + @Test + public void parse_multipleMoveNumbers() { + PgnGame game = parse("[Result \"*\"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 *"); + assertThat(game.moves()).hasSize(5); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(1).san()).isEqualTo("e5"); + assertThat(game.moves().get(2).san()).isEqualTo("Nf3"); + assertThat(game.moves().get(3).san()).isEqualTo("Nc6"); + assertThat(game.moves().get(4).san()).isEqualTo("Bb5"); + } + + @Test + public void parse_blackToMove() { + // Continuation notation: 15... Qxd4 + PgnGame game = parse("[Result \"*\"] 15... Qxd4 *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("Qxd4"); + } + + // === Castling Tests === + + @Test + public void parse_castleKingside() { + PgnGame game = parse("[Result \"*\"] 1. O-O *"); + assertThat(game.moves().get(0).san()).isEqualTo("O-O"); + } + + @Test + public void parse_castleQueenside() { + PgnGame game = parse("[Result \"*\"] 1. O-O-O *"); + assertThat(game.moves().get(0).san()).isEqualTo("O-O-O"); + } + + // === Check and Checkmate Tests === + + @Test + public void parse_check() { + PgnGame game = parse("[Result \"*\"] 1. Qh5+ *"); + assertThat(game.moves().get(0).san()).isEqualTo("Qh5+"); + } + + @Test + public void parse_checkmate() { + PgnGame game = parse("[Result \"1-0\"] 1. Qxf7# 1-0"); + assertThat(game.moves().get(0).san()).isEqualTo("Qxf7#"); + } + + // === Promotion Tests === + + @Test + public void parse_promotion() { + PgnGame game = parse("[Result \"*\"] 1. e8=Q *"); + assertThat(game.moves().get(0).san()).isEqualTo("e8=Q"); + } + + @Test + public void parse_promotionWithCheck() { + PgnGame game = parse("[Result \"*\"] 1. e8=Q+ *"); + assertThat(game.moves().get(0).san()).isEqualTo("e8=Q+"); + } + + // === Capture Tests === + + @Test + public void parse_pieceCapture() { + PgnGame game = parse("[Result \"*\"] 1. Bxe5 *"); + assertThat(game.moves().get(0).san()).isEqualTo("Bxe5"); + } + + @Test + public void parse_pawnCapture() { + PgnGame game = parse("[Result \"*\"] 1. exd5 *"); + assertThat(game.moves().get(0).san()).isEqualTo("exd5"); + } + + // === Disambiguation Tests === + + @Test + public void parse_disambiguatedByFile() { + PgnGame game = parse("[Result \"*\"] 1. Rae1 *"); + assertThat(game.moves().get(0).san()).isEqualTo("Rae1"); + } + + @Test + public void parse_disambiguatedByRank() { + PgnGame game = parse("[Result \"*\"] 1. R1e4 *"); + assertThat(game.moves().get(0).san()).isEqualTo("R1e4"); + } + + @Test + public void parse_fullyDisambiguated() { + PgnGame game = parse("[Result \"*\"] 1. Qd1e2 *"); + assertThat(game.moves().get(0).san()).isEqualTo("Qd1e2"); + } + + // === Comment Tests === + + @Test + public void parse_moveWithComment() { + PgnGame game = parse("[Result \"*\"] 1. e4 {King's pawn opening} *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(0).comment()).hasValue("King's pawn opening"); + } + + @Test + public void parse_multipleCommentsAttachToMove() { + // Multiple comments after a move - they should be concatenated or only first kept + PgnGame game = parse("[Result \"*\"] 1. e4 {comment one} {comment two} *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).comment()).isPresent(); + } + + @Test + public void parse_commentBeforeMove() { + // Comment before moves is valid PGN + PgnGame game = parse("[Result \"*\"] {Opening comment} 1. e4 *"); + assertThat(game.moves()).hasSize(1); + } + + // === NAG Tests === + + @Test + public void parse_moveWithNag() { + PgnGame game = parse("[Result \"*\"] 1. e4 $1 *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).nags()).hasSize(1); + assertThat(game.moves().get(0).nags().get(0).value()).isEqualTo(1); + } + + @Test + public void parse_moveWithMultipleNags() { + PgnGame game = parse("[Result \"*\"] 1. e4 $1 $14 *"); + assertThat(game.moves().get(0).nags()).hasSize(2); + assertThat(game.moves().get(0).nags().get(0).value()).isEqualTo(1); + assertThat(game.moves().get(0).nags().get(1).value()).isEqualTo(14); + } + + @Test + public void parse_inlineAnnotation_goodMove() { + // ! should be converted to $1 + PgnGame game = parse("[Result \"*\"] 1. e4! *"); + assertThat(game.moves()).hasSize(1); + // Either the ! is part of the SAN or converted to NAG + Move move = game.moves().get(0); + boolean hasGoodMoveIndicator = move.san().endsWith("!") || + move.nags().stream().anyMatch(n -> n.value() == 1); + assertThat(hasGoodMoveIndicator).isTrue(); + } + + @Test + public void parse_inlineAnnotation_blunder() { + // ?? should be converted to $4 + PgnGame game = parse("[Result \"*\"] 1. e4?? *"); + assertThat(game.moves()).hasSize(1); + Move move = game.moves().get(0); + boolean hasBlunderIndicator = move.san().endsWith("??") || + move.nags().stream().anyMatch(n -> n.value() == 4); + assertThat(hasBlunderIndicator).isTrue(); + } + + // === Variation Tests === + + @Test + public void parse_simpleVariation() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4) e5 *"); + assertThat(game.moves()).hasSize(2); // e4 and e5 in main line + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(0).variations()).hasSize(1); + assertThat(game.moves().get(0).variations().get(0)).hasSize(1); + assertThat(game.moves().get(0).variations().get(0).get(0).san()).isEqualTo("d4"); + } + + @Test + public void parse_variationWithMultipleMoves() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 d5 2. c4) e5 *"); + assertThat(game.moves().get(0).variations()).hasSize(1); + List variation = game.moves().get(0).variations().get(0); + assertThat(variation).hasSize(3); + assertThat(variation.get(0).san()).isEqualTo("d4"); + assertThat(variation.get(1).san()).isEqualTo("d5"); + assertThat(variation.get(2).san()).isEqualTo("c4"); + } + + @Test + public void parse_nestedVariation() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 (1. c4)) e5 *"); + assertThat(game.moves().get(0).variations()).hasSize(1); + List variation = game.moves().get(0).variations().get(0); + assertThat(variation.get(0).san()).isEqualTo("d4"); + assertThat(variation.get(0).variations()).hasSize(1); + assertThat(variation.get(0).variations().get(0).get(0).san()).isEqualTo("c4"); + } + + @Test + public void parse_multipleVariations() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4) (1. c4) e5 *"); + assertThat(game.moves().get(0).variations()).hasSize(2); + assertThat(game.moves().get(0).variations().get(0).get(0).san()).isEqualTo("d4"); + assertThat(game.moves().get(0).variations().get(1).get(0).san()).isEqualTo("c4"); + } + + @Test + public void parse_variationWithComment() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 {Queen's pawn}) *"); + List variation = game.moves().get(0).variations().get(0); + assertThat(variation.get(0).comment()).hasValue("Queen's pawn"); + } + + // === Result Tests === + + @Test + public void parse_resultWhiteWins() { + PgnGame game = parse("[Result \"1-0\"] 1. e4 1-0"); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parse_resultBlackWins() { + PgnGame game = parse("[Result \"0-1\"] 1. e4 0-1"); + assertThat(game.result()).isEqualTo(GameResult.BLACK_WINS); + } + + @Test + public void parse_resultDraw() { + PgnGame game = parse("[Result \"1/2-1/2\"] 1. e4 1/2-1/2"); + assertThat(game.result()).isEqualTo(GameResult.DRAW); + } + + @Test + public void parse_resultOngoing() { + PgnGame game = parse("[Result \"*\"] 1. e4 *"); + assertThat(game.result()).isEqualTo(GameResult.ONGOING); + } + + // === Multiple Games Tests === + + @Test + public void parseAll_twoGames() { + String pgn = """ + [Event "Game 1"] + [Result "1-0"] + + 1. e4 1-0 + + [Event "Game 2"] + [Result "0-1"] + + 1. d4 0-1 + """; + List games = parseAll(pgn); + assertThat(games).hasSize(2); + assertThat(games.get(0).getTag("Event")).hasValue("Game 1"); + assertThat(games.get(0).result()).isEqualTo(GameResult.WHITE_WINS); + assertThat(games.get(1).getTag("Event")).hasValue("Game 2"); + assertThat(games.get(1).result()).isEqualTo(GameResult.BLACK_WINS); + } + + @Test + public void parseAll_empty() { + List games = parseAll(""); + assertThat(games).isEmpty(); + } + + // === Complete Game Tests === + + @Test + public void parse_completeGame() { + String pgn = """ + [Event "World Championship"] + [Site "London"] + [Date "2023.04.15"] + [Round "5"] + [White "Carlsen"] + [Black "Nepomniachtchi"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O 1-0 + """; + PgnGame game = parse(pgn); + + assertThat(game.getTag("Event")).hasValue("World Championship"); + assertThat(game.getTag("White")).hasValue("Carlsen"); + assertThat(game.moves()).hasSize(9); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parse_gameWithAnnotations() { + String pgn = """ + [Event "Test"] + [Result "1-0"] + + 1. e4 $1 {Best by test} e5 2. Nf3 Nc6 (2... d6 {Philidor}) 3. Bb5 1-0 + """; + PgnGame game = parse(pgn); + + // Check first move has NAG and comment + Move e4 = game.moves().get(0); + assertThat(e4.san()).isEqualTo("e4"); + assertThat(e4.nags()).isNotEmpty(); + assertThat(e4.comment()).isPresent(); + + // Check variation exists + Move nc6 = game.moves().get(3); + assertThat(nc6.san()).isEqualTo("Nc6"); + assertThat(nc6.variations()).hasSize(1); + } + + // === Error Handling Tests === + + @Test + public void parse_missingResult() { + // A game without a termination marker + assertThatThrownBy(() -> parse("[Event \"Test\"] 1. e4")) + .isInstanceOf(ParseException.class); + } + + @Test + public void parse_unclosedVariation() { + assertThatThrownBy(() -> parse("[Result \"*\"] 1. e4 (1. d4 *")) + .isInstanceOf(ParseException.class); + } + + @Test + public void parse_malformedTag() { + assertThatThrownBy(() -> parse("[Event] *")) + .isInstanceOf(ParseException.class); + } + + // === Real World Examples === + + @Test + public void parse_operaGame() { + String pgn = """ + [Event "Paris"] + [Site "Paris FRA"] + [Date "1858.??.??"] + [Round "?"] + [White "Morphy, Paul"] + [Black "Duke of Brunswick and Count Isouard"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7 + 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 + 13. Rxd7 Rxd7 14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0 + """; + PgnGame game = parse(pgn); + + assertThat(game.getTag("White")).hasValue("Morphy, Paul"); + assertThat(game.moves()).hasSize(33); + assertThat(game.moves().get(32).san()).isEqualTo("Rd8#"); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parse_immortalGame() { + String pgn = """ + [Event "London"] + [Site "London ENG"] + [Date "1851.06.21"] + [Round "?"] + [White "Anderssen, Adolf"] + [Black "Kieseritzky, Lionel"] + [Result "1-0"] + + 1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5 + 8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3 Ng8 + 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2 Na6 + 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7# 1-0 + """; + PgnGame game = parse(pgn); + + assertThat(game.getTag("Event")).hasValue("London"); + assertThat(game.moves()).hasSize(45); + assertThat(game.moves().get(44).san()).isEqualTo("Be7#"); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } +} From 0c2f0edeb1ba165b2b062b21f574cda494743c12 Mon Sep 17 00:00:00 2001 From: aaylward <846647+aaylward@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:23:40 +0000 Subject: [PATCH 2/2] Rebased and moved PGN library stubs and tests to domains/games/libs/pgn as requested. Fixed formatting issues. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .../main/java/com/muchq/pgn/PgnReader.java | 60 +- .../com/muchq/pgn/lexer/LexerException.java | 30 +- .../java/com/muchq/pgn/lexer/PgnLexer.java | 31 +- .../main/java/com/muchq/pgn/lexer/Token.java | 8 +- .../java/com/muchq/pgn/lexer/TokenType.java | 38 +- .../main/java/com/muchq/pgn/model/File.java | 21 +- .../java/com/muchq/pgn/model/GameResult.java | 28 +- .../main/java/com/muchq/pgn/model/Move.java | 18 +- .../main/java/com/muchq/pgn/model/Nag.java | 25 +- .../java/com/muchq/pgn/model/PgnGame.java | 23 +- .../main/java/com/muchq/pgn/model/Piece.java | 32 +- .../main/java/com/muchq/pgn/model/Rank.java | 41 +- .../main/java/com/muchq/pgn/model/Square.java | 14 +- .../java/com/muchq/pgn/model/TagPair.java | 7 +- .../com/muchq/pgn/parser/ParseException.java | 30 +- .../java/com/muchq/pgn/parser/PgnParser.java | 52 +- .../java/com/muchq/pgn/PgnReaderTest.java | 299 +++--- .../com/muchq/pgn/lexer/PgnLexerTest.java | 963 +++++++++--------- .../com/muchq/pgn/model/GameResultTest.java | 66 +- .../java/com/muchq/pgn/model/SquareTest.java | 337 +++--- .../com/muchq/pgn/parser/PgnParserTest.java | 915 ++++++++--------- 21 files changed, 1507 insertions(+), 1531 deletions(-) diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java index 7e78e00a..acd48cd9 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java @@ -3,45 +3,43 @@ import com.muchq.pgn.lexer.PgnLexer; import com.muchq.pgn.model.PgnGame; import com.muchq.pgn.parser.PgnParser; - import java.util.List; /** * High-level API for parsing PGN strings. * - * Usage: - * PgnGame game = PgnReader.parseGame(pgnString); - * List games = PgnReader.parseAll(pgnString); + *

Usage: PgnGame game = PgnReader.parseGame(pgnString); List games = + * PgnReader.parseAll(pgnString); */ public final class PgnReader { - private PgnReader() { - // Utility class - } + private PgnReader() { + // Utility class + } - /** - * Parse a single game from PGN text. - * - * @param pgn The PGN string - * @return The parsed game - */ - public static PgnGame parseGame(String pgn) { - var lexer = new PgnLexer(pgn); - var tokens = lexer.tokenize(); - var parser = new PgnParser(tokens); - return parser.parseGame(); - } + /** + * Parse a single game from PGN text. + * + * @param pgn The PGN string + * @return The parsed game + */ + public static PgnGame parseGame(String pgn) { + var lexer = new PgnLexer(pgn); + var tokens = lexer.tokenize(); + var parser = new PgnParser(tokens); + return parser.parseGame(); + } - /** - * Parse all games from PGN text. - * - * @param pgn The PGN string (may contain multiple games) - * @return List of parsed games - */ - public static List parseAll(String pgn) { - var lexer = new PgnLexer(pgn); - var tokens = lexer.tokenize(); - var parser = new PgnParser(tokens); - return parser.parseAll(); - } + /** + * Parse all games from PGN text. + * + * @param pgn The PGN string (may contain multiple games) + * @return List of parsed games + */ + public static List parseAll(String pgn) { + var lexer = new PgnLexer(pgn); + var tokens = lexer.tokenize(); + var parser = new PgnParser(tokens); + return parser.parseAll(); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java index 6722f451..90a50ea7 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java @@ -1,23 +1,21 @@ package com.muchq.pgn.lexer; -/** - * Exception thrown when the lexer encounters invalid input. - */ +/** Exception thrown when the lexer encounters invalid input. */ public class LexerException extends RuntimeException { - private final int line; - private final int column; + private final int line; + private final int column; - public LexerException(String message, int line, int column) { - super(String.format("%s at line %d, column %d", message, line, column)); - this.line = line; - this.column = column; - } + public LexerException(String message, int line, int column) { + super(String.format("%s at line %d, column %d", message, line, column)); + this.line = line; + this.column = column; + } - public int getLine() { - return line; - } + public int getLine() { + return line; + } - public int getColumn() { - return column; - } + public int getColumn() { + return column; + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java index 5db69cee..4bd25a26 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java @@ -5,25 +5,22 @@ /** * Tokenizes PGN input into a list of tokens. * - * Usage: - * PgnLexer lexer = new PgnLexer(pgnString); - * List tokens = lexer.tokenize(); + *

Usage: PgnLexer lexer = new PgnLexer(pgnString); List tokens = lexer.tokenize(); */ public class PgnLexer { - private final String input; + private final String input; - public PgnLexer(String input) { - this.input = input; - } + public PgnLexer(String input) { + this.input = input; + } - /** - * Tokenize the input and return all tokens. - * The last token will always be EOF. - * - * @return List of tokens - * @throws LexerException if invalid input is encountered - */ - public List tokenize() { - throw new UnsupportedOperationException("TODO: implement"); - } + /** + * Tokenize the input and return all tokens. The last token will always be EOF. + * + * @return List of tokens + * @throws LexerException if invalid input is encountered + */ + public List tokenize() { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java index bae5b07b..b7b45a47 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/Token.java @@ -10,8 +10,8 @@ */ public record Token(TokenType type, String value, int line, int column) { - @Override - public String toString() { - return String.format("%s('%s') at %d:%d", type, value, line, column); - } + @Override + public String toString() { + return String.format("%s('%s') at %d:%d", type, value, line, column); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java index dd76b3ee..a1e94b96 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/TokenType.java @@ -1,28 +1,28 @@ package com.muchq.pgn.lexer; public enum TokenType { - // Delimiters - LEFT_BRACKET, // [ - RIGHT_BRACKET, // ] - LEFT_PAREN, // ( - RIGHT_PAREN, // ) + // Delimiters + LEFT_BRACKET, // [ + RIGHT_BRACKET, // ] + LEFT_PAREN, // ( + RIGHT_PAREN, // ) - // Literals - STRING, // "quoted string" - INTEGER, // 1, 2, 15, etc. - SYMBOL, // Tag names, moves (e4, Nf3, O-O, O-O-O) + // Literals + STRING, // "quoted string" + INTEGER, // 1, 2, 15, etc. + SYMBOL, // Tag names, moves (e4, Nf3, O-O, O-O-O) - // Move notation - PERIOD, // . - ELLIPSIS, // ... + // Move notation + PERIOD, // . + ELLIPSIS, // ... - // Annotations - NAG, // $1, $2, etc. - COMMENT, // {comment text} + // Annotations + NAG, // $1, $2, etc. + COMMENT, // {comment text} - // Game results - RESULT, // 1-0, 0-1, 1/2-1/2, * + // Game results + RESULT, // 1-0, 0-1, 1/2-1/2, * - // End of file - EOF + // End of file + EOF } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java index 9f95d143..bf867766 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java @@ -1,13 +1,20 @@ package com.muchq.pgn.model; public enum File { - A, B, C, D, E, F, G, H; + A, + B, + C, + D, + E, + F, + G, + H; - public static File fromChar(char c) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static File fromChar(char c) { + throw new UnsupportedOperationException("TODO: implement"); + } - public char toChar() { - throw new UnsupportedOperationException("TODO: implement"); - } + public char toChar() { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java index a3f1a880..1e14b8f3 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/GameResult.java @@ -1,22 +1,22 @@ package com.muchq.pgn.model; public enum GameResult { - WHITE_WINS("1-0"), - BLACK_WINS("0-1"), - DRAW("1/2-1/2"), - ONGOING("*"); + WHITE_WINS("1-0"), + BLACK_WINS("0-1"), + DRAW("1/2-1/2"), + ONGOING("*"); - private final String notation; + private final String notation; - GameResult(String notation) { - this.notation = notation; - } + GameResult(String notation) { + this.notation = notation; + } - public String notation() { - return notation; - } + public String notation() { + return notation; + } - public static GameResult fromNotation(String s) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static GameResult fromNotation(String s) { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java index e073bedd..520d3e5a 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java @@ -12,16 +12,12 @@ * @param variations Alternative lines (recursive) */ public record Move( - String san, - Optional comment, - List nags, - List> variations -) { - public Move(String san) { - this(san, Optional.empty(), List.of(), List.of()); - } + String san, Optional comment, List nags, List> variations) { + public Move(String san) { + this(san, Optional.empty(), List.of(), List.of()); + } - public Move(String san, String comment) { - this(san, Optional.of(comment), List.of(), List.of()); - } + public Move(String san, String comment) { + this(san, Optional.of(comment), List.of(), List.of()); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java index 0d1a6224..32cba58f 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java @@ -1,23 +1,18 @@ package com.muchq.pgn.model; /** - * Numeric Annotation Glyph - standard annotations like $1 (good move), $2 (poor move), etc. - * Common NAGs: - * $1 = ! (good move) - * $2 = ? (poor move) - * $3 = !! (very good move) - * $4 = ?? (blunder) - * $5 = !? (interesting move) - * $6 = ?! (dubious move) + * Numeric Annotation Glyph - standard annotations like $1 (good move), $2 (poor move), etc. Common + * NAGs: $1 = ! (good move) $2 = ? (poor move) $3 = !! (very good move) $4 = ?? (blunder) $5 = !? + * (interesting move) $6 = ?! (dubious move) */ public record Nag(int value) { - public static Nag parse(String s) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static Nag parse(String s) { + throw new UnsupportedOperationException("TODO: implement"); + } - @Override - public String toString() { - return "$" + value; - } + @Override + public String toString() { + return "$" + value; + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java index 9ad6a206..b972aded 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java @@ -3,21 +3,10 @@ import java.util.List; import java.util.Optional; -/** - * A complete parsed PGN game. - */ -public record PgnGame( - List tags, - List moves, - GameResult result -) { - /** - * Get a tag value by name. - */ - public Optional getTag(String name) { - return tags.stream() - .filter(t -> t.name().equals(name)) - .map(TagPair::value) - .findFirst(); - } +/** A complete parsed PGN game. */ +public record PgnGame(List tags, List moves, GameResult result) { + /** Get a tag value by name. */ + public Optional getTag(String name) { + return tags.stream().filter(t -> t.name().equals(name)).map(TagPair::value).findFirst(); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java index 763cca19..5bc21bf9 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Piece.java @@ -1,24 +1,24 @@ package com.muchq.pgn.model; public enum Piece { - KING('K'), - QUEEN('Q'), - ROOK('R'), - BISHOP('B'), - KNIGHT('N'), - PAWN('\0'); + KING('K'), + QUEEN('Q'), + ROOK('R'), + BISHOP('B'), + KNIGHT('N'), + PAWN('\0'); - private final char symbol; + private final char symbol; - Piece(char symbol) { - this.symbol = symbol; - } + Piece(char symbol) { + this.symbol = symbol; + } - public char symbol() { - return symbol; - } + public char symbol() { + return symbol; + } - public static Piece fromSymbol(char c) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static Piece fromSymbol(char c) { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java index 01329a6f..70b21fc2 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java @@ -1,27 +1,34 @@ package com.muchq.pgn.model; public enum Rank { - R1(1), R2(2), R3(3), R4(4), R5(5), R6(6), R7(7), R8(8); + R1(1), + R2(2), + R3(3), + R4(4), + R5(5), + R6(6), + R7(7), + R8(8); - private final int number; + private final int number; - Rank(int number) { - this.number = number; - } + Rank(int number) { + this.number = number; + } - public int number() { - return number; - } + public int number() { + return number; + } - public static Rank fromNumber(int n) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static Rank fromNumber(int n) { + throw new UnsupportedOperationException("TODO: implement"); + } - public static Rank fromChar(char c) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static Rank fromChar(char c) { + throw new UnsupportedOperationException("TODO: implement"); + } - public char toChar() { - throw new UnsupportedOperationException("TODO: implement"); - } + public char toChar() { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java index 54889659..ad071178 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Square.java @@ -2,12 +2,12 @@ public record Square(File file, Rank rank) { - public static Square parse(String s) { - throw new UnsupportedOperationException("TODO: implement"); - } + public static Square parse(String s) { + throw new UnsupportedOperationException("TODO: implement"); + } - @Override - public String toString() { - throw new UnsupportedOperationException("TODO: implement"); - } + @Override + public String toString() { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java index 52b935f2..6e430f58 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java @@ -1,7 +1,4 @@ package com.muchq.pgn.model; -/** - * A PGN tag pair like [Event "World Championship"] - */ -public record TagPair(String name, String value) { -} +/** A PGN tag pair like [Event "World Championship"] */ +public record TagPair(String name, String value) {} diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java index ca2ad0d8..3820c04d 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/ParseException.java @@ -2,24 +2,24 @@ import com.muchq.pgn.lexer.Token; -/** - * Exception thrown when the parser encounters invalid input. - */ +/** Exception thrown when the parser encounters invalid input. */ public class ParseException extends RuntimeException { - private final Token token; + private final Token token; - public ParseException(String message, Token token) { - super(String.format("%s at line %d, column %d (token: %s)", + public ParseException(String message, Token token) { + super( + String.format( + "%s at line %d, column %d (token: %s)", message, token.line(), token.column(), token.value())); - this.token = token; - } + this.token = token; + } - public ParseException(String message) { - super(message); - this.token = null; - } + public ParseException(String message) { + super(message); + this.token = null; + } - public Token getToken() { - return token; - } + public Token getToken() { + return token; + } } diff --git a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java index fa6f14d5..20599e13 100644 --- a/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java +++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java @@ -2,43 +2,39 @@ import com.muchq.pgn.lexer.Token; import com.muchq.pgn.model.PgnGame; - import java.util.List; /** * Parses a list of tokens into PgnGame objects. * - * Usage: - * PgnParser parser = new PgnParser(tokens); - * PgnGame game = parser.parseGame(); + *

Usage: PgnParser parser = new PgnParser(tokens); PgnGame game = parser.parseGame(); * - * Or for multiple games: - * List games = parser.parseAll(); + *

Or for multiple games: List games = parser.parseAll(); */ public class PgnParser { - private final List tokens; + private final List tokens; - public PgnParser(List tokens) { - this.tokens = tokens; - } + public PgnParser(List tokens) { + this.tokens = tokens; + } - /** - * Parse a single game from the token stream. - * - * @return The parsed game - * @throws ParseException if the input is malformed - */ - public PgnGame parseGame() { - throw new UnsupportedOperationException("TODO: implement"); - } + /** + * Parse a single game from the token stream. + * + * @return The parsed game + * @throws ParseException if the input is malformed + */ + public PgnGame parseGame() { + throw new UnsupportedOperationException("TODO: implement"); + } - /** - * Parse all games from the token stream. - * - * @return List of parsed games - * @throws ParseException if the input is malformed - */ - public List parseAll() { - throw new UnsupportedOperationException("TODO: implement"); - } + /** + * Parse all games from the token stream. + * + * @return List of parsed games + * @throws ParseException if the input is malformed + */ + public List parseAll() { + throw new UnsupportedOperationException("TODO: implement"); + } } diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java index e2ef13a5..3b5a9604 100644 --- a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java @@ -1,156 +1,161 @@ package com.muchq.pgn; +import static org.assertj.core.api.Assertions.assertThat; + import com.muchq.pgn.model.GameResult; import com.muchq.pgn.model.PgnGame; -import org.junit.Test; - import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.Test; public class PgnReaderTest { - @Test - public void parseGame_minimalGame() { - PgnGame game = PgnReader.parseGame("[Result \"*\"] *"); - assertThat(game.result()).isEqualTo(GameResult.ONGOING); - assertThat(game.moves()).isEmpty(); - } - - @Test - public void parseGame_simpleGame() { - String pgn = """ - [Event "Test"] - [Result "1-0"] - - 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0 - """; - PgnGame game = PgnReader.parseGame(pgn); - - assertThat(game.getTag("Event")).hasValue("Test"); - assertThat(game.moves()).hasSize(7); - assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); - } - - @Test - public void parseGame_withAllAnnotations() { - String pgn = """ - [Event "Annotated Game"] - [Result "*"] - - 1. e4 $1 {The king's pawn opening} e5 - 2. Nf3 (2. f4 {King's Gambit}) Nc6 $2 - 3. Bb5 {Ruy Lopez} * - """; - PgnGame game = PgnReader.parseGame(pgn); - - assertThat(game.moves()).hasSize(5); - assertThat(game.moves().get(0).nags()).isNotEmpty(); - assertThat(game.moves().get(0).comment()).isPresent(); - assertThat(game.moves().get(2).variations()).hasSize(1); - } - - @Test - public void parseAll_multipleGames() { - String pgn = """ - [Event "Game 1"] - [Result "1-0"] - 1. e4 1-0 - - [Event "Game 2"] - [Result "0-1"] - 1. d4 0-1 - - [Event "Game 3"] - [Result "1/2-1/2"] - 1. c4 1/2-1/2 - """; - List games = PgnReader.parseAll(pgn); - - assertThat(games).hasSize(3); - assertThat(games.get(0).getTag("Event")).hasValue("Game 1"); - assertThat(games.get(0).moves().get(0).san()).isEqualTo("e4"); - assertThat(games.get(1).getTag("Event")).hasValue("Game 2"); - assertThat(games.get(1).moves().get(0).san()).isEqualTo("d4"); - assertThat(games.get(2).getTag("Event")).hasValue("Game 3"); - assertThat(games.get(2).moves().get(0).san()).isEqualTo("c4"); - } - - @Test - public void parseAll_empty() { - List games = PgnReader.parseAll(""); - assertThat(games).isEmpty(); - } - - @Test - public void parseGame_realWorldPgn_fischerSpassky() { - String pgn = """ - [Event "F/S Return Match"] - [Site "Belgrade, Serbia JUG"] - [Date "1992.11.04"] - [Round "29"] - [White "Fischer, Robert J."] - [Black "Spassky, Boris V."] - [Result "1/2-1/2"] - - 1. e4 e5 2. Nf3 Nc6 3. Bb5 {This opening is called the Ruy Lopez.} - a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7 - 11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5 - Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6 - 23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5 - hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5 - 35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6 - Nf2 42. g4 Bd3 43. Re6 1/2-1/2 - """; - PgnGame game = PgnReader.parseGame(pgn); - - assertThat(game.getTag("Event")).hasValue("F/S Return Match"); - assertThat(game.getTag("White")).hasValue("Fischer, Robert J."); - assertThat(game.getTag("Black")).hasValue("Spassky, Boris V."); - assertThat(game.result()).isEqualTo(GameResult.DRAW); - assertThat(game.moves()).hasSizeGreaterThan(80); - } - - @Test - public void parseGame_deeplyNestedVariations() { - String pgn = """ - [Result "*"] - - 1. e4 (1. d4 (1. c4 (1. Nf3))) * - """; - PgnGame game = PgnReader.parseGame(pgn); - - assertThat(game.moves()).hasSize(1); - assertThat(game.moves().get(0).san()).isEqualTo("e4"); - - // First level variation - assertThat(game.moves().get(0).variations()).hasSize(1); - var d4 = game.moves().get(0).variations().get(0).get(0); - assertThat(d4.san()).isEqualTo("d4"); - - // Second level variation - assertThat(d4.variations()).hasSize(1); - var c4 = d4.variations().get(0).get(0); - assertThat(c4.san()).isEqualTo("c4"); - - // Third level variation - assertThat(c4.variations()).hasSize(1); - var nf3 = c4.variations().get(0).get(0); - assertThat(nf3.san()).isEqualTo("Nf3"); - } - - @Test - public void parseGame_longVariation() { - String pgn = """ - [Result "*"] - - 1. e4 c5 (1... e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O) 2. Nf3 * - """; - PgnGame game = PgnReader.parseGame(pgn); - - assertThat(game.moves()).hasSize(3); // e4, c5, Nf3 in main line - var c5 = game.moves().get(1); - assertThat(c5.variations()).hasSize(1); - assertThat(c5.variations().get(0)).hasSize(9); // e5, Nf3, Nc6, Bb5, a6, Ba4, Nf6, O-O - } + @Test + public void parseGame_minimalGame() { + PgnGame game = PgnReader.parseGame("[Result \"*\"] *"); + assertThat(game.result()).isEqualTo(GameResult.ONGOING); + assertThat(game.moves()).isEmpty(); + } + + @Test + public void parseGame_simpleGame() { + String pgn = + """ + [Event "Test"] + [Result "1-0"] + + 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0 + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.getTag("Event")).hasValue("Test"); + assertThat(game.moves()).hasSize(7); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parseGame_withAllAnnotations() { + String pgn = + """ + [Event "Annotated Game"] + [Result "*"] + + 1. e4 $1 {The king's pawn opening} e5 + 2. Nf3 (2. f4 {King's Gambit}) Nc6 $2 + 3. Bb5 {Ruy Lopez} * + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.moves()).hasSize(5); + assertThat(game.moves().get(0).nags()).isNotEmpty(); + assertThat(game.moves().get(0).comment()).isPresent(); + assertThat(game.moves().get(2).variations()).hasSize(1); + } + + @Test + public void parseAll_multipleGames() { + String pgn = + """ + [Event "Game 1"] + [Result "1-0"] + 1. e4 1-0 + + [Event "Game 2"] + [Result "0-1"] + 1. d4 0-1 + + [Event "Game 3"] + [Result "1/2-1/2"] + 1. c4 1/2-1/2 + """; + List games = PgnReader.parseAll(pgn); + + assertThat(games).hasSize(3); + assertThat(games.get(0).getTag("Event")).hasValue("Game 1"); + assertThat(games.get(0).moves().get(0).san()).isEqualTo("e4"); + assertThat(games.get(1).getTag("Event")).hasValue("Game 2"); + assertThat(games.get(1).moves().get(0).san()).isEqualTo("d4"); + assertThat(games.get(2).getTag("Event")).hasValue("Game 3"); + assertThat(games.get(2).moves().get(0).san()).isEqualTo("c4"); + } + + @Test + public void parseAll_empty() { + List games = PgnReader.parseAll(""); + assertThat(games).isEmpty(); + } + + @Test + public void parseGame_realWorldPgn_fischerSpassky() { + String pgn = + """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 {This opening is called the Ruy Lopez.} + a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7 + 11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5 + Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6 + 23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5 + hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5 + 35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6 + Nf2 42. g4 Bd3 43. Re6 1/2-1/2 + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.getTag("Event")).hasValue("F/S Return Match"); + assertThat(game.getTag("White")).hasValue("Fischer, Robert J."); + assertThat(game.getTag("Black")).hasValue("Spassky, Boris V."); + assertThat(game.result()).isEqualTo(GameResult.DRAW); + assertThat(game.moves()).hasSizeGreaterThan(80); + } + + @Test + public void parseGame_deeplyNestedVariations() { + String pgn = + """ + [Result "*"] + + 1. e4 (1. d4 (1. c4 (1. Nf3))) * + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + + // First level variation + assertThat(game.moves().get(0).variations()).hasSize(1); + var d4 = game.moves().get(0).variations().get(0).get(0); + assertThat(d4.san()).isEqualTo("d4"); + + // Second level variation + assertThat(d4.variations()).hasSize(1); + var c4 = d4.variations().get(0).get(0); + assertThat(c4.san()).isEqualTo("c4"); + + // Third level variation + assertThat(c4.variations()).hasSize(1); + var nf3 = c4.variations().get(0).get(0); + assertThat(nf3.san()).isEqualTo("Nf3"); + } + + @Test + public void parseGame_longVariation() { + String pgn = + """ + [Result "*"] + + 1. e4 c5 (1... e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O) 2. Nf3 * + """; + PgnGame game = PgnReader.parseGame(pgn); + + assertThat(game.moves()).hasSize(3); // e4, c5, Nf3 in main line + var c5 = game.moves().get(1); + assertThat(c5.variations()).hasSize(1); + assertThat(c5.variations().get(0)).hasSize(9); // e5, Nf3, Nc6, Bb5, a6, Ba4, Nf6, O-O + } } diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java index 5759b697..1f66d7eb 100644 --- a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java @@ -1,490 +1,489 @@ package com.muchq.pgn.lexer; -import org.junit.Test; - -import java.util.List; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.List; +import org.junit.Test; + public class PgnLexerTest { - // === Basic Token Tests === - - @Test - public void tokenize_empty() { - List tokens = new PgnLexer("").tokenize(); - assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.EOF); - } - - @Test - public void tokenize_whitespaceOnly() { - List tokens = new PgnLexer(" \n\t ").tokenize(); - assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.EOF); - } - - // === Delimiter Tests === - - @Test - public void tokenize_brackets() { - List tokens = new PgnLexer("[]").tokenize(); - assertThat(tokens).hasSize(3); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); - assertThat(tokens.get(1).type()).isEqualTo(TokenType.RIGHT_BRACKET); - assertThat(tokens.get(2).type()).isEqualTo(TokenType.EOF); - } - - @Test - public void tokenize_parens() { - List tokens = new PgnLexer("()").tokenize(); - assertThat(tokens).hasSize(3); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_PAREN); - assertThat(tokens.get(1).type()).isEqualTo(TokenType.RIGHT_PAREN); - } - - // === String Tests === - - @Test - public void tokenize_simpleString() { - List tokens = new PgnLexer("\"hello\"").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); - assertThat(tokens.get(0).value()).isEqualTo("hello"); - } - - @Test - public void tokenize_stringWithSpaces() { - List tokens = new PgnLexer("\"World Championship\"").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); - assertThat(tokens.get(0).value()).isEqualTo("World Championship"); - } - - @Test - public void tokenize_stringWithEscapedQuote() { - List tokens = new PgnLexer("\"say \\\"hi\\\"\"").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); - assertThat(tokens.get(0).value()).isEqualTo("say \"hi\""); - } - - @Test - public void tokenize_stringWithBackslash() { - List tokens = new PgnLexer("\"path\\\\file\"").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).value()).isEqualTo("path\\file"); - } - - @Test - public void tokenize_unterminatedString() { - assertThatThrownBy(() -> new PgnLexer("\"unterminated").tokenize()) - .isInstanceOf(LexerException.class) - .hasMessageContaining("Unterminated string"); - } - - // === Integer Tests === - - @Test - public void tokenize_integer() { - List tokens = new PgnLexer("42").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); - assertThat(tokens.get(0).value()).isEqualTo("42"); - } - - @Test - public void tokenize_moveNumber() { - List tokens = new PgnLexer("1.").tokenize(); - assertThat(tokens).hasSize(3); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); - assertThat(tokens.get(0).value()).isEqualTo("1"); - assertThat(tokens.get(1).type()).isEqualTo(TokenType.PERIOD); - } - - @Test - public void tokenize_multiDigitMoveNumber() { - List tokens = new PgnLexer("15.").tokenize(); - assertThat(tokens).hasSize(3); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); - assertThat(tokens.get(0).value()).isEqualTo("15"); - } - - // === Period and Ellipsis Tests === - - @Test - public void tokenize_period() { - List tokens = new PgnLexer(".").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.PERIOD); - } - - @Test - public void tokenize_ellipsis() { - List tokens = new PgnLexer("...").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.ELLIPSIS); - assertThat(tokens.get(0).value()).isEqualTo("..."); - } - - @Test - public void tokenize_blackMoveNumber() { - List tokens = new PgnLexer("1...").tokenize(); - assertThat(tokens).hasSize(3); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); - assertThat(tokens.get(0).value()).isEqualTo("1"); - assertThat(tokens.get(1).type()).isEqualTo(TokenType.ELLIPSIS); - } - - // === Symbol Tests (moves and tag names) === - - @Test - public void tokenize_pawnMove() { - List tokens = new PgnLexer("e4").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("e4"); - } - - @Test - public void tokenize_pieceMove() { - List tokens = new PgnLexer("Nf3").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Nf3"); - } - - @Test - public void tokenize_capture() { - List tokens = new PgnLexer("Bxe5").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Bxe5"); - } - - @Test - public void tokenize_pawnCapture() { - List tokens = new PgnLexer("exd5").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("exd5"); - } - - @Test - public void tokenize_castleKingside() { - List tokens = new PgnLexer("O-O").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("O-O"); - } - - @Test - public void tokenize_castleQueenside() { - List tokens = new PgnLexer("O-O-O").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("O-O-O"); - } - - @Test - public void tokenize_check() { - List tokens = new PgnLexer("Qh7+").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Qh7+"); - } - - @Test - public void tokenize_checkmate() { - List tokens = new PgnLexer("Qxf7#").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Qxf7#"); - } - - @Test - public void tokenize_promotion() { - List tokens = new PgnLexer("e8=Q").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("e8=Q"); - } - - @Test - public void tokenize_promotionWithCheck() { - List tokens = new PgnLexer("e8=Q+").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("e8=Q+"); - } - - @Test - public void tokenize_disambiguatedMove_file() { - List tokens = new PgnLexer("Rae1").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Rae1"); - } - - @Test - public void tokenize_disambiguatedMove_rank() { - List tokens = new PgnLexer("R1e4").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("R1e4"); - } - - @Test - public void tokenize_disambiguatedMove_full() { - List tokens = new PgnLexer("Qd1e2").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Qd1e2"); - } - - @Test - public void tokenize_tagName() { - List tokens = new PgnLexer("Event").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(0).value()).isEqualTo("Event"); - } - - // === Comment Tests === - - @Test - public void tokenize_comment() { - List tokens = new PgnLexer("{this is a comment}").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); - assertThat(tokens.get(0).value()).isEqualTo("this is a comment"); - } - - @Test - public void tokenize_emptyComment() { - List tokens = new PgnLexer("{}").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); - assertThat(tokens.get(0).value()).isEqualTo(""); - } - - @Test - public void tokenize_multilineComment() { - List tokens = new PgnLexer("{line one\nline two}").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); - assertThat(tokens.get(0).value()).isEqualTo("line one\nline two"); - } - - @Test - public void tokenize_unterminatedComment() { - assertThatThrownBy(() -> new PgnLexer("{unclosed").tokenize()) - .isInstanceOf(LexerException.class) - .hasMessageContaining("Unterminated comment"); - } - - // === NAG Tests === - - @Test - public void tokenize_nag() { - List tokens = new PgnLexer("$1").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.NAG); - assertThat(tokens.get(0).value()).isEqualTo("$1"); - } - - @Test - public void tokenize_multiDigitNag() { - List tokens = new PgnLexer("$142").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.NAG); - assertThat(tokens.get(0).value()).isEqualTo("$142"); - } - - @Test - public void tokenize_nagWithoutNumber() { - assertThatThrownBy(() -> new PgnLexer("$").tokenize()) - .isInstanceOf(LexerException.class); - } - - // === Result Tests === - - @Test - public void tokenize_whiteWins() { - List tokens = new PgnLexer("1-0").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); - assertThat(tokens.get(0).value()).isEqualTo("1-0"); - } - - @Test - public void tokenize_blackWins() { - List tokens = new PgnLexer("0-1").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); - assertThat(tokens.get(0).value()).isEqualTo("0-1"); - } - - @Test - public void tokenize_draw() { - List tokens = new PgnLexer("1/2-1/2").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); - assertThat(tokens.get(0).value()).isEqualTo("1/2-1/2"); - } - - @Test - public void tokenize_ongoing() { - List tokens = new PgnLexer("*").tokenize(); - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); - assertThat(tokens.get(0).value()).isEqualTo("*"); - } - - // === Tag Pair Tests === - - @Test - public void tokenize_tagPair() { - List tokens = new PgnLexer("[Event \"Test\"]").tokenize(); - assertThat(tokens).hasSize(5); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); - assertThat(tokens.get(1).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(1).value()).isEqualTo("Event"); - assertThat(tokens.get(2).type()).isEqualTo(TokenType.STRING); - assertThat(tokens.get(2).value()).isEqualTo("Test"); - assertThat(tokens.get(3).type()).isEqualTo(TokenType.RIGHT_BRACKET); - assertThat(tokens.get(4).type()).isEqualTo(TokenType.EOF); - } - - // === Movetext Tests === - - @Test - public void tokenize_simpleMovetext() { - List tokens = new PgnLexer("1. e4 e5 2. Nf3").tokenize(); - assertThat(tokens).hasSize(9); - // 1 - assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); - assertThat(tokens.get(0).value()).isEqualTo("1"); - // . - assertThat(tokens.get(1).type()).isEqualTo(TokenType.PERIOD); - // e4 - assertThat(tokens.get(2).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(2).value()).isEqualTo("e4"); - // e5 - assertThat(tokens.get(3).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(3).value()).isEqualTo("e5"); - // 2 - assertThat(tokens.get(4).type()).isEqualTo(TokenType.INTEGER); - // . - assertThat(tokens.get(5).type()).isEqualTo(TokenType.PERIOD); - // Nf3 - assertThat(tokens.get(6).type()).isEqualTo(TokenType.SYMBOL); - assertThat(tokens.get(6).value()).isEqualTo("Nf3"); - } - - @Test - public void tokenize_movetextWithComment() { - List tokens = new PgnLexer("1. e4 {King's pawn} e5").tokenize(); - assertThat(tokens).hasSize(6); - assertThat(tokens.get(3).type()).isEqualTo(TokenType.COMMENT); - assertThat(tokens.get(3).value()).isEqualTo("King's pawn"); - assertThat(tokens.get(4).type()).isEqualTo(TokenType.SYMBOL); - } - - @Test - public void tokenize_movetextWithNag() { - List tokens = new PgnLexer("1. e4 $1 e5").tokenize(); - assertThat(tokens).hasSize(6); - assertThat(tokens.get(3).type()).isEqualTo(TokenType.NAG); - assertThat(tokens.get(3).value()).isEqualTo("$1"); - } - - @Test - public void tokenize_movetextWithVariation() { - List tokens = new PgnLexer("1. e4 (1. d4) e5").tokenize(); - assertThat(tokens).hasSize(10); - assertThat(tokens.get(3).type()).isEqualTo(TokenType.LEFT_PAREN); - assertThat(tokens.get(7).type()).isEqualTo(TokenType.RIGHT_PAREN); - } - - // === Position Tracking Tests === - - @Test - public void tokenize_trackLineNumber() { - List tokens = new PgnLexer("a\nb\nc").tokenize(); - assertThat(tokens.get(0).line()).isEqualTo(1); - assertThat(tokens.get(1).line()).isEqualTo(2); - assertThat(tokens.get(2).line()).isEqualTo(3); - } - - @Test - public void tokenize_trackColumn() { - List tokens = new PgnLexer("abc def").tokenize(); - assertThat(tokens.get(0).column()).isEqualTo(1); - assertThat(tokens.get(1).column()).isEqualTo(5); - } - - // === Full Game Tokenization === - - @Test - public void tokenize_completeGame() { - String pgn = """ - [Event "Test"] - [Site "Home"] - [Result "1-0"] - - 1. e4 e5 2. Nf3 Nc6 1-0 - """; - List tokens = new PgnLexer(pgn).tokenize(); - - // Should have: 3 tags (each: [ SYMBOL STRING ]) + movetext + result + EOF - assertThat(tokens).isNotEmpty(); - assertThat(tokens.get(tokens.size() - 1).type()).isEqualTo(TokenType.EOF); - - // Verify tag structure - assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); - assertThat(tokens.get(1).value()).isEqualTo("Event"); - assertThat(tokens.get(2).value()).isEqualTo("Test"); - assertThat(tokens.get(3).type()).isEqualTo(TokenType.RIGHT_BRACKET); - } - - // === Line Comment Tests (semicolon) === - - @Test - public void tokenize_lineComment() { - List tokens = new PgnLexer("e4 ; this is a line comment\ne5").tokenize(); - // Line comments should be skipped (or tokenized as COMMENT depending on implementation) - // For this test, we expect comments to be ignored - assertThat(tokens.stream().filter(t -> t.type() == TokenType.SYMBOL).count()).isEqualTo(2); - } - - // === Edge Cases === - - @Test - public void tokenize_moveWithInlineAnnotation() { - // Some PGN files use ! and ? directly after moves - // These could be parsed as part of the symbol or as separate NAGs - List tokens = new PgnLexer("e4!").tokenize(); - // Accept either: symbol "e4!" or symbol "e4" + something - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - } - - @Test - public void tokenize_moveWithDoubleAnnotation() { - List tokens = new PgnLexer("e4!!").tokenize(); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - } - - @Test - public void tokenize_moveWithQuestionMark() { - List tokens = new PgnLexer("Qxf7??").tokenize(); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - } - - @Test - public void tokenize_moveWithMixedAnnotation() { - List tokens = new PgnLexer("Nc3!?").tokenize(); - assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); - } + // === Basic Token Tests === + + @Test + public void tokenize_empty() { + List tokens = new PgnLexer("").tokenize(); + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.EOF); + } + + @Test + public void tokenize_whitespaceOnly() { + List tokens = new PgnLexer(" \n\t ").tokenize(); + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.EOF); + } + + // === Delimiter Tests === + + @Test + public void tokenize_brackets() { + List tokens = new PgnLexer("[]").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.RIGHT_BRACKET); + assertThat(tokens.get(2).type()).isEqualTo(TokenType.EOF); + } + + @Test + public void tokenize_parens() { + List tokens = new PgnLexer("()").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_PAREN); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.RIGHT_PAREN); + } + + // === String Tests === + + @Test + public void tokenize_simpleString() { + List tokens = new PgnLexer("\"hello\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(0).value()).isEqualTo("hello"); + } + + @Test + public void tokenize_stringWithSpaces() { + List tokens = new PgnLexer("\"World Championship\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(0).value()).isEqualTo("World Championship"); + } + + @Test + public void tokenize_stringWithEscapedQuote() { + List tokens = new PgnLexer("\"say \\\"hi\\\"\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(0).value()).isEqualTo("say \"hi\""); + } + + @Test + public void tokenize_stringWithBackslash() { + List tokens = new PgnLexer("\"path\\\\file\"").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).value()).isEqualTo("path\\file"); + } + + @Test + public void tokenize_unterminatedString() { + assertThatThrownBy(() -> new PgnLexer("\"unterminated").tokenize()) + .isInstanceOf(LexerException.class) + .hasMessageContaining("Unterminated string"); + } + + // === Integer Tests === + + @Test + public void tokenize_integer() { + List tokens = new PgnLexer("42").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("42"); + } + + @Test + public void tokenize_moveNumber() { + List tokens = new PgnLexer("1.").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("1"); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.PERIOD); + } + + @Test + public void tokenize_multiDigitMoveNumber() { + List tokens = new PgnLexer("15.").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("15"); + } + + // === Period and Ellipsis Tests === + + @Test + public void tokenize_period() { + List tokens = new PgnLexer(".").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.PERIOD); + } + + @Test + public void tokenize_ellipsis() { + List tokens = new PgnLexer("...").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.ELLIPSIS); + assertThat(tokens.get(0).value()).isEqualTo("..."); + } + + @Test + public void tokenize_blackMoveNumber() { + List tokens = new PgnLexer("1...").tokenize(); + assertThat(tokens).hasSize(3); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("1"); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.ELLIPSIS); + } + + // === Symbol Tests (moves and tag names) === + + @Test + public void tokenize_pawnMove() { + List tokens = new PgnLexer("e4").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("e4"); + } + + @Test + public void tokenize_pieceMove() { + List tokens = new PgnLexer("Nf3").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Nf3"); + } + + @Test + public void tokenize_capture() { + List tokens = new PgnLexer("Bxe5").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Bxe5"); + } + + @Test + public void tokenize_pawnCapture() { + List tokens = new PgnLexer("exd5").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("exd5"); + } + + @Test + public void tokenize_castleKingside() { + List tokens = new PgnLexer("O-O").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("O-O"); + } + + @Test + public void tokenize_castleQueenside() { + List tokens = new PgnLexer("O-O-O").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("O-O-O"); + } + + @Test + public void tokenize_check() { + List tokens = new PgnLexer("Qh7+").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Qh7+"); + } + + @Test + public void tokenize_checkmate() { + List tokens = new PgnLexer("Qxf7#").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Qxf7#"); + } + + @Test + public void tokenize_promotion() { + List tokens = new PgnLexer("e8=Q").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("e8=Q"); + } + + @Test + public void tokenize_promotionWithCheck() { + List tokens = new PgnLexer("e8=Q+").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("e8=Q+"); + } + + @Test + public void tokenize_disambiguatedMove_file() { + List tokens = new PgnLexer("Rae1").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Rae1"); + } + + @Test + public void tokenize_disambiguatedMove_rank() { + List tokens = new PgnLexer("R1e4").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("R1e4"); + } + + @Test + public void tokenize_disambiguatedMove_full() { + List tokens = new PgnLexer("Qd1e2").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Qd1e2"); + } + + @Test + public void tokenize_tagName() { + List tokens = new PgnLexer("Event").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(0).value()).isEqualTo("Event"); + } + + // === Comment Tests === + + @Test + public void tokenize_comment() { + List tokens = new PgnLexer("{this is a comment}").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(0).value()).isEqualTo("this is a comment"); + } + + @Test + public void tokenize_emptyComment() { + List tokens = new PgnLexer("{}").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(0).value()).isEqualTo(""); + } + + @Test + public void tokenize_multilineComment() { + List tokens = new PgnLexer("{line one\nline two}").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(0).value()).isEqualTo("line one\nline two"); + } + + @Test + public void tokenize_unterminatedComment() { + assertThatThrownBy(() -> new PgnLexer("{unclosed").tokenize()) + .isInstanceOf(LexerException.class) + .hasMessageContaining("Unterminated comment"); + } + + // === NAG Tests === + + @Test + public void tokenize_nag() { + List tokens = new PgnLexer("$1").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.NAG); + assertThat(tokens.get(0).value()).isEqualTo("$1"); + } + + @Test + public void tokenize_multiDigitNag() { + List tokens = new PgnLexer("$142").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.NAG); + assertThat(tokens.get(0).value()).isEqualTo("$142"); + } + + @Test + public void tokenize_nagWithoutNumber() { + assertThatThrownBy(() -> new PgnLexer("$").tokenize()).isInstanceOf(LexerException.class); + } + + // === Result Tests === + + @Test + public void tokenize_whiteWins() { + List tokens = new PgnLexer("1-0").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("1-0"); + } + + @Test + public void tokenize_blackWins() { + List tokens = new PgnLexer("0-1").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("0-1"); + } + + @Test + public void tokenize_draw() { + List tokens = new PgnLexer("1/2-1/2").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("1/2-1/2"); + } + + @Test + public void tokenize_ongoing() { + List tokens = new PgnLexer("*").tokenize(); + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.RESULT); + assertThat(tokens.get(0).value()).isEqualTo("*"); + } + + // === Tag Pair Tests === + + @Test + public void tokenize_tagPair() { + List tokens = new PgnLexer("[Event \"Test\"]").tokenize(); + assertThat(tokens).hasSize(5); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); + assertThat(tokens.get(1).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(1).value()).isEqualTo("Event"); + assertThat(tokens.get(2).type()).isEqualTo(TokenType.STRING); + assertThat(tokens.get(2).value()).isEqualTo("Test"); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.RIGHT_BRACKET); + assertThat(tokens.get(4).type()).isEqualTo(TokenType.EOF); + } + + // === Movetext Tests === + + @Test + public void tokenize_simpleMovetext() { + List tokens = new PgnLexer("1. e4 e5 2. Nf3").tokenize(); + assertThat(tokens).hasSize(9); + // 1 + assertThat(tokens.get(0).type()).isEqualTo(TokenType.INTEGER); + assertThat(tokens.get(0).value()).isEqualTo("1"); + // . + assertThat(tokens.get(1).type()).isEqualTo(TokenType.PERIOD); + // e4 + assertThat(tokens.get(2).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(2).value()).isEqualTo("e4"); + // e5 + assertThat(tokens.get(3).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(3).value()).isEqualTo("e5"); + // 2 + assertThat(tokens.get(4).type()).isEqualTo(TokenType.INTEGER); + // . + assertThat(tokens.get(5).type()).isEqualTo(TokenType.PERIOD); + // Nf3 + assertThat(tokens.get(6).type()).isEqualTo(TokenType.SYMBOL); + assertThat(tokens.get(6).value()).isEqualTo("Nf3"); + } + + @Test + public void tokenize_movetextWithComment() { + List tokens = new PgnLexer("1. e4 {King's pawn} e5").tokenize(); + assertThat(tokens).hasSize(6); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.COMMENT); + assertThat(tokens.get(3).value()).isEqualTo("King's pawn"); + assertThat(tokens.get(4).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_movetextWithNag() { + List tokens = new PgnLexer("1. e4 $1 e5").tokenize(); + assertThat(tokens).hasSize(6); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.NAG); + assertThat(tokens.get(3).value()).isEqualTo("$1"); + } + + @Test + public void tokenize_movetextWithVariation() { + List tokens = new PgnLexer("1. e4 (1. d4) e5").tokenize(); + assertThat(tokens).hasSize(10); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.LEFT_PAREN); + assertThat(tokens.get(7).type()).isEqualTo(TokenType.RIGHT_PAREN); + } + + // === Position Tracking Tests === + + @Test + public void tokenize_trackLineNumber() { + List tokens = new PgnLexer("a\nb\nc").tokenize(); + assertThat(tokens.get(0).line()).isEqualTo(1); + assertThat(tokens.get(1).line()).isEqualTo(2); + assertThat(tokens.get(2).line()).isEqualTo(3); + } + + @Test + public void tokenize_trackColumn() { + List tokens = new PgnLexer("abc def").tokenize(); + assertThat(tokens.get(0).column()).isEqualTo(1); + assertThat(tokens.get(1).column()).isEqualTo(5); + } + + // === Full Game Tokenization === + + @Test + public void tokenize_completeGame() { + String pgn = + """ + [Event "Test"] + [Site "Home"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 Nc6 1-0 + """; + List tokens = new PgnLexer(pgn).tokenize(); + + // Should have: 3 tags (each: [ SYMBOL STRING ]) + movetext + result + EOF + assertThat(tokens).isNotEmpty(); + assertThat(tokens.get(tokens.size() - 1).type()).isEqualTo(TokenType.EOF); + + // Verify tag structure + assertThat(tokens.get(0).type()).isEqualTo(TokenType.LEFT_BRACKET); + assertThat(tokens.get(1).value()).isEqualTo("Event"); + assertThat(tokens.get(2).value()).isEqualTo("Test"); + assertThat(tokens.get(3).type()).isEqualTo(TokenType.RIGHT_BRACKET); + } + + // === Line Comment Tests (semicolon) === + + @Test + public void tokenize_lineComment() { + List tokens = new PgnLexer("e4 ; this is a line comment\ne5").tokenize(); + // Line comments should be skipped (or tokenized as COMMENT depending on implementation) + // For this test, we expect comments to be ignored + assertThat(tokens.stream().filter(t -> t.type() == TokenType.SYMBOL).count()).isEqualTo(2); + } + + // === Edge Cases === + + @Test + public void tokenize_moveWithInlineAnnotation() { + // Some PGN files use ! and ? directly after moves + // These could be parsed as part of the symbol or as separate NAGs + List tokens = new PgnLexer("e4!").tokenize(); + // Accept either: symbol "e4!" or symbol "e4" + something + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_moveWithDoubleAnnotation() { + List tokens = new PgnLexer("e4!!").tokenize(); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_moveWithQuestionMark() { + List tokens = new PgnLexer("Qxf7??").tokenize(); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } + + @Test + public void tokenize_moveWithMixedAnnotation() { + List tokens = new PgnLexer("Nc3!?").tokenize(); + assertThat(tokens.get(0).type()).isEqualTo(TokenType.SYMBOL); + } } diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java index c8078606..7872d4f2 100644 --- a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/GameResultTest.java @@ -1,42 +1,42 @@ package com.muchq.pgn.model; -import org.junit.Test; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -public class GameResultTest { - - @Test - public void fromNotation_whiteWins() { - assertThat(GameResult.fromNotation("1-0")).isEqualTo(GameResult.WHITE_WINS); - } - - @Test - public void fromNotation_blackWins() { - assertThat(GameResult.fromNotation("0-1")).isEqualTo(GameResult.BLACK_WINS); - } - - @Test - public void fromNotation_draw() { - assertThat(GameResult.fromNotation("1/2-1/2")).isEqualTo(GameResult.DRAW); - } - - @Test - public void fromNotation_ongoing() { - assertThat(GameResult.fromNotation("*")).isEqualTo(GameResult.ONGOING); - } +import org.junit.Test; - @Test - public void fromNotation_invalidThrows() { - assertThatThrownBy(() -> GameResult.fromNotation("invalid")) - .isInstanceOf(IllegalArgumentException.class); - } +public class GameResultTest { - @Test - public void notation_roundTrip() { - for (GameResult result : GameResult.values()) { - assertThat(GameResult.fromNotation(result.notation())).isEqualTo(result); - } + @Test + public void fromNotation_whiteWins() { + assertThat(GameResult.fromNotation("1-0")).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void fromNotation_blackWins() { + assertThat(GameResult.fromNotation("0-1")).isEqualTo(GameResult.BLACK_WINS); + } + + @Test + public void fromNotation_draw() { + assertThat(GameResult.fromNotation("1/2-1/2")).isEqualTo(GameResult.DRAW); + } + + @Test + public void fromNotation_ongoing() { + assertThat(GameResult.fromNotation("*")).isEqualTo(GameResult.ONGOING); + } + + @Test + public void fromNotation_invalidThrows() { + assertThatThrownBy(() -> GameResult.fromNotation("invalid")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void notation_roundTrip() { + for (GameResult result : GameResult.values()) { + assertThat(GameResult.fromNotation(result.notation())).isEqualTo(result); } + } } diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java index a0c7e6d8..25004b73 100644 --- a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java @@ -1,182 +1,171 @@ package com.muchq.pgn.model; -import org.junit.Test; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.Test; + public class SquareTest { - @Test - public void parse_e4() { - Square square = Square.parse("e4"); - assertThat(square.file()).isEqualTo(File.E); - assertThat(square.rank()).isEqualTo(Rank.R4); - } - - @Test - public void parse_a1() { - Square square = Square.parse("a1"); - assertThat(square.file()).isEqualTo(File.A); - assertThat(square.rank()).isEqualTo(Rank.R1); - } - - @Test - public void parse_h8() { - Square square = Square.parse("h8"); - assertThat(square.file()).isEqualTo(File.H); - assertThat(square.rank()).isEqualTo(Rank.R8); - } - - @Test - public void parse_uppercase() { - Square square = Square.parse("E4"); - assertThat(square.file()).isEqualTo(File.E); - assertThat(square.rank()).isEqualTo(Rank.R4); - } - - @Test - public void parse_invalidFile() { - assertThatThrownBy(() -> Square.parse("z4")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void parse_invalidRank() { - assertThatThrownBy(() -> Square.parse("e9")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void parse_tooShort() { - assertThatThrownBy(() -> Square.parse("e")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void parse_tooLong() { - assertThatThrownBy(() -> Square.parse("e44")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void toString_e4() { - Square square = new Square(File.E, Rank.R4); - assertThat(square.toString()).isEqualTo("e4"); - } - - @Test - public void toString_a1() { - Square square = new Square(File.A, Rank.R1); - assertThat(square.toString()).isEqualTo("a1"); - } - - @Test - public void toString_h8() { - Square square = new Square(File.H, Rank.R8); - assertThat(square.toString()).isEqualTo("h8"); - } - - // File enum tests - - @Test - public void file_fromChar() { - assertThat(File.fromChar('a')).isEqualTo(File.A); - assertThat(File.fromChar('h')).isEqualTo(File.H); - assertThat(File.fromChar('E')).isEqualTo(File.E); - } - - @Test - public void file_fromChar_invalid() { - assertThatThrownBy(() -> File.fromChar('z')) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void file_toChar() { - assertThat(File.A.toChar()).isEqualTo('a'); - assertThat(File.H.toChar()).isEqualTo('h'); - } - - // Rank enum tests - - @Test - public void rank_fromNumber() { - assertThat(Rank.fromNumber(1)).isEqualTo(Rank.R1); - assertThat(Rank.fromNumber(8)).isEqualTo(Rank.R8); - } - - @Test - public void rank_fromNumber_invalid() { - assertThatThrownBy(() -> Rank.fromNumber(0)) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> Rank.fromNumber(9)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void rank_fromChar() { - assertThat(Rank.fromChar('1')).isEqualTo(Rank.R1); - assertThat(Rank.fromChar('8')).isEqualTo(Rank.R8); - } - - @Test - public void rank_toChar() { - assertThat(Rank.R1.toChar()).isEqualTo('1'); - assertThat(Rank.R8.toChar()).isEqualTo('8'); - } - - @Test - public void rank_number() { - assertThat(Rank.R1.number()).isEqualTo(1); - assertThat(Rank.R8.number()).isEqualTo(8); - } - - // Piece enum tests - - @Test - public void piece_fromSymbol() { - assertThat(Piece.fromSymbol('K')).isEqualTo(Piece.KING); - assertThat(Piece.fromSymbol('Q')).isEqualTo(Piece.QUEEN); - assertThat(Piece.fromSymbol('R')).isEqualTo(Piece.ROOK); - assertThat(Piece.fromSymbol('B')).isEqualTo(Piece.BISHOP); - assertThat(Piece.fromSymbol('N')).isEqualTo(Piece.KNIGHT); - } - - @Test - public void piece_fromSymbol_invalid() { - assertThatThrownBy(() -> Piece.fromSymbol('X')) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void piece_symbol() { - assertThat(Piece.KING.symbol()).isEqualTo('K'); - assertThat(Piece.PAWN.symbol()).isEqualTo('\0'); - } - - // Nag tests - - @Test - public void nag_parse() { - assertThat(Nag.parse("$1").value()).isEqualTo(1); - assertThat(Nag.parse("$6").value()).isEqualTo(6); - assertThat(Nag.parse("$142").value()).isEqualTo(142); - } - - @Test - public void nag_parse_invalid() { - assertThatThrownBy(() -> Nag.parse("1")) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> Nag.parse("$")) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> Nag.parse("$abc")) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void nag_toString() { - assertThat(new Nag(1).toString()).isEqualTo("$1"); - assertThat(new Nag(142).toString()).isEqualTo("$142"); - } + @Test + public void parse_e4() { + Square square = Square.parse("e4"); + assertThat(square.file()).isEqualTo(File.E); + assertThat(square.rank()).isEqualTo(Rank.R4); + } + + @Test + public void parse_a1() { + Square square = Square.parse("a1"); + assertThat(square.file()).isEqualTo(File.A); + assertThat(square.rank()).isEqualTo(Rank.R1); + } + + @Test + public void parse_h8() { + Square square = Square.parse("h8"); + assertThat(square.file()).isEqualTo(File.H); + assertThat(square.rank()).isEqualTo(Rank.R8); + } + + @Test + public void parse_uppercase() { + Square square = Square.parse("E4"); + assertThat(square.file()).isEqualTo(File.E); + assertThat(square.rank()).isEqualTo(Rank.R4); + } + + @Test + public void parse_invalidFile() { + assertThatThrownBy(() -> Square.parse("z4")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_invalidRank() { + assertThatThrownBy(() -> Square.parse("e9")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_tooShort() { + assertThatThrownBy(() -> Square.parse("e")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void parse_tooLong() { + assertThatThrownBy(() -> Square.parse("e44")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void toString_e4() { + Square square = new Square(File.E, Rank.R4); + assertThat(square.toString()).isEqualTo("e4"); + } + + @Test + public void toString_a1() { + Square square = new Square(File.A, Rank.R1); + assertThat(square.toString()).isEqualTo("a1"); + } + + @Test + public void toString_h8() { + Square square = new Square(File.H, Rank.R8); + assertThat(square.toString()).isEqualTo("h8"); + } + + // File enum tests + + @Test + public void file_fromChar() { + assertThat(File.fromChar('a')).isEqualTo(File.A); + assertThat(File.fromChar('h')).isEqualTo(File.H); + assertThat(File.fromChar('E')).isEqualTo(File.E); + } + + @Test + public void file_fromChar_invalid() { + assertThatThrownBy(() -> File.fromChar('z')).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void file_toChar() { + assertThat(File.A.toChar()).isEqualTo('a'); + assertThat(File.H.toChar()).isEqualTo('h'); + } + + // Rank enum tests + + @Test + public void rank_fromNumber() { + assertThat(Rank.fromNumber(1)).isEqualTo(Rank.R1); + assertThat(Rank.fromNumber(8)).isEqualTo(Rank.R8); + } + + @Test + public void rank_fromNumber_invalid() { + assertThatThrownBy(() -> Rank.fromNumber(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Rank.fromNumber(9)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void rank_fromChar() { + assertThat(Rank.fromChar('1')).isEqualTo(Rank.R1); + assertThat(Rank.fromChar('8')).isEqualTo(Rank.R8); + } + + @Test + public void rank_toChar() { + assertThat(Rank.R1.toChar()).isEqualTo('1'); + assertThat(Rank.R8.toChar()).isEqualTo('8'); + } + + @Test + public void rank_number() { + assertThat(Rank.R1.number()).isEqualTo(1); + assertThat(Rank.R8.number()).isEqualTo(8); + } + + // Piece enum tests + + @Test + public void piece_fromSymbol() { + assertThat(Piece.fromSymbol('K')).isEqualTo(Piece.KING); + assertThat(Piece.fromSymbol('Q')).isEqualTo(Piece.QUEEN); + assertThat(Piece.fromSymbol('R')).isEqualTo(Piece.ROOK); + assertThat(Piece.fromSymbol('B')).isEqualTo(Piece.BISHOP); + assertThat(Piece.fromSymbol('N')).isEqualTo(Piece.KNIGHT); + } + + @Test + public void piece_fromSymbol_invalid() { + assertThatThrownBy(() -> Piece.fromSymbol('X')).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void piece_symbol() { + assertThat(Piece.KING.symbol()).isEqualTo('K'); + assertThat(Piece.PAWN.symbol()).isEqualTo('\0'); + } + + // Nag tests + + @Test + public void nag_parse() { + assertThat(Nag.parse("$1").value()).isEqualTo(1); + assertThat(Nag.parse("$6").value()).isEqualTo(6); + assertThat(Nag.parse("$142").value()).isEqualTo(142); + } + + @Test + public void nag_parse_invalid() { + assertThatThrownBy(() -> Nag.parse("1")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Nag.parse("$")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Nag.parse("$abc")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void nag_toString() { + assertThat(new Nag(1).toString()).isEqualTo("$1"); + assertThat(new Nag(142).toString()).isEqualTo("$142"); + } } diff --git a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java index a8e47e57..d9d1ac20 100644 --- a/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java +++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java @@ -1,468 +1,471 @@ package com.muchq.pgn.parser; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import com.muchq.pgn.lexer.PgnLexer; import com.muchq.pgn.lexer.Token; import com.muchq.pgn.model.GameResult; import com.muchq.pgn.model.Move; import com.muchq.pgn.model.PgnGame; -import org.junit.Test; - import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.Test; public class PgnParserTest { - private PgnGame parse(String pgn) { - List tokens = new PgnLexer(pgn).tokenize(); - return new PgnParser(tokens).parseGame(); - } - - private List parseAll(String pgn) { - List tokens = new PgnLexer(pgn).tokenize(); - return new PgnParser(tokens).parseAll(); - } - - // === Tag Parsing Tests === - - @Test - public void parse_singleTag() { - PgnGame game = parse("[Event \"Test\"] *"); - assertThat(game.tags()).hasSize(1); - assertThat(game.tags().get(0).name()).isEqualTo("Event"); - assertThat(game.tags().get(0).value()).isEqualTo("Test"); - } - - @Test - public void parse_sevenTagRoster() { - String pgn = """ - [Event "F/S Return Match"] - [Site "Belgrade, Serbia JUG"] - [Date "1992.11.04"] - [Round "29"] - [White "Fischer, Robert J."] - [Black "Spassky, Boris V."] - [Result "1/2-1/2"] - - 1/2-1/2 - """; - PgnGame game = parse(pgn); - assertThat(game.tags()).hasSize(7); - assertThat(game.getTag("Event")).hasValue("F/S Return Match"); - assertThat(game.getTag("Site")).hasValue("Belgrade, Serbia JUG"); - assertThat(game.getTag("Date")).hasValue("1992.11.04"); - assertThat(game.getTag("Round")).hasValue("29"); - assertThat(game.getTag("White")).hasValue("Fischer, Robert J."); - assertThat(game.getTag("Black")).hasValue("Spassky, Boris V."); - assertThat(game.getTag("Result")).hasValue("1/2-1/2"); - } - - @Test - public void parse_tagWithSpecialCharacters() { - PgnGame game = parse("[White \"O'Brien, John\"] *"); - assertThat(game.getTag("White")).hasValue("O'Brien, John"); - } - - @Test - public void parse_tagWithEscapedQuote() { - PgnGame game = parse("[Event \"The \\\"Big\\\" Game\"] *"); - assertThat(game.getTag("Event")).hasValue("The \"Big\" Game"); - } - - // === Simple Movetext Tests === - - @Test - public void parse_singleMove() { - PgnGame game = parse("[Result \"*\"] 1. e4 *"); - assertThat(game.moves()).hasSize(1); - assertThat(game.moves().get(0).san()).isEqualTo("e4"); - } - - @Test - public void parse_twoMoves() { - PgnGame game = parse("[Result \"*\"] 1. e4 e5 *"); - assertThat(game.moves()).hasSize(2); - assertThat(game.moves().get(0).san()).isEqualTo("e4"); - assertThat(game.moves().get(1).san()).isEqualTo("e5"); - } - - @Test - public void parse_multipleMoveNumbers() { - PgnGame game = parse("[Result \"*\"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 *"); - assertThat(game.moves()).hasSize(5); - assertThat(game.moves().get(0).san()).isEqualTo("e4"); - assertThat(game.moves().get(1).san()).isEqualTo("e5"); - assertThat(game.moves().get(2).san()).isEqualTo("Nf3"); - assertThat(game.moves().get(3).san()).isEqualTo("Nc6"); - assertThat(game.moves().get(4).san()).isEqualTo("Bb5"); - } - - @Test - public void parse_blackToMove() { - // Continuation notation: 15... Qxd4 - PgnGame game = parse("[Result \"*\"] 15... Qxd4 *"); - assertThat(game.moves()).hasSize(1); - assertThat(game.moves().get(0).san()).isEqualTo("Qxd4"); - } - - // === Castling Tests === - - @Test - public void parse_castleKingside() { - PgnGame game = parse("[Result \"*\"] 1. O-O *"); - assertThat(game.moves().get(0).san()).isEqualTo("O-O"); - } - - @Test - public void parse_castleQueenside() { - PgnGame game = parse("[Result \"*\"] 1. O-O-O *"); - assertThat(game.moves().get(0).san()).isEqualTo("O-O-O"); - } - - // === Check and Checkmate Tests === - - @Test - public void parse_check() { - PgnGame game = parse("[Result \"*\"] 1. Qh5+ *"); - assertThat(game.moves().get(0).san()).isEqualTo("Qh5+"); - } - - @Test - public void parse_checkmate() { - PgnGame game = parse("[Result \"1-0\"] 1. Qxf7# 1-0"); - assertThat(game.moves().get(0).san()).isEqualTo("Qxf7#"); - } - - // === Promotion Tests === - - @Test - public void parse_promotion() { - PgnGame game = parse("[Result \"*\"] 1. e8=Q *"); - assertThat(game.moves().get(0).san()).isEqualTo("e8=Q"); - } - - @Test - public void parse_promotionWithCheck() { - PgnGame game = parse("[Result \"*\"] 1. e8=Q+ *"); - assertThat(game.moves().get(0).san()).isEqualTo("e8=Q+"); - } - - // === Capture Tests === - - @Test - public void parse_pieceCapture() { - PgnGame game = parse("[Result \"*\"] 1. Bxe5 *"); - assertThat(game.moves().get(0).san()).isEqualTo("Bxe5"); - } - - @Test - public void parse_pawnCapture() { - PgnGame game = parse("[Result \"*\"] 1. exd5 *"); - assertThat(game.moves().get(0).san()).isEqualTo("exd5"); - } - - // === Disambiguation Tests === - - @Test - public void parse_disambiguatedByFile() { - PgnGame game = parse("[Result \"*\"] 1. Rae1 *"); - assertThat(game.moves().get(0).san()).isEqualTo("Rae1"); - } - - @Test - public void parse_disambiguatedByRank() { - PgnGame game = parse("[Result \"*\"] 1. R1e4 *"); - assertThat(game.moves().get(0).san()).isEqualTo("R1e4"); - } - - @Test - public void parse_fullyDisambiguated() { - PgnGame game = parse("[Result \"*\"] 1. Qd1e2 *"); - assertThat(game.moves().get(0).san()).isEqualTo("Qd1e2"); - } - - // === Comment Tests === - - @Test - public void parse_moveWithComment() { - PgnGame game = parse("[Result \"*\"] 1. e4 {King's pawn opening} *"); - assertThat(game.moves()).hasSize(1); - assertThat(game.moves().get(0).san()).isEqualTo("e4"); - assertThat(game.moves().get(0).comment()).hasValue("King's pawn opening"); - } - - @Test - public void parse_multipleCommentsAttachToMove() { - // Multiple comments after a move - they should be concatenated or only first kept - PgnGame game = parse("[Result \"*\"] 1. e4 {comment one} {comment two} *"); - assertThat(game.moves()).hasSize(1); - assertThat(game.moves().get(0).comment()).isPresent(); - } - - @Test - public void parse_commentBeforeMove() { - // Comment before moves is valid PGN - PgnGame game = parse("[Result \"*\"] {Opening comment} 1. e4 *"); - assertThat(game.moves()).hasSize(1); - } - - // === NAG Tests === - - @Test - public void parse_moveWithNag() { - PgnGame game = parse("[Result \"*\"] 1. e4 $1 *"); - assertThat(game.moves()).hasSize(1); - assertThat(game.moves().get(0).nags()).hasSize(1); - assertThat(game.moves().get(0).nags().get(0).value()).isEqualTo(1); - } - - @Test - public void parse_moveWithMultipleNags() { - PgnGame game = parse("[Result \"*\"] 1. e4 $1 $14 *"); - assertThat(game.moves().get(0).nags()).hasSize(2); - assertThat(game.moves().get(0).nags().get(0).value()).isEqualTo(1); - assertThat(game.moves().get(0).nags().get(1).value()).isEqualTo(14); - } - - @Test - public void parse_inlineAnnotation_goodMove() { - // ! should be converted to $1 - PgnGame game = parse("[Result \"*\"] 1. e4! *"); - assertThat(game.moves()).hasSize(1); - // Either the ! is part of the SAN or converted to NAG - Move move = game.moves().get(0); - boolean hasGoodMoveIndicator = move.san().endsWith("!") || - move.nags().stream().anyMatch(n -> n.value() == 1); - assertThat(hasGoodMoveIndicator).isTrue(); - } - - @Test - public void parse_inlineAnnotation_blunder() { - // ?? should be converted to $4 - PgnGame game = parse("[Result \"*\"] 1. e4?? *"); - assertThat(game.moves()).hasSize(1); - Move move = game.moves().get(0); - boolean hasBlunderIndicator = move.san().endsWith("??") || - move.nags().stream().anyMatch(n -> n.value() == 4); - assertThat(hasBlunderIndicator).isTrue(); - } - - // === Variation Tests === - - @Test - public void parse_simpleVariation() { - PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4) e5 *"); - assertThat(game.moves()).hasSize(2); // e4 and e5 in main line - assertThat(game.moves().get(0).san()).isEqualTo("e4"); - assertThat(game.moves().get(0).variations()).hasSize(1); - assertThat(game.moves().get(0).variations().get(0)).hasSize(1); - assertThat(game.moves().get(0).variations().get(0).get(0).san()).isEqualTo("d4"); - } - - @Test - public void parse_variationWithMultipleMoves() { - PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 d5 2. c4) e5 *"); - assertThat(game.moves().get(0).variations()).hasSize(1); - List variation = game.moves().get(0).variations().get(0); - assertThat(variation).hasSize(3); - assertThat(variation.get(0).san()).isEqualTo("d4"); - assertThat(variation.get(1).san()).isEqualTo("d5"); - assertThat(variation.get(2).san()).isEqualTo("c4"); - } - - @Test - public void parse_nestedVariation() { - PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 (1. c4)) e5 *"); - assertThat(game.moves().get(0).variations()).hasSize(1); - List variation = game.moves().get(0).variations().get(0); - assertThat(variation.get(0).san()).isEqualTo("d4"); - assertThat(variation.get(0).variations()).hasSize(1); - assertThat(variation.get(0).variations().get(0).get(0).san()).isEqualTo("c4"); - } - - @Test - public void parse_multipleVariations() { - PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4) (1. c4) e5 *"); - assertThat(game.moves().get(0).variations()).hasSize(2); - assertThat(game.moves().get(0).variations().get(0).get(0).san()).isEqualTo("d4"); - assertThat(game.moves().get(0).variations().get(1).get(0).san()).isEqualTo("c4"); - } - - @Test - public void parse_variationWithComment() { - PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 {Queen's pawn}) *"); - List variation = game.moves().get(0).variations().get(0); - assertThat(variation.get(0).comment()).hasValue("Queen's pawn"); - } - - // === Result Tests === - - @Test - public void parse_resultWhiteWins() { - PgnGame game = parse("[Result \"1-0\"] 1. e4 1-0"); - assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); - } - - @Test - public void parse_resultBlackWins() { - PgnGame game = parse("[Result \"0-1\"] 1. e4 0-1"); - assertThat(game.result()).isEqualTo(GameResult.BLACK_WINS); - } - - @Test - public void parse_resultDraw() { - PgnGame game = parse("[Result \"1/2-1/2\"] 1. e4 1/2-1/2"); - assertThat(game.result()).isEqualTo(GameResult.DRAW); - } - - @Test - public void parse_resultOngoing() { - PgnGame game = parse("[Result \"*\"] 1. e4 *"); - assertThat(game.result()).isEqualTo(GameResult.ONGOING); - } - - // === Multiple Games Tests === - - @Test - public void parseAll_twoGames() { - String pgn = """ - [Event "Game 1"] - [Result "1-0"] - - 1. e4 1-0 - - [Event "Game 2"] - [Result "0-1"] - - 1. d4 0-1 - """; - List games = parseAll(pgn); - assertThat(games).hasSize(2); - assertThat(games.get(0).getTag("Event")).hasValue("Game 1"); - assertThat(games.get(0).result()).isEqualTo(GameResult.WHITE_WINS); - assertThat(games.get(1).getTag("Event")).hasValue("Game 2"); - assertThat(games.get(1).result()).isEqualTo(GameResult.BLACK_WINS); - } - - @Test - public void parseAll_empty() { - List games = parseAll(""); - assertThat(games).isEmpty(); - } - - // === Complete Game Tests === - - @Test - public void parse_completeGame() { - String pgn = """ - [Event "World Championship"] - [Site "London"] - [Date "2023.04.15"] - [Round "5"] - [White "Carlsen"] - [Black "Nepomniachtchi"] - [Result "1-0"] - - 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O 1-0 - """; - PgnGame game = parse(pgn); - - assertThat(game.getTag("Event")).hasValue("World Championship"); - assertThat(game.getTag("White")).hasValue("Carlsen"); - assertThat(game.moves()).hasSize(9); - assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); - } - - @Test - public void parse_gameWithAnnotations() { - String pgn = """ - [Event "Test"] - [Result "1-0"] - - 1. e4 $1 {Best by test} e5 2. Nf3 Nc6 (2... d6 {Philidor}) 3. Bb5 1-0 - """; - PgnGame game = parse(pgn); - - // Check first move has NAG and comment - Move e4 = game.moves().get(0); - assertThat(e4.san()).isEqualTo("e4"); - assertThat(e4.nags()).isNotEmpty(); - assertThat(e4.comment()).isPresent(); - - // Check variation exists - Move nc6 = game.moves().get(3); - assertThat(nc6.san()).isEqualTo("Nc6"); - assertThat(nc6.variations()).hasSize(1); - } - - // === Error Handling Tests === - - @Test - public void parse_missingResult() { - // A game without a termination marker - assertThatThrownBy(() -> parse("[Event \"Test\"] 1. e4")) - .isInstanceOf(ParseException.class); - } - - @Test - public void parse_unclosedVariation() { - assertThatThrownBy(() -> parse("[Result \"*\"] 1. e4 (1. d4 *")) - .isInstanceOf(ParseException.class); - } - - @Test - public void parse_malformedTag() { - assertThatThrownBy(() -> parse("[Event] *")) - .isInstanceOf(ParseException.class); - } - - // === Real World Examples === - - @Test - public void parse_operaGame() { - String pgn = """ - [Event "Paris"] - [Site "Paris FRA"] - [Date "1858.??.??"] - [Round "?"] - [White "Morphy, Paul"] - [Black "Duke of Brunswick and Count Isouard"] - [Result "1-0"] - - 1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7 - 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 - 13. Rxd7 Rxd7 14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0 - """; - PgnGame game = parse(pgn); - - assertThat(game.getTag("White")).hasValue("Morphy, Paul"); - assertThat(game.moves()).hasSize(33); - assertThat(game.moves().get(32).san()).isEqualTo("Rd8#"); - assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); - } - - @Test - public void parse_immortalGame() { - String pgn = """ - [Event "London"] - [Site "London ENG"] - [Date "1851.06.21"] - [Round "?"] - [White "Anderssen, Adolf"] - [Black "Kieseritzky, Lionel"] - [Result "1-0"] - - 1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5 - 8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3 Ng8 - 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2 Na6 - 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7# 1-0 - """; - PgnGame game = parse(pgn); - - assertThat(game.getTag("Event")).hasValue("London"); - assertThat(game.moves()).hasSize(45); - assertThat(game.moves().get(44).san()).isEqualTo("Be7#"); - assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); - } + private PgnGame parse(String pgn) { + List tokens = new PgnLexer(pgn).tokenize(); + return new PgnParser(tokens).parseGame(); + } + + private List parseAll(String pgn) { + List tokens = new PgnLexer(pgn).tokenize(); + return new PgnParser(tokens).parseAll(); + } + + // === Tag Parsing Tests === + + @Test + public void parse_singleTag() { + PgnGame game = parse("[Event \"Test\"] *"); + assertThat(game.tags()).hasSize(1); + assertThat(game.tags().get(0).name()).isEqualTo("Event"); + assertThat(game.tags().get(0).value()).isEqualTo("Test"); + } + + @Test + public void parse_sevenTagRoster() { + String pgn = + """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1/2-1/2 + """; + PgnGame game = parse(pgn); + assertThat(game.tags()).hasSize(7); + assertThat(game.getTag("Event")).hasValue("F/S Return Match"); + assertThat(game.getTag("Site")).hasValue("Belgrade, Serbia JUG"); + assertThat(game.getTag("Date")).hasValue("1992.11.04"); + assertThat(game.getTag("Round")).hasValue("29"); + assertThat(game.getTag("White")).hasValue("Fischer, Robert J."); + assertThat(game.getTag("Black")).hasValue("Spassky, Boris V."); + assertThat(game.getTag("Result")).hasValue("1/2-1/2"); + } + + @Test + public void parse_tagWithSpecialCharacters() { + PgnGame game = parse("[White \"O'Brien, John\"] *"); + assertThat(game.getTag("White")).hasValue("O'Brien, John"); + } + + @Test + public void parse_tagWithEscapedQuote() { + PgnGame game = parse("[Event \"The \\\"Big\\\" Game\"] *"); + assertThat(game.getTag("Event")).hasValue("The \"Big\" Game"); + } + + // === Simple Movetext Tests === + + @Test + public void parse_singleMove() { + PgnGame game = parse("[Result \"*\"] 1. e4 *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + } + + @Test + public void parse_twoMoves() { + PgnGame game = parse("[Result \"*\"] 1. e4 e5 *"); + assertThat(game.moves()).hasSize(2); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(1).san()).isEqualTo("e5"); + } + + @Test + public void parse_multipleMoveNumbers() { + PgnGame game = parse("[Result \"*\"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 *"); + assertThat(game.moves()).hasSize(5); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(1).san()).isEqualTo("e5"); + assertThat(game.moves().get(2).san()).isEqualTo("Nf3"); + assertThat(game.moves().get(3).san()).isEqualTo("Nc6"); + assertThat(game.moves().get(4).san()).isEqualTo("Bb5"); + } + + @Test + public void parse_blackToMove() { + // Continuation notation: 15... Qxd4 + PgnGame game = parse("[Result \"*\"] 15... Qxd4 *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("Qxd4"); + } + + // === Castling Tests === + + @Test + public void parse_castleKingside() { + PgnGame game = parse("[Result \"*\"] 1. O-O *"); + assertThat(game.moves().get(0).san()).isEqualTo("O-O"); + } + + @Test + public void parse_castleQueenside() { + PgnGame game = parse("[Result \"*\"] 1. O-O-O *"); + assertThat(game.moves().get(0).san()).isEqualTo("O-O-O"); + } + + // === Check and Checkmate Tests === + + @Test + public void parse_check() { + PgnGame game = parse("[Result \"*\"] 1. Qh5+ *"); + assertThat(game.moves().get(0).san()).isEqualTo("Qh5+"); + } + + @Test + public void parse_checkmate() { + PgnGame game = parse("[Result \"1-0\"] 1. Qxf7# 1-0"); + assertThat(game.moves().get(0).san()).isEqualTo("Qxf7#"); + } + + // === Promotion Tests === + + @Test + public void parse_promotion() { + PgnGame game = parse("[Result \"*\"] 1. e8=Q *"); + assertThat(game.moves().get(0).san()).isEqualTo("e8=Q"); + } + + @Test + public void parse_promotionWithCheck() { + PgnGame game = parse("[Result \"*\"] 1. e8=Q+ *"); + assertThat(game.moves().get(0).san()).isEqualTo("e8=Q+"); + } + + // === Capture Tests === + + @Test + public void parse_pieceCapture() { + PgnGame game = parse("[Result \"*\"] 1. Bxe5 *"); + assertThat(game.moves().get(0).san()).isEqualTo("Bxe5"); + } + + @Test + public void parse_pawnCapture() { + PgnGame game = parse("[Result \"*\"] 1. exd5 *"); + assertThat(game.moves().get(0).san()).isEqualTo("exd5"); + } + + // === Disambiguation Tests === + + @Test + public void parse_disambiguatedByFile() { + PgnGame game = parse("[Result \"*\"] 1. Rae1 *"); + assertThat(game.moves().get(0).san()).isEqualTo("Rae1"); + } + + @Test + public void parse_disambiguatedByRank() { + PgnGame game = parse("[Result \"*\"] 1. R1e4 *"); + assertThat(game.moves().get(0).san()).isEqualTo("R1e4"); + } + + @Test + public void parse_fullyDisambiguated() { + PgnGame game = parse("[Result \"*\"] 1. Qd1e2 *"); + assertThat(game.moves().get(0).san()).isEqualTo("Qd1e2"); + } + + // === Comment Tests === + + @Test + public void parse_moveWithComment() { + PgnGame game = parse("[Result \"*\"] 1. e4 {King's pawn opening} *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(0).comment()).hasValue("King's pawn opening"); + } + + @Test + public void parse_multipleCommentsAttachToMove() { + // Multiple comments after a move - they should be concatenated or only first kept + PgnGame game = parse("[Result \"*\"] 1. e4 {comment one} {comment two} *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).comment()).isPresent(); + } + + @Test + public void parse_commentBeforeMove() { + // Comment before moves is valid PGN + PgnGame game = parse("[Result \"*\"] {Opening comment} 1. e4 *"); + assertThat(game.moves()).hasSize(1); + } + + // === NAG Tests === + + @Test + public void parse_moveWithNag() { + PgnGame game = parse("[Result \"*\"] 1. e4 $1 *"); + assertThat(game.moves()).hasSize(1); + assertThat(game.moves().get(0).nags()).hasSize(1); + assertThat(game.moves().get(0).nags().get(0).value()).isEqualTo(1); + } + + @Test + public void parse_moveWithMultipleNags() { + PgnGame game = parse("[Result \"*\"] 1. e4 $1 $14 *"); + assertThat(game.moves().get(0).nags()).hasSize(2); + assertThat(game.moves().get(0).nags().get(0).value()).isEqualTo(1); + assertThat(game.moves().get(0).nags().get(1).value()).isEqualTo(14); + } + + @Test + public void parse_inlineAnnotation_goodMove() { + // ! should be converted to $1 + PgnGame game = parse("[Result \"*\"] 1. e4! *"); + assertThat(game.moves()).hasSize(1); + // Either the ! is part of the SAN or converted to NAG + Move move = game.moves().get(0); + boolean hasGoodMoveIndicator = + move.san().endsWith("!") || move.nags().stream().anyMatch(n -> n.value() == 1); + assertThat(hasGoodMoveIndicator).isTrue(); + } + + @Test + public void parse_inlineAnnotation_blunder() { + // ?? should be converted to $4 + PgnGame game = parse("[Result \"*\"] 1. e4?? *"); + assertThat(game.moves()).hasSize(1); + Move move = game.moves().get(0); + boolean hasBlunderIndicator = + move.san().endsWith("??") || move.nags().stream().anyMatch(n -> n.value() == 4); + assertThat(hasBlunderIndicator).isTrue(); + } + + // === Variation Tests === + + @Test + public void parse_simpleVariation() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4) e5 *"); + assertThat(game.moves()).hasSize(2); // e4 and e5 in main line + assertThat(game.moves().get(0).san()).isEqualTo("e4"); + assertThat(game.moves().get(0).variations()).hasSize(1); + assertThat(game.moves().get(0).variations().get(0)).hasSize(1); + assertThat(game.moves().get(0).variations().get(0).get(0).san()).isEqualTo("d4"); + } + + @Test + public void parse_variationWithMultipleMoves() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 d5 2. c4) e5 *"); + assertThat(game.moves().get(0).variations()).hasSize(1); + List variation = game.moves().get(0).variations().get(0); + assertThat(variation).hasSize(3); + assertThat(variation.get(0).san()).isEqualTo("d4"); + assertThat(variation.get(1).san()).isEqualTo("d5"); + assertThat(variation.get(2).san()).isEqualTo("c4"); + } + + @Test + public void parse_nestedVariation() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 (1. c4)) e5 *"); + assertThat(game.moves().get(0).variations()).hasSize(1); + List variation = game.moves().get(0).variations().get(0); + assertThat(variation.get(0).san()).isEqualTo("d4"); + assertThat(variation.get(0).variations()).hasSize(1); + assertThat(variation.get(0).variations().get(0).get(0).san()).isEqualTo("c4"); + } + + @Test + public void parse_multipleVariations() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4) (1. c4) e5 *"); + assertThat(game.moves().get(0).variations()).hasSize(2); + assertThat(game.moves().get(0).variations().get(0).get(0).san()).isEqualTo("d4"); + assertThat(game.moves().get(0).variations().get(1).get(0).san()).isEqualTo("c4"); + } + + @Test + public void parse_variationWithComment() { + PgnGame game = parse("[Result \"*\"] 1. e4 (1. d4 {Queen's pawn}) *"); + List variation = game.moves().get(0).variations().get(0); + assertThat(variation.get(0).comment()).hasValue("Queen's pawn"); + } + + // === Result Tests === + + @Test + public void parse_resultWhiteWins() { + PgnGame game = parse("[Result \"1-0\"] 1. e4 1-0"); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parse_resultBlackWins() { + PgnGame game = parse("[Result \"0-1\"] 1. e4 0-1"); + assertThat(game.result()).isEqualTo(GameResult.BLACK_WINS); + } + + @Test + public void parse_resultDraw() { + PgnGame game = parse("[Result \"1/2-1/2\"] 1. e4 1/2-1/2"); + assertThat(game.result()).isEqualTo(GameResult.DRAW); + } + + @Test + public void parse_resultOngoing() { + PgnGame game = parse("[Result \"*\"] 1. e4 *"); + assertThat(game.result()).isEqualTo(GameResult.ONGOING); + } + + // === Multiple Games Tests === + + @Test + public void parseAll_twoGames() { + String pgn = + """ + [Event "Game 1"] + [Result "1-0"] + + 1. e4 1-0 + + [Event "Game 2"] + [Result "0-1"] + + 1. d4 0-1 + """; + List games = parseAll(pgn); + assertThat(games).hasSize(2); + assertThat(games.get(0).getTag("Event")).hasValue("Game 1"); + assertThat(games.get(0).result()).isEqualTo(GameResult.WHITE_WINS); + assertThat(games.get(1).getTag("Event")).hasValue("Game 2"); + assertThat(games.get(1).result()).isEqualTo(GameResult.BLACK_WINS); + } + + @Test + public void parseAll_empty() { + List games = parseAll(""); + assertThat(games).isEmpty(); + } + + // === Complete Game Tests === + + @Test + public void parse_completeGame() { + String pgn = + """ + [Event "World Championship"] + [Site "London"] + [Date "2023.04.15"] + [Round "5"] + [White "Carlsen"] + [Black "Nepomniachtchi"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O 1-0 + """; + PgnGame game = parse(pgn); + + assertThat(game.getTag("Event")).hasValue("World Championship"); + assertThat(game.getTag("White")).hasValue("Carlsen"); + assertThat(game.moves()).hasSize(9); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parse_gameWithAnnotations() { + String pgn = + """ + [Event "Test"] + [Result "1-0"] + + 1. e4 $1 {Best by test} e5 2. Nf3 Nc6 (2... d6 {Philidor}) 3. Bb5 1-0 + """; + PgnGame game = parse(pgn); + + // Check first move has NAG and comment + Move e4 = game.moves().get(0); + assertThat(e4.san()).isEqualTo("e4"); + assertThat(e4.nags()).isNotEmpty(); + assertThat(e4.comment()).isPresent(); + + // Check variation exists + Move nc6 = game.moves().get(3); + assertThat(nc6.san()).isEqualTo("Nc6"); + assertThat(nc6.variations()).hasSize(1); + } + + // === Error Handling Tests === + + @Test + public void parse_missingResult() { + // A game without a termination marker + assertThatThrownBy(() -> parse("[Event \"Test\"] 1. e4")).isInstanceOf(ParseException.class); + } + + @Test + public void parse_unclosedVariation() { + assertThatThrownBy(() -> parse("[Result \"*\"] 1. e4 (1. d4 *")) + .isInstanceOf(ParseException.class); + } + + @Test + public void parse_malformedTag() { + assertThatThrownBy(() -> parse("[Event] *")).isInstanceOf(ParseException.class); + } + + // === Real World Examples === + + @Test + public void parse_operaGame() { + String pgn = + """ + [Event "Paris"] + [Site "Paris FRA"] + [Date "1858.??.??"] + [Round "?"] + [White "Morphy, Paul"] + [Black "Duke of Brunswick and Count Isouard"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5 6. Bc4 Nf6 7. Qb3 Qe7 + 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5 11. Bxb5+ Nbd7 12. O-O-O Rd8 + 13. Rxd7 Rxd7 14. Rd1 Qe6 15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0 + """; + PgnGame game = parse(pgn); + + assertThat(game.getTag("White")).hasValue("Morphy, Paul"); + assertThat(game.moves()).hasSize(33); + assertThat(game.moves().get(32).san()).isEqualTo("Rd8#"); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } + + @Test + public void parse_immortalGame() { + String pgn = + """ + [Event "London"] + [Site "London ENG"] + [Date "1851.06.21"] + [Round "?"] + [White "Anderssen, Adolf"] + [Black "Kieseritzky, Lionel"] + [Result "1-0"] + + 1. e4 e5 2. f4 exf4 3. Bc4 Qh4+ 4. Kf1 b5 5. Bxb5 Nf6 6. Nf3 Qh6 7. d3 Nh5 + 8. Nh4 Qg5 9. Nf5 c6 10. g4 Nf6 11. Rg1 cxb5 12. h4 Qg6 13. h5 Qg5 14. Qf3 Ng8 + 15. Bxf4 Qf6 16. Nc3 Bc5 17. Nd5 Qxb2 18. Bd6 Bxg1 19. e5 Qxa1+ 20. Ke2 Na6 + 21. Nxg7+ Kd8 22. Qf6+ Nxf6 23. Be7# 1-0 + """; + PgnGame game = parse(pgn); + + assertThat(game.getTag("Event")).hasValue("London"); + assertThat(game.moves()).hasSize(45); + assertThat(game.moves().get(44).san()).isEqualTo("Be7#"); + assertThat(game.result()).isEqualTo(GameResult.WHITE_WINS); + } }