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

View File

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

View File

@ -224,12 +224,11 @@ class GroupStageService
nil nil
end 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? return false if advancing_slots.zero?
cluster = tie_cluster_for(sorted_group_scores, group_score) cluster = tie_cluster_for(sorted_group_scores, group_score)
occupied_slots = (cluster[:start_index] + 1)..(cluster[:end_index] + 1) cluster[:start_index] < advancing_slots
occupied_slots.begin <= advancing_slots && occupied_slots.end > advancing_slots
end end
def tie_cluster_for(sorted_group_scores, group_score) def tie_cluster_for(sorted_group_scores, group_score)
@ -298,7 +297,7 @@ class GroupStageService
tied: comparison_reason[:tied], tied: comparison_reason[:tied],
needs_decider_match: group_ready_for_decider_check && needs_decider_match: group_ready_for_decider_check &&
comparison_reason[:tied] && 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 end

View File

@ -16,7 +16,7 @@ class PlayoffStageService
initial_stage.state = :intermediate_stage unless initial_stage.matches.find(&:single_team?).nil? initial_stage.state = :intermediate_stage unless initial_stage.matches.find(&:single_team?).nil?
playoffs << initial_stage playoffs << initial_stage
# empty stages are the stages, the tournament is filled with to have the matches ready for later # 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.concat empty_stages
playoffs playoffs
end end
@ -25,10 +25,12 @@ class PlayoffStageService
# #
# @param stage_count [Integer] number of stages to generate # @param stage_count [Integer] number of stages to generate
# @return [Array] the generated stages # @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 = [] empty_stages = []
stage_count.times do |i| 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 empty_stages << stage
end end
# as we are generating the stages in the wrong order (starting with the lowest number of matches (which is # 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 does not exist (there are no matches after the finale)
return [] if next_stage.nil? 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 # 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 # 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 # 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) 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)
match_below = next_stage.matches.find { |m| m.position == current_position / 2 }
match_scores = match_below.match_scores.sort_by(&:id)
winners = get_winners_of(companion_match, current_match) winners = get_winners_of(companion_match, current_match)
populate_match!(match: next_stage.matches.find { |m| m.position == current_match.position / 2 }, teams: winners)
# 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
end end
private private
@ -135,9 +118,43 @@ class PlayoffStageService
match_scores match_scores
end 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) def get_winners_of(companion_match, current_match)
matches = [current_match, companion_match].sort_by(&:position) matches = [current_match, companion_match].sort_by(&:position)
matches.map(&:winner) matches.map(&:winner)
end 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
end end

View File

@ -64,8 +64,7 @@ module TurniereE2E
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) 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 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 = finish_final_stage_matches!(tournament)
finish_attempt = play_match!(final_match, winner_name: preferred_playoff_winner_name(final_match))
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = { checkpoints[:final_finish_attempted] = {
finish_attempt: finish_attempt, finish_attempt: finish_attempt,
@ -97,8 +96,7 @@ module TurniereE2E
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament) 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 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 = finish_final_stage_matches!(tournament)
finish_attempt = play_match!(final_match, winner_name: preferred_playoff_winner_name(final_match))
tournament = fetch_tournament!(tournament.fetch(:id)) tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = { checkpoints[:final_finish_attempted] = {
finish_attempt: finish_attempt, finish_attempt: finish_attempt,
@ -340,9 +338,24 @@ module TurniereE2E
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found') tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
end 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') 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 end
def playoff_matches(tournament) 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]) expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B])
end end
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
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(tournament_signature(follower)).to eq(tournament_signature(source))
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty 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') log_phase!('tighten playoff cutoff and rename teams')
update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: { update_cutoff = source_owner_client.patch("/tournaments/#{source.fetch(:id)}", body: {
playoff_teams_amount: 4, playoff_teams_amount: 4,
@ -427,6 +486,24 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
} }
end 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 def unique_suffix
@unique_suffix_counter ||= 0 @unique_suffix_counter ||= 0
@unique_suffix_counter += 1 @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) final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
expect(final_attempt[:status]).to eq(200) expect(final_attempt[:status]).to eq(200)
expect(result.dig(:checkpoints, :final_finish_attempted, :tournament, :stage_summaries) 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 end
it 'drives the 3-group intermediate-round path over HTTP through a finished final' do 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) final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
expect(final_attempt[:status]).to eq(200) expect(final_attempt[:status]).to eq(200)
expect(result.dig(:checkpoints, :final_finish_attempted, :tournament, :stage_summaries) 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 end
{ {
@ -126,7 +126,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
completed_stages = result.dig(:checkpoints, :completed, :stage_summaries) completed_stages = result.dig(:checkpoints, :completed, :stage_summaries)
final_stage = completed_stages.find { |stage| stage[:level] == 0 } 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 end
end end

View File

@ -268,7 +268,7 @@ RSpec.describe GroupStageService do
) )
end 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) 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(:group_match, group: group, state: :finished).tap do |match|
@ -300,6 +300,38 @@ RSpec.describe GroupStageService do
) )
end 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 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) 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 expect(stage.level).to eq stage_level
end end
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 end
describe 'number of teams isn\'t a power of two' do 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([]) expect(PlayoffStageService.populate_match_below(@final_match)).to eq([])
end end
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
end end

View File

@ -56,7 +56,7 @@ RSpec.describe TournamentSnapshotImporter do
expect(sync_signature(follower)).to eq(sync_signature(source)) expect(sync_signature(follower)).to eq(sync_signature(source))
expect(follower.teams.count).to eq(128) expect(follower.teams.count).to eq(128)
expect(follower.stages.where(level: -1).first.groups.count).to eq(32) 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.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(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)) expect(source.teams.pluck(:id) & follower.teams.pluck(:id)).not_to match_array(source.teams.pluck(:id))