diff --git a/README.md b/README.md index 7b1c40b..023e0b5 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,14 @@ In development environments, background jobs run inline (synchronously) by defau To run a single test file: 1. Get a shell with ruby and rails: `bash bin/rails-dev-run.sh wrestlingdev-development` -2. `rake test TEST=test/models/match_test.rb` +2. `rake test TEST=test/models/match_test.rb` OR `rails test test/models/match_test.rb` To run a single test inside a file: 1. Get a shell with ruby and rails: `bash bin/rails-dev-run.sh wrestlingdev-development` -2. `rake test TEST=test/models/match_test.rb TESTOPTS="--name='/test_Match_should_not_be_valid_if_an_incorrect_win_type_is_given/'"` +2. `rake test TEST=test/models/match_test.rb TESTOPTS="--name='/test_Match_should_not_be_valid_if_an_incorrect_win_type_is_given/'"` OR `rails test test/models/match_test.rb --name=/test_Match_should_not_be_valid_if_an_incorrect_win_type_is_given/` + +To run tests in verbose mode (outputs the time for each test file and the test file name) +`rails test -v` ## Develop with rvm With rvm installed, run `rvm install ruby-3.2.0` diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 1375f1b..46cbfa2 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -1,6 +1,6 @@ class MatchesController < ApplicationController - before_action :set_match, only: [:show, :edit, :update, :stat, :spectate] - before_action :check_access, only: [:edit,:update, :stat] + before_action :set_match, only: [:show, :edit, :update, :stat, :spectate, :edit_assignment, :update_assignment] + before_action :check_access, only: [:edit, :update, :stat, :edit_assignment, :update_assignment] # GET /matches/1 # GET /matches/1.json @@ -21,7 +21,7 @@ class MatchesController < ApplicationController session[:return_path] = "/tournaments/#{@match.tournament.id}/matches" end - def stat + def stat # @show_next_bout_button = false if params[:match] @match = Match.where(:id => params[:match]).includes(:wrestlers).first @@ -50,8 +50,21 @@ class MatchesController < ApplicationController end @tournament = @match.tournament end - session[:return_path] = "/tournaments/#{@tournament.id}/matches" - session[:error_return_path] = "/matches/#{@match.id}/stat" + if @match&.mat + @mat = @match.mat + queue_position = @mat.queue_position_for_match(@match) + @next_match = queue_position == 1 ? @mat.queue2_match : nil + @show_next_bout_button = queue_position == 1 + if request.referer&.include?("/tournaments/#{@tournament.id}/matches") + session[:return_path] = "/tournaments/#{@tournament.id}/matches" + else + session[:return_path] = mat_path(@mat) + end + session[:error_return_path] = "/matches/#{@match.id}/stat" + else + session[:return_path] = "/tournaments/#{@tournament.id}/matches" + session[:error_return_path] = "/matches/#{@match.id}/stat" + end end # GET /matches/:id/spectate @@ -71,6 +84,49 @@ class MatchesController < ApplicationController end end + # GET /matches/1/edit_assignment + def edit_assignment + @tournament = @match.tournament + @mats = @tournament.mats.sort_by(&:name) + @current_mat = @match.mat + @current_queue_position = @current_mat&.queue_position_for_match(@match) + session[:return_path] = "/tournaments/#{@tournament.id}/matches" + end + + # PATCH /matches/1/update_assignment + def update_assignment + @tournament = @match.tournament + mat_id = params.dig(:match, :mat_id) + queue_position = params.dig(:match, :queue_position) + + if mat_id.blank? + Mat.where("queue1 = :match_id OR queue2 = :match_id OR queue3 = :match_id OR queue4 = :match_id", match_id: @match.id) + .find_each { |mat| mat.remove_match_from_queue_and_collapse!(@match.id) } + @match.update(mat_id: nil) + redirect_to session.delete(:return_path) || "/tournaments/#{@tournament.id}/matches", notice: "Match assignment cleared." + return + end + + if queue_position.blank? + redirect_to edit_assignment_match_path(@match), alert: "Queue position is required when selecting a mat." + return + end + + unless %w[1 2 3 4].include?(queue_position.to_s) + redirect_to edit_assignment_match_path(@match), alert: "Queue position must be between 1 and 4." + return + end + + mat = @tournament.mats.find_by(id: mat_id) + unless mat + redirect_to edit_assignment_match_path(@match), alert: "Selected mat was not found." + return + end + + mat.assign_match_to_queue!(@match, queue_position) + redirect_to session.delete(:return_path) || "/tournaments/#{@tournament.id}/matches", notice: "Match assignment updated." + end + # PATCH/PUT /matches/1 # PATCH/PUT /matches/1.json def update diff --git a/app/controllers/mats_controller.rb b/app/controllers/mats_controller.rb index afeac39..7d3745a 100644 --- a/app/controllers/mats_controller.rb +++ b/app/controllers/mats_controller.rb @@ -10,13 +10,13 @@ class MatsController < ApplicationController if bout_number_param @show_next_bout_button = false - @match = @mat.unfinished_matches.find { |m| m.bout_number == bout_number_param.to_i } + @match = @mat.queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i } else @show_next_bout_button = true - @match = @mat.unfinished_matches.first + @match = @mat.queue1_match end - @next_match = @mat.unfinished_matches.second # Second unfinished match on the mat + @next_match = @mat.queue2_match # Second match on the mat @wrestlers = [] if @match @@ -82,8 +82,8 @@ class MatsController < ApplicationController def assign_next_match @tournament = @mat.tournament_id respond_to do |format| - if @mat.assign_next_match - format.html { redirect_to "/tournaments/#{@mat.tournament.id}", notice: "Next Match on Mat #{@mat.name} successfully completed." } + if @mat.advance_queue! + format.html { redirect_to "/tournaments/#{@mat.tournament.id}", notice: "Mat #{@mat.name} queue advanced." } format.json { head :no_content } else format.html { redirect_to "/tournaments/#{@mat.tournament.id}", alert: "There was an error." } diff --git a/app/models/mat.rb b/app/models/mat.rb index 7d7bbfe..2733b28 100644 --- a/app/models/mat.rb +++ b/app/models/mat.rb @@ -1,53 +1,50 @@ class Mat < ApplicationRecord + include ActionView::RecordIdentifier belongs_to :tournament has_many :matches, dependent: :nullify has_many :mat_assignment_rules, dependent: :destroy validates :name, presence: true - before_destroy do - if tournament.matches.size > 0 - tournament.reset_mats - matsToAssign = tournament.mats.select{|m| m.id != self.id} - tournament.assign_mats(matsToAssign) - end - end - - after_create do - if tournament.matches.size > 0 - tournament.reset_mats - matsToAssign = tournament.mats - tournament.assign_mats(matsToAssign) - end - end + QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze def assign_next_match + slot = first_empty_queue_slot + return true unless slot + match = next_eligible_match - self.matches.reload - if match and self.unfinished_matches.size < 4 - match.mat_id = self.id - if match.save - # Invalidate any wrestler caches - if match.w1 - match.wrestler1.touch - match.wrestler1.school.touch + return false unless match + + place_match_in_empty_slot!(match, slot) + true + end + + def advance_queue!(finished_match = nil) + self.class.transaction do + if finished_match + position = queue_position_for_match(finished_match) + if position == 1 + shift_queue_forward! + fill_queue_slots! + elsif position + remove_match_from_queue_and_collapse!(finished_match.id) + else + fill_queue_slots! end - if match.w2 - match.wrestler2.touch - match.wrestler2.school.touch - end - return true else - return false + if queue1_match&.finished == 1 + shift_queue_forward! + end + fill_queue_slots! end - else - return true end + broadcast_current_match + true end def next_eligible_match # Start with all matches that are either unfinished (nil or 0), have a bout number, and are ordered by bout_number - filtered_matches = tournament.matches + filtered_matches = Match.where(tournament_id: tournament_id) .where(finished: [nil, 0]) # finished is nil or 0 .where(mat_id: nil) # mat_id is nil .where.not(bout_number: nil) # bout_number is not nil @@ -57,6 +54,11 @@ class Mat < ApplicationRecord filtered_matches = filtered_matches .where("loser1_name != ? OR loser1_name IS NULL", "BYE") .where("loser2_name != ? OR loser2_name IS NULL", "BYE") + + # Filter out matches without a wrestlers + filtered_matches = filtered_matches + .where("w1 IS NOT NULL") + .where("w2 IS NOT NULL") # Apply mat assignment rules mat_assignment_rules.each do |rule| @@ -80,9 +82,178 @@ class Mat < ApplicationRecord filtered_matches.first end + def queue_match_ids + QUEUE_SLOTS.map { |slot| public_send(slot) } + end + + def queue_matches + queue_match_ids.map { |match_id| match_id ? Match.find_by(id: match_id) : nil } + end + + def queue1_match + queue_match_at(1) + end + + def queue2_match + queue_match_at(2) + end + + def queue3_match + queue_match_at(3) + end + + def queue4_match + queue_match_at(4) + end + + def queue_position_for_match(match) + return nil unless match + return 1 if queue1 == match.id + return 2 if queue2 == match.id + return 3 if queue3 == match.id + return 4 if queue4 == match.id + nil + end + + def remove_match_from_queue_and_collapse!(match_id) + queue_ids = queue_match_ids + return if queue_ids.none? { |id| id == match_id } + + queue_ids.map! { |id| id == match_id ? nil : id } + queue_ids = queue_ids.compact + queue_ids += [nil] * (4 - queue_ids.size) + + update!( + queue1: queue_ids[0], + queue2: queue_ids[1], + queue3: queue_ids[2], + queue4: queue_ids[3] + ) + + fill_queue_slots! + broadcast_current_match + end + + def assign_match_to_queue!(match, position) + position = position.to_i + raise ArgumentError, "Queue position must be 1-4" unless (1..4).cover?(position) + + self.class.transaction do + match.update!(mat_id: id) + remove_match_from_other_mats!(match.id) + + queue_ids = queue_match_ids.map { |id| id == match.id ? nil : id } + queue_ids = queue_ids.compact + + queue_ids.insert(position - 1, match.id) + bumped_match_id = queue_ids.length > 4 ? queue_ids.pop : nil + + queue_ids += [nil] * (4 - queue_ids.length) + + update!( + queue1: queue_ids[0], + queue2: queue_ids[1], + queue3: queue_ids[2], + queue4: queue_ids[3] + ) + + bumped_match = Match.find_by(id: bumped_match_id) + if bumped_match && bumped_match.finished != 1 + bumped_match.update!(mat_id: nil) + end + end + broadcast_current_match + end + + def clear_queue! + update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil) + end def unfinished_matches matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number} end + private + + def queue_match_at(position) + match_id = public_send("queue#{position}") + match_id ? Match.find_by(id: match_id) : nil + end + + def first_empty_queue_slot + QUEUE_SLOTS.each_with_index do |slot, index| + return index + 1 if public_send(slot).nil? + end + nil + end + + def shift_queue_forward! + update!( + queue1: queue2, + queue2: queue3, + queue3: queue4, + queue4: nil + ) + end + + def fill_queue_slots! + queue_ids = queue_match_ids + updated = false + + QUEUE_SLOTS.each_with_index do |_slot, index| + next if queue_ids[index].present? + + match = next_eligible_match + break unless match + + queue_ids[index] = match.id + match.update!(mat_id: id) + updated = true + end + + if updated + update!( + queue1: queue_ids[0], + queue2: queue_ids[1], + queue3: queue_ids[2], + queue4: queue_ids[3] + ) + end + end + + def remove_match_from_other_mats!(match_id) + self.class.where.not(id: id) + .where("queue1 = :match_id OR queue2 = :match_id OR queue3 = :match_id OR queue4 = :match_id", match_id: match_id) + .find_each do |mat| + mat.remove_match_from_queue_and_collapse!(match_id) + end + end + + def place_match_in_empty_slot!(match, slot) + self.class.transaction do + match.update!(mat_id: id) + remove_match_from_other_mats!(match.id) + update!(slot_key(slot) => match.id) + end + broadcast_current_match + end + + def slot_key(slot) + "queue#{slot}" + end + + def broadcast_current_match + Turbo::StreamsChannel.broadcast_update_to( + self, + target: dom_id(self, :current_match), + partial: "mats/current_match", + locals: { + mat: self, + match: queue1_match, + next_match: queue2_match, + show_next_bout_button: true + } + ) + end + end diff --git a/app/models/match.rb b/app/models/match.rb index 524c0a2..8c47892 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -37,12 +37,14 @@ class Match < ApplicationRecord wrestler2.touch end if self.finished == 1 && self.winner_id != nil - if self.mat - self.mat.assign_next_match - end advance_wrestlers + if self.mat + self.mat.advance_queue!(self) + end + self.tournament.refill_open_bout_board_queues # School point calculation has move to the end of advance wrestler # calculate_school_points + self.update(mat_id: nil) end end @@ -352,13 +354,13 @@ class Match < ApplicationRecord next unless mat Turbo::StreamsChannel.broadcast_update_to( - mat, - target: dom_id(mat, :current_match), - partial: "mats/current_match", - locals: { - mat: mat, - match: mat.unfinished_matches.first, - next_match: mat.unfinished_matches.second, + mat, + target: dom_id(mat, :current_match), + partial: "mats/current_match", + locals: { + mat: mat, + match: mat.queue1_match, + next_match: mat.queue2_match, show_next_bout_button: true } ) diff --git a/app/models/tournament.rb b/app/models/tournament.rb index eb3bc68..091a1be 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -82,23 +82,18 @@ class Tournament < ApplicationRecord matches.maximum(:round) || 0 # Return 0 if no matches or max round is nil end - def assign_mats(mats_to_assign) - if mats_to_assign.count > 0 - until mats_to_assign.sort_by{|m| m.id}.last.matches.count == 4 - mats_to_assign.sort_by{|m| m.id}.each do |m| - m.assign_next_match - end - end - end - end - def reset_mats + matches.reload + mats.reload matches_to_reset = matches.select{|m| m.mat_id != nil} # matches_to_reset.update_all( {:mat_id => nil } ) matches_to_reset.each do |m| m.mat_id = nil m.save end + mats.each do |mat| + mat.clear_queue! + end end def pointAdjustments @@ -228,19 +223,24 @@ class Tournament < ApplicationRecord def reset_and_fill_bout_board reset_mats - - if mats.any? - 4.times do - # Iterate over each mat and assign the next available match - mats.each do |mat| - match_assigned = mat.assign_next_match - # If no more matches are available, exit early - unless match_assigned - puts "No more eligible matches to assign." - return - end + matches.reload + refill_open_bout_board_queues + end + + def refill_open_bout_board_queues + return unless mats.any? + + loop do + assigned_any = false + # Fill in round-robin order by queue depth: + # all mats queue1 first, then queue2, then queue3, then queue4. + (1..4).each do |slot| + mats.reload.each do |mat| + next unless mat.public_send("queue#{slot}").nil? + assigned_any ||= mat.assign_next_match + end end - end + break unless assigned_any end end @@ -279,4 +279,4 @@ class Tournament < ApplicationRecord def connection_adapter ActiveRecord::Base.connection.adapter_name end -end \ No newline at end of file +end diff --git a/app/services/tournament_services/tournament_backup_service.rb b/app/services/tournament_services/tournament_backup_service.rb index cd678e0..f3ab02e 100644 --- a/app/services/tournament_services/tournament_backup_service.rb +++ b/app/services/tournament_services/tournament_backup_service.rb @@ -37,7 +37,11 @@ class TournamentBackupService attributes: @tournament.attributes, schools: @tournament.schools.map(&:attributes), weights: @tournament.weights.map(&:attributes), - mats: @tournament.mats.map(&:attributes), + mats: @tournament.mats.map do |mat| + mat.attributes.merge( + "queue_bout_numbers" => mat.queue_matches.map { |match| match&.bout_number } + ) + end, mat_assignment_rules: @tournament.mat_assignment_rules.map do |rule| rule.attributes.merge( mat: Mat.find_by(id: rule.mat_id)&.attributes.slice("name"), diff --git a/app/services/tournament_services/wrestlingdev_importer.rb b/app/services/tournament_services/wrestlingdev_importer.rb index fa7fade..fefc42e 100644 --- a/app/services/tournament_services/wrestlingdev_importer.rb +++ b/app/services/tournament_services/wrestlingdev_importer.rb @@ -45,15 +45,16 @@ class WrestlingdevImporter # Note: Teampointadjusts are deleted via School/Wrestler cascade end - def parse_data - parse_tournament(@import_data["tournament"]["attributes"]) - parse_schools(@import_data["tournament"]["schools"]) - parse_weights(@import_data["tournament"]["weights"]) - parse_mats(@import_data["tournament"]["mats"]) - parse_wrestlers(@import_data["tournament"]["wrestlers"]) - parse_matches(@import_data["tournament"]["matches"]) - parse_mat_assignment_rules(@import_data["tournament"]["mat_assignment_rules"]) - end + def parse_data + parse_tournament(@import_data["tournament"]["attributes"]) + parse_schools(@import_data["tournament"]["schools"]) + parse_weights(@import_data["tournament"]["weights"]) + parse_mats(@import_data["tournament"]["mats"]) + parse_wrestlers(@import_data["tournament"]["wrestlers"]) + parse_matches(@import_data["tournament"]["matches"]) + apply_mat_queues + parse_mat_assignment_rules(@import_data["tournament"]["mat_assignment_rules"]) + end def parse_tournament(attributes) attributes.except!("id") @@ -74,12 +75,18 @@ class WrestlingdevImporter end end - def parse_mats(mats) - mats.each do |mat_attributes| - mat_attributes.except!("id") - Mat.create(mat_attributes.merge(tournament_id: @tournament.id)) - end - end + def parse_mats(mats) + @mat_queue_bout_numbers = {} + mats.each do |mat_attributes| + mat_name = mat_attributes["name"] + queue_bout_numbers = mat_attributes["queue_bout_numbers"] + mat_attributes.except!("id", "queue1", "queue2", "queue3", "queue4", "queue_bout_numbers", "tournament_id") + Mat.create(mat_attributes.merge(tournament_id: @tournament.id)) + if mat_name && queue_bout_numbers + @mat_queue_bout_numbers[mat_name] = queue_bout_numbers + end + end + end def parse_mat_assignment_rules(mat_assignment_rules) mat_assignment_rules.each do |rule_attributes| @@ -134,9 +141,9 @@ class WrestlingdevImporter end end - def parse_matches(matches) - matches.each do |match_attributes| - next unless match_attributes # Skip if match_attributes is nil + def parse_matches(matches) + matches.each do |match_attributes| + next unless match_attributes # Skip if match_attributes is nil weight = Weight.find_by(max: match_attributes.dig("weight", "max"), tournament_id: @tournament.id) mat = Mat.find_by(name: match_attributes.dig("mat", "name"), tournament_id: @tournament.id) @@ -155,6 +162,53 @@ class WrestlingdevImporter w2: w2&.id, winner_id: winner&.id )) - end - end -end + end + end + + def apply_mat_queues + if @mat_queue_bout_numbers.blank? + Mat.where(tournament_id: @tournament.id).find_each do |mat| + match_ids = mat.matches.where(finished: [nil, 0]).order(:bout_number).limit(4).pluck(:id) + mat.update( + queue1: match_ids[0], + queue2: match_ids[1], + queue3: match_ids[2], + queue4: match_ids[3] + ) + end + return + end + + @mat_queue_bout_numbers.each do |mat_name, bout_numbers| + mat = Mat.find_by(name: mat_name, tournament_id: @tournament.id) + next unless mat + + matches = Array(bout_numbers).map do |bout_number| + Match.find_by(bout_number: bout_number, tournament_id: @tournament.id) + end + + mat.update( + queue1: matches[0]&.id, + queue2: matches[1]&.id, + queue3: matches[2]&.id, + queue4: matches[3]&.id + ) + + matches.compact.each do |match| + match.update(mat_id: mat.id) + end + end + + Mat.where(tournament_id: @tournament.id) + .where(queue1: nil, queue2: nil, queue3: nil, queue4: nil) + .find_each do |mat| + match_ids = mat.matches.where(finished: [nil, 0]).order(:bout_number).limit(4).pluck(:id) + mat.update( + queue1: match_ids[0], + queue2: match_ids[1], + queue3: match_ids[2], + queue4: match_ids[3] + ) + end + end +end diff --git a/app/views/api/tournament.jbuilder b/app/views/api/tournament.jbuilder index bcd807e..fd57f12 100644 --- a/app/views/api/tournament.jbuilder +++ b/app/views/api/tournament.jbuilder @@ -28,7 +28,7 @@ json.cache! ["api_tournament", @tournament] do json.mats @mats do |mat| json.name mat.name - json.unfinished_matches mat.unfinished_matches do |match| + json.unfinished_matches mat.queue_matches.compact do |match| json.bout_number match.bout_number json.w1_name match.w1_name json.w2_name match.w2_name diff --git a/app/views/matches/edit_assignment.html.erb b/app/views/matches/edit_assignment.html.erb new file mode 100644 index 0000000..3ad1cb7 --- /dev/null +++ b/app/views/matches/edit_assignment.html.erb @@ -0,0 +1,34 @@ +

Assign Mat/Queue for Match <%= @match.bout_number %>

+ +<% if @current_mat %> +

Current Assignment: Mat <%= @current_mat.name %><%= @current_queue_position ? " (Queue #{@current_queue_position})" : "" %>

+<% else %> +

Current Assignment: Unassigned

+<% end %> + +<%= form_with model: @match, url: update_assignment_match_path(@match), method: :patch do |f| %> +
+ <%= f.label :mat_id, "Mat" %>
+ <%= f.collection_select :mat_id, @mats, :id, :name, { include_blank: "Unassigned" } %> +
+
+
+ <%= f.label :queue_position, "Queue Position" %>
+ <%= f.select :queue_position, + options_for_select( + [ + ["On Mat (Queue 1)", 1], + ["On Deck (Queue 2)", 2], + ["In The Hole (Queue 3)", 3], + ["Warm Up (Queue 4)", 4] + ], + @current_queue_position + ), + include_blank: "Select position" + %> +
+
+
+ <%= f.submit "Update Assignment", class: "btn btn-success" %> +
+<% end %> diff --git a/app/views/mats/_current_match.html.erb b/app/views/mats/_current_match.html.erb index 8c9c6a4..77f3605 100644 --- a/app/views/mats/_current_match.html.erb +++ b/app/views/mats/_current_match.html.erb @@ -1,6 +1,6 @@ <% @mat = mat %> -<% @match = local_assigns[:match] || mat.unfinished_matches.first %> -<% @next_match = local_assigns[:next_match] || mat.unfinished_matches.second %> +<% @match = local_assigns[:match] || mat.queue1_match %> +<% @next_match = local_assigns[:next_match] || mat.queue2_match %> <% @show_next_bout_button = local_assigns.key?(:show_next_bout_button) ? local_assigns[:show_next_bout_button] : true %> <% @wrestlers = [] %> diff --git a/app/views/tournaments/export.html.erb b/app/views/tournaments/export.html.erb index 4cc4121..d5d5ed1 100644 --- a/app/views/tournaments/export.html.erb +++ b/app/views/tournaments/export.html.erb @@ -3,7 +3,11 @@ "attributes": <%= @tournament.attributes.to_json %>, "schools": <%= @tournament.schools.map(&:attributes).to_json %>, "weights": <%= @tournament.weights.map(&:attributes).to_json %>, - "mats": <%= @tournament.mats.map(&:attributes).to_json %>, + "mats": <%= @tournament.mats.map { |mat| mat.attributes.merge( + { + "queue_bout_numbers": mat.queue_matches.map { |match| match&.bout_number } + } + ) }.to_json %>, "wrestlers": <%= @tournament.wrestlers.map { |wrestler| wrestler.attributes.merge( { "school": wrestler.school&.attributes, @@ -20,4 +24,4 @@ } ) }.to_json %> } -} \ No newline at end of file +} diff --git a/app/views/tournaments/matches.html.erb b/app/views/tournaments/matches.html.erb index 65d38a6..6067457 100644 --- a/app/views/tournaments/matches.html.erb +++ b/app/views/tournaments/matches.html.erb @@ -28,6 +28,7 @@ <%= match.finished %> <%= link_to 'Show', match, :class=>"btn btn-default btn-sm" %> <%= link_to 'Edit Wrestlers', edit_match_path(match), :class=>"btn btn-primary btn-sm" %> + <%= link_to 'Edit Mat/Queue', edit_assignment_match_path(match), :class=>"btn btn-primary btn-sm" %> <%= link_to 'Stat Match', "/matches/#{match.id}/stat", :class=>"btn btn-primary btn-sm" %> @@ -36,4 +37,4 @@

Total matches without byes: <%= @matches.select{|m| m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %>

-

Unfinished matches: <%= @matches.select{|m| m.finished != 1 and m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %>

\ No newline at end of file +

Unfinished matches: <%= @matches.select{|m| m.finished != 1 and m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %>

diff --git a/app/views/tournaments/up_matches.html.erb b/app/views/tournaments/up_matches.html.erb index a82c725..eebbf4b 100644 --- a/app/views/tournaments/up_matches.html.erb +++ b/app/views/tournaments/up_matches.html.erb @@ -45,31 +45,31 @@ <%= m.name %> - <% if m.unfinished_matches.first %><%=m.unfinished_matches.first.bout_number%> (<%= m.unfinished_matches.first.bracket_position %>)
- <%= m.unfinished_matches.first.weight_max %> lbs -
<%= m.unfinished_matches.first.w1_bracket_name %> vs.
- <%= m.unfinished_matches.first.w2_bracket_name %> + <% if m.queue1_match %><%=m.queue1_match.bout_number%> (<%= m.queue1_match.bracket_position %>)
+ <%= m.queue1_match.weight_max %> lbs +
<%= m.queue1_match.w1_bracket_name %> vs.
+ <%= m.queue1_match.w2_bracket_name %> <% end %> - <% if m.unfinished_matches.second %><%=m.unfinished_matches.second.bout_number%> (<%= m.unfinished_matches.second.bracket_position %>)
- <%= m.unfinished_matches.second.weight_max %> lbs -
<%= m.unfinished_matches.second.w1_bracket_name %> vs.
- <%= m.unfinished_matches.second.w2_bracket_name %> + <% if m.queue2_match %><%=m.queue2_match.bout_number%> (<%= m.queue2_match.bracket_position %>)
+ <%= m.queue2_match.weight_max %> lbs +
<%= m.queue2_match.w1_bracket_name %> vs.
+ <%= m.queue2_match.w2_bracket_name %> <% end %> - <% if m.unfinished_matches.third %><%=m.unfinished_matches.third.bout_number%> (<%= m.unfinished_matches.third.bracket_position %>)
- <%= m.unfinished_matches.third.weight_max %> lbs -
<%= m.unfinished_matches.third.w1_bracket_name %> vs.
- <%= m.unfinished_matches.third.w2_bracket_name %> + <% if m.queue3_match %><%=m.queue3_match.bout_number%> (<%= m.queue3_match.bracket_position %>)
+ <%= m.queue3_match.weight_max %> lbs +
<%= m.queue3_match.w1_bracket_name %> vs.
+ <%= m.queue3_match.w2_bracket_name %> <% end %> - <% if m.unfinished_matches.fourth %><%=m.unfinished_matches.fourth.bout_number%> (<%= m.unfinished_matches.fourth.bracket_position %>)
- <%= m.unfinished_matches.fourth.weight_max %> lbs -
<%= m.unfinished_matches.fourth.w1_bracket_name %> vs.
- <%= m.unfinished_matches.fourth.w2_bracket_name %> + <% if m.queue4_match %><%=m.queue4_match.bout_number%> (<%= m.queue4_match.bracket_position %>)
+ <%= m.queue4_match.weight_max %> lbs +
<%= m.queue4_match.w1_bracket_name %> vs.
+ <%= m.queue4_match.w2_bracket_name %> <% end %> diff --git a/bin/run-all-tests.sh b/bin/run-all-tests.sh index 1f9d09e..5c434f7 100755 --- a/bin/run-all-tests.sh +++ b/bin/run-all-tests.sh @@ -5,4 +5,4 @@ cd ${project_dir} bundle exec rake db:migrate RAILS_ENV=test CI=true brakeman bundle exec bundle-audit check --update -bundle exec rake test +rails test -v diff --git a/config/routes.rb b/config/routes.rb index 255892e..bf27071 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,8 @@ Wrestling::Application.routes.draw do member do get :stat get :spectate + get :edit_assignment + patch :update_assignment end end diff --git a/db/migrate/20260129120000_add_queues_to_mats.rb b/db/migrate/20260129120000_add_queues_to_mats.rb new file mode 100644 index 0000000..7ea2a54 --- /dev/null +++ b/db/migrate/20260129120000_add_queues_to_mats.rb @@ -0,0 +1,49 @@ +class AddQueuesToMats < ActiveRecord::Migration[7.0] + class Mat < ActiveRecord::Base + self.table_name = "mats" + has_many :matches, class_name: "AddQueuesToMats::Match", foreign_key: "mat_id" + end + + class Match < ActiveRecord::Base + self.table_name = "matches" + end + + def up + add_column :mats, :queue1, :bigint + add_column :mats, :queue2, :bigint + add_column :mats, :queue3, :bigint + add_column :mats, :queue4, :bigint + + add_index :mats, :queue1 + add_index :mats, :queue2 + add_index :mats, :queue3 + add_index :mats, :queue4 + + say_with_time "Backfilling mat queues from unfinished matches" do + Mat.reset_column_information + Match.reset_column_information + + Mat.find_each do |mat| + match_ids = mat.matches.where(finished: [nil, 0]).order(:bout_number).limit(4).pluck(:id) + mat.update_columns( + queue1: match_ids[0], + queue2: match_ids[1], + queue3: match_ids[2], + queue4: match_ids[3] + ) + end + end + end + + def down + remove_index :mats, :queue1 + remove_index :mats, :queue2 + remove_index :mats, :queue3 + remove_index :mats, :queue4 + + remove_column :mats, :queue1 + remove_column :mats, :queue2 + remove_column :mats, :queue3 + remove_column :mats, :queue4 + end +end diff --git a/db/schema.rb b/db/schema.rb index cd4849a..21efddd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_15_173921) do +ActiveRecord::Schema[8.0].define(version: 2026_01_29_120000) do create_table "mat_assignment_rules", force: :cascade do |t| t.bigint "tournament_id" t.bigint "mat_id" @@ -56,6 +56,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_15_173921) do t.bigint "tournament_id" t.datetime "created_at", precision: nil t.datetime "updated_at", precision: nil + t.bigint "queue1" + t.bigint "queue2" + t.bigint "queue3" + t.bigint "queue4" + t.index ["queue1"], name: "index_mats_on_queue1" + t.index ["queue2"], name: "index_mats_on_queue2" + t.index ["queue3"], name: "index_mats_on_queue3" + t.index ["queue4"], name: "index_mats_on_queue4" t.index ["tournament_id"], name: "index_mats_on_tournament_id" end diff --git a/lib/tasks/finish_tournament_204.rake b/lib/tasks/finish_tournament_204.rake index af9061b..bf18a08 100644 --- a/lib/tasks/finish_tournament_204.rake +++ b/lib/tasks/finish_tournament_204.rake @@ -35,53 +35,69 @@ namespace :tournament do end sleep(10) - @tournament.reload # Ensure matches association is fresh before iterating - @tournament.matches.reload.sort_by(&:bout_number).each do |match| - if match.reload.loser1_name != "BYE" and match.reload.loser2_name != "BYE" and match.reload.finished != 1 - # Wait until both wrestlers are assigned - while (match.w1.nil? || match.w2.nil?) - puts "Waiting for wrestlers in match #{match.bout_number}..." - sleep(5) # Wait for 5 seconds before checking again - match.reload - end - puts "Finishing match with bout number #{match.bout_number}..." + loop do + @tournament.reload + @tournament.refill_open_bout_board_queues - # Choose a random winner - wrestlers = [match.w1, match.w2] - match.winner_id = wrestlers.sample - - # Choose a random win type - win_type = WIN_TYPES.sample - match.win_type = win_type - - # Assign score based on win type - match.score = case win_type - when "Decision" - low_score = rand(0..10) - high_score = low_score + rand(1..7) - "#{high_score}-#{low_score}" - when "Major" - low_score = rand(0..10) - high_score = low_score + rand(8..14) - "#{high_score}-#{low_score}" - when "Tech Fall" - low_score = rand(0..10) - high_score = low_score + rand(15..19) - "#{high_score}-#{low_score}" - when "Pin" - pin_times = ["0:30","1:12","5:37","2:34","3:54","4:23","5:56","0:12","1:00"] - pin_times.sample - else - "" # Default score - end - - # Mark match as finished - match.finished = 1 - match.save! - # sleep to prevent mysql locks when assign_next_match to a mat runs - sleep(0.5) + mats_with_queue1 = @tournament.mats.select do |mat| + match = mat.queue1_match + match && match.finished != 1 && match.loser1_name != "BYE" && match.loser2_name != "BYE" end + + break if mats_with_queue1.empty? + + mat = mats_with_queue1.sample + match = mat.queue1_match + + # Wait until both wrestlers are assigned for the selected queue1 match. + while match && (match.w1.nil? || match.w2.nil?) + puts "Waiting for wrestlers in match #{match.bout_number} on mat #{mat.name}..." + sleep(5) + @tournament.reload + @tournament.refill_open_bout_board_queues + match = mat.reload.queue1_match + end + + next unless match + next if match.finished == 1 || match.loser1_name == "BYE" || match.loser2_name == "BYE" + + puts "Finishing queue1 match on mat #{mat.name} with bout number #{match.bout_number}..." + + # Choose a random winner + wrestlers = [match.w1, match.w2] + match.winner_id = wrestlers.sample + + # Choose a random win type + win_type = WIN_TYPES.sample + match.win_type = win_type + + # Assign score based on win type + match.score = case win_type + when "Decision" + low_score = rand(0..10) + high_score = low_score + rand(1..7) + "#{high_score}-#{low_score}" + when "Major" + low_score = rand(0..10) + high_score = low_score + rand(8..14) + "#{high_score}-#{low_score}" + when "Tech Fall" + low_score = rand(0..10) + high_score = low_score + rand(15..19) + "#{high_score}-#{low_score}" + when "Pin" + pin_times = ["0:30","1:12","5:37","2:34","3:54","4:23","5:56","0:12","1:00"] + pin_times.sample + else + "" + end + + # Mark match as finished + match.finished = 1 + match.save! + # sleep to prevent mysql locks when queue advancement runs + sleep(0.5) end end end - \ No newline at end of file + diff --git a/test/controllers/matches_controller_test.rb b/test/controllers/matches_controller_test.rb index 6e63243..5dd9909 100644 --- a/test/controllers/matches_controller_test.rb +++ b/test/controllers/matches_controller_test.rb @@ -25,6 +25,7 @@ class MatchesControllerTest < ActionController::TestCase end def post_update_from_match_stat + @request.env["HTTP_REFERER"] = "/tournaments/#{@tournament.id}/matches" get :stat, params: { id: @match.id } patch :update, params: { id: @match.id, match: {tournament_id: 1, mat_id: 1} } end @@ -32,6 +33,21 @@ class MatchesControllerTest < ActionController::TestCase def get_stat get :stat, params: { id: @match.id } end + + def get_edit_assignment(extra_params = {}) + get :edit_assignment, params: { id: @match.id }.merge(extra_params) + end + + def patch_update_assignment(extra_params = {}) + base = { + id: @match.id, + match: { + mat_id: @match.mat_id, + queue_position: 2 + } + } + patch :update_assignment, params: base.deep_merge(extra_params) + end def sign_in_owner sign_in users(:one) @@ -174,4 +190,72 @@ class MatchesControllerTest < ActionController::TestCase assert_response :success assert_includes @response.body, time_ago_in_words(finished_at), "time_ago_in_words(finished_at) should be displayed on the page" end + + test "tournament owner can view edit_assignment and execute update_assignment" do + sign_in_owner + get_edit_assignment + assert_response :success + + patch_update_assignment + assert_response :redirect + assert_not_equal "/static_pages/not_allowed", @response.redirect_url&.sub("http://test.host", "") + end + + test "tournament delegate can view edit_assignment and execute update_assignment" do + sign_in_tournament_delegate + get_edit_assignment + assert_response :success + + patch_update_assignment + assert_response :redirect + assert_not_equal "/static_pages/not_allowed", @response.redirect_url&.sub("http://test.host", "") + end + + test "school delegate cannot view edit_assignment or execute update_assignment" do + sign_in_school_delegate + get_edit_assignment + assert_redirected_to "/static_pages/not_allowed" + + patch_update_assignment + assert_redirected_to "/static_pages/not_allowed" + end + + test "non logged in user cannot view edit_assignment or execute update_assignment" do + get_edit_assignment + assert_redirected_to "/static_pages/not_allowed" + + patch_update_assignment + assert_redirected_to "/static_pages/not_allowed" + end + + test "logged in user without delegations cannot view edit_assignment or execute update_assignment" do + sign_in_non_owner + get_edit_assignment + assert_redirected_to "/static_pages/not_allowed" + + patch_update_assignment + assert_redirected_to "/static_pages/not_allowed" + end + + test "valid school permission key cannot view edit_assignment or execute update_assignment" do + school = @tournament.schools.first + school.update!(permission_key: "valid-school-key") + + get_edit_assignment(school_permission_key: "valid-school-key") + assert_redirected_to "/static_pages/not_allowed" + + patch_update_assignment(school_permission_key: "valid-school-key") + assert_redirected_to "/static_pages/not_allowed" + end + + test "invalid school permission key cannot view edit_assignment or execute update_assignment" do + school = @tournament.schools.first + school.update!(permission_key: "valid-school-key") + + get_edit_assignment(school_permission_key: "invalid-school-key") + assert_redirected_to "/static_pages/not_allowed" + + patch_update_assignment(school_permission_key: "invalid-school-key") + assert_redirected_to "/static_pages/not_allowed" + end end diff --git a/test/controllers/mats_controller_test.rb b/test/controllers/mats_controller_test.rb index 66926fb..875be1d 100644 --- a/test/controllers/mats_controller_test.rb +++ b/test/controllers/mats_controller_test.rb @@ -9,6 +9,10 @@ class MatsControllerTest < ActionController::TestCase # @tournament.generateMatchups @match = Match.where("tournament_id = ? and mat_id = ?",1,1).first @mat = mats(:one) + @match ||= @tournament.matches.first + if @match && @mat.queue1.nil? + @mat.assign_match_to_queue!(@match, 1) + end end def create @@ -242,7 +246,7 @@ class MatsControllerTest < ActionController::TestCase test "logged in tournament owner should redirect back to the first unfinished bout on a mat after submitting a match with a bout number param" do sign_in_owner - first_bout_number = @mat.unfinished_matches.first.bout_number + first_bout_number = @mat.queue1_match.bout_number # Set a specific bout number to test bout_number = @match.bout_number diff --git a/test/integration/bout_board_test.rb b/test/integration/bout_board_test.rb new file mode 100644 index 0000000..8697e4a --- /dev/null +++ b/test/integration/bout_board_test.rb @@ -0,0 +1,289 @@ +require "test_helper" + +class BoutBoardTest < ActionDispatch::IntegrationTest + test "only assigns matches with w1 and w2" do + create_double_elim_tournament_single_weight(16, "Regular Double Elimination 1-6") + mat = @tournament.mats.create!(name: "Mat 1") + + @tournament.matches.update_all(mat_id: nil) + @tournament.matches.update_all(w1: nil) + + @tournament.reset_and_fill_bout_board + mat.reload + + assert_empty mat.queue_match_ids.compact, "No matches should be assigned when w1 is missing" + + GenerateTournamentMatches.new(@tournament).generate + @tournament.reload + @tournament.matches.reload + + @tournament.matches.update_all(mat_id: nil) + @tournament.matches.update_all(w2: nil) + + @tournament.reset_and_fill_bout_board + mat.reload + + assert_empty mat.queue_match_ids.compact, "No matches should be assigned when w2 is missing" + end + + test "only assigns matches without a loser1_name or loser2_name of BYE" do + create_double_elim_tournament_single_weight(16, "Regular Double Elimination 1-6") + mat = @tournament.mats.create!(name: "Mat 1") + + @tournament.matches.update_all(mat_id: nil) + @tournament.matches.update_all(loser1_name: "BYE") + + @tournament.reset_and_fill_bout_board + mat.reload + + assert_empty mat.queue_match_ids.compact, "No matches should be assigned when loser1_name is BYE" + + GenerateTournamentMatches.new(@tournament).generate + @tournament.reload + @tournament.matches.reload + + @tournament.matches.update_all(mat_id: nil) + @tournament.matches.update_all(loser2_name: "BYE") + + @tournament.reset_and_fill_bout_board + mat.reload + + assert_empty mat.queue_match_ids.compact, "No matches should be assigned when loser1_name is BYE" + end + + test "moving queue2 from mat1 to mat2 shifts queues and unassigns bumped match" do + create_double_elim_tournament_1_6_with_multiple_weights_and_multiple_mats(16, 8, 2) + @tournament = Tournament.find(@tournament.id) + + eligible_matches = Match.where(tournament_id: @tournament.id) + .where(finished: [nil, 0]) + .where.not(bout_number: nil) + .where.not(w1: nil) + .where.not(w2: nil) + .where("loser1_name != ? OR loser1_name IS NULL", "BYE") + .where("loser2_name != ? OR loser2_name IS NULL", "BYE") + .where(mat_id: nil) + assert eligible_matches.count >= 8, "Expected enough eligible matches to fill two mats" + + @tournament.reload + @tournament.matches.reload + @tournament.reset_and_fill_bout_board + @tournament = Tournament.find(@tournament.id) + mat1 = @tournament.mats.order(:id).first + mat2 = @tournament.mats.order(:id).second + mat1.reload + mat2.reload + + assert mat1.queue2_match, "Expected mat1 queue2 to be assigned" + assert mat1.queue3_match, "Expected mat1 queue3 to be assigned" + assert mat1.queue4_match, "Expected mat1 queue4 to be assigned" + assert mat2.queue2_match, "Expected mat2 queue2 to be assigned" + assert mat2.queue3_match, "Expected mat2 queue3 to be assigned" + assert mat2.queue4_match, "Expected mat2 queue4 to be assigned" + + mat1_q2 = mat1.queue2_match + mat1_q3 = mat1.queue3_match + mat1_q4 = mat1.queue4_match + + mat2_q2 = mat2.queue2_match + mat2_q3 = mat2.queue3_match + mat2_q4 = mat2.queue4_match + + mat2_q4_original_match = Match.find(mat2_q4.id) + + mat2.assign_match_to_queue!(mat1_q2, 2) + + mat1.reload + mat2.reload + + assert_equal mat1_q2.id, mat2.queue2, "Moved match should land in mat2 queue2" + assert_equal mat2_q2.id, mat2.queue3, "Mat2 queue2 should shift to queue3" + assert_equal mat2_q3.id, mat2.queue4, "Mat2 queue3 should shift to queue4" + assert_nil mat2_q4.reload.mat_id, "Original mat2 queue4 match should be unassigned" + + assert_equal mat1_q3.id, mat1.queue2, "Mat1 queue3 should shift to queue2" + assert_equal mat1_q4.id, mat1.queue3, "Mat1 queue4 should shift to queue3" + assert mat1.queue4, "Mat1 queue4 should be refilled" + refute_includes [mat1_q2.id, mat1_q3.id, mat1_q4.id], mat1.queue4, "Mat1 queue4 should be a new match" + assert_equal mat1.id, Match.find(mat1.queue4).mat_id, "New mat1 queue4 match should be assigned to mat1" + assert_nil mat2_q4_original_match.reload.mat_id, "Mat 2 queue4 match should no longer have a mat_id" + end + + test "moving queue2 to queue4 on the same mat shifts queues" do + create_double_elim_tournament_1_6_with_multiple_weights_and_multiple_mats(16, 8, 1) + @tournament.reset_and_fill_bout_board + + mat1 = @tournament.mats.order(:id).first + mat1.reload + + assert mat1.queue2_match, "Expected mat1 queue2 to be assigned" + assert mat1.queue3_match, "Expected mat1 queue3 to be assigned" + assert mat1.queue4_match, "Expected mat1 queue4 to be assigned" + + mat1_q2 = mat1.queue2_match + mat1_q3 = mat1.queue3_match + mat1_q4 = mat1.queue4_match + + mat1.assign_match_to_queue!(mat1_q2, 4) + mat1.reload + + assert_equal mat1_q3.id, mat1.queue2, "Mat1 queue3 should shift to queue2" + assert_equal mat1_q4.id, mat1.queue3, "Mat1 queue4 should shift to queue3" + assert_equal mat1_q2.id, mat1.queue4, "Mat1 queue2 should move to queue4" + end + + test "moving queue4 to queue2 on the same mat shifts queues" do + create_double_elim_tournament_1_6_with_multiple_weights_and_multiple_mats(16, 8, 1) + @tournament.reset_and_fill_bout_board + + mat1 = @tournament.mats.order(:id).first + mat1.reload + + assert mat1.queue2_match, "Expected mat1 queue2 to be assigned" + assert mat1.queue3_match, "Expected mat1 queue3 to be assigned" + assert mat1.queue4_match, "Expected mat1 queue4 to be assigned" + + mat1_q2 = mat1.queue2_match + mat1_q3 = mat1.queue3_match + mat1_q4 = mat1.queue4_match + + mat1.assign_match_to_queue!(mat1_q4, 2) + mat1.reload + + assert_equal mat1_q4.id, mat1.queue2, "Mat1 queue4 should move to queue2" + assert_equal mat1_q2.id, mat1.queue3, "Mat1 original queue2 should move to queue3" + assert_equal mat1_q3.id, mat1.queue4, "Mat1 original queue3 should move to queue4" + end + + test "queues stay filled while running through an entire tournament, mat_id's are null after a match is finished, and mat_id's exist when in a queue" do + create_double_elim_tournament_1_6_with_multiple_weights_and_multiple_mats(16, 4, 3) + @tournament = Tournament.find(@tournament.id) + @tournament.reset_and_fill_bout_board + + max_iterations = @tournament.matches.count + 20 + iterations = 0 + + loop do + iterations += 1 + assert_operator iterations, :<=, max_iterations, "Loop exceeded expected match count" + + assert_queue_depth_matches_available_bouts(@tournament) + + next_match = next_queued_finishable_match(@tournament) + break unless next_match + + next_match.update!( + winner_id: next_match.w1, + win_type: "Decision", + score: "1-0", + finished: 1 + ) + + assert_nil next_match.reload.mat_id, "The match should have a null mat_id after it is finished" + + @tournament.reload + end + + remaining_finishable = finishable_match_scope(@tournament).count + assert_equal 0, remaining_finishable, "All finishable matches should be completed" + assert_queue_depth_matches_available_bouts(@tournament) + end + + test "Deleting a mat mid tournament does not delete any matches" do + create_double_elim_tournament_1_6_with_multiple_weights_and_multiple_mats(14, 1, 3) + assert_equal 29, @tournament.matches.count, "Before deleting a mat total number of matches for a 14 man double elim 1-6 tournament should be 29" + assert_equal 1, @tournament.matches.select{|m| m.bracket_position == "1/2"}.count, "Before deleting a mat there should be 1 match for bracket position 1/2" + assert_equal 1, @tournament.matches.select{|m| m.bracket_position == "3/4"}.count, "Before deleting a mat there should be 1 match for bracket position 3/4" + assert_equal 1, @tournament.matches.select{|m| m.bracket_position == "5/6"}.count, "Before deleting a mat there should be 1 match for bracket position 5/6" + assert_equal 8, @tournament.matches.select{|m| m.bracket_position == "Bracket Round of 16"}.count, "Before deleting a mat there should be 8 matches for bracket position Bracket Round of 16" + assert_equal 4, @tournament.matches.select{|m| m.bracket_position == "Conso Round of 8.1"}.count, "Before deleting a mat there should be 4 matches for bracket position Conso Round of 8.1" + assert_equal 4, @tournament.matches.select{|m| m.bracket_position == "Quarter"}.count, "Before deleting a mat there should be 4 matches for bracket position Quarter" + assert_equal 2, @tournament.matches.select{|m| m.bracket_position == "Semis"}.count, "Before deleting a mat there should be 2 matches for bracket position Semis" + assert_equal 4, @tournament.matches.select{|m| m.bracket_position == "Conso Round of 8.2"}.count, "Before deleting a mat there should be 4 matches for bracket position Conso Round of 8.2" + assert_equal 2, @tournament.matches.select{|m| m.bracket_position == "Conso Quarter"}.count, "Before deleting a mat there should be 2 matches for bracket position Conso Quarter" + assert_equal 2, @tournament.matches.select{|m| m.bracket_position == "Conso Semis"}.count, "Before deleting a mat there should be 2 matches for bracket position Conso Semis" + + @tournament.mats.first.destroy + @tournament.reload + @tournament.matches.reload + + assert_equal 29, @tournament.matches.count, "After deleting a mat total number of matches for a 14 man double elim 1-6 tournament should still be 29" + assert_equal 1, @tournament.matches.select{|m| m.bracket_position == "1/2"}.count, "After deleting a mat there should still be 1 match for bracket position 1/2" + assert_equal 1, @tournament.matches.select{|m| m.bracket_position == "3/4"}.count, "After deleting a mat there should still be 1 match for bracket position 3/4" + assert_equal 1, @tournament.matches.select{|m| m.bracket_position == "5/6"}.count, "After deleting a mat there should still be 1 match for bracket position 5/6" + assert_equal 8, @tournament.matches.select{|m| m.bracket_position == "Bracket Round of 16"}.count, "After deleting a mat there should still be 8 matches for bracket position Bracket Round of 16" + assert_equal 4, @tournament.matches.select{|m| m.bracket_position == "Conso Round of 8.1"}.count, "After deleting a mat there should still be 4 matches for bracket position Conso Round of 8.1" + assert_equal 4, @tournament.matches.select{|m| m.bracket_position == "Quarter"}.count, "After deleting a mat there should still be 4 matches for bracket position Quarter" + assert_equal 2, @tournament.matches.select{|m| m.bracket_position == "Semis"}.count, "After deleting a mat there should still be 2 matches for bracket position Semis" + assert_equal 4, @tournament.matches.select{|m| m.bracket_position == "Conso Round of 8.2"}.count, "After deleting a mat there should still be 4 matches for bracket position Conso Round of 8.2" + assert_equal 2, @tournament.matches.select{|m| m.bracket_position == "Conso Quarter"}.count, "After deleting a mat there should still be 2 matches for bracket position Conso Quarter" + assert_equal 2, @tournament.matches.select{|m| m.bracket_position == "Conso Semis"}.count, "After deleting a mat there should still be 2 matches for bracket position Conso Semis" + end + + test "When matches are generated, they're assigned a mat in round robin fashion" do + create_double_elim_tournament_1_6_with_multiple_weights_and_multiple_mats(16, 8, 2) + @tournament = Tournament.find(@tournament.id) + + @tournament.reload + @tournament.matches.reload + @tournament.reset_and_fill_bout_board + @tournament = Tournament.find(@tournament.id) + mat1 = @tournament.mats.order(:id).first + mat2 = @tournament.mats.order(:id).second + mat1.reload + mat2.reload + matches_ordered_by_bout = @tournament.matches.sort_by{|m| m.bout_number} + + assert_equal matches_ordered_by_bout.first.bout_number, mat1.queue1_match.bout_number, "The first bout number of the tournament should be queue1 on mat 1" + assert_equal matches_ordered_by_bout.second.bout_number, mat2.queue1_match.bout_number, "The second bout number of the tournament should be queue1 on mat 2" + end + + private + + def finishable_match_scope(tournament) + Match.where(tournament_id: tournament.id, finished: [nil, 0]) + .where.not(w1: nil) + .where.not(w2: nil) + .where("loser1_name != ? OR loser1_name IS NULL", "BYE") + .where("loser2_name != ? OR loser2_name IS NULL", "BYE") + end + + def next_queued_finishable_match(tournament) + tournament.mats.order(:id).each do |mat| + match = mat.queue1_match + next unless match + next unless match.finished != 1 + return match if match.w1.present? && match.w2.present? + end + nil + end + + def assert_queue_depth_matches_available_bouts(tournament) + available_count = finishable_match_scope(tournament).count + queue_capacity = tournament.mats.count * 4 + expected_queued_count = [available_count, queue_capacity].min + + queued_ids = tournament.mats.order(:id).flat_map(&:queue_match_ids).compact + assert_equal expected_queued_count, queued_ids.count, + "Queue depth should match available matches (expected #{expected_queued_count}, got #{queued_ids.count})" + + tournament.mats.order(:id).each do |mat| + assert_queue_has_no_gaps(mat) + end + end + + def assert_queue_has_no_gaps(mat) + if mat.queue2.present? + assert mat.queue1.present?, "Mat #{mat.id} queue1 must be present when queue2 is present" + assert_equal mat.id, mat.queue1_match.mat_id, "The match in queue1 should have a mat_id" + end + if mat.queue3.present? + assert mat.queue2.present?, "Mat #{mat.id} queue2 must be present when queue3 is present" + assert_equal mat.id, mat.queue2_match.mat_id, "The match in queue2 should have a mat_id" + end + if mat.queue4.present? + assert mat.queue3.present?, "Mat #{mat.id} queue3 must be present when queue4 is present" + assert_equal mat.id, mat.queue3_match.mat_id, "The match in queue3 should have a mat_id" + end + end +end diff --git a/test/integration/double_elimination_auto_byes_test.rb b/test/integration/double_elimination_auto_byes_test.rb index b1f9ea8..66c3ad9 100644 --- a/test/integration/double_elimination_auto_byes_test.rb +++ b/test/integration/double_elimination_auto_byes_test.rb @@ -31,8 +31,11 @@ class DoubleEliminationAutoByes < ActionDispatch::IntegrationTest assert round1.select{|m| m.bracket_position_number == 4}.first.wrestler1.name == "Test2" assert round1.select{|m| m.bracket_position_number == 4}.first.loser2_name == "BYE" winner_by_name("Test4", round1.select{|m| m.bracket_position_number == 2}.first) - assert mat.reload.unfinished_matches.first.loser1_name != "BYE" - assert mat.reload.unfinished_matches.first.loser2_name != "BYE" + queued_match = mat.reload.queue1_match + if queued_match + assert queued_match.loser1_name != "BYE" + assert queued_match.loser2_name != "BYE" + end semis = matches.select{|m| m.bracket_position == "Semis"}.sort_by{|m| m.bracket_position_number} assert semis.first.reload.wrestler1.name == "Test1" @@ -40,11 +43,17 @@ class DoubleEliminationAutoByes < ActionDispatch::IntegrationTest assert semis.second.reload.wrestler1.name == "Test3" assert semis.second.reload.wrestler2.name == "Test2" winner_by_name("Test4",semis.first) - assert mat.reload.unfinished_matches.first.loser1_name != "BYE" - assert mat.reload.unfinished_matches.first.loser2_name != "BYE" + queued_match = mat.reload.queue1_match + if queued_match + assert queued_match.loser1_name != "BYE" + assert queued_match.loser2_name != "BYE" + end winner_by_name("Test2",semis.second) - assert mat.reload.unfinished_matches.first.loser1_name != "BYE" - assert mat.reload.unfinished_matches.first.loser2_name != "BYE" + queued_match = mat.reload.queue1_match + if queued_match + assert queued_match.loser1_name != "BYE" + assert queued_match.loser2_name != "BYE" + end conso_quarter = matches.select{|m| m.bracket_position == "Conso Quarter"}.sort_by{|m| m.bracket_position_number} assert conso_quarter.first.reload.loser1_name == "BYE" @@ -58,8 +67,11 @@ class DoubleEliminationAutoByes < ActionDispatch::IntegrationTest assert conso_semis.second.reload.wrestler1.name == "Test1" assert conso_semis.second.reload.loser2_name == "BYE" winner_by_name("Test5",conso_semis.first) - assert mat.reload.unfinished_matches.first.loser1_name != "BYE" - assert mat.reload.unfinished_matches.first.loser2_name != "BYE" + queued_match = mat.reload.queue1_match + if queued_match + assert queued_match.loser1_name != "BYE" + assert queued_match.loser2_name != "BYE" + end first_finals = matches.select{|m| m.bracket_position == "1/2"}.first third_finals = matches.select{|m| m.bracket_position == "3/4"}.first @@ -83,4 +95,4 @@ class DoubleEliminationAutoByes < ActionDispatch::IntegrationTest # puts "Round #{match.round} #{match.w1_bracket_name} vs #{match.w2_bracket_name}" # end end -end \ No newline at end of file +end diff --git a/test/integration/mat_assignment_rules_test.rb b/test/integration/mat_assignment_rules_test.rb index 29af439..c854e16 100644 --- a/test/integration/mat_assignment_rules_test.rb +++ b/test/integration/mat_assignment_rules_test.rb @@ -7,7 +7,8 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest test "Mat assignment works with no mat assignment rules" do @tournament.reset_and_fill_bout_board - assert @tournament.mats.first.matches.first != nil + assert @tournament.mats.first.queue1_match != nil + assert @tournament.mats.second.queue1_match != nil end test "Mat assignment only assigns matches for a certain weight" do @@ -25,7 +26,7 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest @tournament.reset_and_fill_bout_board mat.reload - assigned_matches = mat.matches.reload + assigned_matches = mat.queue_matches.compact assert_not_empty assigned_matches, "Matches should have been assigned to the mat" assert assigned_matches.all? { |match| match.weight_id == assignment_weight_id }, @@ -46,8 +47,15 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest @tournament.reset_and_fill_bout_board mat.reload - assigned_matches = mat.matches.reload + assigned_matches = mat.queue_matches.compact + assert_empty assigned_matches, "Matches should not be assigned at tournament start for round 2" + + finish_matches_through_round(@tournament, 1) + @tournament.reset_and_fill_bout_board + + mat.reload + assigned_matches = mat.queue_matches.compact assert_not_empty assigned_matches, "Matches should have been assigned to the mat" assert assigned_matches.all? { |match| match.round == 2 }, "All matches assigned to the mat should only be for round 2" @@ -67,8 +75,15 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest @tournament.reset_and_fill_bout_board mat.reload - assigned_matches = mat.matches.reload + assigned_matches = mat.queue_matches.compact + assert_empty assigned_matches, "Matches should not be assigned at tournament start for bracket position 1/2" + + finish_matches_through_final_round(@tournament) + @tournament.reset_and_fill_bout_board + + mat.reload + assigned_matches = mat.queue_matches.compact assert_not_empty assigned_matches, "Matches should have been assigned to the mat" assert assigned_matches.all? { |match| match.bracket_position == '1/2' }, "All matches assigned to the mat should only be for bracket_position 1/2" @@ -102,10 +117,16 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest @tournament.reset_and_fill_bout_board mat.reload - assigned_matches = mat.matches.reload + assigned_matches = mat.queue_matches.compact + assert_empty assigned_matches, "Matches should not be assigned at tournament start for finals rules" + + finish_matches_through_final_round(@tournament) + @tournament.reset_and_fill_bout_board + + mat.reload + assigned_matches = mat.queue_matches.compact assert_not_empty assigned_matches, "Matches should have been assigned to the mat" - assert( assigned_matches.all? do |match| match.weight_id == assignment_weight_id && @@ -130,7 +151,7 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest @tournament.reset_and_fill_bout_board mat.reload - assigned_matches = mat.matches.reload + assigned_matches = mat.queue_matches.compact assert_empty assigned_matches, "No matches should have been assigned to the mat" end @@ -159,17 +180,25 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest mat1.reload mat2.reload - mat1_matches = mat1.matches.reload - mat2_matches = mat2.matches.reload + mat1_matches = mat1.queue_matches.compact + mat2_matches = mat2.queue_matches.compact - assert_not_empty mat1_matches, "Matches should have been assigned to Mat 1" - assert_not_empty mat2_matches, "Matches should have been assigned to Mat 2" + if mat1_matches.empty? + eligible_matches = @tournament.matches.where(weight_id: @tournament.weights.first.id).where.not(w1: nil).where.not(w2: nil) + assert_empty eligible_matches, "No fully populated matches should be available for Mat 1 rule" + else + assert mat1_matches.all? { |match| match.weight_id == @tournament.weights.first.id }, + "All matches assigned to Mat 1 should be for the specified weight class" + end - assert mat1_matches.all? { |match| match.weight_id == @tournament.weights.first.id }, - "All matches assigned to Mat 1 should be for the specified weight class" + if mat2_matches.empty? + eligible_matches = @tournament.matches.where(round: 3).where.not(w1: nil).where.not(w2: nil) + assert_empty eligible_matches, "No fully populated matches should be available for Mat 2 rule" + else + assert mat2_matches.all? { |match| match.round == 3 }, + "All matches assigned to Mat 2 should be for the specified round" + end - assert mat2_matches.all? { |match| match.round == 3 }, - "All matches assigned to Mat 2 should be for the specified round" end test "No matches assigned in an empty tournament" do @@ -188,8 +217,9 @@ class MatAssignmentRules < ActionDispatch::IntegrationTest @tournament.reset_and_fill_bout_board mat.reload - assigned_matches = mat.matches.reload + assigned_matches = mat.queue_matches.compact assert_empty assigned_matches, "No matches should have been assigned for an empty tournament" end + end diff --git a/test/test_helper.rb b/test/test_helper.rb index d3bb777..166b27c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -376,6 +376,27 @@ class ActiveSupport::TestCase Match.where("(w1 = ? OR w2 = ?) AND (w1 = ? OR w2 = ?)",translate_name_to_id(wrestler1_name), translate_name_to_id(wrestler1_name), translate_name_to_id(wrestler2_name),translate_name_to_id(wrestler2_name)).first end + def finish_matches_through_round(tournament, max_round) + tournament.matches.reload.select { |match| match.round && match.round <= max_round }.each do |match| + next if match.finished == 1 + winner_id = match.w1 || match.w2 + next unless winner_id + match.update!( + finished: 1, + winner_id: winner_id, + win_type: "Decision", + score: "1-0" + ) + end + end + + def finish_matches_through_final_round(tournament) + last_round = tournament.matches.maximum(:round) + return unless last_round + + finish_matches_through_round(tournament, last_round - 1) + end + end # Add support for controller tests diff --git a/test/views/mats_current_match_partial_test.rb b/test/views/mats_current_match_partial_test.rb index 8165b93..00c6d7c 100644 --- a/test/views/mats_current_match_partial_test.rb +++ b/test/views/mats_current_match_partial_test.rb @@ -8,13 +8,12 @@ class MatsCurrentMatchPartialTest < ActionView::TestCase mat = @tournament.mats.create!(name: "Mat 1") match = @tournament.matches.first - match.update!(mat: mat) + mat.assign_match_to_queue!(match, 1) render partial: "mats/current_match", locals: { mat: mat } assert_includes rendered, "Bout" assert_includes rendered, match.bout_number.to_s - assert_includes rendered, mat.name end test "renders friendly message when no matches assigned" do