From 2912645499167809031ad005eef773d43a2aa9b1 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 15:19:53 +0200 Subject: [PATCH 1/5] fix: require deciders for advancing ties --- app/services/group_stage_service.rb | 7 ++--- spec/controllers/stages_controller_spec.rb | 36 ++++++++++++++++++++++ spec/services/group_stage_service_spec.rb | 34 +++++++++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index 7480bbc..c4ac9e8 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -224,12 +224,11 @@ class GroupStageService nil end - def unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots) + def unresolved_tie_affects_advancing_placements?(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 + cluster[:start_index] < advancing_slots end def tie_cluster_for(sorted_group_scores, group_score) @@ -298,7 +297,7 @@ class GroupStageService tied: comparison_reason[:tied], needs_decider_match: group_ready_for_decider_check && comparison_reason[:tied] && - unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots) + unresolved_tie_affects_advancing_placements?(sorted_group_scores, group_score, advancing_slots) } end diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index 355cd6e..55dbcc7 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -201,5 +201,41 @@ RSpec.describe StagesController, type: :controller do expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B]) end end + + context 'when tied advancing teams still need a decider to fix playoff seeding' 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: 2, instant_finalists_amount: 2, 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 diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 6801da3..972009d 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -268,7 +268,7 @@ RSpec.describe GroupStageService do ) end - it 'keeps shared positions and requests a decider only when an unresolved tie crosses the cutoff' do + it 'keeps shared positions and requests a decider when an unresolved tie affects advancing placements' 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| @@ -300,6 +300,38 @@ RSpec.describe GroupStageService do ) end + it 'requests a decider when tied teams both advance but their seeding stays unresolved' do + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, 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', + hidden_points_result: 'not_played', + tied: true, + needs_decider_match: true + ) + expect(GroupStageService.ranking_decision_for(score_b)).to include( + resolved_by: 'tie', + direct_comparison_result: 'draw', + hidden_points_result: 'not_played', + tied: true, + needs_decider_match: true + ) + end + it 'does not expose unresolved tie metadata while regular matches in the group are still unfinished' do tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) From 57aecd7cc5503baab899b27740ff2e68030f14e1 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 15:46:48 +0200 Subject: [PATCH 2/5] feat: add third place playoff match --- app/models/match.rb | 10 +++ app/services/playoff_stage_service.rb | 67 ++++++++++++------- e2e/lib/scenario_runner.rb | 25 +++++-- spec/e2e/http/tournament_lifecycle_spec.rb | 6 +- spec/services/playoff_stage_service_spec.rb | 44 ++++++++++++ .../tournament_snapshot_importer_spec.rb | 2 +- 6 files changed, 119 insertions(+), 35 deletions(-) diff --git a/app/models/match.rb b/app/models/match.rb index 21d0a6f..778fd4b 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -39,6 +39,16 @@ class Match < ApplicationRecord finished? ? current_leading_team : nil end + def loser + return nil unless finished? + return nil if single_team? + + winning_team = winner + return nil if winning_team.nil? + + teams.find { |team| team != winning_team } + end + def group_match? group.present? end diff --git a/app/services/playoff_stage_service.rb b/app/services/playoff_stage_service.rb index 6cef460..20def6b 100644 --- a/app/services/playoff_stage_service.rb +++ b/app/services/playoff_stage_service.rb @@ -16,7 +16,7 @@ class PlayoffStageService initial_stage.state = :intermediate_stage unless initial_stage.matches.find(&:single_team?).nil? playoffs << initial_stage # empty stages are the stages, the tournament is filled with to have the matches ready for later - empty_stages = generate_stages_with_empty_matches(stage_count - 1) + empty_stages = generate_stages_with_empty_matches(stage_count - 1, teams.size > 3) playoffs.concat empty_stages playoffs end @@ -25,10 +25,12 @@ class PlayoffStageService # # @param stage_count [Integer] number of stages to generate # @return [Array] the generated stages - def generate_stages_with_empty_matches(stage_count) + def generate_stages_with_empty_matches(stage_count, include_third_place_match = false) empty_stages = [] stage_count.times do |i| - stage = Stage.new level: i, matches: generate_empty_matches(2**i) + match_count = 2**i + match_count += 1 if include_third_place_match && i.zero? + stage = Stage.new level: i, matches: generate_empty_matches(match_count) empty_stages << stage end # as we are generating the stages in the wrong order (starting with the lowest number of matches (which is @@ -76,33 +78,14 @@ class PlayoffStageService # return if next stage does not exist (there are no matches after the finale) return [] if next_stage.nil? - current_position = current_match.position - # a "companion" match is the one that with the selected match makes up the two matches # of which the winners advance into the match below # depending on the position of the match, the companion match is either on the left or right of it - companion_match = find_companion_match(current_position, current_stage) - - match_below = next_stage.matches.find { |m| m.position == current_position / 2 } - match_scores = match_below.match_scores.sort_by(&:id) + companion_match = find_companion_match(current_match.position, current_stage) + return populate_final_stage_matches(current_match, companion_match, next_stage) if semifinal_match?(current_stage, next_stage) winners = get_winners_of(companion_match, current_match) - - # depending on the amount of match_scores already present we need to do different things - match_scores = assign_correct_match_scores!(match_scores, winners) - - # If a match is not decided yet, it will return nil as winner. - # This is not allowed in Database. The following code filters out MatchScores that contain nil as team. - match_scores = match_scores.select { |ms| ms.team.present? } - match_below.match_scores = match_scores - match_below.state = if match_below.match_scores.empty? || match_below.match_scores.size == 1 - :not_ready - elsif match_below.match_scores.size == 2 - :not_started - else - raise 'Unprocessable amount of match_scores found' - end - [match_below, match_scores].flatten + populate_match!(match: next_stage.matches.find { |m| m.position == current_match.position / 2 }, teams: winners) end private @@ -135,9 +118,43 @@ class PlayoffStageService match_scores end + def populate_match!(match:, teams:) + match_scores = match.match_scores.sort_by(&:id) + match_scores = assign_correct_match_scores!(match_scores, teams) + match_scores = match_scores.select { |ms| ms.team.present? } + match.match_scores = match_scores + match.state = if match.match_scores.empty? || match.match_scores.size == 1 + :not_ready + elsif match.match_scores.size == 2 + :not_started + else + raise 'Unprocessable amount of match_scores found' + end + [match, match_scores].flatten + end + def get_winners_of(companion_match, current_match) matches = [current_match, companion_match].sort_by(&:position) matches.map(&:winner) end + + def get_losers_of(companion_match, current_match) + matches = [current_match, companion_match].sort_by(&:position) + matches.map(&:loser) + end + + def semifinal_match?(current_stage, next_stage) + current_stage.level == 1 && next_stage.level.zero? && next_stage.matches.size > 1 + end + + def populate_final_stage_matches(current_match, companion_match, next_stage) + final_match = next_stage.matches.find { |match| match.position.zero? } + third_place_match = next_stage.matches.find { |match| match.position == 1 } + + [ + populate_match!(match: final_match, teams: get_winners_of(companion_match, current_match)), + populate_match!(match: third_place_match, teams: get_losers_of(companion_match, current_match)) + ].flatten + end end end diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index 5a2e6b5..d6d3b24 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -64,8 +64,7 @@ module TurniereE2E checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt - final_match = final_match_of!(tournament) - finish_attempt = play_match!(final_match, winner_name: preferred_playoff_winner_name(final_match)) + finish_attempt = finish_final_stage_matches!(tournament) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:final_finish_attempted] = { finish_attempt: finish_attempt, @@ -97,8 +96,7 @@ module TurniereE2E checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt - final_match = final_match_of!(tournament) - finish_attempt = play_match!(final_match, winner_name: preferred_playoff_winner_name(final_match)) + finish_attempt = finish_final_stage_matches!(tournament) tournament = fetch_tournament!(tournament.fetch(:id)) checkpoints[:final_finish_attempted] = { finish_attempt: finish_attempt, @@ -340,9 +338,24 @@ module TurniereE2E tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') end - def final_match_of!(tournament) + def final_stage_matches_of!(tournament) final_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == 0 } || raise('final stage not found') - attach_stage_level(final_stage).fetch(:matches).first + attach_stage_level(final_stage).fetch(:matches).sort_by { |match| match.fetch(:position) } + end + + def final_match_of!(tournament) + final_stage_matches_of!(tournament).first + end + + def finish_final_stage_matches!(tournament) + final_stage_matches_of!(tournament).each do |match| + play_match!(match, winner_name: preferred_playoff_winner_name(match)) + end + + { + status: 200, + finished_match_ids: final_stage_matches_of!(fetch_tournament!(tournament.fetch(:id))).map { |match| match.fetch(:id) } + } end def playoff_matches(tournament) diff --git a/spec/e2e/http/tournament_lifecycle_spec.rb b/spec/e2e/http/tournament_lifecycle_spec.rb index 04edcb1..36f8ee2 100644 --- a/spec/e2e/http/tournament_lifecycle_spec.rb +++ b/spec/e2e/http/tournament_lifecycle_spec.rb @@ -37,7 +37,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) expect(final_attempt[:status]).to eq(200) expect(result.dig(:checkpoints, :final_finish_attempted, :tournament, :stage_summaries) - .find { |stage| stage[:level] == 0 }[:match_states]).to eq(['finished']) + .find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished]) end it 'drives the 3-group intermediate-round path over HTTP through a finished final' do @@ -58,7 +58,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt) expect(final_attempt[:status]).to eq(200) expect(result.dig(:checkpoints, :final_finish_attempted, :tournament, :stage_summaries) - .find { |stage| stage[:level] == 0 }[:match_states]).to eq(['finished']) + .find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished]) end { @@ -126,7 +126,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do completed_stages = result.dig(:checkpoints, :completed, :stage_summaries) final_stage = completed_stages.find { |stage| stage[:level] == 0 } - expect(final_stage[:match_states]).to eq(['finished']) + expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished']) end end end diff --git a/spec/services/playoff_stage_service_spec.rb b/spec/services/playoff_stage_service_spec.rb index 721b7d1..351e5fa 100644 --- a/spec/services/playoff_stage_service_spec.rb +++ b/spec/services/playoff_stage_service_spec.rb @@ -75,6 +75,13 @@ RSpec.describe PlayoffStageService do expect(stage.level).to eq stage_level end end + + it "generates correct amount of final-stage matches for #{parameters[:team_size]} teams" do + teams = build_list(:team, parameters[:team_size]) + stages = PlayoffStageService.generate_playoff_stages(teams, false) + + expect(stages.find { |stage| stage.level.zero? }.matches.size).to eq(parameters[:team_size] > 3 ? 2 : 1) + end end describe 'number of teams isn\'t a power of two' do @@ -229,5 +236,42 @@ RSpec.describe PlayoffStageService do expect(PlayoffStageService.populate_match_below(@final_match)).to eq([]) end end + + context 'when semifinal finishes in tournament with third-place match' do + before do + @tournament = create(:stageless_tournament, teams_count: 4) + @tournament.stages = PlayoffStageService.generate_playoff_stages(@tournament.teams, false) + @tournament.save! + semifinal_stage = @tournament.stages.find { |stage| stage.level == 1 } + @semifinal_match = semifinal_stage.matches.find { |match| match.position.zero? } + @companion_match = semifinal_stage.matches.find { |match| match.position == 1 } + + [@semifinal_match, @companion_match].each do |match| + match.update!(state: :finished) + match.match_scores.sort_by(&:id).each_with_index do |match_score, index| + match_score.update!(points: index.zero? ? 2 : 1) + end + end + + @result = PlayoffStageService.populate_match_below(@semifinal_match) + @final_stage = @tournament.stages.find { |stage| stage.level.zero? } + @final_match = @final_stage.matches.find { |match| match.position.zero? } + @third_place_match = @final_stage.matches.find { |match| match.position == 1 } + end + + it 'fills final with winners' do + expect(@final_match.teams).to match_array([@semifinal_match.winner, @companion_match.winner]) + expect(@final_match.state).to eq('not_started') + end + + it 'fills third-place match with losers' do + expect(@third_place_match.teams).to match_array([@semifinal_match.loser, @companion_match.loser]) + expect(@third_place_match.state).to eq('not_started') + end + + it 'returns both changed matches' do + expect(@result).to include(@final_match, @third_place_match) + end + end end end diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 4788941..0206814 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -56,7 +56,7 @@ RSpec.describe TournamentSnapshotImporter do expect(sync_signature(follower)).to eq(sync_signature(source)) expect(follower.teams.count).to eq(128) expect(follower.stages.where(level: -1).first.groups.count).to eq(32) - expect(follower.stages.where('level >= 0').sum { |stage| stage.matches.count }).to eq(63) + expect(follower.stages.where('level >= 0').sum { |stage| stage.matches.count }).to eq(64) expect(follower.matches.select(&:decider?).count).to eq(source.matches.select(&:decider?).count) expect(follower.teams.pluck(:sync_source_id)).to match_array(source.teams.pluck(:id)) expect(source.teams.pluck(:id) & follower.teams.pluck(:id)).not_to match_array(source.teams.pluck(:id)) From f3ef3f437373e5ef49b54f871fce48d65b16bc71 Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 16:15:25 +0200 Subject: [PATCH 3/5] refactor: simplify playoff loser lookup --- app/models/match.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/models/match.rb b/app/models/match.rb index 778fd4b..2a84518 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -40,9 +40,6 @@ class Match < ApplicationRecord end def loser - return nil unless finished? - return nil if single_team? - winning_team = winner return nil if winning_team.nil? From 6f0ca455f7a18ad81c608905c642bf954928d31e Mon Sep 17 00:00:00 2001 From: Malaber Date: Wed, 22 Apr 2026 16:42:58 +0200 Subject: [PATCH 4/5] Test match swaps in follower sync e2e --- spec/e2e/http/tournament_follow_sync_spec.rb | 77 ++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1c2efa7..2985472 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -50,6 +50,65 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty + log_phase!('swap matches on leader and verify follower behavior') + source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + second_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }[1] + first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) } + first_position_match = first_group_matches.fetch(0) + fourth_position_match = first_group_matches.fetch(3) + wrong_group_match = second_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first + follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } + first_follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first + follower_first_position_match = first_follower_group.fetch(:matches).find do |match| + match_team_names(match) == match_team_names(first_position_match) + end + follower_fourth_position_match = first_follower_group.fetch(:matches).find do |match| + match_team_names(match) == match_team_names(fourth_position_match) + end + + leader_swap = source_owner_client.patch("/matches/#{first_position_match.fetch(:id)}/swap", body: { + other_match_id: fourth_position_match.fetch(:id) + }) + expect(leader_swap[:status]).to eq(200) + expect(leader_swap[:json].map { |match| match.fetch(:position) }).to match_array([ + first_position_match.fetch(:position), + fourth_position_match.fetch(:position) + ]) + source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower)).to eq(tournament_signature(source)) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number))).to eq( + group_match_positions(follower, group_number: first_follower_group.fetch(:number)) + ) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(first_position_match))).to eq( + fourth_position_match.fetch(:position) + ) + expect(group_match_positions(source, group_number: first_source_group.fetch(:number)).fetch(match_team_names(fourth_position_match))).to eq( + first_position_match.fetch(:position) + ) + + follower_before_invalid_swap = tournament_signature(follower) + source_before_invalid_swap = tournament_signature(source) + + wrong_group_swap = source_owner_client.patch("/matches/#{first_position_match.fetch(:id)}/swap", body: { + other_match_id: wrong_group_match.fetch(:id) + }) + expect(wrong_group_swap[:status]).to eq(422) + expect(wrong_group_swap.dig(:json, :error)).to eq('Matches must be different group matches from the same group') + source_after_invalid_swap = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id)) + follower_after_invalid_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) + expect(tournament_signature(source_after_invalid_swap)).to eq(source_before_invalid_swap) + expect(tournament_signature(follower_after_invalid_swap)).to eq(follower_before_invalid_swap) + + follower_swap = follower_owner_client.patch("/matches/#{follower_first_position_match.fetch(:id)}/swap", body: { + other_match_id: follower_fourth_position_match.fetch(:id) + }) + expect(follower_swap[:status]).to eq(423) + expect(follower_swap.dig(:json, :error)).to eq('Tournament is in read only follower mode') + follower_after_locked_swap = fetch_tournament(client: follower_anonymous_client, tournament_id: follower.fetch(:id)) + expect(tournament_signature(follower_after_locked_swap)).to eq(follower_before_invalid_swap) + log_phase!('tighten playoff cutoff and rename teams') update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { playoff_teams_amount: 4, @@ -427,6 +486,24 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do } end + def match_team_names(match) + match.fetch(:match_scores) + .map { |score| score.dig(:team, :name) } + .sort + .join(' vs ') + end + + def group_match_positions(tournament, group_number:) + group = tournament.fetch(:stages) + .find { |stage| stage.fetch(:level) == -1 } + .fetch(:groups) + .find { |candidate| candidate.fetch(:number) == group_number } + + group.fetch(:matches).each_with_object({}) do |match, positions| + positions[match_team_names(match)] = match.fetch(:position) + end + end + def unique_suffix @unique_suffix_counter ||= 0 @unique_suffix_counter += 1 From 1612146be024c9276f8d4b090d61db93118fa96f Mon Sep 17 00:00:00 2001 From: Malaber Date: Thu, 23 Apr 2026 13:07:30 +0200 Subject: [PATCH 5/5] ci: start e2e stage before rails specs --- .gitlab-ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e43e9cf..81e7a6f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,6 @@ stages: - build + - e2e - test - deploy @@ -12,6 +13,8 @@ include: rails spec: stage: test + needs: + - job: build_image tags: - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA @@ -39,7 +42,9 @@ rails spec: expire_in: 30 days rails e2e spec: - stage: test + stage: e2e + needs: + - job: build_image tags: - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA @@ -90,7 +95,9 @@ rails e2e spec: - inv blackbox-service rails follow sync e2e spec: - stage: test + stage: e2e + needs: + - job: build_image tags: - docker image: $CI_REGISTRY_IMAGE/test/commits:$CI_COMMIT_SHA