sciolyff-duosmium 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +92 -0
  4. data/bin/sciolyff +38 -0
  5. data/lib/sciolyff.rb +9 -0
  6. data/lib/sciolyff/interpreter.rb +89 -0
  7. data/lib/sciolyff/interpreter/bids.rb +24 -0
  8. data/lib/sciolyff/interpreter/event.rb +69 -0
  9. data/lib/sciolyff/interpreter/html.rb +65 -0
  10. data/lib/sciolyff/interpreter/html/helpers.rb +230 -0
  11. data/lib/sciolyff/interpreter/html/main.css +1 -0
  12. data/lib/sciolyff/interpreter/html/main.js +74 -0
  13. data/lib/sciolyff/interpreter/html/template.html.erb +480 -0
  14. data/lib/sciolyff/interpreter/model.rb +29 -0
  15. data/lib/sciolyff/interpreter/penalty.rb +19 -0
  16. data/lib/sciolyff/interpreter/placing.rb +127 -0
  17. data/lib/sciolyff/interpreter/raw.rb +50 -0
  18. data/lib/sciolyff/interpreter/subdivisions.rb +72 -0
  19. data/lib/sciolyff/interpreter/team.rb +111 -0
  20. data/lib/sciolyff/interpreter/tiebreaks.rb +34 -0
  21. data/lib/sciolyff/interpreter/tournament.rb +194 -0
  22. data/lib/sciolyff/validator.rb +89 -0
  23. data/lib/sciolyff/validator/canonical.rb +23 -0
  24. data/lib/sciolyff/validator/checker.rb +33 -0
  25. data/lib/sciolyff/validator/events.rb +106 -0
  26. data/lib/sciolyff/validator/logger.rb +48 -0
  27. data/lib/sciolyff/validator/penalties.rb +19 -0
  28. data/lib/sciolyff/validator/placings.rb +136 -0
  29. data/lib/sciolyff/validator/range.rb +15 -0
  30. data/lib/sciolyff/validator/raws.rb +40 -0
  31. data/lib/sciolyff/validator/sections.rb +56 -0
  32. data/lib/sciolyff/validator/subdivisions.rb +64 -0
  33. data/lib/sciolyff/validator/teams.rb +108 -0
  34. data/lib/sciolyff/validator/top_level.rb +23 -0
  35. data/lib/sciolyff/validator/tournament.rb +138 -0
  36. data/sciolyff.gemspec +22 -0
  37. 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