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