Merge branch 'TUR-59-direct-comparison-tiebreak' into 'master'
TUR-59: fix group direct comparison tiebreak Closes TUR-59 See merge request turniere/turniere-backend!34
This commit is contained in:
commit
1c275f49f7
|
|
@ -4,19 +4,75 @@ class GroupScore < ApplicationRecord
|
||||||
belongs_to :team
|
belongs_to :team
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
|
COMPARISON_ATTRIBUTES = {
|
||||||
|
group_points: :group_points,
|
||||||
|
difference_in_points: :difference_in_points,
|
||||||
|
scored_points: :scored_points
|
||||||
|
}.freeze
|
||||||
|
|
||||||
def difference_in_points
|
def difference_in_points
|
||||||
scored_points - received_points
|
scored_points - received_points
|
||||||
end
|
end
|
||||||
|
|
||||||
def <=>(other)
|
def <=>(other)
|
||||||
point_comparison = [-group_points, -difference_in_points, -scored_points] <=> [-other.group_points, -other.difference_in_points, -other.scored_points]
|
comparison_reason = comparison_reason_against(other)
|
||||||
if point_comparison.zero?
|
|
||||||
comparison_match = group.matches.find do |match|
|
case comparison_reason[:resolved_by]
|
||||||
match.match_scores.any? { |match_score| match_score.team == team }
|
when :group_points, :difference_in_points, :scored_points
|
||||||
end
|
other.public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]]) <=>
|
||||||
comparison_match.scored_points_of(team) <=> comparison_match.scored_points_of(other.team)
|
public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]])
|
||||||
|
when :direct_comparison
|
||||||
|
comparison_reason[:direct_comparison_result] == :won ? -1 : 1
|
||||||
else
|
else
|
||||||
point_comparison
|
0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def comparison_reason_against(other)
|
||||||
|
COMPARISON_ATTRIBUTES.each do |reason, attribute|
|
||||||
|
comparison = public_send(attribute) <=> other.public_send(attribute)
|
||||||
|
next if comparison.zero?
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolved_by: reason,
|
||||||
|
tied: false,
|
||||||
|
direct_comparison_result: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
direct_comparison_result = direct_comparison_result_against(other)
|
||||||
|
|
||||||
|
{
|
||||||
|
resolved_by: direct_comparison_result.nil? ? :tie : :direct_comparison,
|
||||||
|
tied: direct_comparison_result.nil?,
|
||||||
|
direct_comparison_result: direct_comparison_result || direct_comparison_tie_reason_against(other)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def direct_comparison_result_against(other)
|
||||||
|
comparison_match = direct_comparison_match_against(other)
|
||||||
|
return nil if comparison_match.nil?
|
||||||
|
|
||||||
|
team_points = comparison_match.scored_points_of(team)
|
||||||
|
other_team_points = comparison_match.scored_points_of(other.team)
|
||||||
|
return :won if team_points > other_team_points
|
||||||
|
return :lost if team_points < other_team_points
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def direct_comparison_tie_reason_against(other)
|
||||||
|
comparison_match = direct_comparison_match_against(other)
|
||||||
|
return :not_played if comparison_match.nil?
|
||||||
|
|
||||||
|
:draw
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def direct_comparison_match_against(other)
|
||||||
|
group.matches.reload.find do |match|
|
||||||
|
match.finished? && match.teams.include?(team) && match.teams.include?(other.team)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class GroupScoreSerializer < ApplicationSerializer
|
class GroupScoreSerializer < ApplicationSerializer
|
||||||
attributes :group_points, :received_points, :scored_points, :position, :difference_in_points
|
attributes :group_points, :received_points, :scored_points, :position, :difference_in_points, :ranking_decision
|
||||||
|
|
||||||
belongs_to :team
|
belongs_to :team
|
||||||
|
|
||||||
|
def ranking_decision
|
||||||
|
GroupStageService.ranking_decision_for(object)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,28 @@ class GroupStageService
|
||||||
group.group_scores.sort.map(&:team)
|
group.group_scores.sort.map(&:team)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ranking_decision_for(group_score)
|
||||||
|
sorted_group_scores = group_score.group.group_scores.reload.sort
|
||||||
|
index = sorted_group_scores.index(group_score)
|
||||||
|
return nil if index.nil? || sorted_group_scores.size <= 1
|
||||||
|
|
||||||
|
comparison_index = comparison_partner_index(sorted_group_scores, index)
|
||||||
|
return nil if comparison_index.nil?
|
||||||
|
|
||||||
|
compared_group_score = sorted_group_scores[comparison_index]
|
||||||
|
comparison_reason = group_score.comparison_reason_against(compared_group_score)
|
||||||
|
|
||||||
|
{
|
||||||
|
resolved_by: comparison_reason[:resolved_by].to_s,
|
||||||
|
compared_with_team_id: compared_group_score.team.id,
|
||||||
|
compared_with_team_name: compared_group_score.team.name,
|
||||||
|
direct_comparison_result: comparison_reason[:direct_comparison_result].to_s,
|
||||||
|
tied: 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))
|
||||||
|
}
|
||||||
|
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
|
||||||
#
|
#
|
||||||
|
|
@ -187,5 +209,56 @@ class GroupStageService
|
||||||
|
|
||||||
group_scores
|
group_scores
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def comparison_partner_index(sorted_group_scores, index)
|
||||||
|
return index - 1 if index.positive? && sorted_group_scores[index - 1].position == sorted_group_scores[index].position
|
||||||
|
return index + 1 if index < sorted_group_scores.size - 1 &&
|
||||||
|
sorted_group_scores[index + 1].position == sorted_group_scores[index].position
|
||||||
|
return index - 1 if index.positive?
|
||||||
|
return index + 1 if sorted_group_scores.size > 1
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots)
|
||||||
|
return false if advancing_slots.zero?
|
||||||
|
|
||||||
|
cluster = tie_cluster_for(sorted_group_scores, group_score)
|
||||||
|
occupied_slots = (cluster[:start_index] + 1)..(cluster[:end_index] + 1)
|
||||||
|
occupied_slots.begin <= advancing_slots && occupied_slots.end > advancing_slots
|
||||||
|
end
|
||||||
|
|
||||||
|
def tie_cluster_for(sorted_group_scores, group_score)
|
||||||
|
index = sorted_group_scores.index(group_score)
|
||||||
|
position = group_score.position
|
||||||
|
|
||||||
|
start_index = index
|
||||||
|
start_index -= 1 while start_index.positive? && sorted_group_scores[start_index - 1].position == position
|
||||||
|
|
||||||
|
end_index = index
|
||||||
|
while end_index < sorted_group_scores.size - 1 && sorted_group_scores[end_index + 1].position == position
|
||||||
|
end_index += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
{ start_index: start_index, end_index: end_index }
|
||||||
|
end
|
||||||
|
|
||||||
|
def advancing_slots_for_group(group)
|
||||||
|
group_stage = group.stage
|
||||||
|
return 0 if group_stage.nil? || group_stage.level != -1 || group_stage.tournament.nil?
|
||||||
|
|
||||||
|
groups = group_stage.groups.sort_by(&:number)
|
||||||
|
group_index = groups.index(group)
|
||||||
|
return 0 if group_index.nil?
|
||||||
|
|
||||||
|
total_advancing_teams = calculate_advancing_teams_amount(group_stage)
|
||||||
|
if special_case_for_po2?(group_stage.tournament.teams.size, total_advancing_teams)
|
||||||
|
total_advancing_teams / groups.size
|
||||||
|
else
|
||||||
|
base_slots = total_advancing_teams / groups.size
|
||||||
|
extra_slot_groups = total_advancing_teams % groups.size
|
||||||
|
base_slots + (group_index < extra_slot_groups ? 1 : 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,30 @@ module TurniereE2E
|
||||||
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores)
|
||||||
|
session = register_session!
|
||||||
|
tournament = create_tournament!(
|
||||||
|
create_group_stage_payload(group_count: 1, teams_per_group: 4, playoff_teams_amount: 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoints = {
|
||||||
|
created: summarize_tournament(tournament)
|
||||||
|
}
|
||||||
|
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
|
||||||
|
|
||||||
|
group = group_stage_of!(tournament).fetch(:groups).first
|
||||||
|
play_group_matches_with_explicit_scores!(group, group_direct_comparison_score_plan(case_name))
|
||||||
|
|
||||||
|
group = get!("/groups/#{group.fetch(:id)}")
|
||||||
|
checkpoints[:group_scores] = {
|
||||||
|
ordered_group_scores: ordered_group_scores(group),
|
||||||
|
team_positions: team_positions(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
tournament = fetch_tournament!(tournament.fetch(:id))
|
||||||
|
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
|
||||||
|
end
|
||||||
|
|
||||||
def wait_for_healthcheck!(timeout_seconds: 30)
|
def wait_for_healthcheck!(timeout_seconds: 30)
|
||||||
started_at = Time.now
|
started_at = Time.now
|
||||||
loop do
|
loop do
|
||||||
|
|
@ -255,6 +279,34 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def play_match_with_points!(match, points_by_team_name:, expected_finish_status: 200, expected_finish_error: nil)
|
||||||
|
started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
|
||||||
|
expect_status!(started, 200, "start match #{match.fetch(:id)}")
|
||||||
|
|
||||||
|
refreshed_match = fetch_match!(match.fetch(:id))
|
||||||
|
refreshed_match.fetch(:match_scores).each do |score|
|
||||||
|
team_name = score.dig(:team, :name)
|
||||||
|
points = points_by_team_name.fetch(team_name) do
|
||||||
|
raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}"
|
||||||
|
end
|
||||||
|
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points })
|
||||||
|
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
|
||||||
|
expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}")
|
||||||
|
if expected_finish_error
|
||||||
|
actual_error = finished.fetch(:json).fetch(:error)
|
||||||
|
raise "expected finish error #{expected_finish_error.inspect}, got #{actual_error.inspect}" unless actual_error == expected_finish_error
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
match_id: match.fetch(:id),
|
||||||
|
status: finished.fetch(:status),
|
||||||
|
body: finished.fetch(:json)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_match!(match_id)
|
def fetch_match!(match_id)
|
||||||
response = client.get("/matches/#{match_id}")
|
response = client.get("/matches/#{match_id}")
|
||||||
expect_status!(response, 200, "fetch match #{match_id}")
|
expect_status!(response, 200, "fetch match #{match_id}")
|
||||||
|
|
@ -292,6 +344,20 @@ module TurniereE2E
|
||||||
.min_by { |name| [seed_identifier_for(name), name] }
|
.min_by { |name| [seed_identifier_for(name), name] }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def play_group_matches_with_explicit_scores!(group, score_plan)
|
||||||
|
matches_by_teams = group.fetch(:matches).each_with_object({}) do |match, memo|
|
||||||
|
team_names = match.fetch(:match_scores).map { |score| score.dig(:team, :name) }.sort
|
||||||
|
memo[team_names] = match
|
||||||
|
end
|
||||||
|
|
||||||
|
score_plan.each do |team_names, points_by_team_name|
|
||||||
|
match = matches_by_teams.fetch(team_names.sort) do
|
||||||
|
raise "match for #{team_names.inspect} not found"
|
||||||
|
end
|
||||||
|
play_match_with_points!(match, points_by_team_name: points_by_team_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def preferred_playoff_winner_name(match)
|
def preferred_playoff_winner_name(match)
|
||||||
match.fetch(:match_scores)
|
match.fetch(:match_scores)
|
||||||
.map { |score| score.dig(:team, :name) }
|
.map { |score| score.dig(:team, :name) }
|
||||||
|
|
@ -335,6 +401,61 @@ module TurniereE2E
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ordered_group_scores(group)
|
||||||
|
group.fetch(:group_scores)
|
||||||
|
.sort_by { |score| [score.fetch(:position), score.dig(:team, :name)] }
|
||||||
|
.map do |score|
|
||||||
|
{
|
||||||
|
team_name: score.dig(:team, :name),
|
||||||
|
position: score.fetch(:position),
|
||||||
|
group_points: score.fetch(:group_points),
|
||||||
|
difference_in_points: score.fetch(:difference_in_points),
|
||||||
|
scored_points: score.fetch(:scored_points),
|
||||||
|
received_points: score.fetch(:received_points)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def team_positions(group)
|
||||||
|
group.fetch(:group_scores).each_with_object({}) do |score, memo|
|
||||||
|
memo[score.dig(:team, :name)] = score.fetch(:position)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_direct_comparison_score_plan(case_name)
|
||||||
|
case case_name.to_sym
|
||||||
|
when :won
|
||||||
|
{
|
||||||
|
%w[G1-S1 G1-S2] => { 'G1-S1' => 2, 'G1-S2' => 1 },
|
||||||
|
%w[G1-S1 G1-S3] => { 'G1-S1' => 3, 'G1-S3' => 0 },
|
||||||
|
%w[G1-S1 G1-S4] => { 'G1-S1' => 0, 'G1-S4' => 1 },
|
||||||
|
%w[G1-S2 G1-S3] => { 'G1-S2' => 2, 'G1-S3' => 0 },
|
||||||
|
%w[G1-S2 G1-S4] => { 'G1-S2' => 2, 'G1-S4' => 0 },
|
||||||
|
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
|
||||||
|
}
|
||||||
|
when :lost
|
||||||
|
{
|
||||||
|
%w[G1-S1 G1-S2] => { 'G1-S1' => 1, 'G1-S2' => 2 },
|
||||||
|
%w[G1-S1 G1-S3] => { 'G1-S1' => 2, 'G1-S3' => 0 },
|
||||||
|
%w[G1-S1 G1-S4] => { 'G1-S1' => 2, 'G1-S4' => 0 },
|
||||||
|
%w[G1-S2 G1-S3] => { 'G1-S2' => 1, 'G1-S3' => 0 },
|
||||||
|
%w[G1-S2 G1-S4] => { 'G1-S2' => 2, 'G1-S4' => 1 },
|
||||||
|
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
|
||||||
|
}
|
||||||
|
when :drawn
|
||||||
|
{
|
||||||
|
%w[G1-S1 G1-S2] => { 'G1-S1' => 1, 'G1-S2' => 1 },
|
||||||
|
%w[G1-S1 G1-S3] => { 'G1-S1' => 2, 'G1-S3' => 0 },
|
||||||
|
%w[G1-S1 G1-S4] => { 'G1-S1' => 1, 'G1-S4' => 0 },
|
||||||
|
%w[G1-S2 G1-S3] => { 'G1-S2' => 2, 'G1-S3' => 0 },
|
||||||
|
%w[G1-S2 G1-S4] => { 'G1-S2' => 1, 'G1-S4' => 0 },
|
||||||
|
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
raise "unknown direct comparison case #{case_name.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def summarize_tournament(tournament)
|
def summarize_tournament(tournament)
|
||||||
{
|
{
|
||||||
id: tournament.fetch(:id),
|
id: tournament.fetch(:id),
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,45 @@ RSpec.describe GroupsController, type: :controller do
|
||||||
expect(body[:matches].size).to eq(@group.matches.size)
|
expect(body[:matches].size).to eq(@group.matches.size)
|
||||||
expect(body[:group_scores].size).to eq(@group.group_scores.size)
|
expect(body[:group_scores].size).to eq(@group.group_scores.size)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns ranking_decision metadata for group scores' do
|
||||||
|
tournament = @group.stage.tournament
|
||||||
|
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
||||||
|
@group.stage.update!(level: -1)
|
||||||
|
@group.matches.destroy_all
|
||||||
|
@group.group_scores.destroy_all
|
||||||
|
|
||||||
|
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: 3)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
score_a = create(:group_score, group: @group, team: team_a, group_points: 3, scored_points: 3, received_points: 1)
|
||||||
|
score_b = create(:group_score, group: @group, team: team_b, group_points: 3, scored_points: 3, received_points: 1)
|
||||||
|
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
|
||||||
|
|
||||||
|
get :show, params: { id: @group.to_param }
|
||||||
|
body = deserialize_response response
|
||||||
|
winner = body[:group_scores].find { |score| score.dig(:team, :name) == 'A' }
|
||||||
|
loser = body[:group_scores].find { |score| score.dig(:team, :name) == 'B' }
|
||||||
|
|
||||||
|
expect(winner[:ranking_decision]).to include(
|
||||||
|
resolved_by: 'direct_comparison',
|
||||||
|
compared_with_team_name: 'B',
|
||||||
|
direct_comparison_result: 'won',
|
||||||
|
tied: false,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
expect(loser[:ranking_decision]).to include(
|
||||||
|
resolved_by: 'direct_comparison',
|
||||||
|
compared_with_team_name: 'A',
|
||||||
|
direct_comparison_result: 'lost',
|
||||||
|
tied: false,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,65 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
|
||||||
expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed')
|
expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
won: [1, 2],
|
||||||
|
lost: [2, 1],
|
||||||
|
drawn: [1, 1]
|
||||||
|
}.each do |direct_result, expected_positions|
|
||||||
|
it "applies the direct-comparison tiebreak over HTTP when team A #{direct_result} the head-to-head" do
|
||||||
|
result = runner.run_group_direct_comparison_tiebreak(case_name: direct_result)
|
||||||
|
|
||||||
|
positions = result.dig(:checkpoints, :group_scores, :team_positions)
|
||||||
|
ordered_scores = result.dig(:checkpoints, :group_scores, :ordered_group_scores)
|
||||||
|
|
||||||
|
expect(positions.fetch('G1-S1')).to eq(expected_positions[0])
|
||||||
|
expect(positions.fetch('G1-S2')).to eq(expected_positions[1])
|
||||||
|
|
||||||
|
if direct_result == :drawn
|
||||||
|
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||||
|
.to eq(
|
||||||
|
team_name: 'G1-S1',
|
||||||
|
position: 1,
|
||||||
|
group_points: 7,
|
||||||
|
difference_in_points: 3,
|
||||||
|
scored_points: 4,
|
||||||
|
received_points: 1
|
||||||
|
)
|
||||||
|
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||||
|
.to eq(
|
||||||
|
team_name: 'G1-S2',
|
||||||
|
position: 1,
|
||||||
|
group_points: 7,
|
||||||
|
difference_in_points: 3,
|
||||||
|
scored_points: 4,
|
||||||
|
received_points: 1
|
||||||
|
)
|
||||||
|
else
|
||||||
|
expected_first_group_points = direct_result == :lost ? 9 : 6
|
||||||
|
expected_second_group_points = direct_result == :lost ? 6 : 6
|
||||||
|
|
||||||
|
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||||
|
.to eq(
|
||||||
|
team_name: expected_positions[0] == 1 ? 'G1-S1' : 'G1-S2',
|
||||||
|
position: 1,
|
||||||
|
group_points: expected_first_group_points,
|
||||||
|
difference_in_points: 3,
|
||||||
|
scored_points: 5,
|
||||||
|
received_points: 2
|
||||||
|
)
|
||||||
|
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
|
||||||
|
.to eq(
|
||||||
|
team_name: expected_positions[0] == 1 ? 'G1-S2' : 'G1-S1',
|
||||||
|
position: 2,
|
||||||
|
group_points: expected_second_group_points,
|
||||||
|
difference_in_points: 3,
|
||||||
|
scored_points: 5,
|
||||||
|
received_points: 2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
[2, 3, 5, 8].each do |team_count|
|
[2, 3, 5, 8].each do |team_count|
|
||||||
it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do
|
it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do
|
||||||
result = runner.run_playoff_only(team_count: team_count)
|
result = runner.run_playoff_only(team_count: team_count)
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,98 @@ RSpec.describe GroupScore, type: :model do
|
||||||
it { should belong_to :team }
|
it { should belong_to :team }
|
||||||
it { should belong_to :group }
|
it { should belong_to :group }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#<=>' do
|
||||||
|
let(:group) { create(:group, match_count: 0) }
|
||||||
|
let(:team_a) { create(:team) }
|
||||||
|
let(:team_b) { create(:team) }
|
||||||
|
let(:team_c) { create(:team) }
|
||||||
|
|
||||||
|
let(:group_score_a) do
|
||||||
|
create(:group_score, group: group, team: team_a, group_points: 6, scored_points: 7, received_points: 4)
|
||||||
|
end
|
||||||
|
let(:group_score_b) do
|
||||||
|
create(:group_score, group: group, team: team_b, group_points: 6, scored_points: 7, received_points: 4)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prefers the winner of the direct comparison when the first three criteria are tied' do
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 3)
|
||||||
|
create(:match_score, match: match, team: team_c, points: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 4)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
group_score_a.reload
|
||||||
|
group_score_b.reload
|
||||||
|
|
||||||
|
expect(group_score_a <=> group_score_b).to eq(-1)
|
||||||
|
expect(group_score_b <=> group_score_a).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps the teams tied when their direct comparison was a draw' do
|
||||||
|
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
|
||||||
|
|
||||||
|
group_score_a.reload
|
||||||
|
group_score_b.reload
|
||||||
|
|
||||||
|
expect(group_score_a <=> group_score_b).to eq(0)
|
||||||
|
expect(group_score_b <=> group_score_a).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#comparison_reason_against' do
|
||||||
|
let(:group) { create(:group, match_count: 0) }
|
||||||
|
let(:team_a) { create(:team) }
|
||||||
|
let(:team_b) { create(:team) }
|
||||||
|
|
||||||
|
let(:group_score_a) do
|
||||||
|
create(:group_score, group: group, team: team_a, group_points: 6, scored_points: 7, received_points: 4)
|
||||||
|
end
|
||||||
|
let(:group_score_b) do
|
||||||
|
create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 7, received_points: 4)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports the first deciding metric when points are different' do
|
||||||
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
resolved_by: :group_points,
|
||||||
|
tied: false,
|
||||||
|
direct_comparison_result: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports the direct comparison result when head-to-head resolves the tie' 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: 3)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
resolved_by: :direct_comparison,
|
||||||
|
tied: false,
|
||||||
|
direct_comparison_result: :won
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports an unresolved tie when head-to-head was a draw' 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
|
||||||
|
|
||||||
|
expect(group_score_a.comparison_reason_against(group_score_b)).to eq(
|
||||||
|
resolved_by: :tie,
|
||||||
|
tied: true,
|
||||||
|
direct_comparison_result: :draw
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,125 @@ RSpec.describe GroupStageService do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'uses the direct comparison when the first three criteria are tied' do
|
||||||
|
group = create(:group, match_count: 0)
|
||||||
|
team_a = create(:team)
|
||||||
|
team_b = create(:team)
|
||||||
|
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 4)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
create(:group_score, group: group, team: team_a, group_points: 3, scored_points: 4, received_points: 2)
|
||||||
|
create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 4, received_points: 2)
|
||||||
|
|
||||||
|
expect(GroupStageService.teams_sorted_by_group_scores(group.reload)).to eq([team_a, team_b])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.ranking_decision_for' do
|
||||||
|
let(:tournament) do
|
||||||
|
create(:prepared_group_stage_tournament,
|
||||||
|
teams_count: 0,
|
||||||
|
playoff_teams_amount: 2,
|
||||||
|
instant_finalists_amount: 2,
|
||||||
|
intermediate_round_participants_amount: 0,
|
||||||
|
group_stage: group_stage)
|
||||||
|
end
|
||||||
|
let(:group_stage) { create(:group_stage, tournament: create(:tournament, teams_count: 0), group_count: 0) }
|
||||||
|
let(:group) { create(:group, stage: group_stage, match_count: 0, number: 1) }
|
||||||
|
let(:team_a) { create(:team, tournament: tournament, name: 'A') }
|
||||||
|
let(:team_b) { create(:team, tournament: tournament, name: 'B') }
|
||||||
|
let(:team_c) { create(:team, tournament: tournament, name: 'C') }
|
||||||
|
let(:team_d) { create(:team, tournament: tournament, name: 'D') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
group_stage.groups = [group]
|
||||||
|
group_stage.level = -1
|
||||||
|
group_stage.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns direct-comparison metadata for teams separated by head-to-head' do
|
||||||
|
create(:group_match, group: group, state: :finished).tap do |match|
|
||||||
|
create(:match_score, match: match, team: team_a, points: 4)
|
||||||
|
create(:match_score, match: match, team: team_b, points: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
score_a = create(:group_score, group: group, team: team_a, group_points: 3, scored_points: 4, received_points: 2)
|
||||||
|
score_b = create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 4, received_points: 2)
|
||||||
|
|
||||||
|
GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!)
|
||||||
|
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_a)).to include(
|
||||||
|
resolved_by: 'direct_comparison',
|
||||||
|
compared_with_team_id: team_b.id,
|
||||||
|
direct_comparison_result: 'won',
|
||||||
|
tied: false,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_b)).to include(
|
||||||
|
resolved_by: 'direct_comparison',
|
||||||
|
compared_with_team_id: team_a.id,
|
||||||
|
direct_comparison_result: 'lost',
|
||||||
|
tied: false,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps shared positions and requests a decider only when an unresolved tie crosses the cutoff' 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
|
||||||
|
|
||||||
|
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, 1, 3, 4])
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_a)).to include(
|
||||||
|
resolved_by: 'tie',
|
||||||
|
direct_comparison_result: 'draw',
|
||||||
|
tied: true,
|
||||||
|
needs_decider_match: true
|
||||||
|
)
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_b)).to include(
|
||||||
|
resolved_by: 'tie',
|
||||||
|
direct_comparison_result: 'draw',
|
||||||
|
tied: true,
|
||||||
|
needs_decider_match: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do
|
||||||
|
tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0)
|
||||||
|
|
||||||
|
score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1)
|
||||||
|
score_b = create(:group_score, group: group, team: team_b, group_points: 6, scored_points: 4, received_points: 2, position: 2)
|
||||||
|
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: 3, scored_points: 2, received_points: 3)
|
||||||
|
|
||||||
|
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, 3])
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_c)).to include(
|
||||||
|
resolved_by: 'tie',
|
||||||
|
tied: true,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
expect(GroupStageService.ranking_decision_for(score_d)).to include(
|
||||||
|
resolved_by: 'tie',
|
||||||
|
tied: true,
|
||||||
|
needs_decider_match: false
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#get_advancing_teams' do
|
describe '#get_advancing_teams' do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue