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.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::MoveText, '#clean_text' do
6
+ it 'strips a single outermost brace pair' do
7
+ mt = PGN::MoveText.new('e4', nil, '{a comment}')
8
+ expect(mt.comment).to eq('a comment')
9
+ end
10
+
11
+ it 'is idempotent on a nested-brace comment' do
12
+ mt = PGN::MoveText.new('e4', nil, '{a {b} c}')
13
+ once = mt.comment
14
+ # Re-running clean_text (as MoveText.new would) must not strip the inner braces.
15
+ twice = PGN::MoveText.new('e4', nil, once).comment
16
+ expect(twice).to eq(once)
17
+ expect(twice).to eq('a {b} c')
18
+ end
19
+
20
+ it 'collapses whitespace and strips' do
21
+ mt = PGN::MoveText.new('e4', nil, "{ multi\nline }")
22
+ expect(mt.comment).to eq('multi line')
23
+ end
24
+
25
+ it 'returns nil for a nil comment' do
26
+ expect(PGN::MoveText.new('e4').comment).to be_nil
27
+ end
28
+ end
29
+
30
+ RSpec.describe PGN::Game, '#moves= with braced comments' do
31
+ it 'reuses a MoveText unchanged when no castling fix is needed' do
32
+ mt = PGN::MoveText.new('e4', nil, 'a {b} c')
33
+ game = PGN::Game.new([mt])
34
+ expect(game.moves.first).to be(mt)
35
+ expect(game.moves.first.comment).to eq('a {b} c')
36
+ end
37
+
38
+ it 'rebuilds without corrupting a nested-brace comment when fixing castling' do
39
+ mt = PGN::MoveText.new('0-0', nil, 'a {b} c')
40
+ game = PGN::Game.new([mt])
41
+ expect(game.moves.first.notation).to eq('O-O')
42
+ expect(game.moves.first.comment).to eq('a {b} c')
43
+ end
44
+ end
data/spec/node_spec.rb ADDED
@@ -0,0 +1,240 @@
1
+ require 'spec_helper'
2
+
3
+ # Helpers for order-independent variation comparison. The parser's
4
+ # `variation_list` rule is right-recursive, so a move's variations are stored
5
+ # in reverse of their PGN-text order; round-tripping flips that order each
6
+ # parse. We therefore compare variation trees as sorted signatures (the same
7
+ # approach the existing game_spec round-trip gate uses), and reference
8
+ # variations by notation rather than by index.
9
+ def node_sig(move)
10
+ [move.notation, (move.variations || []).map { line_sig(_1) }.sort]
11
+ end
12
+
13
+ def line_sig(line)
14
+ line.map { node_sig(_1) }
15
+ end
16
+
17
+ def tree_sig(game)
18
+ game.moves.map { node_sig(_1) }
19
+ end
20
+
21
+ describe PGN::Node do
22
+ let(:game) { PGN.parse(File.read(File.expand_path('pgn_files/variations.pgn', __dir__), encoding: Encoding::ISO_8859_1)).first }
23
+ let(:root) { game.root }
24
+
25
+ it 'root is a root node with no move' do
26
+ expect(root).to be_root
27
+ expect(root.move).to be_nil
28
+ expect(root.notation).to be_nil
29
+ expect(root.previous).to be_nil
30
+ end
31
+
32
+ it 'main_line matches game.moves notations (root excluded)' do
33
+ expect(root.main_line.map(&:notation)).to eq(game.moves.map(&:notation))
34
+ end
35
+
36
+ it 'main_line is an Enumerator without a block' do
37
+ expect(root.main_line).to be_an(Enumerator)
38
+ end
39
+
40
+ it 'children of the position after 1.e4 e5 are Nf3 (main), Nc3, f4 (any order)' do
41
+ node = root.next.next # after e4 (white), after e5 (black) => before Nf3
42
+ expect(node.children.map(&:notation)).to contain_exactly('Nf3', 'Nc3', 'f4')
43
+ expect(node.next.notation).to eq('Nf3') # mainline continuation is first
44
+ expect(node.variations.map(&:notation)).to contain_exactly('Nc3', 'f4')
45
+ end
46
+
47
+ it 'nested variation f5 is a sibling of d5 under the Nc3 node' do
48
+ nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
49
+ expect(nc3_node.children.map(&:notation)).to contain_exactly('d5', 'f5')
50
+ end
51
+
52
+ it 'previous walks back to the root' do
53
+ node = root.next.next.next # Nf3 node
54
+ expect(node.previous.notation).to eq('e5')
55
+ expect(node.previous.previous.notation).to eq('e4')
56
+ expect(node.previous.previous.previous).to be_root
57
+ end
58
+
59
+ describe '#position' do
60
+ it 'root position is the starting position' do
61
+ expect(root.position.to_fen.to_s).to eq(PGN::Position.start.to_fen.to_s)
62
+ end
63
+
64
+ it 'main_line positions match game.positions[1..]' do
65
+ expect(root.main_line.map { |n| n.position.to_fen.to_s })
66
+ .to eq(game.positions[1..].map { |p| p.to_fen.to_s })
67
+ end
68
+
69
+ it 'a variation node position is replayed from the root' do
70
+ # After 1.e4 e5 2.Nc3 (the Nc3 variation first move)
71
+ nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
72
+ expected = PGN::Position.start.move('e4').move('e5').move('Nc3')
73
+ expect(nc3_node.position.to_fen.to_s).to eq(expected.to_fen.to_s)
74
+ end
75
+
76
+ it 'is cached (returns the same object on second call)' do
77
+ node = root.next
78
+ expect(node.position).to equal(node.position)
79
+ end
80
+ end
81
+
82
+ describe '#add_variation' do
83
+ it 'appends a single-SAN variation branching before the next move' do
84
+ root.next.next.add_variation('Nc6')
85
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) })
86
+ .to include(['Nc6'], %w[Nc3 d5 exd5], %w[f4 exf4])
87
+ end
88
+
89
+ it 'appends a multi-move variation line' do
90
+ root.next.next.add_variation(%w[Nc6 Bc4])
91
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nc6 Bc4])
92
+ end
93
+
94
+ it 'normalizes castling 0 -> O' do
95
+ g = PGN::Game.new(%w[e4 e5 Nf3])
96
+ g.root.next.next.add_variation('0-0')
97
+ expect(g.moves[2].variations.map { |v| v.map(&:notation) }).to include(['O-O'])
98
+ end
99
+
100
+ it 'round-trips (variation set stable under re-parse)' do
101
+ root.next.next.add_variation(%w[Nc6 Bc4])
102
+ reparsed = PGN.parse(game.to_pgn).first
103
+ expect(tree_sig(reparsed)).to eq(tree_sig(game))
104
+ end
105
+ end
106
+
107
+ describe '#add_variation at a terminal node' do
108
+ it 'raises ArgumentError' do
109
+ g = PGN::Game.new(%w[e4 e5])
110
+ last = g.root.main_line.to_a.last
111
+ expect { last.add_variation('Nf6') }.to raise_error(ArgumentError)
112
+ end
113
+ end
114
+
115
+ describe '#add_main_variation' do
116
+ it 'extends the line at a terminal node' do
117
+ g = PGN::Game.new(%w[e4 e5])
118
+ g.root.main_line.to_a.last.add_main_variation('Nf3')
119
+ expect(g.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
120
+ end
121
+
122
+ it 'inserts a new mainline move and demotes the old continuation to a variation' do
123
+ node = root.next.next # position before Nf3
124
+ node.add_main_variation('Nc6')
125
+ expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc6])
126
+ # old mainline Nf3 + Nf6 is now a variation of Nc6
127
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
128
+ end
129
+ end
130
+
131
+ describe 'variation reordering' do
132
+ # Storage order of Nf3's variations is reverse of the PGN text
133
+ # (`[f4-line, Nc3-line]`) because the parser reverses variation order.
134
+ # Tests reference variations by notation, never by index.
135
+ let(:branch) { root.next.next }
136
+ let(:nf3) { game.moves[2] }
137
+
138
+ def find_variation(notation)
139
+ branch.children.find { |n| n.notation == notation }
140
+ end
141
+
142
+ it 'promote moves a variation one slot toward mainline' do
143
+ find_variation('Nc3').promote
144
+ # Nc3 was behind f4 in storage; now it is first (just after the mainline)
145
+ expect(nf3.variations.first.first.notation).to eq('Nc3')
146
+ end
147
+
148
+ it 'promote is a no-op for the first variation' do
149
+ find_variation('f4').promote # f4 is already the first stored variation
150
+ expect(nf3.variations.first.first.notation).to eq('f4')
151
+ end
152
+
153
+ it 'demote moves a variation one slot toward the end' do
154
+ find_variation('f4').demote
155
+ expect(nf3.variations.last.first.notation).to eq('f4')
156
+ end
157
+
158
+ it 'demote at index 0 (mainline) is the inverse swap' do
159
+ branch.next.demote # Nf3 (mainline) -> variation #1
160
+ # the first stored variation (f4) becomes the new mainline
161
+ expect(game.moves[2].notation).to eq('f4')
162
+ expect(game.moves[3].notation).to eq('exf4')
163
+ # old mainline Nf3 + Nf6 is now the first variation of f4
164
+ expect(game.moves[2].variations.first.map(&:notation)).to eq(%w[Nf3 Nf6])
165
+ end
166
+
167
+ it 'promote_to_main makes a variation the new mainline' do
168
+ find_variation('Nc3').promote_to_main
169
+ expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc3 d5 exd5])
170
+ # old mainline Nf3 + Nf6 is now a variation of Nc3
171
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
172
+ end
173
+
174
+ it 'demote_to_last moves a variation to the end' do
175
+ find_variation('f4').demote_to_last
176
+ expect(nf3.variations.last.first.notation).to eq('f4')
177
+ end
178
+
179
+ it 'demote_to_last on the mainline makes it the last variation' do
180
+ branch.next.demote_to_last # Nf3 -> last variation
181
+ # the last stored variation (Nc3) is promoted to mainline
182
+ expect(game.moves[2].notation).to eq('Nc3')
183
+ # old mainline Nf3 + Nf6 is the LAST variation of Nc3
184
+ expect(game.moves[2].variations.last.map(&:notation)).to eq(%w[Nf3 Nf6])
185
+ end
186
+
187
+ it 'every reorder round-trips (variation set stable under re-parse)' do
188
+ find_variation('Nc3').promote_to_main
189
+ reparsed = PGN.parse(game.to_pgn).first
190
+ expect(tree_sig(reparsed)).to eq(tree_sig(game))
191
+ end
192
+ end
193
+
194
+ describe 'flatten-on-mutation for same-point nested variations' do
195
+ it 'flattens ( V1 ( V2 ) ) into flat siblings on promote_to_main' do
196
+ # 1. e4 ( e5 ( e6 ) ) ( d5 ) — e5, e6, d5 all branch before e4 (at root).
197
+ g = PGN.parse(%([White "x"]\n\n1. e4 ( e5 ( e6 ) ) ( d5 ) *\n).force_encoding(Encoding::ISO_8859_1)).first
198
+ expect(g.root.children.map(&:notation)).to contain_exactly('e4', 'e5', 'e6', 'd5')
199
+ e6 = g.root.children.find { |n| n.notation == 'e6' }
200
+ e6.promote_to_main
201
+ # e6 is now the mainline; e4, e5, d5 are flat variations of e6
202
+ expect(g.moves.map(&:notation)).to eq(['e6'])
203
+ expect(g.moves[0].variations.map { |v| v.first.notation }).to contain_exactly('e4', 'e5', 'd5')
204
+ reparsed = PGN.parse(g.to_pgn).first
205
+ expect(tree_sig(reparsed)).to eq(tree_sig(g))
206
+ end
207
+ end
208
+
209
+ describe '#delete' do
210
+ let(:branch) { root.next.next }
211
+ let(:nf3) { game.moves[2] }
212
+
213
+ it 'removes a variation' do
214
+ branch.children.find { |n| n.notation == 'f4' }.delete
215
+ expect(nf3.variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
216
+ end
217
+
218
+ it 'removing the mainline continuation promotes the first variation' do
219
+ branch.next.delete # delete Nf3 (mainline)
220
+ # the first stored variation (f4) takes its place
221
+ expect(game.moves[2].notation).to eq('f4')
222
+ expect(game.moves[3].notation).to eq('exf4')
223
+ # the remaining variation (Nc3) is now f4's variation
224
+ expect(game.moves[2].variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
225
+ end
226
+
227
+ it 'removing the mainline continuation with no variations truncates' do
228
+ g = PGN::Game.new(%w[e4 e5 Nf3 Nf6])
229
+ nf3 = g.root.main_line.to_a[2]
230
+ nf3.delete
231
+ expect(g.moves.map(&:notation)).to eq(%w[e4 e5])
232
+ end
233
+
234
+ it 'round-trips after delete' do
235
+ branch.children.find { |n| n.notation == 'f4' }.delete
236
+ reparsed = PGN.parse(game.to_pgn).first
237
+ expect(tree_sig(reparsed)).to eq(tree_sig(game))
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Position, 'outcome' do
6
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
7
+
8
+ it 'is nil for the start position' do
9
+ expect(PGN::Position.start.outcome).to be_nil
10
+ end
11
+
12
+ it 'detects checkmate (fool\'s mate)' do
13
+ pos = PGN::FEN.new('rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3').to_position
14
+ expect(pos.outcome).to eq(:checkmate)
15
+ expect(pos.checkmate?).to be(true)
16
+ expect(pos.stalemate?).to be(false)
17
+ end
18
+
19
+ it 'detects stalemate' do
20
+ pos = PGN::FEN.new('7k/5Q2/6K1/8/8/8/8/8 b - - 0 1').to_position
21
+ expect(pos.outcome).to eq(:stalemate)
22
+ expect(pos.stalemate?).to be(true)
23
+ expect(pos.in_check?).to be(false)
24
+ end
25
+
26
+ it 'detects insufficient material: K vs K' do
27
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 0 1').to_position
28
+ expect(pos.insufficient_material?).to be(true)
29
+ expect(pos.outcome).to eq(:draw)
30
+ end
31
+
32
+ it 'detects insufficient material: K+B vs K' do
33
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/3BK3 w - - 0 1').to_position
34
+ expect(pos.insufficient_material?).to be(true)
35
+ end
36
+
37
+ it 'detects insufficient material: K+N vs K' do
38
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/3NK3 w - - 0 1').to_position
39
+ expect(pos.insufficient_material?).to be(true)
40
+ end
41
+
42
+ it 'does not consider K+Q vs K insufficient' do
43
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/3QK3 w - - 0 1').to_position
44
+ expect(pos.insufficient_material?).to be(false)
45
+ end
46
+
47
+ it 'detects the 50-move rule' do
48
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 100 60').to_position
49
+ expect(pos.fifty_move?).to be(true)
50
+ expect(pos.outcome).to eq(:draw)
51
+ end
52
+
53
+ it 'does not trigger 50-move before 100 halfmoves' do
54
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 99 60').to_position
55
+ expect(pos.fifty_move?).to be(false)
56
+ end
57
+ end
58
+
59
+ RSpec.describe PGN::Game, 'outcome' do
60
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
61
+
62
+ it 'is nil for a game in progress' do
63
+ game = PGN::Game.new(%w[e4 e5 Nf3 Nc6])
64
+ expect(game.outcome).to be_nil
65
+ end
66
+
67
+ it 'detects a checkmate game (scholar\'s mate)' do
68
+ game = PGN::Game.new(%w[e4 e5 Bc4 Nc6 Qh5 Nf6 Qxf7])
69
+ expect(game.outcome).to eq(:checkmate)
70
+ end
71
+
72
+ it 'detects a stalemate game' do
73
+ # Black king h8, white queen f7, white king g6; black to move is stalemated.
74
+ game = PGN::Game.new(
75
+ %w[],
76
+ { 'FEN' => '7k/5Q2/6K1/8/8/8/8/8 b - - 0 1' },
77
+ '1/2-1/2'
78
+ )
79
+ expect(game.outcome).to eq(:stalemate)
80
+ end
81
+
82
+ it 'detects threefold repetition' do
83
+ # 1. Nf3 Nf6 2. Ng1 Ng8 repeats the start position three times.
84
+ game = PGN::Game.new(%w[Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8])
85
+ expect(game.threefold?).to be(true)
86
+ expect(game.outcome).to eq(:draw)
87
+ end
88
+
89
+ it 'does not claim threefold without three repeats' do
90
+ game = PGN::Game.new(%w[Nf3 Nf6 Ng1])
91
+ expect(game.threefold?).to be(false)
92
+ end
93
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'left-recursive parser list rules' do
6
+ let(:pgn) do
7
+ +<<~PGN
8
+ [Event "A"]
9
+ [Site "B"]
10
+ [Event "C"]
11
+ [White "W"]
12
+ [Black "X"]
13
+
14
+ 1. e4 (1...c5 (1...d5)) (1...e5) *
15
+ PGN
16
+ end
17
+ let(:game) { PGN.parse(pgn).first }
18
+
19
+ it 'preserves the legacy reversed tag order' do
20
+ expect(game.tags.keys).to eq(%w[Black White Event Site])
21
+ end
22
+
23
+ it 'keeps first-occurrence-wins on duplicate tags' do
24
+ expect(game.tags['Event']).to eq('A')
25
+ end
26
+
27
+ it 'preserves the legacy reversed variation order' do
28
+ variations = game.moves.first.variations.map { |v| v.map(&:notation) }
29
+ expect(variations).to eq([%w[e5], %w[c5]])
30
+ end
31
+
32
+ it 'keeps nested variations inside their parent' do
33
+ c5 = game.moves.first.variations.last
34
+ expect(c5.map(&:notation)).to eq(%w[c5])
35
+ expect(c5.first.variations.map { |v| v.map(&:notation) }).to eq([%w[d5]])
36
+ end
37
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Position, 'attack helpers' do
6
+ describe '#in_check?' do
7
+ it 'is false for the start position' do
8
+ expect(PGN::Position.start.in_check?).to be(false)
9
+ end
10
+
11
+ it 'is true when the side to move is checked' do
12
+ # White king on e1, black rook on e2 giving check.
13
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/4r3/4K3 w - - 0 1').to_position
14
+ expect(pos.in_check?).to be(true)
15
+ end
16
+
17
+ it 'is false when a piece blocks the check' do
18
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/4p3/4R1K1 w - - 0 1').to_position
19
+ expect(pos.in_check?).to be(false)
20
+ end
21
+
22
+ it 'is false for black to move when black is not in check' do
23
+ pos = PGN::FEN.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1').to_position
24
+ expect(pos.in_check?).to be(false)
25
+ end
26
+ end
27
+
28
+ describe '#attackers' do
29
+ it 'lists the attacking squares for the opponent color' do
30
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/4r3/4K3 w - - 0 1').to_position
31
+ # White king on e1 is attacked by the black rook on e2.
32
+ expect(pos.attackers('e1')).to include('e2')
33
+ end
34
+
35
+ it 'lists pawn attackers' do
36
+ # White pawn d5 attacks e6 and c6; ask for attackers of e6 by white.
37
+ pos = PGN::FEN.new('4k3/8/8/3P4/8/8/8/4K3 w - - 0 1').to_position
38
+ expect(pos.attackers('e6', 'w')).to include('d5')
39
+ end
40
+
41
+ it 'lists knight attackers' do
42
+ pos = PGN::FEN.new('4k3/8/8/8/8/2N5/8/4K3 w - - 0 1').to_position
43
+ expect(pos.attackers('e4', 'w')).to include('c3')
44
+ end
45
+
46
+ it 'lists bishop/rook/queen attackers' do
47
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/Q3K3 w - - 0 1').to_position
48
+ expect(pos.attackers('a8', 'w')).to include('a1')
49
+ end
50
+
51
+ it 'returns an empty array when nothing attacks the square' do
52
+ pos = PGN::Position.start
53
+ expect(pos.attackers('e4')).to eq([])
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe PGN::Position, '#legal?' do
6
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
7
+
8
+ let(:start) { PGN::Position.start }
9
+
10
+ it 'accepts SAN pawn moves' do
11
+ expect(start.legal?('e4')).to be(true)
12
+ expect(start.legal?('d4')).to be(true)
13
+ expect(start.legal?('e5')).to be(false) # no pawn on e5 can move
14
+ end
15
+
16
+ it 'accepts SAN piece moves' do
17
+ expect(start.legal?('Nf3')).to be(true)
18
+ expect(start.legal?('Nc3')).to be(true)
19
+ expect(start.legal?('Nb1')).to be(false) # no piece can move to b1 from start
20
+ end
21
+
22
+ it 'accepts UCI strings' do
23
+ expect(start.legal?('e2e4')).to be(true)
24
+ expect(start.legal?('e2e5')).to be(false)
25
+ expect(start.legal?('g1f3')).to be(true)
26
+ expect(start.legal?('b1a3')).to be(true) # knight on b1 can reach a3
27
+ expect(start.legal?('b1b3')).to be(false) # knights do not move two squares
28
+ end
29
+
30
+ it 'accepts SAN with check/mate suffixes' do
31
+ # Scholar's mate position before the queen move: 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? 4. Qxf7#
32
+ pos = PGN::FEN.new('r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4').to_position
33
+ expect(pos.legal?('Qxf7#')).to be(true)
34
+ expect(pos.legal?('Qxf7+')).to be(true)
35
+ expect(pos.legal?('Qxf7')).to be(true)
36
+ end
37
+
38
+ it 'accepts redundant disambiguation in SAN' do
39
+ # Two knights (b1 and f1) can reach d2; "Nbd2" and "Nfd2" are both legal,
40
+ # and the unambiguous "Nd2" is rejected as ambiguous.
41
+ pos = PGN::FEN.new('4k3/8/8/8/8/8/8/1N3NK1 w - - 0 1').to_position
42
+ expect(pos.legal?('Nbd2')).to be(true)
43
+ expect(pos.legal?('Nfd2')).to be(true)
44
+ expect(pos.legal?('Nd2')).to be(false) # ambiguous, no disambiguation
45
+ end
46
+
47
+ it 'handles castling SAN and UCI' do
48
+ pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
49
+ expect(pos.legal?('O-O')).to be(true)
50
+ expect(pos.legal?('O-O-O')).to be(true)
51
+ expect(pos.legal?('e1g1')).to be(true)
52
+ expect(pos.legal?('e1c1')).to be(true)
53
+ end
54
+
55
+ it 'handles promotion SAN and UCI' do
56
+ pos = PGN::FEN.new('8/P7/8/8/8/8/8/4k2K w - - 0 1').to_position
57
+ expect(pos.legal?('a8=Q')).to be(true)
58
+ expect(pos.legal?('a8=N')).to be(true)
59
+ expect(pos.legal?('a7a8q')).to be(true)
60
+ expect(pos.legal?('a7a8n')).to be(true)
61
+ end
62
+
63
+ it 'handles en passant SAN' do
64
+ pos = PGN::FEN.new('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3').to_position
65
+ expect(pos.legal?('exd6')).to be(true)
66
+ expect(pos.legal?('e5d6')).to be(true)
67
+ end
68
+
69
+ it 'rejects moves that leave the king in check' do
70
+ # White king on e1, rook pinning the knight on e2 to the king along the e-file.
71
+ pos = PGN::FEN.new('4r3/8/8/8/8/8/4N3/4K3 w - - 0 1').to_position
72
+ expect(pos.legal?('Nf4')).to be(false) # knight moves away, king is in check from rook
73
+ end
74
+
75
+ it 'returns false for malformed input' do
76
+ expect(start.legal?('z9z9')).to be(false)
77
+ expect(start.legal?('Qxh8')).to be(false) # no legal queen move from start
78
+ expect(start.legal?('')).to be(false)
79
+ end
80
+
81
+ it 'respects the side to move' do
82
+ # Black to move; a white move should be illegal.
83
+ pos = PGN::FEN.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1').to_position
84
+ expect(pos.legal?('e4')).to be(false)
85
+ expect(pos.legal?('e5')).to be(true)
86
+ end
87
+ end
88
+
89
+ RSpec.describe PGN::Position, '#legal_moves_san' do
90
+ skip 'native extension PGN::Bitboard::Engine not compiled' unless PGN::Bitboard.const_defined?(:Engine)
91
+
92
+ it 'returns 20 sorted SAN moves for the start position' do
93
+ sans = PGN::Position.start.legal_moves_san
94
+ expect(sans.length).to eq(20)
95
+ expect(sans).to eq(sans.sort)
96
+ expect(sans).to include('e4', 'Nf3', 'd4', 'a3')
97
+ end
98
+
99
+ it 'includes castling and promotion SAN' do
100
+ pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
101
+ sans = pos.legal_moves_san
102
+ expect(sans).to include('O-O', 'O-O-O')
103
+ end
104
+
105
+ it 'produces SAN whose moves are all legal? and vice versa' do
106
+ pos = PGN::FEN.new('r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4').to_position
107
+ sans = pos.legal_moves_san
108
+ uci = pos.legal_moves
109
+ expect(sans.length).to eq(uci.length)
110
+ expect(sans.all? { |s| pos.legal?(s) }).to be(true)
111
+ end
112
+
113
+ it 'round-trips: every legal UCI maps to a SAN that legal? accepts' do
114
+ pos = PGN::FEN.new('rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3').to_position
115
+ pos.legal_moves.each do |uci|
116
+ from = uci[0, 2]
117
+ to = uci[2, 2]
118
+ promo = uci[5]
119
+ san = PGN::Notation.san(pos, from, to, promo)
120
+ expect(pos.legal?(san)).to be(true), "#{san} (from #{uci}) should be legal"
121
+ end
122
+ end
123
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-14 00:00:00.000000000 Z
12
+ date: 2026-08-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rb_sys
@@ -226,12 +226,15 @@ files:
226
226
  - docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md
227
227
  - docs/superpowers/plans/2026-08-14-chessie-migration.md
228
228
  - docs/superpowers/plans/2026-08-14-rust-integration-plan.md
229
+ - docs/superpowers/plans/2026-08-15-game-tree-api-plan.md
230
+ - docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md
229
231
  - docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md
230
232
  - docs/superpowers/specs/2026-08-13-attack-masks-design.md
231
233
  - docs/superpowers/specs/2026-08-13-perf-internals-design.md
232
234
  - docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md
233
235
  - docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md
234
236
  - docs/superpowers/specs/2026-08-14-rust-integration-design.md
237
+ - docs/superpowers/specs/2026-08-15-game-tree-api-design.md
235
238
  - examples/immortal_game.pgn
236
239
  - ext/pgn2_native/Cargo.lock
237
240
  - ext/pgn2_native/Cargo.toml
@@ -244,13 +247,16 @@ files:
244
247
  - ext/pgn2_native/pgn2_native/Cargo.toml
245
248
  - ext/pgn2_native/pgn2_native/src/lib.rs
246
249
  - lib/pgn.rb
250
+ - lib/pgn/attack.rb
247
251
  - lib/pgn/bitboard.rb
248
252
  - lib/pgn/board.rb
253
+ - lib/pgn/epd.rb
249
254
  - lib/pgn/fen.rb
250
255
  - lib/pgn/game.rb
251
256
  - lib/pgn/lexer.rb
252
257
  - lib/pgn/move.rb
253
258
  - lib/pgn/move_calculator.rb
259
+ - lib/pgn/node.rb
254
260
  - lib/pgn/notation.rb
255
261
  - lib/pgn/parser.rb
256
262
  - lib/pgn/pgn_parser.rb
@@ -262,13 +268,21 @@ files:
262
268
  - pgn2.gemspec
263
269
  - spec/bitboard_spec.rb
264
270
  - spec/board_spec.rb
271
+ - spec/castling_normalization_spec.rb
272
+ - spec/comment_round_trip_spec.rb
273
+ - spec/epd_spec.rb
265
274
  - spec/fen_spec.rb
275
+ - spec/game_history_spec.rb
266
276
  - spec/game_spec.rb
267
277
  - spec/lexer_spec.rb
268
278
  - spec/move_calculator_spec.rb
269
279
  - spec/move_spec.rb
280
+ - spec/movetext_clean_spec.rb
281
+ - spec/node_spec.rb
270
282
  - spec/notation_spec.rb
283
+ - spec/outcome_spec.rb
271
284
  - spec/parser_explicit_spec.rb
285
+ - spec/parser_left_recursion_spec.rb
272
286
  - spec/parser_spec.rb
273
287
  - spec/pgn_files/alternate_castling.pgn
274
288
  - spec/pgn_files/annotations.pgn
@@ -284,6 +298,8 @@ files:
284
298
  - spec/pgn_files/two_annotations.pgn
285
299
  - spec/pgn_files/two_games.pgn
286
300
  - spec/pgn_files/variations.pgn
301
+ - spec/position_attack_spec.rb
302
+ - spec/position_legal_spec.rb
287
303
  - spec/position_spec.rb
288
304
  - spec/serializer_spec.rb
289
305
  - spec/spec_helper.rb