sciolyff-duosmium 0.13.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 +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
|