pgn2 2.0.0 → 2.0.1

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.
@@ -8,7 +8,7 @@ require 'racc/parser.rb'
8
8
  module PGN
9
9
  class PgnParser < Racc::Parser
10
10
 
11
- module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
11
+ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 96)
12
12
 
13
13
  def parse(input)
14
14
  @lexer = PGN::Lexer.new(input)
@@ -71,37 +71,37 @@ racc_action_table = [
71
71
  21, 25, 31 ]
72
72
 
73
73
  racc_action_check = [
74
- 1, 28, 2, 28, 17, 28, 5, 17, 6, 1,
75
- 28, 9, 9, 9, 24, 9, 11, 24, 15, 18,
76
- 20, 23, 29 ]
74
+ 1, 28, 2, 28, 17, 28, 4, 17, 6, 1,
75
+ 28, 10, 10, 10, 24, 10, 11, 24, 15, 18,
76
+ 19, 23, 29 ]
77
77
 
78
78
  racc_action_pointer = [
79
- nil, 0, 2, nil, nil, -3, 0, nil, nil, 8,
80
- nil, 14, nil, nil, nil, 7, nil, 1, 9, nil,
81
- 9, nil, nil, 15, 11, nil, nil, nil, -2, 16,
79
+ nil, 0, 2, nil, -3, nil, 0, nil, nil, nil,
80
+ 8, 14, nil, nil, nil, 7, nil, 1, 9, 9,
81
+ nil, nil, nil, 15, 11, nil, nil, nil, -2, 16,
82
82
  nil, nil, nil ]
83
83
 
84
84
  racc_action_default = [
85
- -1, -25, -25, -2, -8, -4, -25, 33, -3, -25,
86
- -5, -25, -7, -9, -10, -11, -13, -14, -25, -12,
85
+ -1, -25, -25, -2, -8, -4, -25, 33, -3, -5,
86
+ -25, -25, -7, -9, -10, -11, -13, -14, -25, -12,
87
87
  -22, -8, -15, -16, -17, -20, -6, -23, -25, -19,
88
88
  -18, -21, -24 ]
89
89
 
90
90
  racc_goto_table = [
91
- 9, 24, 4, 19, 1, 3, 10, 29, 27, 8,
92
- 22, nil, nil, nil, nil, nil, nil, 28 ]
91
+ 10, 24, 20, 1, 3, 5, 27, 29, 9, 4,
92
+ 8, 19, 22, nil, nil, nil, nil, 28 ]
93
93
 
94
94
  racc_goto_check = [
95
- 6, 11, 3, 9, 1, 2, 3, 11, 9, 4,
96
- 10, nil, nil, nil, nil, nil, nil, 6 ]
95
+ 6, 11, 12, 1, 2, 5, 12, 11, 5, 3,
96
+ 4, 9, 10, nil, nil, nil, nil, 6 ]
97
97
 
98
98
  racc_goto_pointer = [
99
- nil, 4, 4, 1, 5, nil, -4, nil, nil, -12,
100
- -7, -16, nil ]
99
+ nil, 3, 3, 8, 6, 4, -4, nil, nil, -4,
100
+ -5, -16, -13 ]
101
101
 
102
102
  racc_goto_default = [
103
- nil, nil, nil, nil, nil, 5, nil, 13, 15, nil,
104
- nil, nil, 20 ]
103
+ nil, nil, nil, nil, nil, nil, nil, 13, 15, nil,
104
+ nil, nil, nil ]
105
105
 
106
106
  racc_reduce_table = [
107
107
  0, 0, :racc_error,
@@ -223,7 +223,7 @@ module_eval(<<'.,.,', 'pgn_parser.y', 15)
223
223
  def _reduce_3(val, _values, result)
224
224
  result = val[1].pop
225
225
  result = {
226
- tags: val[0],
226
+ tags: val[0].reverse_each.with_object({}) { |pair, h| h.merge!(pair) },
227
227
  result: result,
228
228
  moves: val[1],
229
229
  pgn: nil,
@@ -236,18 +236,18 @@ module_eval(<<'.,.,', 'pgn_parser.y', 15)
236
236
 
237
237
  module_eval(<<'.,.,', 'pgn_parser.y', 26)
238
238
  def _reduce_4(val, _values, result)
239
- result = val[0]
239
+ result = [val[0]]
240
240
  result
241
241
  end
242
242
  .,.,
243
243
 
244
244
  module_eval(<<'.,.,', 'pgn_parser.y', 29)
245
245
  def _reduce_5(val, _values, result)
246
- # Right-recursive with section.merge(pair) reproduces the legacy
247
- # whittle parser's tag semantics exactly: reverse source insertion
248
- # order, and first-occurrence-wins on duplicate keys. This keeps
249
- # serialized tag order byte-compatible with the legacy behavior.
250
- result = val[1].merge(val[0])
246
+ # Left-recursive; the source-order array is reduced to the legacy
247
+ # tag semantics in {pgn_game} (reverse, then merge with
248
+ # first-occurrence-wins) so the whittle-order compatibility quirk is
249
+ # a single explicit line instead of implicit in recursion direction.
250
+ result = val[0] << val[1]
251
251
 
252
252
  result
253
253
  end
@@ -294,7 +294,7 @@ module_eval(<<'.,.,', 'pgn_parser.y', 50)
294
294
  module_eval(<<'.,.,', 'pgn_parser.y', 54)
295
295
  def _reduce_12(val, _values, result)
296
296
  result = val[0]
297
- result.variations = val[1]
297
+ result.variations = val[1].reverse
298
298
  result
299
299
 
300
300
  result
@@ -377,17 +377,16 @@ module_eval(<<'.,.,', 'pgn_parser.y', 82)
377
377
 
378
378
  module_eval(<<'.,.,', 'pgn_parser.y', 85)
379
379
  def _reduce_23(val, _values, result)
380
- # Right-recursive prepend reproduces the legacy whittle parser's
381
- # variation-order reversal on every parse. This keeps parsed-game
382
- # serialization byte-compatible with the legacy behavior; the quirk
383
- # can be fixed in a separate change.
384
- result = val[1] << val[0]
380
+ # Left-recursive; the legacy variation-order reversal is applied as
381
+ # a single explicit `.reverse` where the list is consumed in
382
+ # {element}, instead of being implicit in recursion direction.
383
+ result = val[0] << val[1]
385
384
 
386
385
  result
387
386
  end
388
387
  .,.,
389
388
 
390
- module_eval(<<'.,.,', 'pgn_parser.y', 93)
389
+ module_eval(<<'.,.,', 'pgn_parser.y', 92)
391
390
  def _reduce_24(val, _values, result)
392
391
  result = val[1]
393
392
  result
data/lib/pgn/pgn_parser.y CHANGED
@@ -15,7 +15,7 @@ rule
15
15
  {
16
16
  result = val[1].pop
17
17
  result = {
18
- tags: val[0],
18
+ tags: val[0].reverse_each.with_object({}) { |pair, h| h.merge!(pair) },
19
19
  result: result,
20
20
  moves: val[1],
21
21
  pgn: nil,
@@ -24,14 +24,14 @@ rule
24
24
  }
25
25
 
26
26
  tag_section:
27
- tag_pair { result = val[0] }
28
- | tag_pair tag_section
27
+ tag_pair { result = [val[0]] }
28
+ | tag_section tag_pair
29
29
  {
30
- # Right-recursive with section.merge(pair) reproduces the legacy
31
- # whittle parser's tag semantics exactly: reverse source insertion
32
- # order, and first-occurrence-wins on duplicate keys. This keeps
33
- # serialized tag order byte-compatible with the legacy behavior.
34
- result = val[1].merge(val[0])
30
+ # Left-recursive; the source-order array is reduced to the legacy
31
+ # tag semantics in {pgn_game} (reverse, then merge with
32
+ # first-occurrence-wins) so the whittle-order compatibility quirk is
33
+ # a single explicit line instead of implicit in recursion direction.
34
+ result = val[0] << val[1]
35
35
  }
36
36
 
37
37
  tag_pair:
@@ -53,7 +53,7 @@ rule
53
53
  | san_move_annotated variation_list
54
54
  {
55
55
  result = val[0]
56
- result.variations = val[1]
56
+ result.variations = val[1].reverse
57
57
  result
58
58
  }
59
59
  | COMMENT
@@ -81,13 +81,12 @@ rule
81
81
 
82
82
  variation_list:
83
83
  variation { result = [val[0]] }
84
- | variation variation_list
84
+ | variation_list variation
85
85
  {
86
- # Right-recursive prepend reproduces the legacy whittle parser's
87
- # variation-order reversal on every parse. This keeps parsed-game
88
- # serialization byte-compatible with the legacy behavior; the quirk
89
- # can be fixed in a separate change.
90
- result = val[1] << val[0]
86
+ # Left-recursive; the legacy variation-order reversal is applied as
87
+ # a single explicit `.reverse` where the list is consumed in
88
+ # {element}, instead of being implicit in recursion direction.
89
+ result = val[0] << val[1]
91
90
  }
92
91
 
93
92
  variation:
data/lib/pgn/position.rb CHANGED
@@ -124,10 +124,131 @@ module PGN
124
124
  PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
125
125
  end
126
126
 
127
+ # All legal moves from this position as sorted SAN strings, computed
128
+ # by delegating the native engine's UCI move list through
129
+ # {PGN::Notation.san}. Requires the compiled native extension; raises
130
+ # NameError if it is absent.
131
+ #
132
+ # @return [Array<String>] sorted lexicographically
133
+ #
134
+ def legal_moves_san
135
+ engine = PGN::Bitboard::Engine.new(to_fen.to_s)
136
+ engine.legal_moves.map { |uci| uci_to_san(uci) }.sort
137
+ end
138
+
139
+ # Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q",
140
+ # "Qxf7#") or UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the
141
+ # compiled native extension; raises NameError if it is absent.
142
+ #
143
+ # UCI is handed straight to the engine. SAN is resolved against the
144
+ # engine's legal move list: a SAN string is legal only when it points
145
+ # at exactly one legal move (so an ambiguous "Nd2" with two knights
146
+ # is rejected, while "Nbd2"/"Nfd2" are accepted). The engine remains
147
+ # the single source of truth for legality (king safety, pins, etc.).
148
+ #
149
+ # @param move [String] SAN or UCI
150
+ # @return [Boolean]
151
+ def legal?(move)
152
+ engine = PGN::Bitboard::Engine.new(to_fen.to_s)
153
+ return engine.legal?(move) if uci?(move)
154
+
155
+ parsed = PGN::Move.new(move, player)
156
+ return false if parsed.destination.nil? && parsed.castle.nil?
157
+
158
+ candidates = engine.legal_moves.select { |uci| matches_san?(uci, parsed) }
159
+ candidates.size == 1
160
+ rescue StandardError
161
+ false
162
+ end
163
+
127
164
  def inspect
128
165
  "\n#{board.inspect}"
129
166
  end
130
167
 
168
+ # The color string for the side to move ('w'/'b').
169
+ def mover_color
170
+ player == :white ? 'w' : 'b'
171
+ end
172
+
173
+ # The color string for the opponent of the side to move ('w'/'b').
174
+ def opponent_color
175
+ player == :white ? 'b' : 'w'
176
+ end
177
+
178
+ # Whether the side to move's king is currently in check.
179
+ #
180
+ # @return [Boolean]
181
+ def in_check?
182
+ king = PGN::Attack.king_idx(board, mover_color)
183
+ !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
184
+ end
185
+
186
+ # The algebraic squares of every piece of the given color that attacks
187
+ # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's
188
+ # opponent, which is useful for check detection.
189
+ #
190
+ # @param square [String] e.g. "e4"
191
+ # @param color [String, nil] 'w' or 'b'; defaults to the opponent color
192
+ # @return [Array<String>] squares attacking +square+
193
+ def attackers(square, color = opponent_color)
194
+ PGN::Attack.attackers(board, board.index_of(square), color)
195
+ end
196
+
197
+ # Whether the side to move has been checkmated (in check and no legal
198
+ # move). Requires the native extension for legal-move enumeration.
199
+ #
200
+ # @return [Boolean]
201
+ def checkmate?
202
+ in_check? && legal_moves.empty?
203
+ end
204
+
205
+ # Whether the side to move has been stalemated (not in check and no
206
+ # legal move). Requires the native extension.
207
+ #
208
+ # @return [Boolean]
209
+ def stalemate?
210
+ !in_check? && legal_moves.empty?
211
+ end
212
+
213
+ # Whether the position has insufficient material to mate. Covers K vs K,
214
+ # K + one minor vs K, and same-colored bishops only.
215
+ #
216
+ # @return [Boolean]
217
+ def insufficient_material?
218
+ non_king = (0...128).each_with_object([]) do |idx, acc|
219
+ next if idx.anybits?(0x88)
220
+
221
+ piece = board.at_index(idx)
222
+ acc << [piece, idx] if piece && piece.upcase != 'K'
223
+ end
224
+ return true if non_king.empty?
225
+ return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase)
226
+
227
+ bishops_same_color?(non_king)
228
+ end
229
+
230
+ # Whether the 50-move rule applies (100 halfmoves since the last pawn
231
+ # move or capture).
232
+ #
233
+ # @return [Boolean]
234
+ def fifty_move?
235
+ halfmove >= 100
236
+ end
237
+
238
+ # The terminal status of this position: :checkmate, :stalemate, or :draw
239
+ # (insufficient material or 50-move rule). nil if the position is still
240
+ # in progress. Threefold repetition requires game history and is
241
+ # answered by {PGN::Game#outcome}.
242
+ #
243
+ # @return [Symbol, nil]
244
+ def outcome
245
+ return :checkmate if checkmate?
246
+ return :stalemate if stalemate?
247
+ return :draw if insufficient_material? || fifty_move?
248
+
249
+ nil
250
+ end
251
+
131
252
  # @return [PGN::FEN] a {PGN::FEN} object representing the current position
132
253
  #
133
254
  def to_fen
@@ -167,5 +288,75 @@ module PGN
167
288
  def zobrist
168
289
  @zobrist ||= Zobrist.seed(board, player, castling, en_passant)
169
290
  end
291
+
292
+ private
293
+
294
+ # True when +move+ looks like a UCI coordinate string ("e2e4",
295
+ # "e1g1", "a7a8q"), so it can be handed straight to the engine.
296
+ def uci?(move)
297
+ move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/)
298
+ end
299
+
300
+ # Convert a UCI string to SAN using the current position, for
301
+ # {#legal_moves_san}. Promotion (if present) is passed as the letter.
302
+ def uci_to_san(uci)
303
+ from = uci[0, 2]
304
+ to = uci[2, 2]
305
+ promo = uci[4]
306
+ PGN::Notation.san(self, from, to, promo)
307
+ end
308
+
309
+ # Map a castling side letter from {PGN::Move#castle} to the king's
310
+ # from/to UCI squares for the side to move.
311
+ CASTLE_UCI = {
312
+ 'K' => 'e1g1',
313
+ 'Q' => 'e1c1',
314
+ 'k' => 'e8g8',
315
+ 'q' => 'e8c8'
316
+ }.freeze
317
+ private_constant :CASTLE_UCI
318
+
319
+ # Does the legal UCI move +uci+ match the parsed SAN +move+? A move
320
+ # matches when destination, piece, promotion, and castling all agree,
321
+ # and the origin square satisfies +move+'s disambiguation (if any).
322
+ def matches_san?(uci, move)
323
+ return uci == CASTLE_UCI[move.castle] if move.castle
324
+
325
+ to = uci[2, 2]
326
+ return false if move.destination != to
327
+
328
+ from_idx = board.index_of(uci[0, 2])
329
+ return false if board.at_index(from_idx) != move.piece
330
+
331
+ promo = uci[4]
332
+ return false if move.promotion&.downcase != promo
333
+
334
+ disambiguation_matches?(move.disambiguation, from_idx)
335
+ end
336
+
337
+ # Whether the disambiguation string from SAN (a file, a rank, or a full
338
+ # square) describes the origin square at +from_idx+. nil disambiguation
339
+ # matches any origin (ambiguity is handled by the caller counting matches).
340
+ def disambiguation_matches?(disambiguation, from_idx)
341
+ return true if disambiguation.nil? || disambiguation.empty?
342
+
343
+ file = Board::INDEX_TO_FILE[from_idx & 0x0F]
344
+ rank = Board::INDEX_TO_RANK[from_idx >> 4]
345
+ case disambiguation
346
+ when /\A[a-h]\z/ then file == disambiguation
347
+ when /\A[1-8]\z/ then rank == disambiguation
348
+ else file + rank == disambiguation
349
+ end
350
+ end
351
+
352
+ # Whether +non_king+ is all bishops on squares of the same color (a known
353
+ # insufficient-material draw, e.g. KB vs KB with same-colored bishops).
354
+ def bishops_same_color?(non_king)
355
+ return false unless non_king.all? { |piece, _| piece.upcase == 'B' }
356
+ return false if non_king.size > 2
357
+
358
+ colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 }
359
+ colors.uniq.size == 1
360
+ end
170
361
  end
171
362
  end
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '2.0.0'.freeze
2
+ VERSION = '2.0.1'.freeze
3
3
  end
data/lib/pgn.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  require 'pgn/board'
2
2
  require 'pgn/fen'
3
+ require 'pgn/epd'
3
4
  require 'pgn/game'
5
+ require 'pgn/node'
4
6
  require 'pgn/move'
5
7
  require 'pgn/move_calculator'
6
8
  require 'pgn/lexer'
7
9
  require 'pgn/notation'
10
+ require 'pgn/attack'
8
11
  require 'pgn/zobrist'
9
12
  require 'pgn/pgn_parser'
10
13
  require 'pgn/parser'
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'UCI-style castling normalization' do
6
+ let(:fen) { 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1' }
7
+ let(:pos) { PGN::FEN.new(fen).to_position }
8
+
9
+ it 'parses 0-0 / 0-0-0 the same as O-O / O-O-O in PGN::Move' do
10
+ expect(PGN::Move.new('0-0', :white).castle).to eq('K')
11
+ expect(PGN::Move.new('0-0-0', :white).castle).to eq('Q')
12
+ expect(PGN::Move.new('0-0', :black).castle).to eq('k')
13
+ expect(PGN::Move.new('0-0-0', :black).castle).to eq('q')
14
+ expect(PGN::Move.new('0-0', :white).piece).to be_nil
15
+ end
16
+
17
+ it 'normalizes parsed 0-0 movetext to O-O on a game' do
18
+ game = PGN::Game.new(%w[0-0])
19
+ expect(game.moves.first.notation).to eq('O-O')
20
+ end
21
+
22
+ it 'Notation.san produces O-O / O-O-O for the king two-file moves' do
23
+ expect(PGN::Notation.san(pos, 'e1', 'g1')).to eq('O-O')
24
+ expect(PGN::Notation.san(pos, 'e1', 'c1')).to eq('O-O-O')
25
+ end
26
+
27
+ it 'Position#legal? accepts both O-O and UCI e1g1 castling' do
28
+ expect(pos.legal?('O-O')).to be(true)
29
+ expect(pos.legal?('O-O-O')).to be(true)
30
+ expect(pos.legal?('e1g1')).to be(true)
31
+ expect(pos.legal?('e1c1')).to be(true)
32
+ end
33
+
34
+ it 'serializes castling as O-O, never 0-0' do
35
+ game = PGN::Game.new(%w[e4 e5 O-O])
36
+ expect(game.to_pgn).to include('O-O')
37
+ expect(game.to_pgn).not_to include('0-0')
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'comment round-tripping' do
6
+ it 'round-trips nested braces through parse -> serialize -> parse' do
7
+ input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
8
+ g1 = PGN.parse(input, Encoding::UTF_8).first
9
+ g2 = PGN.parse(g1.to_pgn, Encoding::UTF_8).first
10
+ expect(g2.moves.first.comment).to eq(g1.moves.first.comment)
11
+ expect(g2.moves.first.comment).to eq('this {is a} nested {comment}')
12
+ end
13
+
14
+ it 'is stable: serialize -> parse -> serialize is idempotent' do
15
+ input = +"[White \"A\"]\n\n1. e4 {this {is a} nested {comment}} *\n"
16
+ once = PGN.parse(input, Encoding::UTF_8).first.to_pgn
17
+ twice = PGN.parse(once, Encoding::UTF_8).first.to_pgn
18
+ expect(twice).to eq(once)
19
+ end
20
+
21
+ it 'round-trips escaped braces and backslashes' do
22
+ input = +"[White \"A\"]\n\n1. e4 {a \\{literal\\} back\\\\slash} *\n"
23
+ g = PGN.parse(input, Encoding::UTF_8).first
24
+ expect(g.moves.first.comment).to eq('a {literal} back\\slash')
25
+ reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
26
+ expect(reparsed.moves.first.comment).to eq(g.moves.first.comment)
27
+ end
28
+
29
+ it 'round-trips the nested_comments fixture comment body' do
30
+ input = File.binread('spec/pgn_files/nested_comments.pgn')
31
+ g = PGN.parse(input, Encoding::UTF_8).first
32
+ reparsed = PGN.parse(g.to_pgn, Encoding::UTF_8).first
33
+ expect(reparsed.moves.map(&:comment)).to eq(g.moves.map(&:comment))
34
+ end
35
+ end
data/spec/epd_spec.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::EPD do
6
+ let(:start_epd) { 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -' }
7
+
8
+ it 'parses the placement/side/castling/ep fields' do
9
+ epd = PGN::EPD.new(start_epd)
10
+ pos = epd.to_position
11
+ expect(pos.player).to eq(:white)
12
+ expect(pos.castling).to eq(%w[K Q k q])
13
+ expect(pos.en_passant).to be_nil
14
+ expect(pos.halfmove).to eq(0)
15
+ expect(pos.fullmove).to eq(1)
16
+ end
17
+
18
+ it 'round-trips a simple position' do
19
+ expect(PGN::EPD.new(start_epd).to_s).to eq(start_epd)
20
+ end
21
+
22
+ it 'preserves trailing operation fields verbatim' do
23
+ epd = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - bm e4; id "start";'
24
+ expect(PGN::EPD.new(epd).to_s).to eq(epd)
25
+ expect(PGN::EPD.new(epd).ops).to eq('bm e4; id "start";')
26
+ end
27
+
28
+ it 'normalizes nil castling/ep to "-"' do
29
+ epd = PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w')
30
+ expect(epd.castling).to eq('-')
31
+ expect(epd.en_passant).to eq('-')
32
+ end
33
+ end
34
+
35
+ RSpec.describe PGN::FEN, '#to_epd' do
36
+ it 'drops the move counters from the start position' do
37
+ expect(PGN::FEN.start.to_epd).to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
38
+ end
39
+
40
+ it 'preserves castling and en passant' do
41
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
42
+ expect(PGN::FEN.new(fen).to_epd).to eq('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6')
43
+ end
44
+ end
data/spec/fen_spec.rb CHANGED
@@ -47,6 +47,12 @@ describe PGN::FEN do
47
47
  next_pos = pos.move('d6')
48
48
  next_pos.to_fen.en_passant.should == '-'
49
49
  end
50
+
51
+ it 'preserves the en passant square through FEN#to_position' do
52
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
53
+ expect(PGN::FEN.new(fen).to_position.en_passant).to eq('d6')
54
+ expect(PGN::FEN.new(fen).to_position.to_fen.to_s).to eq(fen)
55
+ end
50
56
  end
51
57
 
52
58
  describe 'halfmove counter' do
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Game, 'mutable history' do
6
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
7
+
8
+ let(:game) { PGN::Game.new(%w[e4 e5]) }
9
+
10
+ it 'appends a move with #push and invalidates the position cache' do
11
+ expect(game.positions.length).to eq(3) # start, e4, e5
12
+ game.push('Nf3')
13
+ expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
14
+ expect(game.positions.length).to eq(4)
15
+ expect(game.current_position.board.at('f3')).to eq('N')
16
+ end
17
+
18
+ it 'rejects an illegal move with ArgumentError' do
19
+ expect { game.push('e5') }.to raise_error(ArgumentError, /illegal move/)
20
+ expect(game.moves.length).to eq(2) # unchanged
21
+ end
22
+
23
+ it 'removes and returns the last move with #pop' do
24
+ last = game.pop
25
+ expect(last.notation).to eq('e5')
26
+ expect(game.moves.map(&:notation)).to eq(%w[e4])
27
+ expect(game.positions.length).to eq(2)
28
+ end
29
+
30
+ it 'returns nil when popping an empty game' do
31
+ empty = PGN::Game.new([])
32
+ expect(empty.pop).to be_nil
33
+ end
34
+
35
+ it 'normalizes castling on push' do
36
+ game = PGN::Game.new(%w[e4 e5 Nf3 Nc6 Bc4 Bc5 O-O Nf6 d3])
37
+ game.push('0-0')
38
+ expect(game.moves.last.notation).to eq('O-O')
39
+ end
40
+
41
+ it 'lets a freshly pushed game reach a checkmate outcome' do
42
+ game = PGN::Game.new(%w[e4 e5 Bc4 Nc6 Qh5 Nf6])
43
+ game.push('Qxf7#')
44
+ expect(game.outcome).to eq(:checkmate)
45
+ end
46
+ end
data/spec/game_spec.rb CHANGED
@@ -129,4 +129,64 @@ describe PGN::Game do
129
129
  end
130
130
  end
131
131
  end
132
+
133
+ describe 'node mutation round-trip' do
134
+ non_round_trip = %w[doublequotes.pgn specialcharacters.pgn].freeze
135
+
136
+ # A node-sig tree with variations sorted, so the comparison is
137
+ # order-independent (the parser reverses variation order on each parse).
138
+ def node_sig(move)
139
+ [move.notation, (move.variations || []).map { line_sig(_1) }.sort]
140
+ end
141
+
142
+ def line_sig(line)
143
+ line.map { node_sig(_1) }
144
+ end
145
+
146
+ def tree_sig(game)
147
+ game.moves.map { node_sig(_1) }
148
+ end
149
+
150
+ # First node (DFS over children) that has at least one variation. Such a
151
+ # node always has a non-nil continuation, so add_variation can branch.
152
+ def first_branch_with_variations(node)
153
+ stack = [node]
154
+ until stack.empty?
155
+ n = stack.shift
156
+ return n unless n.variations.empty?
157
+
158
+ stack.concat(n.children)
159
+ end
160
+ nil
161
+ end
162
+
163
+ it 'every fixture survives promote/demote/add_variation and re-parses' do
164
+ fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
165
+ fixtures.reject! { |p| non_round_trip.include?(File.basename(p)) }
166
+ fixtures.each do |path|
167
+ PGN.parse(File.read(path, encoding: Encoding::ISO_8859_1)).each do |game|
168
+ root = game.root
169
+
170
+ # promote then demote the first variation if one exists
171
+ branch = first_branch_with_variations(root)
172
+ if branch && !branch.variations.empty?
173
+ branch.variations.first.promote
174
+ game.root
175
+ b2 = first_branch_with_variations(game.root)
176
+ if b2 && !b2.variations.empty?
177
+ b2.variations.first.demote
178
+ game.root
179
+ end
180
+ b3 = first_branch_with_variations(game.root)
181
+ b3&.add_variation('Nc3')
182
+ end
183
+
184
+ once = game.to_pgn
185
+ reparsed = PGN.parse(once).first
186
+ expect(tree_sig(reparsed)).to eq(tree_sig(game)),
187
+ "#{path}: mutated game tree must round-trip (set-stable)"
188
+ end
189
+ end
190
+ end
191
+ end
132
192
  end