From 7cef4a9b5ea793faad5b1e2e63f3fabc68e9d9b6 Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 12 Apr 2026 17:03:33 +0200 Subject: [PATCH] Fix group score direct comparison tiebreak --- app/models/group_score.rb | 24 ++-- e2e/lib/scenario_runner.rb | 121 +++++++++++++++++++++ spec/e2e/http/tournament_lifecycle_spec.rb | 56 ++++++++++ spec/models/group_score_spec.rb | 45 ++++++++ spec/services/group_stage_service_spec.rb | 16 +++ 5 files changed, 255 insertions(+), 7 deletions(-) diff --git a/app/models/group_score.rb b/app/models/group_score.rb index 72c3ab8..7f532d5 100644 --- a/app/models/group_score.rb +++ b/app/models/group_score.rb @@ -10,13 +10,23 @@ class GroupScore < ApplicationRecord def <=>(other) point_comparison = [-group_points, -difference_in_points, -scored_points] <=> [-other.group_points, -other.difference_in_points, -other.scored_points] - if point_comparison.zero? - comparison_match = group.matches.find do |match| - match.match_scores.any? { |match_score| match_score.team == team } - end - comparison_match.scored_points_of(team) <=> comparison_match.scored_points_of(other.team) - else - point_comparison + return point_comparison unless point_comparison.zero? + + direct_comparison(other) + end + + private + + def direct_comparison(other) + comparison_match = group.matches.find do |match| + match.finished? && match.teams.include?(team) && match.teams.include?(other.team) end + return 0 if comparison_match.nil? + + team_points = comparison_match.scored_points_of(team) + other_team_points = comparison_match.scored_points_of(other.team) + return 0 if team_points == other_team_points + + other_team_points <=> team_points end end diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index 7da46d5..f310070 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -133,6 +133,30 @@ module TurniereE2E result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) 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) started_at = Time.now loop do @@ -255,6 +279,34 @@ module TurniereE2E } 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) response = client.get("/matches/#{match_id}") expect_status!(response, 200, "fetch match #{match_id}") @@ -292,6 +344,20 @@ module TurniereE2E .min_by { |name| [seed_identifier_for(name), name] } 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) match.fetch(:match_scores) .map { |score| score.dig(:team, :name) } @@ -335,6 +401,61 @@ module TurniereE2E } 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) { id: tournament.fetch(:id), diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index a65b492..39a7e0e 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -59,6 +59,62 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed') 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 + 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: 6, + 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: 6, + difference_in_points: 3, + scored_points: 5, + received_points: 2 + ) + end + end + end + [2, 3, 5, 8].each do |team_count| it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do result = runner.run_playoff_only(team_count: team_count) diff --git a/spec/models/group_score_spec.rb b/spec/models/group_score_spec.rb index 1a995eb..f0bfbe7 100644 --- a/spec/models/group_score_spec.rb +++ b/spec/models/group_score_spec.rb @@ -7,4 +7,49 @@ RSpec.describe GroupScore, type: :model do it { should belong_to :team } it { should belong_to :group } 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 end diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 51e9b14..2d83249 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -191,6 +191,22 @@ RSpec.describe GroupStageService do 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 '#get_advancing_teams' do