scottwillson/racing_on_rails

View on GitHub
app/models/categories/matching.rb

Summary

Maintainability
F
4 days
Test Coverage
# frozen_string_literal: true

require "active_support/core_ext/object/inclusion"

module Categories
  module Matching
    extend ActiveSupport::Concern

    # Find best matching competition race for category.
    # Iterate through traits (weight, equipment, ages, gender, abilities) until there is a single match (or none).
    # Sometimes, a category's results need to be split into multiple categories based on the participant's age
    def best_match_in(event_categories, result_age = nil)
      debug "Category#best_match_in for #{name} in #{event_categories.map(&:name).join(', ')}"

      candidate_categories = event_categories.dup

      equivalent_matches = candidate_categories.select { |category| equivalent?(category) }
      debug "equivalent: #{equivalent_matches.map(&:name).join(', ')}"
      return equivalent_matches.first if one_match?(equivalent_matches)

      # Sometimes categories like Beginner and Cat 4 are equivalent but need to
      # be separated if both categories exist
      exact_equivalent = equivalent_matches.detect { |category| category.name == name }
      debug "exact equivalent: #{exact_equivalent}"
      return exact_equivalent if exact_equivalent

      # If no weight match, ignore weight and match on age and gender
      if candidate_categories.any? { |category| weight == category.weight }
        candidate_categories = candidate_categories.select { |category| weight == category.weight }
      end
      debug "weight: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if one_match?(candidate_categories)
      return nil if candidate_categories.empty?

      # Eddy is essentially senior men/women for BAR
      if equipment == "Eddy"
        highest_senior_category = candidate_categories.detect do |category|
          category.ability_begin == 0 &&
            category.gender == gender &&
            !category.age_group? &&
            (category.equipment == "Eddy" || category.equipment.blank?)
        end
        debug "eddy: #{highest_senior_category&.name}"
        return highest_senior_category if highest_senior_category
      end

      # Equipment matches are fuzzier
      candidate_categories = candidate_categories.select { |category| equipment == category.equipment }
      debug "equipment: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if one_match?(candidate_categories)
      return candidate_categories.first if candidate_categories.one? && equipment?
      return nil if candidate_categories.empty?

      if equipment?
        equipment_categories = candidate_categories.select do |category|
          equipment == category.equipment && gender == category.gender
        end
        debug "equipment and gender: #{equipment_categories.map(&:name).join(', ')}"
        return equipment_categories.first if equipment_categories.one?
      end

      candidate_categories = candidate_categories.reject { |category| gender == "M" && category.gender == "F" }
      debug "gender: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if one_match?(candidate_categories)
      return nil if candidate_categories.empty?

      candidate_categories = if result_age && !senior? && candidate_categories.none? { |category| ages_begin.in?(category.ages) }
                               candidate_categories.select { |category| category.ages.include?(result_age) }
                             elsif junior? && ages_begin == 0
                               candidate_categories.select { |category| ages_end.in?(category.ages) }
                             else
                               candidate_categories.select { |category| ages_begin.in?(category.ages) }
                             end
      debug "ages: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if one_match?(candidate_categories)
      return nil if candidate_categories.empty?

      unless all_abilities?
        candidate_categories = candidate_categories.select { |category| ability_begin.in?(category.abilities) }
        debug "ability: #{candidate_categories.map(&:name).join(', ')}"
        return candidate_categories.first if one_match?(candidate_categories)
        return nil if candidate_categories.empty?
      end

      # Edge case for unusual age ranges that span juniors and seniors like 15-24
      if !senior? && ages_begin <= Ages::JUNIORS.end && ages_end > Ages::JUNIORS.end
        candidate_categories = candidate_categories.reject(&:junior?)
        debug "overlapping ages: #{candidate_categories.map(&:name).join(', ')}"
        return candidate_categories.first if one_match?(candidate_categories)
        return nil if candidate_categories.empty?
      end

      if junior?
        junior_categories = candidate_categories.select(&:junior?)
        debug "junior: #{junior_categories.map(&:name).join(', ')}"
        return junior_categories.first if junior_categories.one?

        candidate_categories = junior_categories if junior_categories.present?
      end

      if masters?
        masters_categories = candidate_categories.select(&:masters?)
        debug "masters?: #{masters_categories.map(&:name).join(', ')}"
        return masters_categories.first if masters_categories.one?

        candidate_categories = masters_categories if masters_categories.present?
      end

      # E.g., if Cat 3 matches Senior Men and Cat 3, use Cat 3
      # Could check size of range and use narrowest if there is a single one more narrow than the others
      unless junior? || candidate_categories.all?(&:all_abilities?) || all_abilities?
        candidate_categories = candidate_categories.reject(&:all_abilities?)
      end
      debug "reject wildcards: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if one_match?(candidate_categories)
      return nil if candidate_categories.empty?

      # "Highest" is lowest ability number
      # Choose exact ability category begin if women
      # Common edge case where the two highest categories are Pro/1/2 and Women 1/2
      if candidate_categories.one? { |category| category.ability_begin == ability_begin && category.women? && women? }
        ability_category = candidate_categories.detect { |category| category.ability_begin == ability_begin && category.women? && women? }
        debug "ability begin: #{ability_category.name}"
        return ability_category if ability_category.include?(self)
      end

      # Edge case for next two matchers: don't choose Junior Open 1/2/3 over Junior Open 3/4/5 9-12 for Junior Open 3/4/5 11-12,
      # but still match Junior Women with Category 1

      # Choose highest ability category
      highest_ability = candidate_categories.map(&:ability_begin).min
      if candidate_categories.one? { |category| category.ability_begin == highest_ability && (!category.junior? || category.ages.size <= ages.size) }
        highest_ability_category = candidate_categories.detect { |category| category.ability_begin == highest_ability && (!category.junior? || category.ages.size <= ages.size) }
        debug "highest ability: #{highest_ability_category.name}"
        return highest_ability_category if highest_ability_category.include?(self)
      end

      # Choose highest ability by gender
      if candidate_categories.one? { |category| category.ability_begin == highest_ability && category.gender == gender && (!category.junior? || category.ages.size <= ages.size) }
        highest_ability_category = candidate_categories.detect { |category| category.ability_begin == highest_ability && category.gender == gender && (!category.junior? || category.ages.size <= ages.size) }
        debug "highest ability for gender: #{highest_ability_category.name}"
        return highest_ability_category if highest_ability_category.include?(self)
      end

      # Choose highest minimum age if multiple Masters 'and over' categories
      if masters? && candidate_categories.all?(&:and_over?)
        if result_age
          candidate_categories = candidate_categories.reject { |category| category.ages_begin > result_age }
        end
        highest_age = candidate_categories.map(&:ages_begin).max
        highest_age_category = candidate_categories.detect { |category| category.ages_begin == highest_age }
        debug "highest age: #{highest_age_category&.name}"
        return highest_age_category if highest_age_category&.include?(self)
      end

      # Choose narrowest age if multiple Masters categories
      if masters?
        ranges = candidate_categories.select(&:masters?).map do |category|
          category.ages_end - category.ages_begin
        end

        minimum_range = ranges.min
        candidate_categories = candidate_categories.select do |category|
          (category.ages_end - category.ages_begin) == minimum_range
        end

        return candidate_categories.first if one_match?(candidate_categories)
      end

      # Choose narrowest age if multiple Juniors categories
      if junior?
        ranges = candidate_categories.select(&:junior?).map do |category|
          category.ages_end - category.ages_begin
        end

        minimum_range = ranges.min
        candidate_categories = candidate_categories.select do |category|
          (category.ages_end - category.ages_begin) == minimum_range
        end

        debug "narrow junior ages: #{candidate_categories.map(&:name).join(', ')}"
        return candidate_categories.first if one_match?(candidate_categories)
      end

      candidate_categories = candidate_categories.reject { |category| gender == "F" && category.gender == "M" }
      debug "exact gender: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if one_match?(candidate_categories)
      return nil if candidate_categories.empty?

      if wildcard? && candidate_categories.none?(&:wildcard?)
        debug "no wild cards: #{candidate_categories.map(&:name).join(', ')}"
        return nil
      end

      if candidate_categories.size > 1
        raise "Multiple matches #{candidate_categories.map(&:name)} for #{name}, result age: #{result_age} in #{event_categories.map(&:name).join(', ')}"
      end
    end

    def best_match_by_age_in(event_categories, result_age = nil)
      debug "Category#best_match_by_age_in for #{name}, #{result_age} in #{event_categories.map(&:name).join(', ')}"

      candidate_categories = event_categories.dup

      equivalent_match = candidate_categories.detect { |category| equivalent?(category) }
      debug "equivalent: #{equivalent_match&.name}"
      return equivalent_match if equivalent_match

      candidate_categories = if result_age
                               candidate_categories.select { |category| category.ages.include?(result_age) }
                             else
                               candidate_categories.select { |category| ages_begin.in?(category.ages) }
                             end
      debug "ages: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if candidate_categories.size == 1

      candidate_categories = candidate_categories.reject { |category| gender == "M" && category.gender == "F" }
      debug "gender: #{candidate_categories.map(&:name).join(', ')}"
      return candidate_categories.first if candidate_categories.size == 1

      # Choose highest minimum age if multiple Masters 'and over' categories
      if masters? && candidate_categories.all?(&:and_over?)
        if result_age
          candidate_categories = candidate_categories.reject { |category| category.ages_begin > result_age }
        end
        highest_age = candidate_categories.map(&:ages_begin).max
        highest_age_category = candidate_categories.select { |category| category.ages_begin == highest_age }
        debug "highest age: #{highest_age_category.map(&:name)}"
        return highest_age_category.first if highest_age_category.one?
      end

      candidate_categories = candidate_categories.reject { |category| gender == "F" && category.gender == "M" }
      debug "exact gender: #{candidate_categories.map(&:name).join(', ')}"

      return candidate_categories.first if candidate_categories.size == 1

      if candidate_categories.size > 1
        raise "Multiple matches #{candidate_categories.map(&:name)} for #{name}, result age: #{result_age} in #{event_categories.map(&:name).join(', ')}"
      end
    end

    def equivalent?(other)
      return false unless other

      abilities == other.abilities &&
        ages == other.ages &&
        equipment == other.equipment &&
        gender == other.gender &&
        weight == other.weight
    end

    # This could be done at the start of best_match_in
    def include?(other, result_age = nil)
      return false unless other

      abilities_include?(other) &&
        ages_include?(other, result_age) &&
        equipment == other.equipment &&
        (men? || other.women?) &&
        (!weight? || weight == other.weight)
    end

    # Some Calculations only have one category, and some categories shoould bever match.
    # E.g., Masters Women and Junior Men
    # Age-matching is more permissive of weight, equipment, etc.
    def one_match?(candidate_categories, result_age = nil)
      return false unless candidate_categories.one?

      candidate_category = candidate_categories.first
      match = candidate_category.include? self, result_age
      debug "one_match? #{match}"
      match
    end

    def debug(message)
      if defined?(Rails) && defined?(Rails.logger) && ENV["DEBUG_CATEGORY_MATCHING"]
        Rails.logger&.debug message
      end
    end

    # No restrictions expect defaults. E.g., Senior Men, Open.
    def wildcard?
      all_abilities? && all_ages? && !equipment? && !weight?
    end
  end
end