sanichi-chess_icu 0.1.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.
data/lib/result.rb ADDED
@@ -0,0 +1,103 @@
1
+ module ICU
2
+ class Result
3
+ attr_reader :round, :player, :score, :colour, :opponent, :rateable
4
+
5
+ def initialize(round, player, score, opt={})
6
+ self.round = round
7
+ self.player = player
8
+ self.score = score
9
+ [:colour, :opponent].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
10
+ self.rateable = opt[:rateable] # always attempt to set this, and do it last, to get the right default
11
+ end
12
+
13
+ # Round number. Must be a positive integer.
14
+ def round=(round)
15
+ @round = round.to_i
16
+ raise "invalid round number (#{round})" unless @round > 0
17
+ end
18
+
19
+ # Player number. Can be any integer.
20
+ def player=(player)
21
+ @player = case player
22
+ when Fixnum then player
23
+ else player.to_i
24
+ end
25
+ raise "invalid player number (#{player})" if @player == 0 && !player.to_s.match(/\d/)
26
+ end
27
+
28
+ # Score for the game, even if a default. One of 'W', 'L' or 'D' (after some cleaning up).
29
+ def score=(score)
30
+ @score = case score.to_s.strip
31
+ when /^(1\.0|1|\+|W|w)$/ then 'W'
32
+ when /^(0\.5|½|\=|D|d)$/ then 'D'
33
+ when /^(0\.0|0|\-|L|l)$/ then 'L'
34
+ else raise "invalid score (#{score})"
35
+ end
36
+ end
37
+
38
+ # The score as a number.
39
+ def points
40
+ case @score
41
+ when 'W' then 1.0
42
+ when 'L' then 0.0
43
+ else 0.5
44
+ end
45
+ end
46
+
47
+ # Colour. Either 'W' or 'B' after some cleaning up.
48
+ def colour=(colour)
49
+ @colour = case colour.to_s
50
+ when '' then nil
51
+ when /W/i then 'W'
52
+ when /B/i then 'B'
53
+ else raise "invalid colour (#{colour})"
54
+ end
55
+ end
56
+
57
+ # Opponent player number. Either absent (nil) or any integer except the player number.
58
+ def opponent=(opponent)
59
+ @opponent = case opponent
60
+ when nil then nil
61
+ when Fixnum then opponent
62
+ when /^\s*$/ then nil
63
+ else opponent.to_i
64
+ end
65
+ raise "invalid opponent number (#{opponent})" if @opponent == 0 && !opponent.to_s.match(/\d/)
66
+ raise "opponent number and player number (#{@opponent}) must be different" if @opponent == player
67
+ self.rateable = true if @opponent
68
+ end
69
+
70
+ # Rateable flag. If false, game is not rateable. Can only be true if there is an opponent.
71
+ def rateable=(rateable)
72
+ if opponent.nil?
73
+ @rateable = false
74
+ return
75
+ end
76
+ @rateable = case rateable
77
+ when nil then true # default (when absent) is true
78
+ when false then false # this is the only way to turn it off
79
+ else true
80
+ end
81
+ end
82
+
83
+ # Reverse a result so it is seen from the opponent's perspective.
84
+ def reverse(rateable=nil)
85
+ return unless @opponent
86
+ r = Result.new(@round, @opponent, @score == 'W' ? 'L' : (@score == 'L' ? 'W' : 'D'))
87
+ r.opponent = @player
88
+ r.colour = 'W' if @colour == 'B'
89
+ r.colour = 'B' if @colour == 'W'
90
+ r.rateable = rateable || @rateable
91
+ r
92
+ end
93
+
94
+ # Loose equality.
95
+ def ==(other)
96
+ return unless other.is_a? Result
97
+ [:round, :player, :opponent, :colour, :score].each do |m|
98
+ return false unless self.send(m) == other.send(m)
99
+ end
100
+ true
101
+ end
102
+ end
103
+ end
data/lib/tournament.rb ADDED
@@ -0,0 +1,81 @@
1
+ module ICU
2
+ class Tournament
3
+ attr_reader :name, :start, :rounds, :site
4
+
5
+ # Constructor.
6
+ def initialize(name, start, opt={})
7
+ self.name = name
8
+ self.start = start
9
+ [:rounds, :site].each { |a| self.send("#{a}=", opt[a]) unless opt[a].nil? }
10
+ @player = {}
11
+ end
12
+
13
+ # Tournament name.
14
+ def name=(name)
15
+ raise "invalid tournament name (#{name})" unless name.to_s.match(/[a-z]/i)
16
+ @name = name.to_s.strip
17
+ end
18
+
19
+ # Start data in yyyy-mm-dd format.
20
+ def start=(start)
21
+ start = start.to_s.strip
22
+ @start = Util.parsedate(start)
23
+ raise "invalid start date (#{start})" unless @start
24
+ end
25
+
26
+ # Number of rounds. Is either unknown (nil) or a positive integer.
27
+ def rounds=(rounds)
28
+ @rounds = case rounds
29
+ when nil then nil
30
+ when Fixnum then rounds
31
+ when /^\s*$/ then nil
32
+ else rounds.to_i
33
+ end
34
+ raise "invalid number of rounds (#{rounds})" unless @rounds.nil? || @rounds > 0
35
+ end
36
+
37
+ # Web site. Either unknown or a reasonably valid looking URL.
38
+ def site=(site)
39
+ @site = site.to_s.strip
40
+ @site = nil if @site == ''
41
+ @site = "http://#{@site}" if @site && !@site.match(/^https?:\/\//)
42
+ raise "invalid site (#{site})" unless @site.nil? || @site.match(/^https?:\/\/[-\w]+(\.[-\w]+)+(\/[^\s]*)?$/i)
43
+ end
44
+
45
+ # Add players.
46
+ def add_player(player)
47
+ raise "invalid player" unless player.class == ICU::Player
48
+ raise "player number (#{player.num}) should be unique" if @player[player.num]
49
+ @player[player.num] = player
50
+ end
51
+
52
+ # Get a player by their number.
53
+ def player(num)
54
+ @player[num]
55
+ end
56
+
57
+ # Return an array of all the players.
58
+ def players
59
+ @player.values
60
+ end
61
+
62
+ # Lookup a player in the tournament.
63
+ def find_player(player)
64
+ players.find { |p| p == player }
65
+ end
66
+
67
+ # Add results.
68
+ def add_result(result, reverse_rateable=true)
69
+ raise "invalid result" unless result.class == ICU::Result
70
+ raise "result round number (#{result.round}) inconsistent with number of tournament rounds" if @rounds && result.round > @rounds
71
+ raise "player number (#{result.player}) does not exist" unless @player[result.player]
72
+ @player[result.player].add_result(result)
73
+ if result.opponent
74
+ raise "opponent number (#{result.opponent}) does not exist" unless @player[result.opponent]
75
+ reverse = result.reverse
76
+ reverse.rateable = false unless reverse_rateable
77
+ @player[result.opponent].add_result(reverse)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,155 @@
1
+ require 'fastercsv'
2
+
3
+ module ICU
4
+ class Tournament
5
+ class ForeignCSV
6
+ attr_reader :tournament
7
+
8
+ # Constructor.
9
+ def initialize(csv)
10
+ @state, @line, @round, @sum = 0, 0, nil, nil
11
+ parse(csv)
12
+ end
13
+
14
+ private
15
+
16
+ def parse(csv)
17
+ @tournament = Tournament.new('Dummy', '2000-01-01')
18
+ FasterCSV.parse(csv, :row_sep => :auto) do |r|
19
+ @line += 1 # increment line number
20
+ next if r.size == 0 # skip empty lines
21
+ r = r.map{|c| c.nil? ? '' : c.strip} # trim all spaces, turn nils to blanks
22
+ next if r[0] == '' # skip blanks in column 1
23
+ @r = r # remember this record for later
24
+
25
+ begin
26
+ case @state
27
+ when 0 then event
28
+ when 1 then start
29
+ when 2 then rounds
30
+ when 3 then website
31
+ when 4 then player
32
+ when 5 then result
33
+ when 6 then total
34
+ else raise "internal error - state #{@state} does not exist"
35
+ end
36
+ rescue => err
37
+ raise err.class, "line #{@line}: #{err.message}", err.backtrace unless err.message.match(/^line [1-9]/)
38
+ raise
39
+ end
40
+ end
41
+
42
+ unless @state == 4
43
+ exp = case @state
44
+ when 0 then "the event name"
45
+ when 1 then "the start date"
46
+ when 2 then "the number of rounds"
47
+ when 3 then "the website address"
48
+ when 5 then "a result for round #{@round+1}"
49
+ when 6 then "a total score"
50
+ end
51
+ raise "line #{@line}: premature termination - expected #{exp}"
52
+ end
53
+ raise "line #{@line}: no players found in file" if @tournament.players.size == 0
54
+ end
55
+
56
+ def event
57
+ abort "the 'Event' keyword", 0 unless @r[0].match(/^(Event|Tournament)$/i)
58
+ abort "the event name", 1 unless @r.size > 1 && @r[1] != ''
59
+ @tournament.name = @r[1]
60
+ @state = 1
61
+ end
62
+
63
+ def start
64
+ abort "the 'Start' keyword", 0 unless @r[0].match(/^(Start(\s+Date)?|Date)$/i)
65
+ abort "the start date", 1 unless @r.size > 1 && @r[1] != ''
66
+ @tournament.start = @r[1]
67
+ @state = 2
68
+ end
69
+
70
+ def rounds
71
+ abort "the 'Rounds' keyword", 0 unless @r[0].match(/(Number of )?Rounds$/)
72
+ abort "the number of rounds", 1 unless @r.size > 1 && @r[1].match(/^[1-9]\d*/)
73
+ @tournament.rounds = @r[1]
74
+ @state = 3
75
+ end
76
+
77
+ def website
78
+ abort "the 'Website' keyword", 0 unless @r[0].match(/^(Web(\s?site)?|Site)$/i)
79
+ abort "the event website", 1 unless @r.size > 1 && @r[1] != ''
80
+ @tournament.site = @r[1]
81
+ @state = 4
82
+ end
83
+
84
+ def player
85
+ abort "the 'Player' keyword", 0 unless @r[0].match(/^Player$/i)
86
+ abort "a player's ICU number", 1 unless @r.size > 1 && @r[1].match(/^[1-9]/i)
87
+ abort "a player's last name", 2 unless @r.size > 2 && @r[2].match(/[a-z]/i)
88
+ abort "a player's first name", 3 unless @r.size > 3 && @r[3].match(/[a-z]/i)
89
+ @player = Player.new(@r[3], @r[2], @tournament.players.size + 1, :id => @r[1])
90
+ old_player = @tournament.find_player(@player)
91
+ if old_player
92
+ raise "two players with the same name (#{@player.name}) have conflicting details" unless old_player.eql?(@player)
93
+ raise "same player (#{@player.name}) has more than one set of results" if old_player.id
94
+ old_player.subsume(@player)
95
+ @player = old_player
96
+ else
97
+ @tournament.add_player(@player)
98
+ end
99
+ @round = 0
100
+ @state = 5
101
+ end
102
+
103
+ def result
104
+ @round+= 1
105
+ abort "round number #{round}", 0 unless @r[0].to_i == @round
106
+ abort "a colour (W/B) or dash (for a bye)", 2 unless @r.size > 2 && @r[2].match(/^(W|B|-)/i)
107
+ result = Result.new(@round, @player.num, @r[1])
108
+ if @r[2] == '-'
109
+ @tournament.add_result(result)
110
+ else
111
+ result.colour = @r[2]
112
+ opponent = Player.new(@r[4], @r[3], @tournament.players.size + 1, :rating => @r[5], :title => @r[6], :fed => @r[7])
113
+ raise "opponent must have a rating and federation" unless opponent.rating && opponent.fed
114
+ old_player = @tournament.find_player(opponent)
115
+ if old_player
116
+ raise "two players with the same name (#{opponent.name}) have conflicting details" unless old_player.eql?(opponent)
117
+ result.opponent = old_player.num
118
+ if old_player.id
119
+ old_player.subsume(opponent)
120
+ old_result = @player.find_result(@round)
121
+ raise "missing result for player (#{@player.name}) in round #{@round}" unless old_result
122
+ raise "mismatched results for player (#{old_player.name}) in round #{@round}" unless result == old_result
123
+ old_result.rateable = true
124
+ else
125
+ old_result = old_player.find_result(@round)
126
+ raise "a player (#{old_player.name}) has more than one game in the same round (#{@round})" if old_result
127
+ @tournament.add_result(result, false)
128
+ end
129
+ else
130
+ @tournament.add_player(opponent)
131
+ result.opponent = opponent.num
132
+ @tournament.add_result(result, false)
133
+ end
134
+ end
135
+ @state = 6 if @round == @tournament.rounds
136
+ end
137
+
138
+ def total
139
+ points = @player.points
140
+ abort "the 'Total' keyword", 0 unless @r[0].match(/^Total$/i)
141
+ abort "the player's (#{@player.object_id}, #{@player.results.size}) total points to be #{points}", 1 unless @r[1].to_f == points
142
+ @state = 4
143
+ end
144
+
145
+ def abort(expected, cell)
146
+ got = @r[cell]
147
+ error = "line #{@line}"
148
+ error << ", cell #{cell+1}"
149
+ error << ": expected #{expected}"
150
+ error << " but got #{got == '' ? 'a blank cell' : "'#{got}'"}"
151
+ raise error
152
+ end
153
+ end
154
+ end
155
+ end
data/lib/util.rb ADDED
@@ -0,0 +1,15 @@
1
+ module ICU
2
+ class Util
3
+ # Parse dates into yyyy-mm-dd format, preferring European format. Return nil on error.
4
+ def self.parsedate(date)
5
+ date = date.to_s
6
+ return nil unless date.match(/[1-9]/)
7
+ date.sub!(/^([1-9]|0[1-9]|[12][0-9]|3[01])([^\d])([1-9]|0[1-9]|1[0-2])([^\d])/, '\3\2\1\4')
8
+ begin
9
+ Date.parse(date, true).to_s
10
+ rescue
11
+ return nil
12
+ end
13
+ end
14
+ end
15
+ end
data/spec/name_spec.rb ADDED
@@ -0,0 +1,172 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ module ICU
4
+ describe Name do
5
+ context "public methods" do
6
+ before(:each) do
7
+ @simple = Name.new('mark j l', 'orr')
8
+ end
9
+
10
+ it "#first returns the first name(s)" do
11
+ @simple.first.should == 'Mark J. L.'
12
+ end
13
+
14
+ it "#last returns the last name(s)" do
15
+ @simple.last.should == 'Orr'
16
+ end
17
+
18
+ it "#name returns the full name with first name(s) first" do
19
+ @simple.name.should == 'Mark J. L. Orr'
20
+ end
21
+
22
+ it "#rname returns the full name with last name(s) first" do
23
+ @simple.rname.should == 'Orr, Mark J. L.'
24
+ end
25
+
26
+ it "#to_s is the same as rname" do
27
+ @simple.to_s.should == 'Orr, Mark J. L.'
28
+ end
29
+
30
+ it "#match returns true if and only if two names match" do
31
+ @simple.match('mark j l orr').should be_true
32
+ @simple.match('malcolm g l orr').should be_false
33
+ end
34
+ end
35
+
36
+ context "names that are already canonical" do
37
+ it "should not be altered" do
38
+ Name.new('Mark J. L.', 'Orr').name.should == 'Mark J. L. Orr'
39
+ Name.new('Anna-Marie J.-K.', 'Liviu-Dieter').name.should == 'Anna-Marie J.-K. Liviu-Dieter'
40
+ end
41
+ end
42
+
43
+ context "last names beginning with a single letter followed by a quote" do
44
+ it "should be handled correctly" do
45
+ Name.new('una', "O'boyle").name.should == "Una O'Boyle"
46
+ Name.new('jonathan', 'd`arcy').name.should == "Jonathan D'Arcy"
47
+ Name.new('erwin e', "L'AMI").name.should == "Erwin E. L'Ami"
48
+ Name.new('cormac', "o brien").name.should == "Cormac O'Brien"
49
+ end
50
+ end
51
+
52
+ context "last beginning with Mc" do
53
+ it "should be handled correctly" do
54
+ Name.new('shane', "mccabe").name.should == "Shane McCabe"
55
+ Name.new('shawn', "macDonagh").name.should == "Shawn MacDonagh"
56
+ Name.new('shawn', "macdonagh").name.should == "Shawn Macdonagh"
57
+ Name.new('bartlomiej', "macieja").name.should == "Bartlomiej Macieja"
58
+ end
59
+ end
60
+
61
+ context "doubled barrelled names or initials" do
62
+ it "should be handled correctly" do
63
+ Name.new('anna-marie', 'den-otter').name.should == 'Anna-Marie Den-Otter'
64
+ Name.new('j-k', 'rowling').name.should == 'J.-K. Rowling'
65
+ Name.new("mark j. - l", 'ORR').name.should == 'Mark J.-L. Orr'
66
+ Name.new('JOHANNA', "lowry-o'REILLY").name.should == "Johanna Lowry-O'Reilly"
67
+ Name.new('hannah', "lowry - o reilly").name.should == "Hannah Lowry-O'Reilly"
68
+ end
69
+ end
70
+
71
+ context "extraneous white space" do
72
+ it "should be handled correctly" do
73
+ Name.new(' mark j l ', " \t\r\n orr \n").name.should == 'Mark J. L. Orr'
74
+ end
75
+ end
76
+
77
+ context "extraneous full stops" do
78
+ it "should be handled correctly" do
79
+ Name.new('. mark j..l', 'orr.').name.should == 'Mark J. L. Orr'
80
+ end
81
+ end
82
+
83
+ context "construction from a single string" do
84
+ it "should be possible in simple cases" do
85
+ Name.new('ORR, mark j l').name.should == 'Mark J. L. Orr'
86
+ Name.new('MARK J L ORR').name.should == 'Mark J. L. Orr'
87
+ Name.new("O'Reilly, j-k").name.should == "J.-K. O'Reilly"
88
+ end
89
+ end
90
+
91
+ context "construction from an instance" do
92
+ it "should be possible" do
93
+ Name.new(Name.new('ORR, mark j l')).name.should == 'Mark J. L. Orr'
94
+ end
95
+ end
96
+
97
+ context "constuction corner cases" do
98
+ it "should be handled correctly" do
99
+ Name.new('Orr').name.should == 'Orr'
100
+ Name.new('Orr').rname.should == 'Orr'
101
+ Name.new('').name.should == ''
102
+ Name.new('').rname.should == ''
103
+ Name.new.name.should == ''
104
+ Name.new.rname.should == ''
105
+ end
106
+ end
107
+
108
+ context "inputs to matching" do
109
+ before(:all) do
110
+ @mark = Name.new('Mark', 'Orr')
111
+ @kram = Name.new('Mark', 'Orr')
112
+ end
113
+
114
+ it "should be flexible" do
115
+ @mark.match('Mark', 'Orr').should be_true
116
+ @mark.match('Mark Orr').should be_true
117
+ @mark.match('Orr, Mark').should be_true
118
+ @mark.match(@kram).should be_true
119
+ end
120
+ end
121
+
122
+ context "first name matches" do
123
+ it "should match when first names are the same" do
124
+ Name.new('Mark', 'Orr').match('Mark', 'Orr').should be_true
125
+ end
126
+
127
+ it "should be flexible with regards to hyphens in double barrelled names" do
128
+ Name.new('J.-K.', 'Rowling').match('J. K.', 'Rowling').should be_true
129
+ Name.new('Joanne-K.', 'Rowling').match('Joanne K.', 'Rowling').should be_true
130
+ end
131
+
132
+ it "should match initials" do
133
+ Name.new('M. J. L.', 'Orr').match('Mark John Legard', 'Orr').should be_true
134
+ Name.new('M.', 'Orr').match('Mark', 'Orr').should be_true
135
+ Name.new('M. J. L.', 'Orr').match('Mark', 'Orr').should be_true
136
+ Name.new('M.', 'Orr').match('M. J.', 'Orr').should be_true
137
+ Name.new('M. J. L.', 'Orr').match('M. G.', 'Orr').should be_false
138
+ end
139
+
140
+ it "should not match on full names not in first position or without an exact match" do
141
+ Name.new('J. M.', 'Orr').match('John', 'Orr').should be_true
142
+ Name.new('M. J.', 'Orr').match('John', 'Orr').should be_false
143
+ Name.new('M. John', 'Orr').match('John', 'Orr').should be_true
144
+ end
145
+
146
+ it "should handle common nicknames" do
147
+ Name.new('William', 'Orr').match('Bill', 'Orr').should be_true
148
+ Name.new('David', 'Orr').match('Dave', 'Orr').should be_true
149
+ Name.new('Mick', 'Orr').match('Mike', 'Orr').should be_true
150
+ end
151
+
152
+ it "should not mix up nick names" do
153
+ Name.new('David', 'Orr').match('Bill', 'Orr').should be_false
154
+ end
155
+ end
156
+
157
+ context "last name matches" do
158
+ it "should be flexible with regards to hyphens in double barrelled names" do
159
+ Name.new('Johanna', "Lowry-O'Reilly").match('Johanna', "Lowry O'Reilly").should be_true
160
+ end
161
+
162
+ it "should be case insensitive in matches involving Macsomething and MacSomething" do
163
+ Name.new('Alan', 'MacDonagh').match('Alan', 'Macdonagh').should be_true
164
+ end
165
+
166
+ it "should cater for the common mispelling of names beginning with Mc or Mac" do
167
+ Name.new('Alan', 'McDonagh').match('Alan', 'MacDonagh').should be_true
168
+ Name.new('Darko', 'Polimac').match('Darko', 'Polimc').should be_false
169
+ end
170
+ end
171
+ end
172
+ end