pgn2 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. metadata +99 -15
data/pgn2.gemspec CHANGED
@@ -11,16 +11,26 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = 'A PGN parser for Ruby'
12
12
  spec.homepage = 'https://github.com/muriloime/pgn'
13
13
  spec.license = 'MIT'
14
+ spec.required_ruby_version = '>= 3.0'
15
+
16
+ spec.metadata = {
17
+ 'homepage_uri' => 'https://github.com/muriloime/pgn',
18
+ 'source_code_uri' => 'https://github.com/muriloime/pgn',
19
+ 'bug_tracker_uri' => 'https://github.com/muriloime/pgn/issues',
20
+ 'changelog_uri' => 'https://github.com/muriloime/pgn/blob/main/CHANGELOG.md'
21
+ }
14
22
 
15
23
  spec.files = `git ls-files`.split($/)
16
24
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
25
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
26
  spec.require_paths = ['lib']
19
27
 
20
- spec.add_dependency 'whittle'
21
-
22
28
  spec.add_development_dependency 'bundler', '~> 2.3'
23
29
  spec.add_development_dependency 'pry'
24
30
  spec.add_development_dependency 'rake'
25
31
  spec.add_development_dependency 'rspec'
32
+ spec.add_development_dependency 'racc'
33
+ spec.add_development_dependency 'benchmark-ips'
34
+ spec.add_development_dependency 'memory_profiler'
35
+ spec.add_development_dependency 'rubocop', '~> 1.60'
26
36
  end
@@ -0,0 +1,111 @@
1
+ require 'spec_helper'
2
+
3
+ describe PGN::Board do
4
+ describe '.start' do
5
+ it 'uses the START constant for its squares' do
6
+ expect(PGN::Board.start.squares).to eq(PGN::Board::START)
7
+ end
8
+ end
9
+
10
+ describe '#at' do
11
+ # Use .dup because PGN::Board.start returns the same shared board
12
+ # backed by the frozen START constant.
13
+ let(:board) { PGN::Board.start.dup }
14
+
15
+ it 'returns the starting pieces on their home squares' do
16
+ expect(board.at('a1')).to eq('R')
17
+ expect(board.at('b1')).to eq('N')
18
+ expect(board.at('c1')).to eq('B')
19
+ expect(board.at('d1')).to eq('Q')
20
+ expect(board.at('e1')).to eq('K')
21
+ expect(board.at('h1')).to eq('R')
22
+ expect(board.at('a2')).to eq('P')
23
+ expect(board.at('e2')).to eq('P')
24
+ expect(board.at('a7')).to eq('p')
25
+ expect(board.at('e8')).to eq('k')
26
+ expect(board.at('d8')).to eq('q')
27
+ end
28
+
29
+ it 'returns nil for empty squares' do
30
+ expect(board.at('e3')).to be_nil
31
+ expect(board.at('d4')).to be_nil
32
+ expect(board.at('a3')).to be_nil
33
+ end
34
+
35
+ it 'agrees between the string and coordinate overloads' do
36
+ expect(board.at('e4')).to eq(board.at(4, 3))
37
+ expect(board.at('a1')).to eq(board.at(0, 0))
38
+ expect(board.at('h8')).to eq(board.at(7, 7))
39
+ end
40
+ end
41
+
42
+ describe '#coordinates_for / #position_for' do
43
+ it 'round-trips every square on the board' do
44
+ ('a'..'h').each_with_index do |file, fi|
45
+ ('1'..'8').each_with_index do |rank, ri|
46
+ square = "#{file}#{rank}"
47
+ expect(PGN::Board.start.coordinates_for(square)).to eq([fi, ri])
48
+ expect(PGN::Board.start.position_for([fi, ri])).to eq(square)
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ describe '#update' do
55
+ it 'places a piece and mutates self, returning self' do
56
+ board = PGN::Board.start.dup
57
+ result = board.update('e4', 'P')
58
+ expect(result).to be(board)
59
+ expect(board.at('e4')).to eq('P')
60
+ end
61
+
62
+ it 'can clear a square with nil' do
63
+ board = PGN::Board.start.dup
64
+ board.update('e2', nil)
65
+ expect(board.at('e2')).to be_nil
66
+ end
67
+ end
68
+
69
+ describe '#change!' do
70
+ it 'applies several squares at once and returns self' do
71
+ board = PGN::Board.start.dup
72
+ result = board.change!('e2' => nil, 'e4' => 'P')
73
+ expect(result).to be(board)
74
+ expect(board.at('e2')).to be_nil
75
+ expect(board.at('e4')).to eq('P')
76
+ end
77
+ end
78
+
79
+ describe '#dup' do
80
+ it 'returns a PGN::Board with equal squares' do
81
+ original = PGN::Board.start.dup
82
+ copy = original.dup
83
+ expect(copy).to be_a(PGN::Board)
84
+ expect(copy.squares).to eq(original.squares)
85
+ end
86
+
87
+ it 'is independent: mutating the copy leaves the original untouched' do
88
+ original = PGN::Board.start.dup
89
+ copy = original.dup
90
+ copy.update('e4', 'Q')
91
+ expect(original.at('e4')).to be_nil
92
+ expect(copy.at('e4')).to eq('Q')
93
+ end
94
+
95
+ it 'is independent: mutating the original leaves the copy untouched' do
96
+ original = PGN::Board.start.dup
97
+ copy = original.dup
98
+ original.update('e4', 'Q')
99
+ expect(copy.at('e4')).to be_nil
100
+ end
101
+ end
102
+
103
+ describe '#inspect' do
104
+ it 'returns a string with unicode pieces and newlines' do
105
+ inspected = PGN::Board.start.inspect
106
+ expect(inspected).to be_a(String)
107
+ expect(inspected).to include("\u{2659}") # white pawn
108
+ expect(inspected).to include("\n")
109
+ end
110
+ end
111
+ end
data/spec/fen_spec.rb CHANGED
@@ -97,4 +97,29 @@ describe PGN::FEN do
97
97
  fen.inspect.should be_a(String)
98
98
  end
99
99
  end
100
+
101
+ describe "board_string round-trip" do
102
+ fens = [
103
+ PGN::FEN::INITIAL,
104
+ "r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6",
105
+ "8/8/8/8/8/8/8/8 w - - 0 1",
106
+ "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1",
107
+ "4k3/4P3/8/8/8/8/8/4K3 w - - 0 1",
108
+ "rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3",
109
+ ]
110
+
111
+ it "round-trips every fixture through FEN#to_s" do
112
+ fens.each do |fen|
113
+ expect(PGN::FEN.new(fen).to_s).to eq(fen), fen
114
+ end
115
+ end
116
+
117
+ it "round-trips the board portion through FEN#to_position.to_fen" do
118
+ fens.each do |fen|
119
+ original = PGN::FEN.new(fen)
120
+ roundtrip = original.to_position.to_fen
121
+ expect(roundtrip.board_string).to eq(original.board_string), fen
122
+ end
123
+ end
124
+ end
100
125
  end
data/spec/game_spec.rb CHANGED
@@ -18,4 +18,78 @@ describe PGN::Game do
18
18
  expect(last_pos.fullmove).to eq 2
19
19
  end
20
20
  end
21
+
22
+ describe '#to_pgn' do
23
+ it 'returns a canonical PGN string ending with a newline' do
24
+ game = PGN::Game.new(%w[e4 e5], { 'White' => 'A' }, '1-0')
25
+ expect(game.to_pgn).to eq("[White \"A\"]\n\n1. e4 e5 1-0\n")
26
+ expect(game.to_pgn).to end_with("\n")
27
+ end
28
+
29
+ # Recursively compare two move lists: notation, annotation, comment,
30
+ # and the structure of their variations.
31
+ #
32
+ # The current parser reverses the order of a move's variations on each
33
+ # parse (its `variation_list` rule is right-recursive), so variation
34
+ # *order* does not round-trip; only the set of variations does. We
35
+ # therefore compare variations order-independently via a canonical
36
+ # signature.
37
+ def move_signature(m)
38
+ c = m.comment.to_s
39
+ # The current parser does not unescape \\, {, or }, so comments
40
+ # containing those do not round-trip byte-for-byte (design spec v1
41
+ # limitation). Treat them as a single sentinel so the set comparison
42
+ # ignores the difference.
43
+ c = "<unescaped-comment>" if c.match?(/[\\{}]/)
44
+ vars = (m.variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort.join("\x03")
45
+ "#{m.notation}\x01#{(m.annotation || []).inspect}\x01#{c}\x01#{vars}"
46
+ end
47
+
48
+ def expect_moves_equal(expected, actual, ctx)
49
+ expect(actual.map(&:notation)).to eq(expected.map(&:notation)),
50
+ "#{ctx}: notation"
51
+ expected.each_with_index do |e, i|
52
+ expect(actual[i].annotation).to eq(e.annotation), "#{ctx}[#{i}]: annotation"
53
+ # The current parser cannot unescape braces, so comments containing
54
+ # literal braces do not round-trip byte-for-byte (design spec v1
55
+ # limitation). Skip the comparison for those.
56
+ unless e.comment.to_s.match?(/[\\{}]/)
57
+ expect(actual[i].comment).to eq(e.comment),
58
+ "#{ctx}[#{i}]: comment #{e.comment.inspect} vs #{actual[i].comment.inspect}"
59
+ end
60
+ # Variations compared as an order-independent set of signatures.
61
+ exp_sigs = (e.variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort
62
+ act_sigs = (actual[i].variations || []).map { |v| v.map { |mm| move_signature(mm) }.join("\x02") }.sort
63
+ expect(act_sigs).to eq(exp_sigs), "#{ctx}[#{i}]: variations"
64
+ end
65
+ end
66
+
67
+ # Fixtures that do not round-trip byte-for-byte: doublequotes has
68
+ # unescaped inner quotes (serializer escapes them) and specialcharacters
69
+ # is multibyte and requires the Encoding::UTF_8 argument to parse.
70
+ NON_ROUND_TRIP = %w[doublequotes.pgn specialcharacters.pgn].freeze
71
+
72
+ it 'round-trips every tracked fixture' do
73
+ fixtures = `git ls-files spec/pgn_files/`.split.map { |p| File.expand_path(p) }
74
+ fixtures.reject! { |p| NON_ROUND_TRIP.include?(File.basename(p)) }
75
+ fixtures.each do |path|
76
+ PGN.parse(File.read(path)).each_with_index do |game, gi|
77
+ reparsed = PGN.parse(game.to_pgn).first
78
+
79
+ expect(reparsed.result).to eq(game.result),
80
+ "#{path}##{gi}: result"
81
+ expect_moves_equal(game.moves, reparsed.moves, "#{path}##{gi}")
82
+
83
+ # Reparsed tags must be a superset of the original (a no-tag game
84
+ # gains a synthesized Result tag).
85
+ original_tags = game.tags || {}
86
+ reparsed_tags = reparsed.tags || {}
87
+ original_tags.each do |k, v|
88
+ expect(reparsed_tags[k]).to eq(v),
89
+ "#{path}##{gi}: tag #{k} #{v.inspect} vs #{reparsed_tags[k].inspect}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
21
95
  end
@@ -0,0 +1,153 @@
1
+ require 'spec_helper'
2
+ require 'pgn/lexer'
3
+
4
+ RSpec.describe PGN::Lexer do
5
+ def lex(input)
6
+ PGN::Lexer.new(input).tokens
7
+ end
8
+
9
+ it 'returns nil on empty input' do
10
+ expect(PGN::Lexer.new('').next_token).to be_nil
11
+ end
12
+
13
+ it 'discards whitespace' do
14
+ toks = lex(" \n\t e4")
15
+ expect(toks.map { |t| [t.type, t.value] }).to eq([[:san_move, 'e4']])
16
+ end
17
+
18
+ it 'discards % rest-of-line comments' do
19
+ toks = lex("% a comment line\n[Event \"X\"]")
20
+ expect(toks.map(&:type)).to eq([:lbracket, :tag_name, :string, :rbracket])
21
+ end
22
+
23
+ it 'tokenizes the four single-character literals' do
24
+ toks = lex('[]()')
25
+ expect(toks.map { |t| [t.type, t.value] }).to eq(
26
+ [[:lbracket, '['], [:rbracket, ']'], [:lparen, '('], [:rparen, ')']]
27
+ )
28
+ end
29
+
30
+ it 'tokenizes a tag pair' do
31
+ toks = lex('[White "Kasparov"]')
32
+ expect(toks.map { |t| [t.type, t.value] }).to eq(
33
+ [[:lbracket, '['], [:tag_name, 'White'], [:string, '"Kasparov"'], [:rbracket, ']']]
34
+ )
35
+ end
36
+
37
+ it 'allows unescaped double-quotes inside a string value' do
38
+ toks = lex('[Event "IRT BLITZ "Sub Zonal""]')
39
+ expect(toks[2].value).to eq('"IRT BLITZ "Sub Zonal""')
40
+ end
41
+
42
+ it 'handles an escaped backslash in a string' do
43
+ input = %([X "a\\\\b"])
44
+ expect(lex(input)[2].value).to eq(%("a\\\\b"))
45
+ end
46
+
47
+ it 'handles an escaped quote in a string' do
48
+ input = %([X "a\\"b"])
49
+ expect(lex(input)[2].value).to eq(%("a\\"b"))
50
+ end
51
+
52
+ it 'tokenizes a brace comment including newlines' do
53
+ toks = lex("{line one\nline two}")
54
+ expect(toks.size).to eq(1)
55
+ expect(toks[0].type).to eq(:comment)
56
+ expect(toks[0].value).to eq("{line one\nline two}")
57
+ end
58
+
59
+ it 'tokenizes nested brace comments recursively' do
60
+ toks = lex("{outer {inner} tail}")
61
+ expect(toks[0].value).to eq("{outer {inner} tail}")
62
+ end
63
+
64
+ it 'tokenizes all game terminations' do
65
+ %w[1-0 0-1 1/2-1/2 *].each do |term|
66
+ expect(lex(term).map { |t| [t.type, t.value] }).to eq([[:game_termination, term]])
67
+ end
68
+ end
69
+
70
+ it 'prefers a game termination over a move number (1-0, 0-1)' do
71
+ expect(lex('1-0')[0].type).to eq(:game_termination)
72
+ expect(lex('0-1')[0].type).to eq(:game_termination)
73
+ end
74
+
75
+ it 'prefers castling (0-0) over a move number (0)' do
76
+ t = lex('0-0')[0]
77
+ expect(t.type).to eq(:san_move)
78
+ expect(t.value).to eq('0-0')
79
+ end
80
+
81
+ it 'prefers a draw result (1/2-1/2) over a move number (1)' do
82
+ expect(lex('1/2-1/2')[0].value).to eq('1/2-1/2')
83
+ end
84
+
85
+ it 'tokenizes move number indications' do
86
+ expect(lex('1.')[0].value).to eq('1.')
87
+ expect(lex('12.')[0].value).to eq('12.')
88
+ expect(lex('1...')[0].value).to eq('1...')
89
+ end
90
+
91
+ it 'tokenizes san moves: pawn, capture, promotion, check, mate' do
92
+ expect(lex('e4')[0].value).to eq('e4')
93
+ expect(lex('exd5')[0].value).to eq('exd5')
94
+ expect(lex('e8=Q')[0].value).to eq('e8=Q')
95
+ expect(lex('Qe7+')[0].value).to eq('Qe7+')
96
+ expect(lex('Qxf7#')[0].value).to eq('Qxf7#')
97
+ end
98
+
99
+ it 'tokenizes castling (O and 0 forms) and the dont-care move' do
100
+ expect(lex('O-O')[0].value).to eq('O-O')
101
+ expect(lex('O-O-O')[0].value).to eq('O-O-O')
102
+ expect(lex('--')[0].value).to eq('--')
103
+ end
104
+
105
+ it 'tokenizes major-piece moves with disambiguation' do
106
+ expect(lex('Nf3')[0].value).to eq('Nf3')
107
+ expect(lex('Nef6')[0].value).to eq('Nef6')
108
+ expect(lex('Raxc1')[0].value).to eq('Raxc1')
109
+ end
110
+
111
+ it 'tokenizes numeric annotation glyphs and punctuation annotations' do
112
+ expect(lex('$4')[0].value).to eq('$4')
113
+ expect(lex('??')[0].value).to eq('??')
114
+ expect(lex('?!')[0].value).to eq('?!')
115
+ expect(lex('!?')[0].value).to eq('!?')
116
+ end
117
+
118
+ it 'records byte offsets on every token' do
119
+ toks = lex("1. e4 e5 1-0")
120
+ expect(toks[0].offset).to eq(0) # "1."
121
+ expect(toks[1].offset).to eq(3) # "e4"
122
+ expect(toks[2].offset).to eq(6) # "e5"
123
+ expect(toks[3].offset).to eq(9) # "1-0"
124
+ end
125
+
126
+ it 'counts lines as it consumes newlines' do
127
+ toks = lex("[X \"1\"]\n[Y \"2\"]")
128
+ expect(toks.last.line).to eq(2)
129
+ end
130
+
131
+ it 'records per-game content-start offsets' do
132
+ input = "[Event \"A\"]\n\n1. e4 e5 1-0\n\n[Event \"B\"]\n\n1. d4 d5 0-1"
133
+ lex = PGN::Lexer.new(input)
134
+ lex.tokens
135
+ # game_starts has one entry per game (game 0's is recorded but game 0's
136
+ # pgn span starts at 0 by construction; games 1+ start at their offset).
137
+ expect(lex.game_starts.size).to eq(2)
138
+ # game 1's content-start is the '[' of "[Event \"B\"]"
139
+ second_game = input.index('[Event "B"]')
140
+ expect(lex.game_starts[1]).to eq(second_game)
141
+ end
142
+
143
+ it 'tokenizes UTF-8 (multibyte) input without error' do
144
+ input = "[Site \"HOTEL – Pereira\"]\n1. e4 e5 *\n".force_encoding(Encoding::UTF_8)
145
+ toks = PGN::Lexer.new(input).tokens
146
+ expect(toks[1].value).to eq('Site')
147
+ expect(toks[2].value).to eq('"HOTEL – Pereira"')
148
+ end
149
+
150
+ it 'raises on unmatched input' do
151
+ expect { lex('@bogus') }.to raise_error(PGN::UnconsumedInputError)
152
+ end
153
+ end
@@ -0,0 +1,226 @@
1
+ require 'spec_helper'
2
+
3
+ # All tests exercise MoveCalculator *indirectly* through PGN::Position#move
4
+ # so they remain valid across an internal refactor of the calculator.
5
+ def position(fen)
6
+ PGN::FEN.new(fen).to_position
7
+ end
8
+
9
+ describe PGN::MoveCalculator do
10
+ describe 'pawn pushes' do
11
+ it 'white single push from start empties e2 and fills e4' do
12
+ nxt = PGN::Position.start.move('e4')
13
+ expect(nxt.board.at('e2')).to be_nil
14
+ expect(nxt.board.at('e4')).to eq('P')
15
+ end
16
+
17
+ it 'white double push leaves e3 empty (not just e2 cleared)' do
18
+ nxt = PGN::Position.start.move('e4')
19
+ expect(nxt.board.at('e3')).to be_nil
20
+ end
21
+
22
+ it 'black single push empties d7 and fills d5' do
23
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('d5')
24
+ expect(nxt.board.at('d7')).to be_nil
25
+ expect(nxt.board.at('d5')).to eq('p')
26
+ expect(nxt.board.at('d6')).to be_nil
27
+ end
28
+ end
29
+
30
+ describe 'pawn captures' do
31
+ it 'captures diagonally onto the target square' do
32
+ nxt = position('rnbqkbnr/3ppppp/8/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 0 1').move('exd5')
33
+ expect(nxt.board.at('e4')).to be_nil
34
+ expect(nxt.board.at('d5')).to eq('P')
35
+ end
36
+ end
37
+
38
+ describe 'en passant' do
39
+ let(:fen) { 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3' }
40
+
41
+ it 'captures the pawn behind the destination' do
42
+ nxt = position(fen).move('exd6')
43
+ expect(nxt.board.at('e5')).to be_nil # origin emptied
44
+ expect(nxt.board.at('d5')).to be_nil # captured pawn removed
45
+ expect(nxt.board.at('d6')).to eq('P') # landed on the en-passant square
46
+ end
47
+ end
48
+
49
+ describe 'piece moves with a single origin' do
50
+ it 'knight from g1 to f3' do
51
+ nxt = PGN::Position.start.move('Nf3')
52
+ expect(nxt.board.at('g1')).to be_nil
53
+ expect(nxt.board.at('f3')).to eq('N')
54
+ end
55
+
56
+ it 'bishop along a clear diagonal' do
57
+ nxt = position('rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 1').move('Bc4')
58
+ expect(nxt.board.at('f1')).to be_nil
59
+ expect(nxt.board.at('c4')).to eq('B')
60
+ end
61
+
62
+ it 'rook along a clear file' do
63
+ nxt = position('4k3/8/8/8/8/8/8/R3K3 w - - 0 1').move('Ra2')
64
+ expect(nxt.board.at('a1')).to be_nil
65
+ expect(nxt.board.at('a2')).to eq('R')
66
+ end
67
+
68
+ it 'queen along a ray' do
69
+ nxt = position('4k3/8/8/8/8/8/8/Q3K3 w - - 0 1').move('Qa4')
70
+ expect(nxt.board.at('a1')).to be_nil
71
+ expect(nxt.board.at('a4')).to eq('Q')
72
+ end
73
+
74
+ it 'king steps one square' do
75
+ nxt = position('4k3/8/8/8/8/8/8/4K3 w - - 0 1').move('Ke2')
76
+ expect(nxt.board.at('e1')).to be_nil
77
+ expect(nxt.board.at('e2')).to eq('K')
78
+ end
79
+ end
80
+
81
+ describe 'castling' do
82
+ let(:fen) { 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1' }
83
+
84
+ it 'white kingside places king on g1 and rook on f1' do
85
+ nxt = position(fen).move('O-O')
86
+ expect(nxt.board.at('e1')).to be_nil
87
+ expect(nxt.board.at('g1')).to eq('K')
88
+ expect(nxt.board.at('h1')).to be_nil
89
+ expect(nxt.board.at('f1')).to eq('R')
90
+ expect(nxt.castling.sort).to eq(%w[k q])
91
+ end
92
+
93
+ it 'white queenside places king on c1 and rook on d1' do
94
+ nxt = position(fen).move('O-O-O')
95
+ expect(nxt.board.at('e1')).to be_nil
96
+ expect(nxt.board.at('c1')).to eq('K')
97
+ expect(nxt.board.at('a1')).to be_nil
98
+ expect(nxt.board.at('d1')).to eq('R')
99
+ expect(nxt.castling.sort).to eq(%w[k q])
100
+ end
101
+
102
+ it 'black kingside places king on g8 and rook on f8' do
103
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1').move('O-O')
104
+ expect(nxt.board.at('e8')).to be_nil
105
+ expect(nxt.board.at('g8')).to eq('k')
106
+ expect(nxt.board.at('f8')).to eq('r')
107
+ expect(nxt.castling.sort).to eq(%w[K Q])
108
+ end
109
+
110
+ it 'black queenside places king on c8 and rook on d8' do
111
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1').move('O-O-O')
112
+ expect(nxt.board.at('e8')).to be_nil
113
+ expect(nxt.board.at('c8')).to eq('k')
114
+ expect(nxt.board.at('d8')).to eq('r')
115
+ expect(nxt.castling.sort).to eq(%w[K Q])
116
+ end
117
+ end
118
+
119
+ describe 'promotion' do
120
+ let(:fen) { '4k3/4P3/8/8/8/8/8/4K3 w - - 0 1' }
121
+
122
+ it 'promotes a white pawn to a queen on e8' do
123
+ nxt = position(fen).move('e8=Q')
124
+ expect(nxt.board.at('e7')).to be_nil
125
+ expect(nxt.board.at('e8')).to eq('Q')
126
+ end
127
+ end
128
+
129
+ describe 'disambiguation' do
130
+ it 'resolves by SAN file (Ndb5 empties the d-file knight)' do
131
+ nxt = position('r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6').move('Ndb5')
132
+ expect(nxt.board.at('d4')).to be_nil
133
+ expect(nxt.board.at('c3')).to eq('N')
134
+ end
135
+
136
+ it 'resolves by SAN rank' do
137
+ # Two white rooks on the a-file (a1 and a3), both can reach a2.
138
+ # R1a2 must move the rook on rank 1.
139
+ nxt = position('4k3/8/8/8/8/R7/8/R3K3 w - - 0 1').move('R1a2')
140
+ expect(nxt.board.at('a1')).to be_nil
141
+ expect(nxt.board.at('a2')).to eq('R')
142
+ expect(nxt.board.at('a3')).to eq('R')
143
+ end
144
+
145
+ it 'resolves by discovered check (Ne2 empties g1, not c3)' do
146
+ nxt = position('rnbqk2r/p1pp1ppp/1p2pn2/8/1bPP4/2N1P3/PP3PPP/R1BQKBNR w KQkq - 0 5').move('Ne2')
147
+ expect(nxt.board.at('g1')).to be_nil
148
+ expect(nxt.board.at('c3')).to eq('N')
149
+ end
150
+
151
+ it 'resolves two pawns on a file by rejecting the double push when blocked' do
152
+ nxt = position('r2q1rk1/4bppp/p3n3/1p2n3/4N3/1B2BP2/PP3P1P/R2Q1RK1 w - - 4 19').move('f4')
153
+ expect(nxt.board.at('f3')).to be_nil
154
+ expect(nxt.board.at('f2')).to eq('P')
155
+ end
156
+ end
157
+
158
+ describe 'castling restrictions (observable via position.castling)' do
159
+ it 'moving the white king drops both white rights' do
160
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Ke2')
161
+ expect(nxt.castling.sort).to eq(%w[k q])
162
+ end
163
+
164
+ it 'moving the a1 rook drops queenside white' do
165
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Ra2')
166
+ expect(nxt.castling.sort).to eq(%w[K k q])
167
+ end
168
+
169
+ it 'moving the h1 rook drops kingside white' do
170
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Rh2')
171
+ expect(nxt.castling.sort).to eq(%w[Q k q])
172
+ end
173
+
174
+ it 'capturing a corner rook drops the matching right' do
175
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Rxa8')
176
+ expect(nxt.castling).not_to include('q')
177
+ expect(nxt.castling).to include('k')
178
+ end
179
+ end
180
+
181
+ describe 'halfmove and fullmove counters' do
182
+ it 'a pawn move resets the halfmove clock' do
183
+ nxt = position('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 5 3').move('e4')
184
+ expect(nxt.halfmove).to eq(0)
185
+ end
186
+
187
+ it 'a quiet non-pawn move increments the halfmove clock' do
188
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('Nf6')
189
+ expect(nxt.halfmove).to eq(1)
190
+ end
191
+
192
+ it 'a capture resets the halfmove clock' do
193
+ nxt = position('rnbqkbnr/3ppppp/8/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 7 4').move('exd5')
194
+ expect(nxt.halfmove).to eq(0)
195
+ end
196
+
197
+ it "black's move increments the fullmove counter" do
198
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('Nf6')
199
+ expect(nxt.fullmove).to eq(2)
200
+ end
201
+
202
+ it "white's move does not increment the fullmove counter" do
203
+ nxt = PGN::Position.start.move('e4')
204
+ expect(nxt.fullmove).to eq(1)
205
+ end
206
+ end
207
+
208
+ describe 'en passant square' do
209
+ it 'is set after a white double push' do
210
+ expect(PGN::Position.start.move('e4').en_passant).to eq('e3')
211
+ end
212
+
213
+ it 'is set after a black double push' do
214
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('d5')
215
+ expect(nxt.en_passant).to eq('d6')
216
+ end
217
+
218
+ it 'is nil after a single push' do
219
+ expect(PGN::Position.start.move('Nf3').en_passant).to be_nil
220
+ end
221
+
222
+ it 'is nil after castling' do
223
+ expect(position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('O-O').en_passant).to be_nil
224
+ end
225
+ end
226
+ end