sciolyff-duosmium 0.13.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +92 -0
- data/bin/sciolyff +38 -0
- data/lib/sciolyff.rb +9 -0
- data/lib/sciolyff/interpreter.rb +89 -0
- data/lib/sciolyff/interpreter/bids.rb +24 -0
- data/lib/sciolyff/interpreter/event.rb +69 -0
- data/lib/sciolyff/interpreter/html.rb +65 -0
- data/lib/sciolyff/interpreter/html/helpers.rb +230 -0
- data/lib/sciolyff/interpreter/html/main.css +1 -0
- data/lib/sciolyff/interpreter/html/main.js +74 -0
- data/lib/sciolyff/interpreter/html/template.html.erb +480 -0
- data/lib/sciolyff/interpreter/model.rb +29 -0
- data/lib/sciolyff/interpreter/penalty.rb +19 -0
- data/lib/sciolyff/interpreter/placing.rb +127 -0
- data/lib/sciolyff/interpreter/raw.rb +50 -0
- data/lib/sciolyff/interpreter/subdivisions.rb +72 -0
- data/lib/sciolyff/interpreter/team.rb +111 -0
- data/lib/sciolyff/interpreter/tiebreaks.rb +34 -0
- data/lib/sciolyff/interpreter/tournament.rb +194 -0
- data/lib/sciolyff/validator.rb +89 -0
- data/lib/sciolyff/validator/canonical.rb +23 -0
- data/lib/sciolyff/validator/checker.rb +33 -0
- data/lib/sciolyff/validator/events.rb +106 -0
- data/lib/sciolyff/validator/logger.rb +48 -0
- data/lib/sciolyff/validator/penalties.rb +19 -0
- data/lib/sciolyff/validator/placings.rb +136 -0
- data/lib/sciolyff/validator/range.rb +15 -0
- data/lib/sciolyff/validator/raws.rb +40 -0
- data/lib/sciolyff/validator/sections.rb +56 -0
- data/lib/sciolyff/validator/subdivisions.rb +64 -0
- data/lib/sciolyff/validator/teams.rb +108 -0
- data/lib/sciolyff/validator/top_level.rb +23 -0
- data/lib/sciolyff/validator/tournament.rb +138 -0
- data/sciolyff.gemspec +22 -0
- metadata +121 -0
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sciolyff/interpreter/model'
|
4
|
+
require 'sciolyff/interpreter/bids'
|
5
|
+
|
6
|
+
module SciolyFF
|
7
|
+
# Models a Science Olympiad tournament
|
8
|
+
class Interpreter::Tournament < Interpreter::Model
|
9
|
+
def initialize(rep)
|
10
|
+
@rep = rep[:Tournament]
|
11
|
+
end
|
12
|
+
|
13
|
+
def link_to_other_models(interpreter)
|
14
|
+
@events = interpreter.events
|
15
|
+
@teams = interpreter.teams
|
16
|
+
@placings = interpreter.placings
|
17
|
+
@penalties = interpreter.penalties
|
18
|
+
@subdivisions = interpreter.subdivisions
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_reader :events, :teams, :placings, :penalties, :subdivisions
|
22
|
+
|
23
|
+
undef tournament
|
24
|
+
|
25
|
+
%i[
|
26
|
+
name
|
27
|
+
location
|
28
|
+
level
|
29
|
+
state
|
30
|
+
division
|
31
|
+
year
|
32
|
+
].each do |sym|
|
33
|
+
define_method(sym) { @rep[sym] }
|
34
|
+
end
|
35
|
+
|
36
|
+
def short_name
|
37
|
+
@rep[:'short name']
|
38
|
+
end
|
39
|
+
|
40
|
+
def date
|
41
|
+
@date ||= if @rep[:date].instance_of?(Date)
|
42
|
+
@rep[:date]
|
43
|
+
else
|
44
|
+
unless @rep[:date].nil?
|
45
|
+
Date.parse(@rep[:date])
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def start_date
|
51
|
+
@start_date ||= if !@rep[:'start date'].nil?
|
52
|
+
if @rep[:'start date'].instance_of?(Date)
|
53
|
+
@rep[:'start date']
|
54
|
+
else
|
55
|
+
Date.parse(@rep[:'start date'])
|
56
|
+
end
|
57
|
+
else
|
58
|
+
if @rep[:date].instance_of?(Date)
|
59
|
+
@rep[:date]
|
60
|
+
else
|
61
|
+
unless @rep[:date].nil?
|
62
|
+
Date.parse(@rep[:date])
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def end_date
|
69
|
+
@end_date ||= if !@rep[:'end date'].nil?
|
70
|
+
if @rep[:'end date'].instance_of?(Date)
|
71
|
+
@rep[:'end date']
|
72
|
+
else
|
73
|
+
Date.parse(@rep[:'end date'])
|
74
|
+
end
|
75
|
+
else
|
76
|
+
if @rep[:date].instance_of?(Date)
|
77
|
+
@rep[:date]
|
78
|
+
else
|
79
|
+
unless @rep[:date].nil?
|
80
|
+
Date.parse(@rep[:date])
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def awards_date
|
87
|
+
@awards_date ||= if !@rep[:'awards date'].nil?
|
88
|
+
if @rep[:'awards date'].instance_of?(Date)
|
89
|
+
@rep[:'awards date']
|
90
|
+
else
|
91
|
+
Date.parse(@rep[:'awards date'])
|
92
|
+
end
|
93
|
+
else
|
94
|
+
if !@rep[:'end date'].nil?
|
95
|
+
if @rep[:'end date'].instance_of?(Date)
|
96
|
+
@rep[:'end date']
|
97
|
+
else
|
98
|
+
Date.parse(@rep[:'end date'])
|
99
|
+
end
|
100
|
+
else
|
101
|
+
if @rep[:date].instance_of?(Date)
|
102
|
+
@rep[:date]
|
103
|
+
else
|
104
|
+
unless @rep[:date].nil?
|
105
|
+
Date.parse(@rep[:date])
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def medals
|
113
|
+
@rep[:medals] || [calc_medals, maximum_place].min
|
114
|
+
end
|
115
|
+
|
116
|
+
def trophies
|
117
|
+
@rep[:trophies] || [calc_trophies, nonexhibition_teams_count].min
|
118
|
+
end
|
119
|
+
|
120
|
+
def bids
|
121
|
+
@rep[:bids] || 0
|
122
|
+
end
|
123
|
+
|
124
|
+
def bids_per_school
|
125
|
+
@rep[:'bids per school'] || 1
|
126
|
+
end
|
127
|
+
|
128
|
+
def worst_placings_dropped?
|
129
|
+
worst_placings_dropped.positive?
|
130
|
+
end
|
131
|
+
|
132
|
+
def worst_placings_dropped
|
133
|
+
@rep[:'worst placings dropped'] || 0
|
134
|
+
end
|
135
|
+
|
136
|
+
def exempt_placings?
|
137
|
+
exempt_placings.positive?
|
138
|
+
end
|
139
|
+
|
140
|
+
def exempt_placings
|
141
|
+
@rep[:'exempt placings'] || 0
|
142
|
+
end
|
143
|
+
|
144
|
+
def custom_maximum_place?
|
145
|
+
maximum_place != nonexhibition_teams_count
|
146
|
+
end
|
147
|
+
|
148
|
+
def maximum_place
|
149
|
+
@rep[:'maximum place'] || nonexhibition_teams_count
|
150
|
+
end
|
151
|
+
|
152
|
+
def per_event_n
|
153
|
+
@rep[:'per-event n']
|
154
|
+
end
|
155
|
+
|
156
|
+
def per_event_n?
|
157
|
+
@rep.key? :'per-event n'
|
158
|
+
end
|
159
|
+
|
160
|
+
def n_offset
|
161
|
+
@rep[:'n offset'] || 0
|
162
|
+
end
|
163
|
+
|
164
|
+
def ties?
|
165
|
+
@ties ||= placings.map(&:tie?).any?
|
166
|
+
end
|
167
|
+
|
168
|
+
def ties_outside_of_maximum_places?
|
169
|
+
@ties_outside_of_maximum_places ||= placings.any? do |p|
|
170
|
+
p.tie? && !p.points_limited_by_maximum_place?
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def subdivisions?
|
175
|
+
!@subdivisions.empty?
|
176
|
+
end
|
177
|
+
|
178
|
+
def nonexhibition_teams_count
|
179
|
+
@nonexhibition_teams_count ||= @teams.count { |t| !t.exhibition? }
|
180
|
+
end
|
181
|
+
|
182
|
+
include Interpreter::Bids
|
183
|
+
|
184
|
+
private
|
185
|
+
|
186
|
+
def calc_medals
|
187
|
+
(nonexhibition_teams_count / 10r).ceil.clamp(3, 10)
|
188
|
+
end
|
189
|
+
|
190
|
+
def calc_trophies
|
191
|
+
(nonexhibition_teams_count / 6r).ceil.clamp(3, 10)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SciolyFF
|
4
|
+
# Checks if SciolyFF YAML files and/or representations (i.e. hashes that can
|
5
|
+
# be directly converted to YAML) comply with spec (i.e. safe for interpreting)
|
6
|
+
class Validator
|
7
|
+
require 'sciolyff/validator/logger'
|
8
|
+
require 'sciolyff/validator/checker'
|
9
|
+
require 'sciolyff/validator/sections'
|
10
|
+
|
11
|
+
require 'sciolyff/validator/top_level'
|
12
|
+
require 'sciolyff/validator/tournament'
|
13
|
+
require 'sciolyff/validator/subdivisions'
|
14
|
+
require 'sciolyff/validator/events'
|
15
|
+
require 'sciolyff/validator/teams'
|
16
|
+
require 'sciolyff/validator/placings'
|
17
|
+
require 'sciolyff/validator/penalties'
|
18
|
+
require 'sciolyff/validator/raws'
|
19
|
+
|
20
|
+
def initialize(loglevel: Logger::WARN, canonical: true)
|
21
|
+
@logger = Logger.new loglevel, canonical_checks: canonical
|
22
|
+
@checkers = {}
|
23
|
+
end
|
24
|
+
|
25
|
+
def valid?(rep_or_yaml)
|
26
|
+
@logger.flush
|
27
|
+
|
28
|
+
if rep_or_yaml.instance_of? String
|
29
|
+
valid_yaml?(rep_or_yaml, @logger)
|
30
|
+
else
|
31
|
+
valid_rep?(rep_or_yaml, @logger)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def last_log
|
36
|
+
@logger.log
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def valid_rep?(rep, logger)
|
42
|
+
unless rep.instance_of? Hash
|
43
|
+
logger.error 'improper file structure'
|
44
|
+
return false
|
45
|
+
end
|
46
|
+
|
47
|
+
result = check_all(rep, logger)
|
48
|
+
|
49
|
+
@checkers.clear # aka this method is not thread-safe
|
50
|
+
result
|
51
|
+
end
|
52
|
+
|
53
|
+
def valid_yaml?(yaml, logger)
|
54
|
+
rep = Psych.safe_load(
|
55
|
+
yaml,
|
56
|
+
permitted_classes: [Date],
|
57
|
+
symbolize_names: true
|
58
|
+
)
|
59
|
+
rescue StandardError => e
|
60
|
+
logger.error "could not read input as YAML:\n#{e.message}"
|
61
|
+
else
|
62
|
+
valid_rep?(rep, logger)
|
63
|
+
end
|
64
|
+
|
65
|
+
def check_all(rep, logger)
|
66
|
+
check(TopLevel, rep, rep, logger) &&
|
67
|
+
check(Tournament, rep, rep[:Tournament], logger) &&
|
68
|
+
[Subdivisions, Events, Teams, Placings, Penalties].all? do |klass|
|
69
|
+
check_list(klass, rep, logger)
|
70
|
+
end &&
|
71
|
+
rep[:Placings].map { |p| p[:raw] }.compact.all? do |r|
|
72
|
+
check(Raws, rep, r, logger)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def check_list(klass, rep, logger)
|
77
|
+
key = klass.to_s.split('::').last.to_sym
|
78
|
+
return true unless rep.key? key # ignore optional sections like Penalties
|
79
|
+
|
80
|
+
rep[key].map { |e| check(klass, rep, e, logger) }.all?
|
81
|
+
end
|
82
|
+
|
83
|
+
def check(klass, top_level_rep, rep, logger)
|
84
|
+
@checkers[klass] ||= klass.new top_level_rep, logger
|
85
|
+
checks = klass.instance_methods - Checker.instance_methods
|
86
|
+
checks.map { |im| @checkers[klass].send im, rep, logger }.all?
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SciolyFF
|
4
|
+
# Logic for checking against canonical lists of events and schools
|
5
|
+
module Validator::Canonical
|
6
|
+
BASE = 'https://raw.githubusercontent.com/unosmium/canonical-names/master/'
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def canonical?(rep, file, logger)
|
11
|
+
return true if @canonical_warned || !logger.options[:canonical_checks]
|
12
|
+
|
13
|
+
@canonical_list ||= CSV.parse(URI.open(BASE + file))
|
14
|
+
# don't try to make this more efficient, harder than it looks because of
|
15
|
+
# nil comparisons
|
16
|
+
@canonical_list.include?(rep)
|
17
|
+
rescue StandardError => e
|
18
|
+
logger.warn "could not read canonical names file: #{BASE + file}"
|
19
|
+
logger.debug "#{e}\n #{e.backtrace.first}"
|
20
|
+
@canonical_warned = true
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SciolyFF
|
4
|
+
# An empty base class to ensure consistent inheritance. All instance methods
|
5
|
+
# in children classes should take the arguments rep and logger.
|
6
|
+
class Validator::Checker
|
7
|
+
def initialize(rep); end
|
8
|
+
|
9
|
+
# Wraps initialize for child classes
|
10
|
+
module SafeInitialize
|
11
|
+
def initialize(rep, logger)
|
12
|
+
super rep
|
13
|
+
rescue StandardError => e
|
14
|
+
logger.debug "#{e}\n #{e.backtrace.first}"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.inherited(subclass)
|
19
|
+
subclass.prepend SafeInitialize
|
20
|
+
end
|
21
|
+
|
22
|
+
# wraps method calls (always using send in Validator) so that exceptions
|
23
|
+
# in the check cause check to pass, as what caused the exception should
|
24
|
+
# cause some other check to fail if the SciolyFF is truly invalid
|
25
|
+
#
|
26
|
+
# this simplifies the checking code greatly, even if it is a bit hacky
|
27
|
+
def send(method, *args)
|
28
|
+
super
|
29
|
+
rescue StandardError => e
|
30
|
+
args[1].debug "#{e}\n #{e.backtrace.first}" # args[1] is the logger
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sciolyff/validator/checker'
|
4
|
+
require 'sciolyff/validator/sections'
|
5
|
+
require 'sciolyff/validator/canonical'
|
6
|
+
|
7
|
+
module SciolyFF
|
8
|
+
# Checks for one event in the Events section of a SciolyFF file
|
9
|
+
class Validator::Events < Validator::Checker
|
10
|
+
include Validator::Sections
|
11
|
+
|
12
|
+
REQUIRED = {
|
13
|
+
name: String
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
OPTIONAL = {
|
17
|
+
trial: [true, false],
|
18
|
+
trialed: [true, false],
|
19
|
+
scoring: %w[high low]
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
def initialize(rep)
|
23
|
+
@events = rep[:Events]
|
24
|
+
@names = @events.map { |e| e[:name] }
|
25
|
+
@placings = rep[:Placings].group_by { |p| p[:event] }
|
26
|
+
@teams = rep[:Teams].count
|
27
|
+
end
|
28
|
+
|
29
|
+
def unique_name?(event, logger)
|
30
|
+
return true if @names.count(event[:name]) == 1
|
31
|
+
|
32
|
+
logger.error "duplicate event name: #{event[:name]}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def placings_for_all_teams?(event, logger)
|
36
|
+
count = @placings[event[:name]]&.count || 0
|
37
|
+
return true if count == @teams
|
38
|
+
|
39
|
+
logger.error "'event: #{event[:name]}' has incorrect number of "\
|
40
|
+
"placings (#{count} instead of #{@teams})"
|
41
|
+
end
|
42
|
+
|
43
|
+
def ties_marked?(event, logger)
|
44
|
+
unmarked_ties = placings_by_place(event).select do |_place, placings|
|
45
|
+
placings.count { |p| !p[:tie] } > 1
|
46
|
+
end
|
47
|
+
return true if unmarked_ties.empty?
|
48
|
+
|
49
|
+
logger.error "'event: #{event[:name]}' has unmarked ties at "\
|
50
|
+
"place #{unmarked_ties.keys.join ', '}"
|
51
|
+
end
|
52
|
+
|
53
|
+
def ties_paired?(event, logger)
|
54
|
+
unpaired_ties = placings_by_place(event).select do |_place, placings|
|
55
|
+
placings.count { |p| p[:tie] } == 1
|
56
|
+
end
|
57
|
+
return true if unpaired_ties.empty?
|
58
|
+
|
59
|
+
logger.error "'event: #{event[:name]}' has unpaired ties at "\
|
60
|
+
"place #{unpaired_ties.keys.join ', '}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def no_gaps_in_places?(event, logger)
|
64
|
+
places = places_with_expanded_ties(event)
|
65
|
+
return true if places.empty?
|
66
|
+
|
67
|
+
gaps = (places.min..places.max).to_a - places
|
68
|
+
return true if gaps.empty?
|
69
|
+
|
70
|
+
logger.error "'event: #{event[:name]}' has gaps in "\
|
71
|
+
"place #{gaps.join ', '}"
|
72
|
+
end
|
73
|
+
|
74
|
+
def places_start_at_one?(event, logger)
|
75
|
+
lowest_place = @placings[event[:name]].map { |p| p[:place] }.compact.min
|
76
|
+
return true if lowest_place == 1 || lowest_place.nil?
|
77
|
+
|
78
|
+
logger.error "places for 'event: #{event[:name]}' start at "\
|
79
|
+
"#{lowest_place} instead of 1"
|
80
|
+
end
|
81
|
+
|
82
|
+
include Validator::Canonical
|
83
|
+
|
84
|
+
def in_canonical_list?(event, logger)
|
85
|
+
rep = [event[:name]]
|
86
|
+
return true if canonical?(rep, 'events.csv', logger)
|
87
|
+
|
88
|
+
logger.warn "non-canonical event: #{event[:name]}"
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def placings_by_place(event)
|
94
|
+
@placings[event[:name]]
|
95
|
+
.select { |p| p[:place] }
|
96
|
+
.group_by { |p| p[:place] }
|
97
|
+
end
|
98
|
+
|
99
|
+
def places_with_expanded_ties(event)
|
100
|
+
# e.g. [6, 6, 8] -> [6, 7, 8]
|
101
|
+
placings_by_place(event).map do |place, placings|
|
102
|
+
(place..(place + (placings.size - 1))).to_a
|
103
|
+
end.flatten
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|