Implement TUR-58 group stage decider matches
This commit is contained in:
parent
84cdb230fb
commit
4713a020fa
|
|
@ -29,6 +29,6 @@ class MatchScoresController < ApplicationController
|
||||||
|
|
||||||
# Only allow a trusted parameter "white list" through.
|
# Only allow a trusted parameter "white list" through.
|
||||||
def match_score_params
|
def match_score_params
|
||||||
params.slice(:points).permit!
|
params.slice(:points, :hidden_points).permit!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
|
|
||||||
class MatchesController < ApplicationController
|
class MatchesController < ApplicationController
|
||||||
before_action :set_match, only: %i[show update]
|
before_action :set_match, only: %i[show update]
|
||||||
|
before_action :set_group, only: %i[create]
|
||||||
|
before_action :authenticate_user!, only: %i[create update]
|
||||||
before_action :validate_params, only: %i[update]
|
before_action :validate_params, only: %i[update]
|
||||||
before_action -> { require_owner! @match.owner }, only: %i[update]
|
before_action -> { require_owner! @match.owner }, only: %i[update]
|
||||||
|
before_action -> { require_owner! @group.owner }, only: %i[create]
|
||||||
before_action :set_tournament, only: %i[index]
|
before_action :set_tournament, only: %i[index]
|
||||||
|
|
||||||
# GET/tournaments/1/matches
|
# GET/tournaments/1/matches
|
||||||
|
|
@ -44,6 +47,20 @@ class MatchesController < ApplicationController
|
||||||
render json: @match, include: %w[match_scores.points match_scores.team bets]
|
render json: @match, include: %w[match_scores.points match_scores.team bets]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /groups/:group_id/matches
|
||||||
|
def create
|
||||||
|
match = build_decider_match
|
||||||
|
|
||||||
|
if match.errors.any?
|
||||||
|
render json: match.errors, status: :unprocessable_entity
|
||||||
|
elsif match.save
|
||||||
|
UpdateGroupsGroupScoresAndSave.call(group: @group)
|
||||||
|
render json: match, include: %w[match_scores.team group], status: :created
|
||||||
|
else
|
||||||
|
render json: match.errors, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# PATCH/PUT /matches/1
|
# PATCH/PUT /matches/1
|
||||||
def update
|
def update
|
||||||
new_state = match_params['state']
|
new_state = match_params['state']
|
||||||
|
|
@ -112,7 +129,32 @@ class MatchesController < ApplicationController
|
||||||
@tournament = Tournament.find(params[:tournament_id])
|
@tournament = Tournament.find(params[:tournament_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_group
|
||||||
|
@group = Group.find(params[:group_id])
|
||||||
|
end
|
||||||
|
|
||||||
def match_params
|
def match_params
|
||||||
params.slice(:state).permit!
|
params.slice(:state).permit!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_match_params
|
||||||
|
params.slice(:team_ids).permit(team_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_decider_match
|
||||||
|
team_ids = create_match_params[:team_ids].to_a.map(&:to_i).uniq
|
||||||
|
teams = @group.teams.select { |team| team_ids.include?(team.id) }
|
||||||
|
|
||||||
|
Match.new(
|
||||||
|
group: @group,
|
||||||
|
state: :not_started,
|
||||||
|
position: (@group.matches.maximum(:position) || -1) + 1,
|
||||||
|
decider: true,
|
||||||
|
match_scores: teams.map { |team| MatchScore.new(team: team) }
|
||||||
|
).tap do |match|
|
||||||
|
if team_ids.size != 2 || teams.size != 2
|
||||||
|
match.errors.add(:team_ids, 'must contain exactly two teams from this group')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,22 @@ class StagesController < ApplicationController
|
||||||
raise ActiveRecord::Rollback
|
raise ActiveRecord::Rollback
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@stage.groups.each do |group|
|
||||||
|
next if UpdateGroupsGroupScoresAndSave.call(group: group).success?
|
||||||
|
|
||||||
|
render json: { error: 'Updating group scores failed' }, status: :unprocessable_entity
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
|
||||||
|
blocking_ties = GroupStageService.blocking_ties_for(@stage)
|
||||||
|
if blocking_ties.any?
|
||||||
|
render json: {
|
||||||
|
error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place',
|
||||||
|
blocking_ties: blocking_ties
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
|
|
||||||
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament,
|
||||||
teams: GroupStageService.get_advancing_teams(@stage)).success?
|
teams: GroupStageService.get_advancing_teams(@stage)).success?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class GroupScore < ApplicationRecord
|
||||||
public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]])
|
public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]])
|
||||||
when :direct_comparison
|
when :direct_comparison
|
||||||
comparison_reason[:direct_comparison_result] == :won ? -1 : 1
|
comparison_reason[:direct_comparison_result] == :won ? -1 : 1
|
||||||
|
when :hidden_points
|
||||||
|
comparison_reason[:hidden_points_result] == :won ? -1 : 1
|
||||||
else
|
else
|
||||||
0
|
0
|
||||||
end
|
end
|
||||||
|
|
@ -36,16 +38,23 @@ class GroupScore < ApplicationRecord
|
||||||
return {
|
return {
|
||||||
resolved_by: reason,
|
resolved_by: reason,
|
||||||
tied: false,
|
tied: false,
|
||||||
direct_comparison_result: nil
|
direct_comparison_result: nil,
|
||||||
|
hidden_points_result: :not_played,
|
||||||
|
direct_comparison_match_id: nil,
|
||||||
|
decider_match_id: nil
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
direct_comparison_result = direct_comparison_result_against(other)
|
direct_comparison_result = direct_comparison_result_against(other)
|
||||||
|
hidden_points_result = hidden_points_result_against(other)
|
||||||
|
|
||||||
{
|
{
|
||||||
resolved_by: direct_comparison_result.nil? ? :tie : :direct_comparison,
|
resolved_by: resolved_by_for(direct_comparison_result, hidden_points_result),
|
||||||
tied: direct_comparison_result.nil?,
|
tied: direct_comparison_result.nil? && hidden_points_result.nil?,
|
||||||
direct_comparison_result: direct_comparison_result || direct_comparison_tie_reason_against(other)
|
direct_comparison_result: direct_comparison_result || direct_comparison_tie_reason_against(other),
|
||||||
|
hidden_points_result: hidden_points_result || hidden_points_tie_reason_against(other),
|
||||||
|
direct_comparison_match_id: direct_comparison_match_against(other)&.id,
|
||||||
|
decider_match_id: decider_match_against(other)&.id
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -68,11 +77,47 @@ class GroupScore < ApplicationRecord
|
||||||
:draw
|
:draw
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def hidden_points_result_against(other)
|
||||||
|
decider_match = decider_match_against(other)
|
||||||
|
return nil if decider_match.nil? || !decider_match.finished?
|
||||||
|
|
||||||
|
team_hidden_points = decider_match.hidden_points_of(team)
|
||||||
|
other_team_hidden_points = decider_match.hidden_points_of(other.team)
|
||||||
|
return :won if team_hidden_points > other_team_hidden_points
|
||||||
|
return :lost if team_hidden_points < other_team_hidden_points
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def hidden_points_tie_reason_against(other)
|
||||||
|
decider_match = decider_match_against(other)
|
||||||
|
return :not_played if decider_match.nil? || !decider_match.finished?
|
||||||
|
|
||||||
|
:draw
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def resolved_by_for(direct_comparison_result, hidden_points_result)
|
||||||
|
return :direct_comparison unless direct_comparison_result.nil?
|
||||||
|
return :hidden_points unless hidden_points_result.nil?
|
||||||
|
|
||||||
|
:tie
|
||||||
|
end
|
||||||
|
|
||||||
def direct_comparison_match_against(other)
|
def direct_comparison_match_against(other)
|
||||||
group.matches.reload.find do |match|
|
comparison_match_against(other, decider: false)
|
||||||
match.finished? && match.teams.include?(team) && match.teams.include?(other.team)
|
end
|
||||||
end
|
|
||||||
|
def decider_match_against(other)
|
||||||
|
comparison_match_against(other, decider: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def comparison_match_against(other, decider:)
|
||||||
|
group.matches.reload
|
||||||
|
.select do |match|
|
||||||
|
match.decider? == decider && match.teams.include?(team) && match.teams.include?(other.team)
|
||||||
|
end
|
||||||
|
.max_by(&:position)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@ class Match < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_leading_team
|
def current_leading_team
|
||||||
return nil if match_scores.first.points == match_scores.second.points
|
return nil if match_scores.size < 2
|
||||||
|
return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second)
|
||||||
|
|
||||||
match_scores.max_by(&:points).team
|
match_scores.max_by { |match_score| score_value_for(match_score) }.team
|
||||||
end
|
end
|
||||||
|
|
||||||
def winner
|
def winner
|
||||||
|
|
@ -39,14 +40,19 @@ class Match < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def scored_points_of(team)
|
def scored_points_of(team)
|
||||||
|
return 0 if decider?
|
||||||
|
|
||||||
teams.include?(team) ? match_scores.find_by(team: team).points : 0
|
teams.include?(team) ? match_scores.find_by(team: team).points : 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def received_points_of(team)
|
def received_points_of(team)
|
||||||
|
return 0 if decider?
|
||||||
|
|
||||||
teams.include?(team) ? match_scores.find { |ms| ms.team != team }.points : 0
|
teams.include?(team) ? match_scores.find { |ms| ms.team != team }.points : 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def group_points_of(team)
|
def group_points_of(team)
|
||||||
|
return 0 if decider?
|
||||||
return 0 unless (finished? || in_progress?) && teams.include?(team)
|
return 0 unless (finished? || in_progress?) && teams.include?(team)
|
||||||
|
|
||||||
case current_leading_team
|
case current_leading_team
|
||||||
|
|
@ -59,9 +65,17 @@ class Match < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def hidden_points_of(team)
|
||||||
|
teams.include?(team) ? match_scores.find_by(team: team).hidden_points : 0
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def stage_xor_group
|
def stage_xor_group
|
||||||
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
|
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def score_value_for(match_score)
|
||||||
|
decider? ? match_score.hidden_points : match_score.points
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ class Stage < ApplicationRecord
|
||||||
return matches.find { |m| m.state != 'finished' }.nil? unless matches.size.zero?
|
return matches.find { |m| m.state != 'finished' }.nil? unless matches.size.zero?
|
||||||
|
|
||||||
unless groups.size.zero? && groups.map(&:matches).flatten.size.zero?
|
unless groups.size.zero? && groups.map(&:matches).flatten.size.zero?
|
||||||
return groups.map(&:matches).flatten.find { |m| m.state != 'finished' }.nil?
|
relevant_matches = groups.map(&:matches).flatten.reject(&:decider?)
|
||||||
|
return relevant_matches.find { |m| m.state != 'finished' }.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MatchScoreSerializer < ApplicationSerializer
|
class MatchScoreSerializer < ApplicationSerializer
|
||||||
attributes :points
|
attributes :points, :hidden_points
|
||||||
|
|
||||||
belongs_to :team
|
belongs_to :team
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MatchSerializer < ApplicationSerializer
|
class MatchSerializer < ApplicationSerializer
|
||||||
attributes :state, :position, :winner
|
attributes :state, :position, :winner, :decider
|
||||||
|
|
||||||
def winner
|
def winner
|
||||||
ActiveModelSerializers::SerializableResource.new(object.winner).as_json
|
ActiveModelSerializers::SerializableResource.new(object.winner).as_json
|
||||||
|
|
|
||||||
|
|
@ -112,12 +112,38 @@ class GroupStageService
|
||||||
compared_with_team_id: compared_group_score.team.id,
|
compared_with_team_id: compared_group_score.team.id,
|
||||||
compared_with_team_name: compared_group_score.team.name,
|
compared_with_team_name: compared_group_score.team.name,
|
||||||
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
||||||
|
direct_comparison_match_id: comparison_reason[:direct_comparison_match_id],
|
||||||
|
hidden_points_result: comparison_reason[:hidden_points_result].to_s,
|
||||||
|
decider_match_id: comparison_reason[:decider_match_id],
|
||||||
tied: comparison_reason[:tied],
|
tied: comparison_reason[:tied],
|
||||||
needs_decider_match: comparison_reason[:tied] &&
|
needs_decider_match: comparison_reason[:tied] &&
|
||||||
unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots_for_group(group_score.group))
|
unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots_for_group(group_score.group))
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def blocking_ties_for(group_stage)
|
||||||
|
return [] if group_stage.nil?
|
||||||
|
|
||||||
|
group_stage.groups.flat_map do |group|
|
||||||
|
sorted_scores = group.group_scores.reload.sort
|
||||||
|
sorted_scores.filter_map do |group_score|
|
||||||
|
decision = ranking_decision_for(group_score)
|
||||||
|
next unless decision&.fetch(:needs_decider_match, false)
|
||||||
|
|
||||||
|
comparison_team_id = decision[:compared_with_team_id]
|
||||||
|
next if comparison_team_id.nil? || group_score.team.id > comparison_team_id
|
||||||
|
|
||||||
|
{
|
||||||
|
group_id: group.id,
|
||||||
|
group_number: group.number,
|
||||||
|
team_ids: [group_score.team.id, comparison_team_id],
|
||||||
|
team_names: [group_score.team.name, decision[:compared_with_team_name]],
|
||||||
|
ranking_decision: decision
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Returns all teams advancing to playoff stage from given group stage
|
# Returns all teams advancing to playoff stage from given group stage
|
||||||
# They are ordered in such a way, that PlayoffStageService will correctly match the teams
|
# They are ordered in such a way, that PlayoffStageService will correctly match the teams
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ Rails.application.routes.draw do
|
||||||
resources :matches, only: %i[show update] do
|
resources :matches, only: %i[show update] do
|
||||||
resources :bets, only: %i[index create]
|
resources :bets, only: %i[index create]
|
||||||
end
|
end
|
||||||
|
resources :groups, only: %i[show] do
|
||||||
|
resources :matches, only: %i[create]
|
||||||
|
end
|
||||||
resources :stages, only: %i[show update]
|
resources :stages, only: %i[show update]
|
||||||
resources :teams, only: %i[show update]
|
resources :teams, only: %i[show update]
|
||||||
resources :tournaments do
|
resources :tournaments do
|
||||||
|
|
@ -20,7 +23,5 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :match_scores, only: %i[show update]
|
resources :match_scores, only: %i[show update]
|
||||||
resources :groups, only: %i[show]
|
|
||||||
|
|
||||||
get 'healthz', to: 'health#index'
|
get 'healthz', to: 'health#index'
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddDeciderSupportToGroupMatches < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :matches, :decider, :boolean, null: false, default: false
|
||||||
|
add_column :match_scores, :hidden_points, :integer, null: false, default: 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -50,6 +50,7 @@ RSpec.describe GroupsController, type: :controller do
|
||||||
resolved_by: 'direct_comparison',
|
resolved_by: 'direct_comparison',
|
||||||
compared_with_team_name: 'B',
|
compared_with_team_name: 'B',
|
||||||
direct_comparison_result: 'won',
|
direct_comparison_result: 'won',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: false,
|
tied: false,
|
||||||
needs_decider_match: false
|
needs_decider_match: false
|
||||||
)
|
)
|
||||||
|
|
@ -57,6 +58,7 @@ RSpec.describe GroupsController, type: :controller do
|
||||||
resolved_by: 'direct_comparison',
|
resolved_by: 'direct_comparison',
|
||||||
compared_with_team_name: 'A',
|
compared_with_team_name: 'A',
|
||||||
direct_comparison_result: 'lost',
|
direct_comparison_result: 'lost',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: false,
|
tied: false,
|
||||||
needs_decider_match: false
|
needs_decider_match: false
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ RSpec.describe MatchScoresController, type: :controller do
|
||||||
describe 'PUT #update' do
|
describe 'PUT #update' do
|
||||||
let(:valid_update) do
|
let(:valid_update) do
|
||||||
{
|
{
|
||||||
points: 42
|
points: 42,
|
||||||
|
hidden_points: 13
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ RSpec.describe MatchScoresController, type: :controller do
|
||||||
put :update, params: { id: @match_score.to_param }.merge(valid_update)
|
put :update, params: { id: @match_score.to_param }.merge(valid_update)
|
||||||
@match_score.reload
|
@match_score.reload
|
||||||
expect(@match_score.points).to eq(valid_update[:points])
|
expect(@match_score.points).to eq(valid_update[:points])
|
||||||
|
expect(@match_score.hidden_points).to eq(valid_update[:hidden_points])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders a response with the updated team' do
|
it 'renders a response with the updated team' do
|
||||||
|
|
@ -46,6 +48,7 @@ RSpec.describe MatchScoresController, type: :controller do
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
body = deserialize_response response
|
body = deserialize_response response
|
||||||
expect(body[:points]).to eq(valid_update[:points])
|
expect(body[:points]).to eq(valid_update[:points])
|
||||||
|
expect(body[:hidden_points]).to eq(valid_update[:hidden_points])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -86,7 +89,8 @@ RSpec.describe MatchScoresController, type: :controller do
|
||||||
|
|
||||||
let(:valid_update) do
|
let(:valid_update) do
|
||||||
{
|
{
|
||||||
points: 42
|
points: 42,
|
||||||
|
hidden_points: 13
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,41 @@ RSpec.describe MatchesController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST #create' do
|
||||||
|
let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match) }
|
||||||
|
let(:group) { group_stage_tournament.stages.find_by(level: -1).groups.first }
|
||||||
|
let(:team_ids) { group.teams.first(2).map(&:id) }
|
||||||
|
|
||||||
|
context 'as owner' do
|
||||||
|
before do
|
||||||
|
apply_authentication_headers_for group.owner
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a decider match at the end of the group' do
|
||||||
|
expect do
|
||||||
|
post :create, params: { group_id: group.to_param, team_ids: team_ids }
|
||||||
|
end.to change { group.matches.reload.count }.by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
created_match = group.matches.reload.max_by(&:position)
|
||||||
|
expect(created_match.decider?).to eq(true)
|
||||||
|
expect(created_match.state).to eq('not_started')
|
||||||
|
expect(created_match.teams.map(&:id)).to match_array(team_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid teams' do
|
||||||
|
before do
|
||||||
|
apply_authentication_headers_for group.owner
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects the request' do
|
||||||
|
post :create, params: { group_id: group.to_param, team_ids: [group.teams.first.id] }
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'POST #update' do
|
describe 'POST #update' do
|
||||||
context 'on a running playoff match' do
|
context 'on a running playoff match' do
|
||||||
let(:valid_update) do
|
let(:valid_update) do
|
||||||
|
|
|
||||||
|
|
@ -136,5 +136,41 @@ RSpec.describe StagesController, type: :controller do
|
||||||
expect(deserialize_response(response)[:error]).to eq('The state attribute may only be changed to finished')
|
expect(deserialize_response(response)[:error]).to eq('The state attribute may only be changed to finished')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when a tie around the advancing cutoff still needs a decider match' do
|
||||||
|
let(:group_stage) do
|
||||||
|
create(:group_stage, tournament: create(:tournament, teams_count: 0), group_count: 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
tournament = group_stage.tournament
|
||||||
|
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0)
|
||||||
|
group = create(:group, stage: group_stage, match_count: 0, number: 1)
|
||||||
|
group_stage.groups = [group]
|
||||||
|
team_a = create(:team, tournament: tournament, name: 'A')
|
||||||
|
team_b = create(:team, tournament: tournament, name: 'B')
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 2)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1)
|
||||||
|
score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1)
|
||||||
|
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
|
||||||
|
|
||||||
|
apply_authentication_headers_for group_stage.owner
|
||||||
|
put :update, params: { id: group_stage.to_param }.merge(finished)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unprocessable entity' do
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns a helpful decider error' do
|
||||||
|
body = deserialize_response(response)
|
||||||
|
expect(body[:error]).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place')
|
||||||
|
expect(body[:blocking_ties].size).to eq(1)
|
||||||
|
expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ FactoryBot.define do
|
||||||
factory :group_match, class: Match do
|
factory :group_match, class: Match do
|
||||||
group
|
group
|
||||||
position { 0 }
|
position { 0 }
|
||||||
|
|
||||||
|
trait :decider do
|
||||||
|
decider { true }
|
||||||
|
end
|
||||||
factory :filled_group_match do
|
factory :filled_group_match do
|
||||||
transient do
|
transient do
|
||||||
match_scores_count { 2 }
|
match_scores_count { 2 }
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,10 @@ RSpec.describe GroupScore, type: :model do
|
||||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
resolved_by: :group_points,
|
resolved_by: :group_points,
|
||||||
tied: false,
|
tied: false,
|
||||||
direct_comparison_result: nil
|
direct_comparison_result: nil,
|
||||||
|
hidden_points_result: :not_played,
|
||||||
|
direct_comparison_match_id: nil,
|
||||||
|
decider_match_id: nil
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -83,7 +86,10 @@ RSpec.describe GroupScore, type: :model do
|
||||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
resolved_by: :direct_comparison,
|
resolved_by: :direct_comparison,
|
||||||
tied: false,
|
tied: false,
|
||||||
direct_comparison_result: :won
|
direct_comparison_result: :won,
|
||||||
|
hidden_points_result: :not_played,
|
||||||
|
direct_comparison_match_id: group.matches.last.id,
|
||||||
|
decider_match_id: nil
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -97,7 +103,31 @@ RSpec.describe GroupScore, type: :model do
|
||||||
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
resolved_by: :tie,
|
resolved_by: :tie,
|
||||||
tied: true,
|
tied: true,
|
||||||
direct_comparison_result: :draw
|
direct_comparison_result: :draw,
|
||||||
|
hidden_points_result: :not_played,
|
||||||
|
direct_comparison_match_id: group.matches.last.id,
|
||||||
|
decider_match_id: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to hidden points when direct comparison is still tied' do
|
||||||
|
group_score_b.update!(group_points: 6)
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 2)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
create(:group_match, group: group, state: :finished, decider: true).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, hidden_points: 3)
|
||||||
|
create(:match_score, match: match, team: team_b, hidden_points: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
resolved_by: :hidden_points,
|
||||||
|
tied: false,
|
||||||
|
direct_comparison_result: :draw,
|
||||||
|
hidden_points_result: :won,
|
||||||
|
direct_comparison_match_id: group.matches.reject(&:decider?).last.id,
|
||||||
|
decider_match_id: group.matches.select(&:decider?).last.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,9 @@ RSpec.describe MatchesController, type: :routing do
|
||||||
it 'routes to #show' do
|
it 'routes to #show' do
|
||||||
expect(get: '/matches/1').to route_to('matches#show', id: '1')
|
expect(get: '/matches/1').to route_to('matches#show', id: '1')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'routes nested group matches to #create' do
|
||||||
|
expect(post: '/groups/1/matches').to route_to('matches#create', group_id: '1')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,7 @@ RSpec.describe GroupStageService do
|
||||||
resolved_by: 'direct_comparison',
|
resolved_by: 'direct_comparison',
|
||||||
compared_with_team_id: team_b.id,
|
compared_with_team_id: team_b.id,
|
||||||
direct_comparison_result: 'won',
|
direct_comparison_result: 'won',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: false,
|
tied: false,
|
||||||
needs_decider_match: false
|
needs_decider_match: false
|
||||||
)
|
)
|
||||||
|
|
@ -253,6 +254,7 @@ RSpec.describe GroupStageService do
|
||||||
resolved_by: 'direct_comparison',
|
resolved_by: 'direct_comparison',
|
||||||
compared_with_team_id: team_a.id,
|
compared_with_team_id: team_a.id,
|
||||||
direct_comparison_result: 'lost',
|
direct_comparison_result: 'lost',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: false,
|
tied: false,
|
||||||
needs_decider_match: false
|
needs_decider_match: false
|
||||||
)
|
)
|
||||||
|
|
@ -277,12 +279,14 @@ RSpec.describe GroupStageService do
|
||||||
expect(GroupStageService.ranking_decision_for(score_a)).to include(
|
expect(GroupStageService.ranking_decision_for(score_a)).to include(
|
||||||
resolved_by: 'tie',
|
resolved_by: 'tie',
|
||||||
direct_comparison_result: 'draw',
|
direct_comparison_result: 'draw',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: true,
|
tied: true,
|
||||||
needs_decider_match: true
|
needs_decider_match: true
|
||||||
)
|
)
|
||||||
expect(GroupStageService.ranking_decision_for(score_b)).to include(
|
expect(GroupStageService.ranking_decision_for(score_b)).to include(
|
||||||
resolved_by: 'tie',
|
resolved_by: 'tie',
|
||||||
direct_comparison_result: 'draw',
|
direct_comparison_result: 'draw',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: true,
|
tied: true,
|
||||||
needs_decider_match: true
|
needs_decider_match: true
|
||||||
)
|
)
|
||||||
|
|
@ -301,15 +305,46 @@ RSpec.describe GroupStageService do
|
||||||
expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3])
|
expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3])
|
||||||
expect(GroupStageService.ranking_decision_for(score_c)).to include(
|
expect(GroupStageService.ranking_decision_for(score_c)).to include(
|
||||||
resolved_by: 'tie',
|
resolved_by: 'tie',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: true,
|
tied: true,
|
||||||
needs_decider_match: false
|
needs_decider_match: false
|
||||||
)
|
)
|
||||||
expect(GroupStageService.ranking_decision_for(score_d)).to include(
|
expect(GroupStageService.ranking_decision_for(score_d)).to include(
|
||||||
resolved_by: 'tie',
|
resolved_by: 'tie',
|
||||||
|
hidden_points_result: 'not_played',
|
||||||
tied: true,
|
tied: true,
|
||||||
needs_decider_match: false
|
needs_decider_match: false
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'uses the decider hidden points to resolve a cutoff tie' do
|
||||||
|
tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0)
|
||||||
|
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 2)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
create(:group_match, group: group, state: :finished, decider: true).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, hidden_points: 5)
|
||||||
|
create(:match_score, match: match, team: team_b, hidden_points: 3)
|
||||||
|
end
|
||||||
|
|
||||||
|
score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1)
|
||||||
|
score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1)
|
||||||
|
score_c = create(:group_score, group: group, team: team_c, group_points: 3, scored_points: 2, received_points: 3)
|
||||||
|
score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 1, received_points: 7)
|
||||||
|
|
||||||
|
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!)
|
||||||
|
|
||||||
|
expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 4])
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_a)).to include(
|
||||||
|
resolved_by: 'hidden_points',
|
||||||
|
direct_comparison_result: 'draw',
|
||||||
|
hidden_points_result: 'won',
|
||||||
|
tied: false,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#get_advancing_teams' do
|
describe '#get_advancing_teams' do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue