diff --git a/jvm/src/main/java/com/muchq/pgn/BUILD.bazel b/jvm/src/main/java/com/muchq/pgn/BUILD.bazel new file mode 100644 index 00000000..52f12bf7 --- /dev/null +++ b/jvm/src/main/java/com/muchq/pgn/BUILD.bazel @@ -0,0 +1,7 @@ +load("@rules_java//java:java_library.bzl", "java_library") + +java_library( + name = "pgn", + srcs = glob(["**/*.java"]), + visibility = ["//visibility:public"], +) diff --git a/jvm/src/main/java/com/muchq/pgn/PgnReader.java b/jvm/src/main/java/com/muchq/pgn/PgnReader.java new file mode 100644 index 00000000..7e78e00a --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/lexer/LexerException.java b/jvm/src/main/java/com/muchq/pgn/lexer/LexerException.java new file mode 100644 index 00000000..6722f451 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/lexer/PgnLexer.java b/jvm/src/main/java/com/muchq/pgn/lexer/PgnLexer.java new file mode 100644 index 00000000..5db69cee --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/lexer/Token.java b/jvm/src/main/java/com/muchq/pgn/lexer/Token.java new file mode 100644 index 00000000..bae5b07b --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/lexer/TokenType.java b/jvm/src/main/java/com/muchq/pgn/lexer/TokenType.java new file mode 100644 index 00000000..dd76b3ee --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/File.java b/jvm/src/main/java/com/muchq/pgn/model/File.java new file mode 100644 index 00000000..9f95d143 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/GameResult.java b/jvm/src/main/java/com/muchq/pgn/model/GameResult.java new file mode 100644 index 00000000..a3f1a880 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/Move.java b/jvm/src/main/java/com/muchq/pgn/model/Move.java new file mode 100644 index 00000000..e073bedd --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/Nag.java b/jvm/src/main/java/com/muchq/pgn/model/Nag.java new file mode 100644 index 00000000..0d1a6224 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/PgnGame.java b/jvm/src/main/java/com/muchq/pgn/model/PgnGame.java new file mode 100644 index 00000000..9ad6a206 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/Piece.java b/jvm/src/main/java/com/muchq/pgn/model/Piece.java new file mode 100644 index 00000000..763cca19 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/Rank.java b/jvm/src/main/java/com/muchq/pgn/model/Rank.java new file mode 100644 index 00000000..01329a6f --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/Square.java b/jvm/src/main/java/com/muchq/pgn/model/Square.java new file mode 100644 index 00000000..54889659 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/model/TagPair.java b/jvm/src/main/java/com/muchq/pgn/model/TagPair.java new file mode 100644 index 00000000..52b935f2 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/parser/ParseException.java b/jvm/src/main/java/com/muchq/pgn/parser/ParseException.java new file mode 100644 index 00000000..ca2ad0d8 --- /dev/null +++ b/jvm/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/jvm/src/main/java/com/muchq/pgn/parser/PgnParser.java b/jvm/src/main/java/com/muchq/pgn/parser/PgnParser.java new file mode 100644 index 00000000..fa6f14d5 --- /dev/null +++ b/jvm/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/jvm/src/test/java/com/muchq/pgn/BUILD.bazel b/jvm/src/test/java/com/muchq/pgn/BUILD.bazel new file mode 100644 index 00000000..4261603f --- /dev/null +++ b/jvm/src/test/java/com/muchq/pgn/BUILD.bazel @@ -0,0 +1,12 @@ +load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite") + +java_test_suite( + name = "pgn", + size = "small", + srcs = glob(["**/*Test.java"]), + deps = [ + "//jvm/src/main/java/com/muchq/pgn", + "@maven//:junit_junit", + "@maven//:org_assertj_assertj_core", + ], +) diff --git a/jvm/src/test/java/com/muchq/pgn/PgnReaderTest.java b/jvm/src/test/java/com/muchq/pgn/PgnReaderTest.java new file mode 100644 index 00000000..e2ef13a5 --- /dev/null +++ b/jvm/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/jvm/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java b/jvm/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java new file mode 100644 index 00000000..5759b697 --- /dev/null +++ b/jvm/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/jvm/src/test/java/com/muchq/pgn/model/GameResultTest.java b/jvm/src/test/java/com/muchq/pgn/model/GameResultTest.java new file mode 100644 index 00000000..c8078606 --- /dev/null +++ b/jvm/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/jvm/src/test/java/com/muchq/pgn/model/SquareTest.java b/jvm/src/test/java/com/muchq/pgn/model/SquareTest.java new file mode 100644 index 00000000..a0c7e6d8 --- /dev/null +++ b/jvm/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/jvm/src/test/java/com/muchq/pgn/parser/PgnParserTest.java b/jvm/src/test/java/com/muchq/pgn/parser/PgnParserTest.java new file mode 100644 index 00000000..a8e47e57 --- /dev/null +++ b/jvm/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); + } +}