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..acd48cd9
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/PgnReader.java
@@ -0,0 +1,45 @@
+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..90a50ea7
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/LexerException.java
@@ -0,0 +1,21 @@
+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..4bd25a26
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/lexer/PgnLexer.java
@@ -0,0 +1,26 @@
+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..b7b45a47
--- /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..a1e94b96
--- /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..bf867766
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/File.java
@@ -0,0 +1,20 @@
+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..1e14b8f3
--- /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..520d3e5a
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Move.java
@@ -0,0 +1,23 @@
+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..32cba58f
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Nag.java
@@ -0,0 +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)
+ */
+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..b972aded
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/PgnGame.java
@@ -0,0 +1,12 @@
+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..5bc21bf9
--- /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..70b21fc2
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/Rank.java
@@ -0,0 +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);
+
+ 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..ad071178
--- /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..6e430f58
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/model/TagPair.java
@@ -0,0 +1,4 @@
+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..3820c04d
--- /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..20599e13
--- /dev/null
+++ b/domains/games/libs/pgn/src/main/java/com/muchq/pgn/parser/PgnParser.java
@@ -0,0 +1,40 @@
+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..3b5a9604
--- /dev/null
+++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/PgnReaderTest.java
@@ -0,0 +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 java.util.List;
+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
+ }
+}
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..1f66d7eb
--- /dev/null
+++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/lexer/PgnLexerTest.java
@@ -0,0 +1,489 @@
+package com.muchq.pgn.lexer;
+
+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);
+ }
+}
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..7872d4f2
--- /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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.Test;
+
+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..25004b73
--- /dev/null
+++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/model/SquareTest.java
@@ -0,0 +1,171 @@
+package com.muchq.pgn.model;
+
+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");
+ }
+}
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..d9d1ac20
--- /dev/null
+++ b/domains/games/libs/pgn/src/test/java/com/muchq/pgn/parser/PgnParserTest.java
@@ -0,0 +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 java.util.List;
+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);
+ }
+}