pgn2 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. metadata +99 -15
data/spec/move_spec.rb ADDED
@@ -0,0 +1,136 @@
1
+ require 'spec_helper'
2
+
3
+ describe PGN::Move do
4
+ describe 'pawn pushes' do
5
+ it 'parses a white pawn push' do
6
+ m = PGN::Move.new('e4', :white)
7
+ expect(m.piece).to eq('P')
8
+ expect(m.destination).to eq('e4')
9
+ expect(m.capture).to eq(false)
10
+ expect(m.pawn?).to eq(true)
11
+ expect(m.white?).to eq(true)
12
+ end
13
+
14
+ it 'parses a black pawn push' do
15
+ m = PGN::Move.new('d5', :black)
16
+ expect(m.piece).to eq('p')
17
+ expect(m.destination).to eq('d5')
18
+ expect(m.pawn?).to eq(true)
19
+ expect(m.black?).to eq(true)
20
+ end
21
+ end
22
+
23
+ describe 'pawn captures' do
24
+ it 'parses a pawn capture, using the file as disambiguation' do
25
+ m = PGN::Move.new('exd5', :white)
26
+ expect(m.piece).to eq('P')
27
+ expect(m.capture).to eq(true)
28
+ expect(m.disambiguation).to eq('e')
29
+ expect(m.destination).to eq('d5')
30
+ end
31
+ end
32
+
33
+ describe 'piece moves' do
34
+ it 'parses knight, bishop, queen, king moves' do
35
+ expect(PGN::Move.new('Nf3', :white).piece).to eq('N')
36
+ expect(PGN::Move.new('Bc4', :white).piece).to eq('B')
37
+ expect(PGN::Move.new('Qd1', :white).piece).to eq('Q')
38
+ expect(PGN::Move.new('Ke2', :white).piece).to eq('K')
39
+ end
40
+
41
+ it 'parses black piece moves as lowercase' do
42
+ expect(PGN::Move.new('Nf6', :black).piece).to eq('n')
43
+ expect(PGN::Move.new('Qd8', :black).piece).to eq('q')
44
+ end
45
+
46
+ it 'is not a pawn for piece moves' do
47
+ expect(PGN::Move.new('Nf3', :white).pawn?).to eq(false)
48
+ end
49
+ end
50
+
51
+ describe 'disambiguation' do
52
+ it 'parses file disambiguation' do
53
+ m = PGN::Move.new('Nbd2', :white)
54
+ expect(m.piece).to eq('N')
55
+ expect(m.disambiguation).to eq('b')
56
+ expect(m.destination).to eq('d2')
57
+ end
58
+
59
+ it 'parses rank disambiguation' do
60
+ m = PGN::Move.new('N4c3', :white)
61
+ expect(m.disambiguation).to eq('4')
62
+ expect(m.destination).to eq('c3')
63
+ end
64
+
65
+ it 'parses a capturing disambiguated rook move' do
66
+ m = PGN::Move.new('Raxc1', :white)
67
+ expect(m.piece).to eq('R')
68
+ expect(m.disambiguation).to eq('a')
69
+ expect(m.capture).to eq(true)
70
+ expect(m.destination).to eq('c1')
71
+ end
72
+ end
73
+
74
+ describe 'promotion' do
75
+ it 'parses a white promotion' do
76
+ m = PGN::Move.new('e8=Q', :white)
77
+ expect(m.piece).to eq('P')
78
+ expect(m.destination).to eq('e8')
79
+ expect(m.promotion).to eq('Q')
80
+ end
81
+
82
+ it 'lowercases the promotion piece for black' do
83
+ m = PGN::Move.new('b8=Q', :black)
84
+ expect(m.promotion).to eq('q')
85
+ end
86
+
87
+ it 'parses a capturing promotion' do
88
+ m = PGN::Move.new('exd8=Q', :white)
89
+ expect(m.capture).to eq(true)
90
+ expect(m.disambiguation).to eq('e')
91
+ expect(m.destination).to eq('d8')
92
+ expect(m.promotion).to eq('Q')
93
+ end
94
+ end
95
+
96
+ describe 'check and mate' do
97
+ it 'parses a checking move' do
98
+ m = PGN::Move.new('g5+', :white)
99
+ expect(m.check).to eq('+')
100
+ expect(m.check?).to eq(true)
101
+ expect(m.checkmate?).to eq(false)
102
+ end
103
+
104
+ it 'parses a checkmate move' do
105
+ m = PGN::Move.new('Qe7#', :white)
106
+ expect(m.check).to eq('#')
107
+ expect(m.checkmate?).to eq(true)
108
+ expect(m.check?).to eq(false)
109
+ end
110
+ end
111
+
112
+ describe 'castling' do
113
+ it 'parses white kingside and queenside' do
114
+ expect(PGN::Move.new('O-O', :white).castle).to eq('K')
115
+ expect(PGN::Move.new('O-O-O', :white).castle).to eq('Q')
116
+ end
117
+
118
+ it 'parses black kingside and queenside (lowercase)' do
119
+ expect(PGN::Move.new('O-O', :black).castle).to eq('k')
120
+ expect(PGN::Move.new('O-O-O', :black).castle).to eq('q')
121
+ end
122
+
123
+ it 'has nil piece for castling moves' do
124
+ expect(PGN::Move.new('O-O', :white).piece).to be_nil
125
+ end
126
+ end
127
+
128
+ describe "the don't-care move" do
129
+ it 'parses -- as a no-op with no piece or destination' do
130
+ m = PGN::Move.new('--', :white)
131
+ expect(m.piece).to be_nil
132
+ expect(m.destination).to be_nil
133
+ expect(m.pawn?).to eq(false)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,210 @@
1
+ require 'spec_helper'
2
+ require 'pgn'
3
+
4
+ # Explicit, hardcoded expectations for the Racc + StringScanner parser.
5
+ # These do not reference the legacy whittle parser and survive its removal;
6
+ # they pin the public PGN.parse behavior on the features exercised by the
7
+ # fixtures plus representative edge cases.
8
+ RSpec.describe PGN, '.parse (racc parser)' do
9
+ def notations(moves)
10
+ moves.map(&:notation)
11
+ end
12
+
13
+ def variations_as(move)
14
+ (move.variations || []).map { |v| notations(v) }
15
+ end
16
+
17
+ describe 'results' do
18
+ it 'parses a 1-0 result' do
19
+ game = PGN.parse('[White "A"] 1. e4 e5 1-0').first
20
+ expect(game.result).to eq('1-0')
21
+ end
22
+
23
+ it 'parses a 0-1 result' do
24
+ game = PGN.parse('[White "A"] 1. e4 e5 0-1').first
25
+ expect(game.result).to eq('0-1')
26
+ end
27
+
28
+ it 'parses a 1/2-1/2 draw result' do
29
+ game = PGN.parse('[White "A"] 1. e4 e5 1/2-1/2').first
30
+ expect(game.result).to eq('1/2-1/2')
31
+ end
32
+
33
+ it 'parses a * (unknown) result' do
34
+ game = PGN.parse('[White "A"] 1. e4 e5 *').first
35
+ expect(game.result).to eq('*')
36
+ end
37
+
38
+ it 'parses a result-only (empty movetext) game' do
39
+ game = PGN.parse('[Event "?"] *').first
40
+ expect(game.result).to eq('*')
41
+ expect(game.moves).to be_empty
42
+ end
43
+ end
44
+
45
+ describe 'tags' do
46
+ it 'extracts tag values (stripping surrounding quotes)' do
47
+ game = PGN.parse(%([White "Kasparov"]\n[Black "Deep Blue"] 1. e4 1-0)).first
48
+ expect(game.tags['White']).to eq('Kasparov')
49
+ expect(game.tags['Black']).to eq('Deep Blue')
50
+ end
51
+
52
+ it 'keeps the first value on duplicate tag keys' do
53
+ game = PGN.parse(%([White "First"]\n[White "Second"] 1. e4 1-0)).first
54
+ expect(game.tags['White']).to eq('First')
55
+ end
56
+
57
+ it 'preserves unescaped double-quotes inside a tag value' do
58
+ game = PGN.parse(File.read('./spec/pgn_files/doublequotes.pgn')).first
59
+ expect(game.tags['Event']).to eq('IRT BLITZ "Sub Zonal"')
60
+ end
61
+
62
+ it 'honors the Encoding argument for multibyte tag values' do
63
+ text = File.read('./spec/pgn_files/specialcharacters.pgn')
64
+ games = PGN.parse(text, Encoding::UTF_8)
65
+ expect(games.first.tags['WhiteTeam']).to eq('NARIÑO')
66
+ expect(games.last.tags['Site']).to eq(
67
+ 'HOTEL CAFEIRA Calle 18 Nro. 5 – 38 Centro de Pereira – Risaralda'
68
+ )
69
+ end
70
+ end
71
+
72
+ describe 'san moves' do
73
+ it 'parses pawn moves and captures' do
74
+ game = PGN.parse('[White "A"] 1. e4 d5 2. exd5 1-0').first
75
+ expect(notations(game.moves)).to eq(%w[e4 d5 exd5])
76
+ end
77
+
78
+ it 'parses promotion with check' do
79
+ game = PGN.parse('[White "A"] 1. e7 d8=Q+ 1-0').first
80
+ expect(game.moves[-1].notation).to eq('d8=Q+')
81
+ end
82
+
83
+ it 'parses check and checkmate' do
84
+ game = PGN.parse('[White "A"] 1. Qe7+ Qxe7 2. Qxf7# 1-0').first
85
+ expect(notations(game.moves)).to eq(%w[Qe7+ Qxe7 Qxf7#])
86
+ end
87
+
88
+ it 'parses castling (O-O and O-O-O)' do
89
+ game = PGN.parse('[White "A"] 1. O-O O-O-O 1-0').first
90
+ expect(notations(game.moves)).to eq(%w[O-O O-O-O])
91
+ end
92
+
93
+ it 'normalizes 0-form castling to O-form in move notation' do
94
+ game = PGN.parse(File.read('./spec/pgn_files/alternate_castling.pgn')).first
95
+ expect(game.moves.last.notation).to eq('O-O-O')
96
+ end
97
+
98
+ it 'parses major-piece moves with disambiguation' do
99
+ game = PGN.parse('[White "A"] 1. Nf3 Nc6 2. Nef6 Nef6 1-0').first
100
+ expect(notations(game.moves)).to eq(%w[Nf3 Nc6 Nef6 Nef6])
101
+ end
102
+
103
+ it 'parses the dont-care move (--) inside a variation' do
104
+ game = PGN.parse('[White "A"] 1. e4 (1. -- d5) e5 1-0').first
105
+ expect(variations_as(game.moves.first)).to eq([%w[-- d5]])
106
+ end
107
+ end
108
+
109
+ describe 'annotations' do
110
+ it 'parses numeric annotation glyphs' do
111
+ game = PGN.parse('[White "A"] 1. e4 e5 2. Nf3 $1 Nc6 1-0').first
112
+ expect(game.moves[2].annotation).to eq(['$1'])
113
+ end
114
+
115
+ it 'parses punctuation annotations (!?, ?!, !!, ??)' do
116
+ game = PGN.parse('[White "A"] 1. e4!? e5?! 2. Nf3 Nc6 1-0').first
117
+ expect(game.moves[0].annotation).to eq(['!?'])
118
+ expect(game.moves[1].annotation).to eq(['?!'])
119
+ end
120
+
121
+ it 'parses multiple annotations on one move' do
122
+ game = PGN.parse(File.read('./spec/pgn_files/two_annotations.pgn')).first
123
+ expect(game.moves[1].annotation).to eq(['$2', '$11'])
124
+ end
125
+ end
126
+
127
+ describe 'comments' do
128
+ it 'attaches a comment after a move and cleans whitespace' do
129
+ game = PGN.parse('[White "A"] 1. e4 { a comment } e5 1-0').first
130
+ expect(game.moves[0].comment).to eq('a comment')
131
+ end
132
+
133
+ it 'parses a multiline comment and collapses whitespace' do
134
+ game = PGN.parse(File.read('./spec/pgn_files/multiline_comments.pgn')).first
135
+ expect(game.moves[5].comment).to eq('I pity the fool!')
136
+ end
137
+
138
+ it 'parses nested brace comments (lexer recurses inner braces)' do
139
+ game = PGN.parse(File.read('./spec/pgn_files/nested_comments.pgn')).first
140
+ # The lexer recurses and captures the full nested comment; the exact
141
+ # cleaned text is sensitive to the (pre-existing, shared with whittle)
142
+ # double-clean in Game#moves=, so assert stable substrings.
143
+ comment = game.moves[2].comment
144
+ expect(comment).to include('nested')
145
+ expect(comment).to include('comment')
146
+ end
147
+
148
+ it 'captures a standalone comment as the game comment (with braces)' do
149
+ game = PGN.parse(File.read('./spec/pgn_files/no_moves.pgn')).last
150
+ expect(game.comment).to eq('{game comment}')
151
+ end
152
+ end
153
+
154
+ describe 'variations' do
155
+ it 'parses a single variation attached to a move' do
156
+ game = PGN.parse('[White "A"] 1. e4 (1. d4 d5) e5 1-0').first
157
+ expect(variations_as(game.moves.first)).to eq([%w[d4 d5]])
158
+ end
159
+
160
+ it 'parses a nested variation' do
161
+ game = PGN.parse('[White "A"] 1. e4 (1. d4 (1... d5) dxc4) e5 1-0').first
162
+ outer = game.moves.first.variations.first
163
+ expect(notations(outer)).to eq(%w[d4 dxc4])
164
+ expect(variations_as(outer[0])).to eq([%w[d5]])
165
+ end
166
+
167
+ it 'compares multiple variations as an order-independent set' do
168
+ game = PGN.parse('[White "A"] 1. e4 (1. d4) (1. Nf3) e5 1-0').first
169
+ expect(variations_as(game.moves.first).sort).to eq([%w[d4], %w[Nf3]].sort)
170
+ end
171
+
172
+ it 'parses a variation that itself contains an empty move' do
173
+ game = PGN.parse(File.read('./spec/pgn_files/empty_variation_move.pgn')).first
174
+ expect(game.result).to eq('*')
175
+ expect(game.moves.last.notation).to eq('Ng5')
176
+ end
177
+ end
178
+
179
+ describe 'pgn (verbatim raw text)' do
180
+ it 'returns the verbatim text for a single game' do
181
+ input = "[White \"A\"] 1. e4 1-0\n"
182
+ expect(PGN.parse(input).first.pgn).to eq(input)
183
+ end
184
+
185
+ it 'partitions consecutive games contiguously' do
186
+ input = "[White \"A\"] 1. e4 1-0\n\n[White \"B\"] 1. d4 0-1\n"
187
+ games = PGN.parse(input)
188
+ expect(games.map(&:pgn)).to eq(["[White \"A\"] 1. e4 1-0\n\n", "[White \"B\"] 1. d4 0-1\n"])
189
+ end
190
+
191
+ it 'includes a leading percent comment in the first game pgn' do
192
+ input = "% header\n[White \"A\"] 1. e4 *\n"
193
+ expect(PGN.parse(input).first.pgn).to eq(input)
194
+ end
195
+ end
196
+
197
+ describe 'multiple games' do
198
+ it 'returns games in source order' do
199
+ games = PGN.parse("[White \"One\"] 1. e4 1-0\n\n[White \"Two\"] 1. d4 0-1")
200
+ expect(games.map { |g| g.tags['White'] }).to eq(%w[One Two])
201
+ end
202
+ end
203
+
204
+ describe 'starting position (FEN tag)' do
205
+ it 'uses the FEN tag for positions' do
206
+ game = PGN.parse(File.read('./spec/pgn_files/fen.pgn')).first
207
+ expect(game.positions.first.to_fen.to_s).to eq(game.tags['FEN'])
208
+ end
209
+ end
210
+ end
data/spec/parser_spec.rb CHANGED
@@ -139,5 +139,20 @@ describe PGN do
139
139
  game = games.last
140
140
  expect(game.comment).to eq('{game comment}')
141
141
  end
142
+
143
+ it 'tag_values with double quotes' do
144
+ games = PGN.parse(File.read('./spec/pgn_files/doublequotes.pgn'))
145
+ game = games.last
146
+ expect(game.tags['Event']).to eq('IRT BLITZ "Sub Zonal"')
147
+ end
148
+
149
+ it 'set encoding' do
150
+ file = File.read('./spec/pgn_files/specialcharacters.pgn')
151
+ games = PGN.parse(file, Encoding::UTF_8)
152
+ game1 = games.first
153
+ game2 = games.last
154
+ expect(game1.tags['WhiteTeam']).to eq('NARIÑO')
155
+ expect(game2.tags['Site']).to eq('HOTEL CAFEIRA Calle 18 Nro. 5 – 38 Centro de Pereira – Risaralda')
156
+ end
142
157
  end
143
158
  end
@@ -0,0 +1,21 @@
1
+ [Event "IRT BLITZ "Sub Zonal""]
2
+ [Site "HOTEL CAFEIRA Calle 18 Nro. 5 - 38 Centro de Pereira Risaralda"]
3
+ [Date "2021.11.27"]
4
+ [Round "5"]
5
+ [Board "2"]
6
+ [White "Estrada Velandia, Henry Andres"]
7
+ [Black "Vargas Arteaga, Alexis"]
8
+ [Result "0-1"]
9
+ [ECO "D00"]
10
+ [WhiteElo "2055"]
11
+ [BlackElo "2343"]
12
+ [PlyCount "64"]
13
+ [EventDate "2021.11.27"]
14
+ [EventRounds "18"]
15
+ [EventCountry "COL"]
16
+
17
+ 1. d4 Nf6 2. c3 d5 3. e3 c5 4. Bd3 Nc6 5. f4 e6 6. Nf3 Bd6 7. O-O O-O 8. Bd2 b6
18
+ 9. Be1 Ne7 10. Ne5 Nf5 11. Bxf5 exf5 12. Bh4 Be6 13. Nd2 Rc8 14. Qf3 Be7 15.
19
+ Bxf6 Bxf6 16. Kh1 g6 17. Rg1 b5 18. g4 b4 19. gxf5 Bxf5 20. Raf1 Bg7 21. Rg5
20
+ bxc3 22. bxc3 cxd4 23. Rxf5 dxc3 24. Nxf7 Qd7 25. Rxd5 Qxf7 26. Nb3 c2 27. Rc1
21
+ Bb2 28. Rg1 c1=Q 29. Nxc1 Rxc1 30. Rxc1 Bxc1 31. h4 Bxe3 32. f5 Bh6 0-1
@@ -0,0 +1,79 @@
1
+ [Event "XXI Juegos Deportivos Nacionales AJEDREZ RAPIDO Absoluto por Equipos 2019"]
2
+ [Site "Cartagena (Bolivar-Colombia) Centro Amurallado, Palacio de la Proclamación"]
3
+ [Date "2019.11.29"]
4
+ [Round "1"]
5
+ [Board "1"]
6
+ [White "Pai, Juan David"]
7
+ [Black "Vargas Arteaga, Alexis"]
8
+ [Result "0-1"]
9
+ [ECO ""]
10
+ [WhiteElo "2008"]
11
+ [BlackElo "2339"]
12
+ [PlyCount "0"]
13
+ [EventDate "2019.11.29"]
14
+ [EventType "team"]
15
+ [EventRounds "7"]
16
+ [EventCountry "COL"]
17
+ [WhiteTeam "NARIÑO"]
18
+ [BlackTeam "SANTANDER"]
19
+
20
+ 1. d4 {[%clk 00:15:18]} {[%emt 00:00:02]} Nf6 {[%clk 00:15:17]} {[%emt
21
+ 00:00:03]} 2. Nf3 {[%clk 00:15:12]} {[%emt 00:00:16]} e6 {[%clk 00:15:25]}
22
+ {[%emt 00:00:01]} 3. c4 {[%clk 00:15:19]} {[%emt 00:00:03]} b6 {[%clk
23
+ 00:15:30]} {[%emt 00:00:05]} 4. g3 {[%clk 00:15:28]} {[%emt 00:00:02]} Ba6
24
+ {[%clk 00:15:37]} {[%emt 00:00:02]} 5. b3 {[%clk 00:15:37]} {[%emt 00:00:02]}
25
+ b5 {[%clk 00:15:46]} {[%emt 00:00:01]} 6. Bg2 {[%clk 00:15:36]} {[%emt
26
+ 00:00:12]} bxc4 {[%clk 00:15:54]} {[%emt 00:00:01]} 7. Ne5 {[%clk 00:15:45]}
27
+ Bb4+ {[%clk 00:16:03]} {[%emt 00:00:01]} 8. Bd2 {[%clk 00:15:54]} cxb3 {[%clk
28
+ 00:16:12]} {[%emt 00:00:01]} 9. axb3 {[%clk 00:15:53]} {[%emt 00:00:11]} Bxd2+
29
+ {[%clk 00:16:18]} {[%emt 00:00:04]} 10. Qxd2 {[%clk 00:14:36]} {[%emt
30
+ 00:01:27]} d5 {[%clk 00:16:27]} 11. O-O {[%clk 00:13:50]} {[%emt 00:00:57]} O-O
31
+ {[%clk 00:16:34]} {[%emt 00:00:03]} 12. Rc1 {[%clk 00:13:49]} {[%emt 00:00:11]}
32
+ Nfd7 {[%clk 00:16:15]} {[%emt 00:00:28]} 13. Nc6 {[%clk 00:11:27]} {[%emt
33
+ 00:02:32]} Nxc6 {[%clk 00:16:23]} {[%emt 00:00:02]} 14. Rxa6 {[%clk 00:11:26]}
34
+ {[%emt 00:00:11]} Ncb8 {[%clk 00:16:06]} {[%emt 00:00:27]} 15. Ra5 {[%clk
35
+ 00:11:12]} {[%emt 00:00:24]} a6 {[%clk 00:16:13]} {[%emt 00:00:02]} 16. Qb4
36
+ {[%clk 00:10:47]} {[%emt 00:00:36]} Ra7 {[%clk 00:16:17]} {[%emt 00:00:05]} 17.
37
+ Nc3 {[%clk 00:10:51]} {[%emt 00:00:07]} Nc6 {[%clk 00:16:15]} {[%emt 00:00:12]}
38
+ 18. Qa4 {[%clk 00:10:45]} {[%emt 00:00:17]} Nxa5 {[%clk 00:16:23]} {[%emt
39
+ 00:00:01]} 19. Qxa5 {[%clk 00:10:54]} {[%emt 00:00:01]} c5 {[%clk 00:16:22]}
40
+ {[%emt 00:00:11]} 20. Qxd8 {[%clk 00:10:27]} {[%emt 00:00:37]} Rxd8 {[%clk
41
+ 00:16:30]} {[%emt 00:00:01]} 21. dxc5 {[%clk 00:10:36]} {[%emt 00:00:02]} Nxc5
42
+ {[%clk 00:16:23]} {[%emt 00:00:16]} 22. Nxd5 {[%clk 00:10:36]} {[%emt
43
+ 00:00:12]} Nxb3 {[%clk 00:15:29]} {[%emt 00:01:03]} 23. Rc3 {[%clk 00:10:33]}
44
+ {[%emt 00:00:13]} Nd4 {[%clk 00:15:23]} {[%emt 00:00:16]} 24. Nf4 {[%clk
45
+ 00:10:30]} {[%emt 00:00:13]} g5 {[%clk 00:15:21]} {[%emt 00:00:12]} 25. e3
46
+ {[%clk 00:10:25]} {[%emt 00:00:16]} gxf4 {[%clk 00:15:28]} {[%emt 00:00:02]} 26.
47
+ exd4 {[%clk 00:10:34]} {[%emt 00:00:02]} Rxd4 {[%clk 00:15:36]} {[%emt
48
+ 00:00:01]} 27. gxf4 {[%clk 00:10:36]} {[%emt 00:00:08]} Rxf4 {[%clk 00:15:42]}
49
+ {[%emt 00:00:04]} 28. Rg3+ {[%clk 00:09:57]} {[%emt 00:00:48]} Kf8 {[%clk
50
+ 00:15:49]} {[%emt 00:00:03]} 29. Rh3 {[%clk 00:10:06]} {[%emt 00:00:02]} f6
51
+ {[%clk 00:15:51]} {[%emt 00:00:07]} 30. Ra3 {[%clk 00:10:07]} {[%emt 00:00:10]}
52
+ a5 {[%clk 00:15:45]} {[%emt 00:00:16]} 31. Bc6 {[%clk 00:10:15]} {[%emt
53
+ 00:00:01]} Ke7 {[%clk 00:15:43]} {[%emt 00:00:13]} 32. Rd3 {[%clk 00:10:23]}
54
+ {[%emt 00:00:02]} Rc7 {[%clk 00:15:42]} {[%emt 00:00:10]} 33. Ba4 {[%clk
55
+ 00:10:12]} {[%emt 00:00:22]} Rxa4 {[%clk 00:15:49]} {[%emt 00:00:03]} 0-1
56
+
57
+ [Event "Subzonal 2.3.4 Absoluto COLOMBIA"]
58
+ [Site "HOTEL CAFEIRA Calle 18 Nro. 5 – 38 Centro de Pereira – Risaralda"]
59
+ [Date "2021.11.23"]
60
+ [Round "1"]
61
+ [Board "7"]
62
+ [White "Vargas Arteaga, Alexis"]
63
+ [Black "Murillo Loreo, Oscar Miguel"]
64
+ [Result "1-0"]
65
+ [ECO "B37"]
66
+ [WhiteElo "2277"]
67
+ [BlackElo "1775"]
68
+ [PlyCount "83"]
69
+ [EventDate "2021.11.23"]
70
+ [EventRounds "9"]
71
+ [EventCountry "COL"]
72
+
73
+ 1. e4 c5 2. Nf3 Nc6 3. d4 cxd4 4. Nxd4 g6 5. c4 Bg7 6. Nc2 Nf6 7. Nc3 d6 8. Be2
74
+ O-O 9. O-O Nd7 10. Re1 Nc5 11. Bf1 Be6 12. Nd5 h6 13. Rb1 a5 14. b3 Kh7 15. Bb2
75
+ Bxb2 16. Rxb2 f5 17. exf5 Bxf5 18. Nd4 e5 19. Nb5 e4 20. Ne3 Ne5 21. Rd2 Ncd3
76
+ 22. Bxd3 Nxd3 23. Nxf5 gxf5 24. Rxd3 exd3 25. Qxd3 Qf6 26. Qd5 Rae8 27. Rd1 Qe5
77
+ 28. g3 Qxd5 29. Rxd5 f4 30. Kf1 f3 31. Rd2 Rf4 32. Nd4 a4 33. Rd3 axb3 34. axb3
78
+ Ref8 35. Re3 R8f7 36. h3 h5 37. h4 Kg6 38. Ne6 Kf5 39. Ng5 Rf8 40. Nh7 Kg4 41.
79
+ Re4 Kh3 42. Kg1 1-0
@@ -52,3 +52,76 @@ describe PGN::Position do
52
52
  end
53
53
  end
54
54
  end
55
+
56
+ # New exhaustive coverage below. Existing `should` examples above are
57
+ # intentionally left untouched.
58
+
59
+ describe PGN::Position do
60
+ describe '.start attributes' do
61
+ it 'has the expected starting state' do
62
+ pos = PGN::Position.start
63
+ expect(pos.player).to eq(:white)
64
+ expect(pos.castling).to eq(%w[K Q k q])
65
+ expect(pos.en_passant).to be_nil
66
+ expect(pos.halfmove).to eq(0)
67
+ expect(pos.fullmove).to eq(1)
68
+ end
69
+ end
70
+
71
+ describe '#next_player' do
72
+ it 'toggles white to black and back' do
73
+ expect(PGN::Position.start.next_player).to eq(:black)
74
+ expect(PGN::Position.start.move('e4').next_player).to eq(:white)
75
+ end
76
+ end
77
+
78
+ describe '#move' do
79
+ it 'returns a new PGN::Position and does not mutate the source' do
80
+ pos = PGN::Position.start
81
+ nxt = pos.move('e4')
82
+ expect(nxt).to be_a(PGN::Position)
83
+ expect(nxt).not_to be(pos)
84
+ expect(pos.board.at('e4')).to be_nil
85
+ expect(pos.player).to eq(:white)
86
+ end
87
+
88
+ it 'toggles the player to move' do
89
+ expect(PGN::Position.start.move('e4').player).to eq(:black)
90
+ expect(PGN::Position.start.move('e4').move('e5').player).to eq(:white)
91
+ end
92
+
93
+ it 'updates state after 1.e4' do
94
+ nxt = PGN::Position.start.move('e4')
95
+ expect(nxt.en_passant).to eq('e3')
96
+ expect(nxt.halfmove).to eq(0)
97
+ expect(nxt.fullmove).to eq(1)
98
+ expect(nxt.board.at('e4')).to eq('P')
99
+ expect(nxt.board.at('e2')).to be_nil
100
+ end
101
+
102
+ it 'updates state after 1.e4 e5' do
103
+ nxt = PGN::Position.start.move('e4').move('e5')
104
+ expect(nxt.player).to eq(:white)
105
+ expect(nxt.en_passant).to eq('e6')
106
+ expect(nxt.fullmove).to eq(2)
107
+ end
108
+
109
+ it 'propagates castling restrictions' do
110
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
111
+ nxt = PGN::FEN.new(fen).to_position.move('O-O')
112
+ expect(nxt.castling.sort).to eq(%w[k q])
113
+ end
114
+ end
115
+
116
+ describe '#to_fen' do
117
+ it 'round-trips the start position to FEN::INITIAL' do
118
+ expect(PGN::Position.start.to_fen.to_s).to eq(PGN::FEN::INITIAL)
119
+ end
120
+
121
+ it 'round-trips an arbitrary position through FEN' do
122
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
123
+ parsed = PGN::FEN.new(fen).to_position
124
+ expect(parsed.to_fen.to_s).to eq(fen)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+
3
+ describe PGN::Serializer do
4
+ describe '#to_s' do
5
+ it 'serializes a simple game with tags and result' do
6
+ game = PGN::Game.new(%w[e4 e5], { 'White' => 'A', 'Black' => 'B' }, '1-0')
7
+ expected = "[White \"A\"]\n[Black \"B\"]\n\n1. e4 e5 1-0\n"
8
+ expect(PGN::Serializer.new(game).to_s).to eq(expected)
9
+ end
10
+
11
+ it 'synthesizes a Result tag when there are no tags' do
12
+ game = PGN::Game.new(%w[e4 e5])
13
+ expected = "[Result \"*\"]\n\n1. e4 e5 *\n"
14
+ expect(PGN::Serializer.new(game).to_s).to eq(expected)
15
+ end
16
+
17
+ it 'serializes an empty game as just the result' do
18
+ game = PGN::Game.new([], nil, '*')
19
+ expected = "[Result \"*\"]\n\n*\n"
20
+ expect(PGN::Serializer.new(game).to_s).to eq(expected)
21
+ end
22
+
23
+ it 'emits * when there is no result' do
24
+ game = PGN::Game.new(%w[e4 e5], { 'White' => 'A' })
25
+ expect(PGN::Serializer.new(game).to_s).to end_with("1. e4 e5 *\n")
26
+ end
27
+
28
+ it 'escapes backslashes and quotes in tag values' do
29
+ game = PGN::Game.new(%w[e4], { 'White' => 'A "B" \\C' }, '1-0')
30
+ expect(PGN::Serializer.new(game).to_s).to start_with("[White \"A \\\"B\\\" \\\\C\"]\n")
31
+ end
32
+
33
+ it 'serializes castling as O-O / O-O-O' do
34
+ game = PGN::Game.new(%w[O-O O-O-O])
35
+ expect(PGN::Serializer.new(game).to_s).to eq("[Result \"*\"]\n\n1. O-O O-O-O *\n")
36
+ end
37
+
38
+ it 'emits a single annotation after the notation' do
39
+ moves = [PGN::MoveText.new('e4', ['$4'])]
40
+ game = PGN::Game.new(moves, { 'White' => 'A' }, '1-0')
41
+ expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. e4 $4 1-0\n")
42
+ end
43
+
44
+ it 'emits symbolic annotations verbatim' do
45
+ moves = [PGN::MoveText.new('e4', ['??'])]
46
+ game = PGN::Game.new(moves, { 'White' => 'A' }, '1-0')
47
+ expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. e4 ?? 1-0\n")
48
+ end
49
+
50
+ it 'emits multiple annotations in order' do
51
+ moves = [PGN::MoveText.new('d4'), PGN::MoveText.new('d5', ['$2', '$11'])]
52
+ game = PGN::Game.new(moves, { 'White' => 'A' }, '1/2-1/2')
53
+ expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. d4 d5 $2 $11 1/2-1/2\n")
54
+ end
55
+
56
+ it 'wraps move comments in braces with spaces' do
57
+ moves = [PGN::MoveText.new('e4', nil, 'good move')]
58
+ game = PGN::Game.new(moves, { 'White' => 'A' }, '1-0')
59
+ expect(PGN::Serializer.new(game).to_s).to eq("[White \"A\"]\n\n1. e4 { good move } 1-0\n")
60
+ end
61
+
62
+ it 'reproduces the variations.pgn movetext shape, including 2... after variations' do
63
+ game = PGN.parse(File.read('./spec/pgn_files/variations.pgn')).first
64
+ expected = "[Black \"Petrov\"]\n[White \"Somebody\"]\n\n" \
65
+ "1. e4 e5 2. Nf3 { comment } " \
66
+ "(2. f4 exf4 { final variation }) " \
67
+ "(2. Nc3 { other } 2... d5 (2... f5) 3. exd5) " \
68
+ "2... Nf6 *\n"
69
+ expect(PGN::Serializer.new(game).to_s).to eq(expected)
70
+ end
71
+
72
+ it 'emits a game comment as the first movetext token' do
73
+ game = PGN::Game.new([], nil, '*', nil, 'game comment')
74
+ expect(PGN::Serializer.new(game).to_s).to eq("[Result \"*\"]\n\n{ game comment } *\n")
75
+ end
76
+
77
+ it 'numbers the first move 1... when starting from a FEN with black to move' do
78
+ fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1'
79
+ game = PGN::Game.new(%w[e5], { 'FEN' => fen }, '*')
80
+ expected = "[FEN \"#{fen}\"]\n\n1... e5 *\n"
81
+ expect(PGN::Serializer.new(game).to_s).to eq(expected)
82
+ end
83
+
84
+ it 'serializes -- moves verbatim and alternates color/fullmove' do
85
+ game = PGN::Game.new(%w[-- e4])
86
+ expect(PGN::Serializer.new(game).to_s).to eq("[Result \"*\"]\n\n1. -- e4 *\n")
87
+ end
88
+ end
89
+ end
data/spec/spec_helper.rb CHANGED
@@ -7,7 +7,6 @@ require 'pgn'
7
7
  #
8
8
  # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
9
9
  RSpec.configure do |config|
10
- config.treat_symbols_as_metadata_keys_with_true_values = true
11
10
  config.run_all_when_everything_filtered = true
12
11
  config.filter_run :focus
13
12