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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/README.md +104 -4
- data/Rakefile +20 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +75 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +11 -2
- data/lib/pgn/lexer.rb +201 -0
- data/lib/pgn/move.rb +7 -3
- data/lib/pgn/move_calculator.rb +18 -17
- data/lib/pgn/parser.rb +19 -199
- data/lib/pgn/pgn_parser.rb +392 -0
- data/lib/pgn/pgn_parser.y +142 -0
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +15 -0
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- 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
|
data/spec/board_spec.rb
ADDED
|
@@ -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
|
data/spec/lexer_spec.rb
ADDED
|
@@ -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
|