sciolyff 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SciolyFF
4
+ # Grants ability to convert a SciolyFF file into stand-alone HTML and other
5
+ # formats (YAML, JSON)
6
+ module Interpreter::HTML
7
+ require 'erb'
8
+ require 'sciolyff/interpreter/html/helpers'
9
+ require 'json'
10
+
11
+ def html
12
+ helpers = Interpreter::HTML::Helpers.new
13
+ ERB.new(
14
+ helpers.template,
15
+ trim_mode: '<>'
16
+ ).result(helpers.get_binding(self))
17
+ .gsub(/^\s*$/, '') # remove empty lines
18
+ .gsub(/\s+$/, '') # remove trailing whitespace
19
+ end
20
+
21
+ def yaml
22
+ stringify_keys(@rep).to_yaml
23
+ end
24
+
25
+ def json(pretty: false)
26
+ return JSON.pretty_generate(@rep) if pretty
27
+
28
+ @rep.to_json
29
+ end
30
+
31
+ private
32
+
33
+ def stringify_keys(hash)
34
+ return hash unless hash.instance_of? Hash
35
+
36
+ hash.map do |k, v|
37
+ new_k = k.to_s
38
+ new_v = case v
39
+ when Array then v.map { |e| stringify_keys(e) }
40
+ when Hash then stringify_keys(v)
41
+ else v
42
+ end
43
+ [new_k, new_v]
44
+ end.to_h
45
+ end
46
+ end
47
+ end
@@ -5,13 +5,17 @@ require 'sciolyff/interpreter/model'
5
5
  module SciolyFF
6
6
  # Models the result of a team participating (or not) in an event
7
7
  class Interpreter::Placing < Interpreter::Model
8
+ require 'sciolyff/interpreter/raw'
9
+
8
10
  def link_to_other_models(interpreter)
9
11
  super
10
12
  @event = interpreter.events.find { |e| e.name == @rep[:event] }
11
13
  @team = interpreter.teams .find { |t| t.number == @rep[:team] }
14
+
15
+ link_to_placing_in_subdivision_interpreter(interpreter)
12
16
  end
13
17
 
14
- attr_reader :event, :team
18
+ attr_reader :event, :team, :subdivision_placing
15
19
 
16
20
  def participated?
17
21
  @rep[:participated] == true || @rep[:participated].nil?
@@ -30,11 +34,19 @@ module SciolyFF
30
34
  end
31
35
 
32
36
  def tie?
33
- @rep[:tie] == true
37
+ raw? ? @tie ||= event.raws.count(raw) > 1 : @rep[:tie] == true
34
38
  end
35
39
 
36
40
  def place
37
- @rep[:place]
41
+ raw? ? @place ||= event.raws.find_index(raw) + 1 : @rep[:place]
42
+ end
43
+
44
+ def raw
45
+ @raw ||= Raw.new(@rep[:raw], event.low_score_wins?) if raw?
46
+ end
47
+
48
+ def raw?
49
+ @rep.key? :raw
38
50
  end
39
51
 
40
52
  def did_not_participate?
@@ -56,12 +68,13 @@ module SciolyFF
56
68
  end
57
69
 
58
70
  def isolated_points
59
- n = event.maximum_place
71
+ max_place = event.maximum_place
72
+ n = max_place + tournament.n_offset
60
73
 
61
74
  if disqualified? then n + 2
62
75
  elsif did_not_participate? then n + 1
63
76
  elsif participation_only? || unknown? then n
64
- else [calculate_points, n].min
77
+ else [calculate_points, max_place].min
65
78
  end
66
79
  end
67
80
 
@@ -80,7 +93,11 @@ module SciolyFF
80
93
 
81
94
  def points_limited_by_maximum_place?
82
95
  tournament.custom_maximum_place? &&
83
- (unknown? || (place && calculate_points > event.maximum_place))
96
+ (unknown? ||
97
+ (place &&
98
+ (calculate_points > event.maximum_place ||
99
+ calculate_points == event.maximum_place && tie?
100
+ )))
84
101
  end
85
102
 
86
103
  private
@@ -98,5 +115,13 @@ module SciolyFF
98
115
  p.place < place
99
116
  end
100
117
  end
118
+
119
+ def link_to_placing_in_subdivision_interpreter(interpreter)
120
+ return @subdivision_placing = nil unless (sub = team.subdivision)
121
+
122
+ @subdivision_placing = interpreter.subdivisions[sub].placings.find do |p|
123
+ p.event.name == event.name && p.team.number == team.number
124
+ end
125
+ end
101
126
  end
102
127
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SciolyFF
4
+ # Models the raw score representation for a Placing
5
+ class Interpreter::Placing::Raw
6
+ def initialize(rep, low_score_wins)
7
+ @rep = rep
8
+ @low_score_wins = low_score_wins
9
+ end
10
+
11
+ def score
12
+ @rep[:score]
13
+ end
14
+
15
+ def tiered?
16
+ tier > 1
17
+ end
18
+
19
+ def tier
20
+ @rep[:tier] || 1
21
+ end
22
+
23
+ def lost_tiebreaker?
24
+ tiebreaker_rank > 1
25
+ end
26
+
27
+ def tiebreaker_rank
28
+ @rep[:'tiebreaker rank'] || Float::INFINITY
29
+ end
30
+
31
+ def ==(other)
32
+ score == other.score &&
33
+ tier == other.tier &&
34
+ tiebreaker_rank == other.tiebreaker_rank
35
+ end
36
+
37
+ def <=>(other)
38
+ [
39
+ tier,
40
+ @low_score_wins ? score : -score,
41
+ tiebreaker_rank
42
+ ] <=>
43
+ [
44
+ other.tier,
45
+ @low_score_wins ? other.score : -other.score,
46
+ other.tiebreaker_rank
47
+ ]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SciolyFF
4
+ # Subdivision logic, to be used in the Interpreter class
5
+ module Interpreter::Subdivisions
6
+ private
7
+
8
+ def subdivision_rep(sub)
9
+ # make a deep copy of rep and remove teams not in the subdivision
10
+ rep = Marshal.load(Marshal.dump(@rep))
11
+ rep[:Teams].select! { |t| t.delete(:subdivision) == sub }
12
+
13
+ team_numbers = rep[:Teams].map { |t| t[:number] }
14
+ rep[:Placings].select! { |p| team_numbers.include? p[:team] }
15
+
16
+ fix_placings_for_existing_teams(rep)
17
+ rep
18
+ end
19
+
20
+ def fix_placings_for_existing_teams(rep)
21
+ rep[:Placings]
22
+ .group_by { |p| p[:event] }
23
+ .each { |_, ep| fix_event_placings(ep) }
24
+ end
25
+
26
+ def fix_event_placings(event_placings)
27
+ event_placings
28
+ .select { |p| p[:place] }
29
+ .sort_by { |p| p[:place] }
30
+ .each_with_index { |p, i| p[:temp_place] = i + 1 }
31
+ .each { |p| fix_placing_ties(p, event_placings) }
32
+ .each { |p| p.delete(:temp_place) }
33
+ end
34
+
35
+ def fix_placing_ties(placing, event_placings)
36
+ ties = event_placings.select { |o| o[:place] == placing[:place] }
37
+ placing[:place] = ties.map { |t| t[:temp_place] }.max - ties.count + 1
38
+ ties.count > 1 ? placing[:tie] = true : placing.delete(:tie)
39
+ end
40
+ end
41
+ end
@@ -12,11 +12,10 @@ module SciolyFF
12
12
  @placings_by_event =
13
13
  @placings.group_by(&:event).transform_values!(&:first)
14
14
 
15
- @placings.freeze
16
- @penalties.freeze
15
+ link_to_team_in_subdivision_interpreter(interpreter)
17
16
  end
18
17
 
19
- attr_reader :placings, :penalties
18
+ attr_reader :placings, :penalties, :subdivision_team
20
19
 
21
20
  def school
22
21
  @rep[:school]
@@ -38,6 +37,10 @@ module SciolyFF
38
37
  @rep[:exhibition] == true
39
38
  end
40
39
 
40
+ def disqualified?
41
+ @rep[:disqualified] == true
42
+ end
43
+
41
44
  def number
42
45
  @rep[:number]
43
46
  end
@@ -89,5 +92,15 @@ module SciolyFF
89
92
  .count { |p| p.isolated_points == medal_points }
90
93
  end
91
94
  end
95
+
96
+ private
97
+
98
+ def link_to_team_in_subdivision_interpreter(interpreter)
99
+ return @subdivision_team = nil unless (sub = subdivision)
100
+
101
+ @subdivision_team = interpreter.subdivisions[sub].teams.find do |t|
102
+ t.number == number
103
+ end
104
+ end
92
105
  end
93
106
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SciolyFF
4
+ # Tie-breaking logic for teams, to be used in the Interpreter class
5
+ module Interpreter::Tiebreaks
6
+ private
7
+
8
+ def sort_teams_by_points(teams)
9
+ teams.sort do |team_a, team_b|
10
+ cmp = team_a.points <=> team_b.points
11
+ cmp.zero? ? break_tie(team_a, team_b) : cmp
12
+ end
13
+ end
14
+
15
+ def break_tie(team_a, team_b)
16
+ team_a.medal_counts
17
+ .zip(team_b.medal_counts)
18
+ .map { |counts| counts.last - counts.first }
19
+ .find(proc { break_second_tie(team_a, team_b) }, &:nonzero?)
20
+ end
21
+
22
+ def break_second_tie(team_a, team_b)
23
+ cmp = team_a.trial_event_points <=> team_b.trial_event_points
24
+ cmp.zero? ? break_third_tie(team_a, team_b) : cmp
25
+ end
26
+
27
+ def break_third_tie(team_a, team_b)
28
+ team_a.trial_event_medal_counts
29
+ .zip(team_b.trial_event_medal_counts)
30
+ .map { |counts| counts.last - counts.first }
31
+ .find(proc { team_a.number <=> team_b.number }, &:nonzero?)
32
+ end
33
+ end
34
+ end
@@ -14,9 +14,10 @@ module SciolyFF
14
14
  @teams = interpreter.teams
15
15
  @placings = interpreter.placings
16
16
  @penalties = interpreter.penalties
17
+ @subdivisions = interpreter.subdivisions
17
18
  end
18
19
 
19
- attr_reader :events, :teams, :placings, :penalties
20
+ attr_reader :events, :teams, :placings, :penalties, :subdivisions
20
21
 
21
22
  undef tournament
22
23
 
@@ -53,19 +54,19 @@ module SciolyFF
53
54
  end
54
55
 
55
56
  def worst_placings_dropped?
56
- @rep.key? :'worst placings dropped'
57
+ worst_placings_dropped.positive?
57
58
  end
58
59
 
59
60
  def worst_placings_dropped
60
- worst_placings_dropped? ? @rep[:'worst placings dropped'] : 0
61
+ @rep[:'worst placings dropped'] || 0
61
62
  end
62
63
 
63
64
  def exempt_placings?
64
- @rep.key? :'exempt placings'
65
+ exempt_placings.positive?
65
66
  end
66
67
 
67
68
  def exempt_placings
68
- exempt_placings? ? @rep[:'exempt placings'] : 0
69
+ @rep[:'exempt placings'] || 0
69
70
  end
70
71
 
71
72
  def custom_maximum_place?
@@ -77,5 +78,27 @@ module SciolyFF
77
78
 
78
79
  @teams.count { |t| !t.exhibition? }
79
80
  end
81
+
82
+ def per_event_n?
83
+ @rep[:'per-event n']
84
+ end
85
+
86
+ def n_offset
87
+ @rep[:'n offset'] || 0
88
+ end
89
+
90
+ def ties?
91
+ @ties ||= placings.map(&:tie?).any?
92
+ end
93
+
94
+ def ties_outside_of_maximum_places?
95
+ @ties_outside_of_maximum_places ||= placings.map do |p|
96
+ p.tie? && !p.points_limited_by_maximum_place?
97
+ end.any?
98
+ end
99
+
100
+ def subdivisions?
101
+ !@subdivisions.empty?
102
+ end
80
103
  end
81
104
  end
@@ -10,26 +10,42 @@ module SciolyFF
10
10
  require 'sciolyff/interpreter/placing'
11
11
  require 'sciolyff/interpreter/penalty'
12
12
 
13
+ require 'sciolyff/interpreter/tiebreaks'
14
+ require 'sciolyff/interpreter/subdivisions'
15
+ require 'sciolyff/interpreter/html'
16
+
13
17
  attr_reader :tournament, :events, :teams, :placings, :penalties
14
18
 
15
19
  def initialize(rep)
16
- create_models(rep)
20
+ if rep.instance_of? String
21
+ rep = YAML.safe_load(File.read(rep),
22
+ permitted_classes: [Date],
23
+ symbolize_names: true)
24
+ end
25
+ create_models(@rep = rep)
17
26
  link_models(self)
18
27
 
19
28
  sort_events_naturally
20
29
  sort_teams_by_rank
30
+ end
21
31
 
22
- freeze_models
32
+ def subdivisions
33
+ @subdivisions ||=
34
+ teams.map(&:subdivision)
35
+ .uniq
36
+ .compact
37
+ .map { |sub| [sub, Interpreter.new(subdivision_rep(sub))] }
38
+ .to_h
23
39
  end
24
40
 
25
41
  private
26
42
 
27
43
  def create_models(rep)
28
44
  @tournament = Tournament.new(rep)
29
- @events = map_array_to_models rep[:Events], Event, rep
30
- @teams = map_array_to_models rep[:Teams], Team, rep
31
- @placings = map_array_to_models rep[:Placings], Placing, rep
32
- @penalties = map_array_to_models rep[:Penalties], Penalty, rep
45
+ @events = map_array_to_models rep[:Events], Event, rep
46
+ @teams = map_array_to_models rep[:Teams], Team, rep
47
+ @placings = map_array_to_models rep[:Placings], Placing, rep
48
+ @penalties = map_array_to_models rep[:Penalties], Penalty, rep
33
49
  end
34
50
 
35
51
  def map_array_to_models(arr, object_class, rep)
@@ -47,49 +63,23 @@ module SciolyFF
47
63
  @tournament.link_to_other_models(interpreter)
48
64
  end
49
65
 
50
- def freeze_models
51
- @events.freeze
52
- @teams.freeze
53
- @placings.freeze
54
- @penalties.freeze
55
- end
56
-
57
66
  def sort_events_naturally
58
- @events.sort! do |a, b|
59
- next 1 if a.trial? && !b.trial?
60
- next -1 if !a.trial? && b.trial?
61
-
62
- a.name <=> b.name
63
- end
67
+ @events.sort_by! { |e| [e.trial?.to_s, e.name] }
64
68
  end
65
69
 
66
70
  def sort_teams_by_rank
67
- @teams.sort! do |team_a, team_b|
68
- next 1 if team_a.exhibition? && !team_b.exhibition?
69
- next -1 if !team_a.exhibition? && team_b.exhibition?
70
-
71
- cmp = team_a.points <=> team_b.points
72
- cmp.zero? ? break_tie(team_a, team_b) : cmp
73
- end
74
- end
75
-
76
- def break_tie(team_a, team_b)
77
- team_a.medal_counts
78
- .zip(team_b.medal_counts)
79
- .map { |counts| counts.last - counts.first }
80
- .find(proc { break_second_tie(team_a, team_b) }, &:nonzero?)
71
+ sorted =
72
+ @teams
73
+ .group_by { |t| [t.disqualified?.to_s, t.exhibition?.to_s] }
74
+ .map { |key, teams| [key, sort_teams_by_points(teams)] }
75
+ .sort_by(&:first)
76
+ .map(&:last)
77
+ .flatten
78
+ @teams.map!.with_index { |_, i| sorted[i] }
81
79
  end
82
80
 
83
- def break_second_tie(team_a, team_b)
84
- cmp = team_a.trial_event_points <=> team_b.trial_event_points
85
- cmp.zero? ? break_third_tie(team_a, team_b) : cmp
86
- end
87
-
88
- def break_third_tie(team_a, team_b)
89
- team_a.trial_event_medal_counts
90
- .zip(team_b.trial_event_medal_counts)
91
- .map { |counts| counts.last - counts.first }
92
- .find(proc { team_a.number <=> team_b.number }, &:nonzero?)
93
- end
81
+ include Interpreter::Tiebreaks
82
+ include Interpreter::Subdivisions
83
+ include Interpreter::HTML
94
84
  end
95
85
  end
@@ -0,0 +1,20 @@
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 method calls (always using send in Validator) so that exceptions
10
+ # in the check cause check to pass, as what caused the exception should
11
+ # cause some other check to fail if the SciolyFF is truly invalid
12
+ #
13
+ # this simplifies the checking code greatly, even if it is a bit hacky
14
+ def send(method, *args)
15
+ super
16
+ rescue StandardError => e
17
+ args[1].debug "#{e}\n #{e.backtrace.first}" # args[1] is the logger
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sciolyff/validator/checker'
4
+ require 'sciolyff/validator/sections'
5
+
6
+ module SciolyFF
7
+ # Checks for one event in the Events section of a SciolyFF file
8
+ class Validator::Events < Validator::Checker
9
+ include Validator::Sections
10
+
11
+ REQUIRED = {
12
+ name: String
13
+ }.freeze
14
+
15
+ OPTIONAL = {
16
+ trial: [true, false],
17
+ trialed: [true, false],
18
+ scoring: %w[high low]
19
+ }.freeze
20
+
21
+ def initialize(rep)
22
+ @events = rep[:Events]
23
+ @names = @events.map { |e| e[:name] }
24
+ @placings = rep[:Placings].group_by { |p| p[:event] }
25
+ @teams = rep[:Teams].count
26
+ end
27
+
28
+ def unique_name?(event, logger)
29
+ return true if @names.count(event[:name]) == 1
30
+
31
+ logger.error "duplicate event name: #{event[:name]}"
32
+ end
33
+
34
+ def placings_for_all_teams?(event, logger)
35
+ count = @placings[event[:name]].count
36
+ return true if count == @teams
37
+
38
+ logger.error "'event: #{event[:name]}' has incorrect number of "\
39
+ "placings (#{count} instead of #{@teams})"
40
+ end
41
+
42
+ def ties_marked?(event, logger)
43
+ unmarked_ties = placings_by_place(event).select do |_place, placings|
44
+ placings.count { |p| !p[:tie] } > 1
45
+ end
46
+ return true if unmarked_ties.empty?
47
+
48
+ logger.error "'event: #{event[:name]}' has unmarked ties at "\
49
+ "place #{unmarked_ties.keys.join ', '}"
50
+ end
51
+
52
+ def ties_paired?(event, logger)
53
+ unpaired_ties = placings_by_place(event).select do |_place, placings|
54
+ placings.count { |p| p[:tie] } == 1
55
+ end
56
+ return true if unpaired_ties.empty?
57
+
58
+ logger.error "'event: #{event[:name]}' has unpaired ties at "\
59
+ "place #{unpaired_ties.keys.join ', '}"
60
+ end
61
+
62
+ def no_gaps_in_places?(event, logger)
63
+ places = places_with_expanded_ties(event)
64
+ return true if places.empty?
65
+
66
+ gaps = (places.min..places.max).to_a - places
67
+ return true if gaps.empty?
68
+
69
+ logger.error "'event: #{event[:name]}' has gaps in "\
70
+ "place #{gaps.join ', '}"
71
+ end
72
+
73
+ def places_start_at_one?(event, logger)
74
+ lowest_place = @placings[event[:name]].map { |p| p[:place] }.compact.min
75
+ return true if lowest_place == 1 || lowest_place.nil?
76
+
77
+ logger.error "places for 'event: #{event[:name]}' start at "\
78
+ "#{lowest_place} instead of 1"
79
+ end
80
+
81
+ private
82
+
83
+ def placings_by_place(event)
84
+ @placings[event[:name]]
85
+ .select { |p| p[:place] }
86
+ .group_by { |p| p[:place] }
87
+ end
88
+
89
+ def places_with_expanded_ties(event)
90
+ # e.g. [6, 6, 8] -> [6, 7, 8]
91
+ placings_by_place(event).map do |place, placings|
92
+ (place..(place + (placings.size - 1))).to_a
93
+ end.flatten
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,47 @@
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
12
+
13
+ def initialize(loglevel)
14
+ @loglevel = loglevel
15
+ flush
16
+ end
17
+
18
+ def flush
19
+ @log = String.new
20
+ end
21
+
22
+ def error(msg)
23
+ return false if @loglevel < ERROR
24
+
25
+ @log << "ERROR (invalid SciolyFF): #{msg}\n"
26
+ false # convenient for using logging the error as return value
27
+ end
28
+
29
+ def warn(msg)
30
+ return true if @loglevel < WARN
31
+
32
+ @log << "WARNING (still valid SciolyFF): #{msg}\n"
33
+ end
34
+
35
+ def info(msg)
36
+ return true if @loglevel < INFO
37
+
38
+ @log << "INFO: #{msg}\n"
39
+ end
40
+
41
+ def debug(msg)
42
+ return true if @loglevel < DEBUG
43
+
44
+ @log << "DEBUG (possible intentional exception): #{msg}\n"
45
+ end
46
+ end
47
+ 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