pgn2 2.0.0 → 2.0.2

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.
data/lib/pgn/notation.rb CHANGED
@@ -145,8 +145,8 @@ module PGN
145
145
 
146
146
  def suffix(from_idx, to_idx, piece, promotion, castling)
147
147
  nb = apply_move(from_idx, to_idx, piece, promotion, castling)
148
- opp_king = king_idx(nb, @enemy)
149
- return '' unless opp_king && attacked?(nb, opp_king, @mover)
148
+ opp_king = PGN::Attack.king_idx(nb, @enemy)
149
+ return '' unless opp_king && PGN::Attack.attacked?(nb, opp_king, @mover)
150
150
 
151
151
  result_pos = PGN::Position.new(
152
152
  nb,
@@ -219,8 +219,8 @@ module PGN
219
219
  nb.update_index(ep, nil) if ep
220
220
  nb.update_index(from_idx, nil)
221
221
  nb.update_index(to_idx, piece)
222
- king = king_idx(nb, @mover)
223
- king && !attacked?(nb, king, @enemy)
222
+ king = PGN::Attack.king_idx(nb, @mover)
223
+ king && !PGN::Attack.attacked?(nb, king, @enemy)
224
224
  end
225
225
 
226
226
  # Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
@@ -278,66 +278,8 @@ module PGN
278
278
 
279
279
  # -- attack / legality helpers ----------------------------------------
280
280
 
281
- def king_idx(board, color)
282
- king = color == 'w' ? 'K' : 'k'
283
- (0...128).each do |idx|
284
- next if idx.anybits?(0x88)
285
-
286
- return idx if board.at_index(idx) == king
287
- end
288
- nil
289
- end
290
-
291
- # Is `target` attacked by `color`-colored pieces on `board`?
292
- def attacked?(board, target, color)
293
- pawn_attacked?(board, target, color) ||
294
- knight_attacked?(board, target, color) ||
295
- king_attacked?(board, target, color) ||
296
- slider_attacked?(board, target, color)
297
- end
298
-
299
- def pawn_attacked?(board, target, color)
300
- offs = color == 'w' ? [-15, -17] : [15, 17]
301
- pawn = color == 'w' ? 'P' : 'p'
302
- offs.any? do |off|
303
- i = target + off
304
- i.nobits?(0x88) && board.at_index(i) == pawn
305
- end
306
- end
307
-
308
- def knight_attacked?(board, target, color)
309
- knight = color == 'w' ? 'N' : 'n'
310
- Board::KNIGHT_ATTACKS[target].any? { |i| board.at_index(i) == knight }
311
- end
312
-
313
- def king_attacked?(board, target, color)
314
- king = color == 'w' ? 'K' : 'k'
315
- Board::KING_ATTACKS[target].any? { |i| board.at_index(i) == king }
316
- end
317
-
318
- def slider_attacked?(board, target, color)
319
- bishop = color == 'w' ? 'B' : 'b'
320
- rook = color == 'w' ? 'R' : 'r'
321
- queen = color == 'w' ? 'Q' : 'q'
322
- ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
323
- ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
324
- end
325
-
326
- def ray_hit?(board, target, dirs)
327
- dirs.each do |off|
328
- i = target + off
329
- while i.nobits?(0x88)
330
- piece = board.at_index(i)
331
- if piece
332
- return true if yield(piece)
333
-
334
- break
335
- end
336
- i += off
337
- end
338
- end
339
- false
340
- end
281
+ # (Attack detection is delegated to {PGN::Attack}, the shared source of
282
+ # truth used by {PGN::Position#in_check?} and {PGN::Position#attackers}.)
341
283
 
342
284
  # -- legal-move generation (for checkmate detection) ------------------
343
285
 
@@ -454,8 +396,8 @@ module PGN
454
396
  nb.update_index(from_idx, nil)
455
397
  nb.update_index(to_idx, place_piece(piece, promotion))
456
398
  color = own?(piece, 'w') ? 'w' : 'b'
457
- king = king_idx(nb, color)
458
- king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
399
+ king = PGN::Attack.king_idx(nb, color)
400
+ king && !PGN::Attack.attacked?(nb, king, color == 'w' ? 'b' : 'w')
459
401
  end
460
402
 
461
403
  # The piece letter to place on the destination after a pseudo-move.
@@ -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,133 @@ 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
+ return @in_check if defined?(@in_check)
183
+
184
+ king = PGN::Attack.king_idx(board, mover_color)
185
+ @in_check = !king.nil? && PGN::Attack.attacked?(board, king, opponent_color)
186
+ end
187
+
188
+ # The algebraic squares of every piece of the given color that attacks
189
+ # +square+ (algebraic, e.g. "e4"). Defaults to the side to move's
190
+ # opponent, which is useful for check detection.
191
+ #
192
+ # @param square [String] e.g. "e4"
193
+ # @param color [String, nil] 'w' or 'b'; defaults to the opponent color
194
+ # @return [Array<String>] squares attacking +square+
195
+ def attackers(square, color = opponent_color)
196
+ PGN::Attack.attackers(board, board.index_of(square), color)
197
+ end
198
+
199
+ # Whether the side to move has been checkmated (in check and no legal
200
+ # move). Requires the native extension for legal-move enumeration.
201
+ #
202
+ # @return [Boolean]
203
+ def checkmate?
204
+ in_check? && legal_moves.empty?
205
+ end
206
+
207
+ # Whether the side to move has been stalemated (not in check and no
208
+ # legal move). Requires the native extension.
209
+ #
210
+ # @return [Boolean]
211
+ def stalemate?
212
+ !in_check? && legal_moves.empty?
213
+ end
214
+
215
+ # Whether the position has insufficient material to mate. Covers K vs K,
216
+ # K + one minor vs K, and same-colored bishops only.
217
+ #
218
+ # @return [Boolean]
219
+ def insufficient_material?
220
+ non_king = (0...128).each_with_object([]) do |idx, acc|
221
+ next if idx.anybits?(0x88)
222
+
223
+ piece = board.at_index(idx)
224
+ acc << [piece, idx] if piece && piece.upcase != 'K'
225
+ end
226
+ return true if non_king.empty?
227
+ return true if non_king.size == 1 && %w[B N].include?(non_king.first.first.upcase)
228
+
229
+ bishops_same_color?(non_king)
230
+ end
231
+
232
+ # Whether the 50-move rule applies (100 halfmoves since the last pawn
233
+ # move or capture).
234
+ #
235
+ # @return [Boolean]
236
+ def fifty_move?
237
+ halfmove >= 100
238
+ end
239
+
240
+ # The terminal status of this position: :checkmate, :stalemate, or :draw
241
+ # (insufficient material or 50-move rule). nil if the position is still
242
+ # in progress. Threefold repetition requires game history and is
243
+ # answered by {PGN::Game#outcome}.
244
+ #
245
+ # @return [Symbol, nil]
246
+ def outcome
247
+ return :checkmate if checkmate?
248
+ return :stalemate if stalemate?
249
+ return :draw if insufficient_material? || fifty_move?
250
+
251
+ nil
252
+ end
253
+
131
254
  # @return [PGN::FEN] a {PGN::FEN} object representing the current position
132
255
  #
133
256
  def to_fen
@@ -167,5 +290,75 @@ module PGN
167
290
  def zobrist
168
291
  @zobrist ||= Zobrist.seed(board, player, castling, en_passant)
169
292
  end
293
+
294
+ private
295
+
296
+ # True when +move+ looks like a UCI coordinate string ("e2e4",
297
+ # "e1g1", "a7a8q"), so it can be handed straight to the engine.
298
+ def uci?(move)
299
+ move.is_a?(String) && move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/)
300
+ end
301
+
302
+ # Convert a UCI string to SAN using the current position, for
303
+ # {#legal_moves_san}. Promotion (if present) is passed as the letter.
304
+ def uci_to_san(uci)
305
+ from = uci[0, 2]
306
+ to = uci[2, 2]
307
+ promo = uci[4]
308
+ PGN::Notation.san(self, from, to, promo)
309
+ end
310
+
311
+ # Map a castling side letter from {PGN::Move#castle} to the king's
312
+ # from/to UCI squares for the side to move.
313
+ CASTLE_UCI = {
314
+ 'K' => 'e1g1',
315
+ 'Q' => 'e1c1',
316
+ 'k' => 'e8g8',
317
+ 'q' => 'e8c8'
318
+ }.freeze
319
+ private_constant :CASTLE_UCI
320
+
321
+ # Does the legal UCI move +uci+ match the parsed SAN +move+? A move
322
+ # matches when destination, piece, promotion, and castling all agree,
323
+ # and the origin square satisfies +move+'s disambiguation (if any).
324
+ def matches_san?(uci, move)
325
+ return uci == CASTLE_UCI[move.castle] if move.castle
326
+
327
+ to = uci[2, 2]
328
+ return false if move.destination != to
329
+
330
+ from_idx = board.index_of(uci[0, 2])
331
+ return false if board.at_index(from_idx) != move.piece
332
+
333
+ promo = uci[4]
334
+ return false if move.promotion&.downcase != promo
335
+
336
+ disambiguation_matches?(move.disambiguation, from_idx)
337
+ end
338
+
339
+ # Whether the disambiguation string from SAN (a file, a rank, or a full
340
+ # square) describes the origin square at +from_idx+. nil disambiguation
341
+ # matches any origin (ambiguity is handled by the caller counting matches).
342
+ def disambiguation_matches?(disambiguation, from_idx)
343
+ return true if disambiguation.nil? || disambiguation.empty?
344
+
345
+ file = Board::INDEX_TO_FILE[from_idx & 0x0F]
346
+ rank = Board::INDEX_TO_RANK[from_idx >> 4]
347
+ case disambiguation
348
+ when /\A[a-h]\z/ then file == disambiguation
349
+ when /\A[1-8]\z/ then rank == disambiguation
350
+ else file + rank == disambiguation
351
+ end
352
+ end
353
+
354
+ # Whether +non_king+ is all bishops on squares of the same color (a known
355
+ # insufficient-material draw, e.g. KB vs KB with same-colored bishops).
356
+ def bishops_same_color?(non_king)
357
+ return false unless non_king.all? { |piece, _| piece.upcase == 'B' }
358
+ return false if non_king.size > 2
359
+
360
+ colors = non_king.map { |_piece, idx| ((idx & 0x0F) + (idx >> 4)) % 2 }
361
+ colors.uniq.size == 1
362
+ end
170
363
  end
171
364
  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.2'.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