Merge remote-tracking branch 'origin/master' into codex/buildx-multistage-multiarch

This commit is contained in:
Daniel Schädler 2026-04-23 13:39:19 +02:00
commit 4e04aabb12
11 changed files with 274 additions and 42 deletions

View File

@ -1,5 +1,6 @@
stages:
- build
- e2e
- test
- deploy
@ -68,6 +69,8 @@ build test image:
rails spec:
stage: test
needs:
- job: build_image
tags:
- docker
needs:
@ -97,7 +100,9 @@ rails spec:
expire_in: 30 days
rails e2e spec:
stage: test
stage: e2e
needs:
- job: build_image
tags:
- docker
needs:
@ -151,7 +156,9 @@ rails e2e spec:
- inv blackbox-service
rails follow sync e2e spec:
stage: test
stage: e2e
needs:
- job: build_image
tags:
- docker
needs:

View File

@ -39,6 +39,13 @@ class Match < ApplicationRecord
finished? ? current_leading_team : nil
end
def loser
winning_team = winner
return nil if winning_team.nil?
teams.find { |team| team != winning_team }
end
def group_match?
group.present?
end

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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))