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.
@@ -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,50 @@ 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
+ # First node (DFS over children) that has at least one variation. Such a
137
+ # node always has a non-nil continuation, so add_variation can branch.
138
+ def first_branch_with_variations(node)
139
+ stack = [node]
140
+ until stack.empty?
141
+ n = stack.shift
142
+ return n unless n.variations.empty?
143
+
144
+ stack.concat(n.children)
145
+ end
146
+ nil
147
+ end
148
+
149
+ it 'every fixture survives promote/demote/add_variation and re-parses' do
150
+ fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
151
+ fixtures.reject! { |p| non_round_trip.include?(File.basename(p)) }
152
+ fixtures.each do |path|
153
+ PGN.parse(File.read(path, encoding: Encoding::ISO_8859_1)).each do |game|
154
+ root = game.root
155
+
156
+ # promote then demote the first variation if one exists
157
+ branch = first_branch_with_variations(root)
158
+ if branch && !branch.variations.empty?
159
+ branch.variations.first.promote
160
+ game.root
161
+ b2 = first_branch_with_variations(game.root)
162
+ if b2 && !b2.variations.empty?
163
+ b2.variations.first.demote
164
+ game.root
165
+ end
166
+ b3 = first_branch_with_variations(game.root)
167
+ b3&.add_variation('Nc3')
168
+ end
169
+
170
+ once = game.to_pgn
171
+ reparsed = PGN.parse(once).first
172
+ expect(tree_sig(reparsed)).to eq(tree_sig(game)),
173
+ "#{path}: mutated game tree must round-trip (set-stable)"
174
+ end
175
+ end
176
+ end
177
+ end
132
178
  end
@@ -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,222 @@
1
+ require 'spec_helper'
2
+
3
+ describe PGN::Node do
4
+ let(:game) { PGN.parse(File.read(File.expand_path('pgn_files/variations.pgn', __dir__), encoding: Encoding::ISO_8859_1)).first }
5
+ let(:root) { game.root }
6
+
7
+ it 'root is a root node with no move' do
8
+ expect(root).to be_root
9
+ expect(root.move).to be_nil
10
+ expect(root.notation).to be_nil
11
+ expect(root.previous).to be_nil
12
+ end
13
+
14
+ it 'main_line matches game.moves notations (root excluded)' do
15
+ expect(root.main_line.map(&:notation)).to eq(game.moves.map(&:notation))
16
+ end
17
+
18
+ it 'main_line is an Enumerator without a block' do
19
+ expect(root.main_line).to be_an(Enumerator)
20
+ end
21
+
22
+ it 'children of the position after 1.e4 e5 are Nf3 (main), Nc3, f4 (any order)' do
23
+ node = root.next.next # after e4 (white), after e5 (black) => before Nf3
24
+ expect(node.children.map(&:notation)).to contain_exactly('Nf3', 'Nc3', 'f4')
25
+ expect(node.next.notation).to eq('Nf3') # mainline continuation is first
26
+ expect(node.variations.map(&:notation)).to contain_exactly('Nc3', 'f4')
27
+ end
28
+
29
+ it 'nested variation f5 is a sibling of d5 under the Nc3 node' do
30
+ nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
31
+ expect(nc3_node.children.map(&:notation)).to contain_exactly('d5', 'f5')
32
+ end
33
+
34
+ it 'previous walks back to the root' do
35
+ node = root.next.next.next # Nf3 node
36
+ expect(node.previous.notation).to eq('e5')
37
+ expect(node.previous.previous.notation).to eq('e4')
38
+ expect(node.previous.previous.previous).to be_root
39
+ end
40
+
41
+ describe '#position' do
42
+ it 'root position is the starting position' do
43
+ expect(root.position.to_fen.to_s).to eq(PGN::Position.start.to_fen.to_s)
44
+ end
45
+
46
+ it 'main_line positions match game.positions[1..]' do
47
+ expect(root.main_line.map { |n| n.position.to_fen.to_s })
48
+ .to eq(game.positions[1..].map { |p| p.to_fen.to_s })
49
+ end
50
+
51
+ it 'a variation node position is replayed from the root' do
52
+ # After 1.e4 e5 2.Nc3 (the Nc3 variation first move)
53
+ nc3_node = root.next.next.children.find { |n| n.notation == 'Nc3' }
54
+ expected = PGN::Position.start.move('e4').move('e5').move('Nc3')
55
+ expect(nc3_node.position.to_fen.to_s).to eq(expected.to_fen.to_s)
56
+ end
57
+
58
+ it 'is cached (returns the same object on second call)' do
59
+ node = root.next
60
+ expect(node.position).to equal(node.position)
61
+ end
62
+ end
63
+
64
+ describe '#add_variation' do
65
+ it 'appends a single-SAN variation branching before the next move' do
66
+ root.next.next.add_variation('Nc6')
67
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) })
68
+ .to include(['Nc6'], %w[Nc3 d5 exd5], %w[f4 exf4])
69
+ end
70
+
71
+ it 'appends a multi-move variation line' do
72
+ root.next.next.add_variation(%w[Nc6 Bc4])
73
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nc6 Bc4])
74
+ end
75
+
76
+ it 'normalizes castling 0 -> O' do
77
+ g = PGN::Game.new(%w[e4 e5 Nf3])
78
+ g.root.next.next.add_variation('0-0')
79
+ expect(g.moves[2].variations.map { |v| v.map(&:notation) }).to include(['O-O'])
80
+ end
81
+
82
+ it 'round-trips (variation set stable under re-parse)' do
83
+ root.next.next.add_variation(%w[Nc6 Bc4])
84
+ reparsed = PGN.parse(game.to_pgn).first
85
+ expect(tree_sig(reparsed)).to eq(tree_sig(game))
86
+ end
87
+ end
88
+
89
+ describe '#add_variation at a terminal node' do
90
+ it 'raises ArgumentError' do
91
+ g = PGN::Game.new(%w[e4 e5])
92
+ last = g.root.main_line.to_a.last
93
+ expect { last.add_variation('Nf6') }.to raise_error(ArgumentError)
94
+ end
95
+ end
96
+
97
+ describe '#add_main_variation' do
98
+ it 'extends the line at a terminal node' do
99
+ g = PGN::Game.new(%w[e4 e5])
100
+ g.root.main_line.to_a.last.add_main_variation('Nf3')
101
+ expect(g.moves.map(&:notation)).to eq(%w[e4 e5 Nf3])
102
+ end
103
+
104
+ it 'inserts a new mainline move and demotes the old continuation to a variation' do
105
+ node = root.next.next # position before Nf3
106
+ node.add_main_variation('Nc6')
107
+ expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc6])
108
+ # old mainline Nf3 + Nf6 is now a variation of Nc6
109
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
110
+ end
111
+ end
112
+
113
+ describe 'variation reordering' do
114
+ # Storage order of Nf3's variations is reverse of the PGN text
115
+ # (`[f4-line, Nc3-line]`) because the parser reverses variation order.
116
+ # Tests reference variations by notation, never by index.
117
+ let(:branch) { root.next.next }
118
+ let(:nf3) { game.moves[2] }
119
+
120
+ def find_variation(notation)
121
+ branch.children.find { |n| n.notation == notation }
122
+ end
123
+
124
+ it 'promote moves a variation one slot toward mainline' do
125
+ find_variation('Nc3').promote
126
+ # Nc3 was behind f4 in storage; now it is first (just after the mainline)
127
+ expect(nf3.variations.first.first.notation).to eq('Nc3')
128
+ end
129
+
130
+ it 'promote is a no-op for the first variation' do
131
+ find_variation('f4').promote # f4 is already the first stored variation
132
+ expect(nf3.variations.first.first.notation).to eq('f4')
133
+ end
134
+
135
+ it 'demote moves a variation one slot toward the end' do
136
+ find_variation('f4').demote
137
+ expect(nf3.variations.last.first.notation).to eq('f4')
138
+ end
139
+
140
+ it 'demote at index 0 (mainline) is the inverse swap' do
141
+ branch.next.demote # Nf3 (mainline) -> variation #1
142
+ # the first stored variation (f4) becomes the new mainline
143
+ expect(game.moves[2].notation).to eq('f4')
144
+ expect(game.moves[3].notation).to eq('exf4')
145
+ # old mainline Nf3 + Nf6 is now the first variation of f4
146
+ expect(game.moves[2].variations.first.map(&:notation)).to eq(%w[Nf3 Nf6])
147
+ end
148
+
149
+ it 'promote_to_main makes a variation the new mainline' do
150
+ find_variation('Nc3').promote_to_main
151
+ expect(game.moves.map(&:notation)).to eq(%w[e4 e5 Nc3 d5 exd5])
152
+ # old mainline Nf3 + Nf6 is now a variation of Nc3
153
+ expect(game.moves[2].variations.map { |v| v.map(&:notation) }).to include(%w[Nf3 Nf6])
154
+ end
155
+
156
+ it 'demote_to_last moves a variation to the end' do
157
+ find_variation('f4').demote_to_last
158
+ expect(nf3.variations.last.first.notation).to eq('f4')
159
+ end
160
+
161
+ it 'demote_to_last on the mainline makes it the last variation' do
162
+ branch.next.demote_to_last # Nf3 -> last variation
163
+ # the last stored variation (Nc3) is promoted to mainline
164
+ expect(game.moves[2].notation).to eq('Nc3')
165
+ # old mainline Nf3 + Nf6 is the LAST variation of Nc3
166
+ expect(game.moves[2].variations.last.map(&:notation)).to eq(%w[Nf3 Nf6])
167
+ end
168
+
169
+ it 'every reorder round-trips (variation set stable under re-parse)' do
170
+ find_variation('Nc3').promote_to_main
171
+ reparsed = PGN.parse(game.to_pgn).first
172
+ expect(tree_sig(reparsed)).to eq(tree_sig(game))
173
+ end
174
+ end
175
+
176
+ describe 'flatten-on-mutation for same-point nested variations' do
177
+ it 'flattens ( V1 ( V2 ) ) into flat siblings on promote_to_main' do
178
+ # 1. e4 ( e5 ( e6 ) ) ( d5 ) — e5, e6, d5 all branch before e4 (at root).
179
+ g = PGN.parse(%([White "x"]\n\n1. e4 ( e5 ( e6 ) ) ( d5 ) *\n).force_encoding(Encoding::ISO_8859_1)).first
180
+ expect(g.root.children.map(&:notation)).to contain_exactly('e4', 'e5', 'e6', 'd5')
181
+ e6 = g.root.children.find { |n| n.notation == 'e6' }
182
+ e6.promote_to_main
183
+ # e6 is now the mainline; e4, e5, d5 are flat variations of e6
184
+ expect(g.moves.map(&:notation)).to eq(['e6'])
185
+ expect(g.moves[0].variations.map { |v| v.first.notation }).to contain_exactly('e4', 'e5', 'd5')
186
+ reparsed = PGN.parse(g.to_pgn).first
187
+ expect(tree_sig(reparsed)).to eq(tree_sig(g))
188
+ end
189
+ end
190
+
191
+ describe '#delete' do
192
+ let(:branch) { root.next.next }
193
+ let(:nf3) { game.moves[2] }
194
+
195
+ it 'removes a variation' do
196
+ branch.children.find { |n| n.notation == 'f4' }.delete
197
+ expect(nf3.variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
198
+ end
199
+
200
+ it 'removing the mainline continuation promotes the first variation' do
201
+ branch.next.delete # delete Nf3 (mainline)
202
+ # the first stored variation (f4) takes its place
203
+ expect(game.moves[2].notation).to eq('f4')
204
+ expect(game.moves[3].notation).to eq('exf4')
205
+ # the remaining variation (Nc3) is now f4's variation
206
+ expect(game.moves[2].variations.map { |v| v.first.notation }).to contain_exactly('Nc3')
207
+ end
208
+
209
+ it 'removing the mainline continuation with no variations truncates' do
210
+ g = PGN::Game.new(%w[e4 e5 Nf3 Nf6])
211
+ nf3 = g.root.main_line.to_a[2]
212
+ nf3.delete
213
+ expect(g.moves.map(&:notation)).to eq(%w[e4 e5])
214
+ end
215
+
216
+ it 'round-trips after delete' do
217
+ branch.children.find { |n| n.notation == 'f4' }.delete
218
+ reparsed = PGN.parse(game.to_pgn).first
219
+ expect(tree_sig(reparsed)).to eq(tree_sig(game))
220
+ end
221
+ end
222
+ 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