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.
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