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,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SciolyFF
|
4
|
+
# Prints extra information produced by validation process
|
5
|
+
class Validator::Logger
|
6
|
+
ERROR = 0
|
7
|
+
WARN = 1
|
8
|
+
INFO = 2
|
9
|
+
DEBUG = 3
|
10
|
+
|
11
|
+
attr_reader :log, :options
|
12
|
+
|
13
|
+
def initialize(loglevel, **options)
|
14
|
+
@loglevel = loglevel
|
15
|
+
@options = options
|
16
|
+
flush
|
17
|
+
end
|
18
|
+
|
19
|
+
def flush
|
20
|
+
@log = String.new
|
21
|
+
end
|
22
|
+
|
23
|
+
def error(msg)
|
24
|
+
return false if @loglevel < ERROR
|
25
|
+
|
26
|
+
@log << "ERROR (invalid SciolyFF): #{msg}\n"
|
27
|
+
false # convenient for using logging the error as return value
|
28
|
+
end
|
29
|
+
|
30
|
+
def warn(msg)
|
31
|
+
return true if @loglevel < WARN
|
32
|
+
|
33
|
+
@log << "WARNING (still valid SciolyFF): #{msg}\n"
|
34
|
+
end
|
35
|
+
|
36
|
+
def info(msg)
|
37
|
+
return true if @loglevel < INFO
|
38
|
+
|
39
|
+
@log << "INFO: #{msg}\n"
|
40
|
+
end
|
41
|
+
|
42
|
+
def debug(msg)
|
43
|
+
return true if @loglevel < DEBUG
|
44
|
+
|
45
|
+
@log << "DEBUG (possible intentional exception): #{msg}\n"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sciolyff/validator/checker'
|
4
|
+
require 'sciolyff/validator/sections'
|
5
|
+
|
6
|
+
module SciolyFF
|
7
|
+
# Checks for one penalty in the Penalties section of a SciolyFF file
|
8
|
+
class Validator::Penalties < Validator::Checker
|
9
|
+
include Validator::Sections
|
10
|
+
|
11
|
+
REQUIRED = {
|
12
|
+
team: Integer,
|
13
|
+
points: Integer
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
OPTIONAL = {
|
17
|
+
}.freeze
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sciolyff/validator/checker'
|
4
|
+
require 'sciolyff/validator/sections'
|
5
|
+
|
6
|
+
module SciolyFF
|
7
|
+
# Checks for one placing in the Placings section of a SciolyFF file
|
8
|
+
class Validator::Placings < Validator::Checker
|
9
|
+
include Validator::Sections
|
10
|
+
|
11
|
+
REQUIRED = {
|
12
|
+
event: String,
|
13
|
+
team: Integer
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
OPTIONAL = {
|
17
|
+
place: Integer,
|
18
|
+
participated: [true, false],
|
19
|
+
disqualified: [true, false],
|
20
|
+
exempt: [true, false],
|
21
|
+
tie: [true, false],
|
22
|
+
unknown: [true, false],
|
23
|
+
raw: Hash
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
def initialize(rep)
|
27
|
+
@events_by_name = group(rep[:Events], :name)
|
28
|
+
@teams_by_number = group(rep[:Teams], :number)
|
29
|
+
@event_names = @events_by_name.keys
|
30
|
+
@team_numbers = @teams_by_number.keys
|
31
|
+
@placings = rep[:Placings]
|
32
|
+
@maximum_place = rep[:Tournament][:'maximum place']
|
33
|
+
@has_places = rep[:Placings].any? { |p| p[:place] }
|
34
|
+
end
|
35
|
+
|
36
|
+
def matching_event?(placing, logger)
|
37
|
+
return true if @event_names.include? placing[:event]
|
38
|
+
|
39
|
+
logger.error "'event: #{placing[:event]}' in Placings "\
|
40
|
+
'does not match any event name in Events'
|
41
|
+
end
|
42
|
+
|
43
|
+
def matching_team?(placing, logger)
|
44
|
+
return true if @team_numbers.include? placing[:team]
|
45
|
+
|
46
|
+
logger.error "'team: #{placing[:team]}' in Placings "\
|
47
|
+
'does not match any team number in Teams'
|
48
|
+
end
|
49
|
+
|
50
|
+
def unique_event_and_team?(placing, logger)
|
51
|
+
return true if @placings.count do |other|
|
52
|
+
placing[:event] == other[:event] && placing[:team] == other[:team]
|
53
|
+
end == 1
|
54
|
+
|
55
|
+
logger.error "duplicate #{placing_log(placing)}"
|
56
|
+
end
|
57
|
+
|
58
|
+
def having_a_place_makes_sense?(placing, logger)
|
59
|
+
return true unless placing[:place] &&
|
60
|
+
(placing[:participated] == false ||
|
61
|
+
placing[:disqualified] ||
|
62
|
+
placing[:unknown] ||
|
63
|
+
placing[:raw])
|
64
|
+
|
65
|
+
logger.error 'having a place does not make sense for '\
|
66
|
+
"#{placing_log(placing)}"
|
67
|
+
end
|
68
|
+
|
69
|
+
def having_a_raw_makes_sense?(placing, logger)
|
70
|
+
return true unless placing[:raw] &&
|
71
|
+
(placing[:participated] == false ||
|
72
|
+
placing[:disqualified] ||
|
73
|
+
placing[:unknown] ||
|
74
|
+
placing[:place])
|
75
|
+
|
76
|
+
logger.error 'having raw section does not make sense for '\
|
77
|
+
"#{placing_log(placing)}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def having_a_tie_makes_sense?(placing, logger)
|
81
|
+
return true unless placing.key?(:tie) && placing[:raw]
|
82
|
+
|
83
|
+
logger.error 'having a tie value does make sense for '\
|
84
|
+
"#{placing_log(placing)}"
|
85
|
+
end
|
86
|
+
|
87
|
+
def possible_participated_disqualified_combination?(placing, logger)
|
88
|
+
return true unless placing[:participated] == false &&
|
89
|
+
placing[:disqualified]
|
90
|
+
|
91
|
+
logger.error 'impossible participation-disqualified combination for '\
|
92
|
+
"#{placing_log(placing)}"
|
93
|
+
end
|
94
|
+
|
95
|
+
def possible_unknown_disqualified_combination?(placing, logger)
|
96
|
+
return true unless placing[:unknown] && placing[:disqualified]
|
97
|
+
|
98
|
+
logger.error 'impossible unknown-disqualified combination for '\
|
99
|
+
"#{placing_log(placing)}"
|
100
|
+
end
|
101
|
+
|
102
|
+
def unknown_allowed?(placing, logger)
|
103
|
+
event = @events_by_name[placing[:event]]
|
104
|
+
team = @teams_by_number[placing[:team]]
|
105
|
+
return true unless invalid_unknown?(placing, event, team)
|
106
|
+
|
107
|
+
logger.error "unknown place not allowed for #{placing_log(placing)} "\
|
108
|
+
'(either placing must be exempt or event must be trial/trialed)'
|
109
|
+
end
|
110
|
+
|
111
|
+
def no_mix_of_raws_and_places(placing, logger)
|
112
|
+
return true unless @has_places && placing.key?(:raw)
|
113
|
+
|
114
|
+
logger.error "cannot mix 'raw:' and 'place:' in same file"
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def placing_log(placing)
|
120
|
+
"placing with 'team: #{placing[:team]}' and 'event: #{placing[:event]}'"
|
121
|
+
end
|
122
|
+
|
123
|
+
def invalid_unknown?(placing, event, team)
|
124
|
+
placing[:unknown] &&
|
125
|
+
@maximum_place.nil? &&
|
126
|
+
!placing[:exempt] &&
|
127
|
+
!event[:trial] &&
|
128
|
+
!event[:trialed] &&
|
129
|
+
!team[:exhibition]
|
130
|
+
end
|
131
|
+
|
132
|
+
def group(arr, key)
|
133
|
+
arr.group_by { |e| e[key] }.transform_values(&:first)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SciolyFF
|
4
|
+
# Helper method to check for value in range and report error if not
|
5
|
+
module Validator::Range
|
6
|
+
private
|
7
|
+
|
8
|
+
def within_range?(rep, key, logger, min, max)
|
9
|
+
value = rep[key]
|
10
|
+
return true if value.nil? || value.between?(min, max)
|
11
|
+
|
12
|
+
logger.error "'#{key}: #{value}' is not within range [#{min}, #{max}]"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sciolyff/validator/checker'
|
4
|
+
require 'sciolyff/validator/sections'
|
5
|
+
|
6
|
+
module SciolyFF
|
7
|
+
# Checks for one raw of a placing in the Placings section of a SciolyFF file
|
8
|
+
class Validator::Raws < Validator::Checker
|
9
|
+
include Validator::Sections
|
10
|
+
|
11
|
+
REQUIRED = {
|
12
|
+
score: Float
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
OPTIONAL = {
|
16
|
+
'tier': Integer,
|
17
|
+
'tiebreaker rank': Integer
|
18
|
+
}.freeze
|
19
|
+
|
20
|
+
def score_is_not_nan?(raw, logger)
|
21
|
+
return true unless raw[:score].nan?
|
22
|
+
|
23
|
+
logger.error "'score: .nan' (not a number) is not permitted"
|
24
|
+
end
|
25
|
+
|
26
|
+
def positive_tier?(raw, logger)
|
27
|
+
tier = raw[:tier]
|
28
|
+
return true if tier.nil? || tier.positive?
|
29
|
+
|
30
|
+
logger.error "'tier: #{tier}' is not positive"
|
31
|
+
end
|
32
|
+
|
33
|
+
def positive_tiebreaker_rank?(raw, logger)
|
34
|
+
rank = raw[:'tiebreaker rank']
|
35
|
+
return true if rank.nil? || rank.positive?
|
36
|
+
|
37
|
+
logger.error "'tiebreaker rank: #{rank}' is not positive"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SciolyFF
|
4
|
+
# Generic tests for (sub-)sections and types. Including classes must have two
|
5
|
+
# hashes REQUIRED and OPTIONAL (see other files in this dir for examples)
|
6
|
+
module Validator::Sections
|
7
|
+
def rep_is_hash?(rep, logger)
|
8
|
+
return true if rep.instance_of? Hash
|
9
|
+
|
10
|
+
logger.error "entry in #{section_name} is not a Hash"
|
11
|
+
end
|
12
|
+
|
13
|
+
def all_required_sections?(rep, logger)
|
14
|
+
missing = self.class::REQUIRED.keys - rep.keys
|
15
|
+
return true if missing.empty?
|
16
|
+
|
17
|
+
logger.error "missing required sections: #{missing.join ', '}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def no_extra_sections?(rep, logger)
|
21
|
+
extra = rep.keys - (self.class::REQUIRED.keys + self.class::OPTIONAL.keys)
|
22
|
+
return true if extra.empty?
|
23
|
+
|
24
|
+
logger.error "extra section(s) found: #{extra.join ', '}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def sections_are_correct_type?(rep, logger)
|
28
|
+
correct_types = self.class::REQUIRED.merge self.class::OPTIONAL
|
29
|
+
rep.all? do |key, value|
|
30
|
+
type = correct_types[key]
|
31
|
+
next true if correct_type?(type, value)
|
32
|
+
|
33
|
+
logger.error "#{key}: #{value} is not #{type}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def correct_type?(type, value)
|
40
|
+
type.nil? ||
|
41
|
+
(type.instance_of?(Array) && type.include?(value)) ||
|
42
|
+
(type.instance_of?(Class) && value.instance_of?(type)) ||
|
43
|
+
correct_date?(type, value)
|
44
|
+
end
|
45
|
+
|
46
|
+
def correct_date?(type, value)
|
47
|
+
type == Date && Date.parse(value)
|
48
|
+
rescue StandardError
|
49
|
+
false
|
50
|
+
end
|
51
|
+
|
52
|
+
def section_name
|
53
|
+
self.class.to_s.split('::').last
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sciolyff/validator/checker'
|
4
|
+
require 'sciolyff/validator/sections'
|
5
|
+
require 'sciolyff/validator/range'
|
6
|
+
|
7
|
+
module SciolyFF
|
8
|
+
# Checks for one subdivision in the Subdivisions section of a SciolyFF file
|
9
|
+
class Validator::Subdivisions < Validator::Checker
|
10
|
+
include Validator::Sections
|
11
|
+
|
12
|
+
REQUIRED = {
|
13
|
+
name: String
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
OPTIONAL = {
|
17
|
+
medals: Integer,
|
18
|
+
trophies: Integer,
|
19
|
+
'maximum place': Integer
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
def initialize(rep)
|
23
|
+
@names = rep[:Subdivisions].map { |s| s[:name] }
|
24
|
+
@teams = rep[:Teams].group_by { |t| t[:subdivision] }
|
25
|
+
end
|
26
|
+
|
27
|
+
def unique_name?(subdivision, logger)
|
28
|
+
return true if @names.count(subdivision[:name]) == 1
|
29
|
+
|
30
|
+
logger.error "duplicate subdivision name: #{subdivision[:name]}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def matching_teams?(subdivision, logger)
|
34
|
+
name = subdivision[:name]
|
35
|
+
return true if @teams[name]
|
36
|
+
|
37
|
+
logger.error "subdivision with 'name: #{name}' has no teams"
|
38
|
+
end
|
39
|
+
|
40
|
+
include Validator::Range
|
41
|
+
|
42
|
+
def maximum_place_within_range?(subdivision, logger)
|
43
|
+
max = team_count(subdivision)
|
44
|
+
within_range?(subdivision, :'maximum place', logger, 1, max)
|
45
|
+
end
|
46
|
+
|
47
|
+
def medals_within_range?(subdivision, logger)
|
48
|
+
max = [team_count(subdivision), subdivision[:'maximum place']].compact.min
|
49
|
+
within_range?(subdivision, :medals, logger, 1, max)
|
50
|
+
end
|
51
|
+
|
52
|
+
def trophies_within_range?(subdivision, logger)
|
53
|
+
within_range?(subdivision, :trophies, logger, 1, team_count(subdivision))
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def team_count(subdivision)
|
59
|
+
@teams[subdivision[:name]].count do |t|
|
60
|
+
t[:subdivision] == subdivision[:name] && !t[:exhibition]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,108 @@
|
|
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 team in the Teams section of a SciolyFF file
|
9
|
+
class Validator::Teams < Validator::Checker
|
10
|
+
include Validator::Sections
|
11
|
+
|
12
|
+
REQUIRED = {
|
13
|
+
number: Integer,
|
14
|
+
school: String,
|
15
|
+
state: String
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
OPTIONAL = {
|
19
|
+
'school abbreviation': String,
|
20
|
+
subdivision: String,
|
21
|
+
suffix: String,
|
22
|
+
city: String,
|
23
|
+
disqualified: [true, false],
|
24
|
+
exhibition: [true, false]
|
25
|
+
}.freeze
|
26
|
+
|
27
|
+
def initialize(rep)
|
28
|
+
initialize_teams_info(rep[:Teams])
|
29
|
+
@subdivisions = rep[:Subdivisions]&.map { |s| s[:name] } || []
|
30
|
+
@placings = rep[:Placings].group_by { |p| p[:team] }
|
31
|
+
@exempt = rep[:Tournament][:'exempt placings'] || 0
|
32
|
+
end
|
33
|
+
|
34
|
+
def unique_number?(team, logger)
|
35
|
+
return true if @numbers.count(team[:number]) == 1
|
36
|
+
|
37
|
+
logger.error "duplicate team number: #{team[:number]}"
|
38
|
+
end
|
39
|
+
|
40
|
+
def unique_suffix_per_school?(team, logger)
|
41
|
+
full_school = [team[:school], team[:city], team[:state]]
|
42
|
+
return true if @schools[full_school].count do |other|
|
43
|
+
!other[:suffix].nil? && other[:suffix] == team[:suffix]
|
44
|
+
end <= 1
|
45
|
+
|
46
|
+
logger.error "team number #{team[:number]} has the same suffix "\
|
47
|
+
'as another team from the same school'
|
48
|
+
end
|
49
|
+
|
50
|
+
def suffix_needed?(team, logger)
|
51
|
+
rep = [team[:school], team[:city], team[:state]]
|
52
|
+
return true unless team[:suffix] && @schools[rep].count == 1
|
53
|
+
|
54
|
+
logger.warn "team number #{team[:number]} may have unnecessary "\
|
55
|
+
"suffix: #{team[:suffix]}"
|
56
|
+
end
|
57
|
+
|
58
|
+
def unambiguous_cities_per_school?(team, logger)
|
59
|
+
return true unless @schools.keys.find do |other|
|
60
|
+
team[:city].nil? && !other[1].nil? &&
|
61
|
+
team[:school] == other[0] &&
|
62
|
+
team[:state] == other[2]
|
63
|
+
end
|
64
|
+
|
65
|
+
logger.error "city for team number #{team[:number]} is ambiguous, "\
|
66
|
+
'value is required for unambiguity'
|
67
|
+
end
|
68
|
+
|
69
|
+
def correct_number_of_exempt_placings?(team, logger)
|
70
|
+
count = @placings[team[:number]].count { |p| p[:exempt] }
|
71
|
+
return true if count == @exempt || team[:exhibition]
|
72
|
+
|
73
|
+
logger.error "'team: #{team[:number]}' has incorrect number of "\
|
74
|
+
"exempt placings (#{count} insteand of #{@exempt})"
|
75
|
+
end
|
76
|
+
|
77
|
+
def matching_subdivision?(team, logger)
|
78
|
+
sub = team[:subdivision]
|
79
|
+
return true if sub.nil? || @subdivisions.include?(sub)
|
80
|
+
|
81
|
+
logger.error "'subdivision: #{sub}' does not match any name in "\
|
82
|
+
'section Subdivisions'
|
83
|
+
end
|
84
|
+
|
85
|
+
def in_a_subdivision_if_possible?(team, logger)
|
86
|
+
return true unless !@subdivisions.empty? && !team[:subdivision]
|
87
|
+
|
88
|
+
logger.warn "missing subdivision for 'team: #{team[:number]}'"
|
89
|
+
end
|
90
|
+
|
91
|
+
include Validator::Canonical
|
92
|
+
|
93
|
+
def in_canonical_list?(team, logger)
|
94
|
+
rep = [team[:school], team[:city], team[:state]]
|
95
|
+
return true if canonical?(rep, 'schools.csv', logger)
|
96
|
+
|
97
|
+
location = rep[1..-1].compact.join ', '
|
98
|
+
logger.warn "non-canonical school: #{team[:school]} in #{location}"
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def initialize_teams_info(teams)
|
104
|
+
@numbers = teams.map { |t| t[:number] }
|
105
|
+
@schools = teams.group_by { |t| [t[:school], t[:city], t[:state]] }
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|