Merge remote-tracking branch 'origin/master' into codex/tur-166-match-stopping

This commit is contained in:
Daniel Schädler 2026-04-29 19:56:40 +02:00
commit 650631d3b3
3 changed files with 142 additions and 52 deletions

View File

@ -3,17 +3,11 @@
class TournamentGroupMatchStarter
class << self
def upcoming_matches(tournament)
group_stage = tournament.group_stage
return [] if group_stage.nil?
next_matches = group_stage.groups.filter_map do |group|
group.matches.not_started.order(:position).first
end
lowest_position = next_matches.map(&:position).min
group_matches = not_started_group_matches(tournament)
lowest_position = group_matches.minimum(:position)
return [] if lowest_position.nil?
next_matches.select { |match| match.position == lowest_position }
.sort_by { |match| [match.position, match.group.number, match.id] }
selected_lowest_matches(group_matches, lowest_position)
end
def start_next!(tournament, logger: Rails.logger)
@ -30,6 +24,25 @@ class TournamentGroupMatchStarter
private
def not_started_group_matches(tournament)
group_stage = tournament.group_stage
return Match.none if group_stage.nil?
group_ids = group_stage.groups.ids
return Match.none if group_ids.empty?
Match.where(group_id: group_ids).not_started
end
def selected_lowest_matches(group_matches, lowest_position)
group_matches.where(position: lowest_position)
.includes(:group)
.group_by(&:group_id)
.values
.map { |matches| matches.min_by(&:id) }
.sort_by { |match| [match.position, match.group.number, match.id] }
end
def recalculate_group_scores!(matches, logger:)
matches.map(&:group).uniq.each do |group|
next if UpdateGroupsGroupScoresAndSave.call(group:).success?

View File

@ -424,43 +424,54 @@ RSpec.describe 'Backend API surface HTTP E2E' do
.to eq([first_match.fetch(:position)])
end
it 'starts next group matches at the lowest pending position and returns started matches' do
it 'starts only a single lagging group before all groups can advance over HTTP' do
tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Next Groups')
groups = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
.fetch(:groups)
.sort_by { |group| group.fetch(:number) }
matches_by_group = groups.to_h do |group|
[group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }]
end
groups = group_stage_groups(tournament)
matches_by_group = indexed_group_matches(groups)
start_group_positions!(matches_by_group:, group: groups[0], positions: [0, 1])
start_group_positions!(matches_by_group:, group: groups[1], positions: [0])
start_group_positions!(matches_by_group:, group: groups[2], positions: [0, 1])
start_group_positions!(matches_by_group:, group: groups[3], positions: [0, 1])
first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
expect(first_start[:status]).to eq(200)
expect(first_start[:json].map { |match| match.dig(:group, :id) })
.to match_array(groups.map { |group| group.fetch(:id) })
expect(first_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([0])
matches_by_group.fetch(groups.first.fetch(:id))[1..3].each do |match|
start_group_match!(match_id: match.fetch(:id))
end
groups.drop(1).each do |group|
matches_by_group.fetch(group.fetch(:id))[1..2].each do |match|
start_group_match!(match_id: match.fetch(:id))
end
end
expected_match_ids = groups.drop(1).map do |group|
matches_by_group.fetch(group.fetch(:id)).fetch(3).fetch(:id)
end
first_expected_ids = match_ids_for_positions(matches_by_group:, pairs: [[groups[1], 1]])
second_expected_ids = match_ids_for_positions(
matches_by_group:,
pairs: groups.map { |group| [group, 2] }
)
upcoming = owner_client.get("/tournaments/#{tournament.fetch(:id)}/matches", params: { state: 'upcoming' })
catch_up_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
second_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
expect(upcoming[:status]).to eq(200)
expect(upcoming[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids)
expect_match_ids(upcoming, first_expected_ids)
expect_started_match_ids(first_start, first_expected_ids)
expect_started_match_ids(second_start, second_expected_ids)
end
expect(catch_up_start[:status]).to eq(200)
expect(catch_up_start[:json].map { |match| match.fetch(:id) }).to match_array(expected_match_ids)
expect(catch_up_start[:json].map { |match| match.fetch(:position) }.uniq).to eq([3])
expect(catch_up_start[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress'])
it 'starts catch-up waves before the next position is started everywhere over HTTP' do
tournament = create_group_stage_tournament(client: owner_client, public: false, name_prefix: 'Start Catch Up Waves')
groups = group_stage_groups(tournament)
matches_by_group = indexed_group_matches(groups)
start_group_positions!(matches_by_group:, group: groups[0], positions: [0, 1, 2])
start_group_positions!(matches_by_group:, group: groups[1], positions: [0])
start_group_positions!(matches_by_group:, group: groups[2], positions: [0, 1, 2])
start_group_positions!(matches_by_group:, group: groups[3], positions: [0, 1])
first_expected_ids = match_ids_for_positions(matches_by_group:, pairs: [[groups[1], 1]])
second_expected_ids = match_ids_for_positions(
matches_by_group:,
pairs: [[groups[1], 2], [groups[3], 2]]
)
third_expected_ids = match_ids_for_positions(
matches_by_group:,
pairs: groups.map { |group| [group, 3] }
)
first_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
second_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
third_start = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/start_next_group_matches")
expect_started_match_ids(first_start, first_expected_ids)
expect_started_match_ids(second_start, second_expected_ids)
expect_started_match_ids(third_start, third_expected_ids)
end
def login_client(email:, password:)
@ -517,6 +528,40 @@ RSpec.describe 'Backend API surface HTTP E2E' do
fetch_tournament(client: client, tournament_id: response.dig(:json, :id))
end
def group_stage_groups(tournament)
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
.fetch(:groups)
.sort_by { |group| group.fetch(:number) }
end
def indexed_group_matches(groups)
groups.to_h do |group|
[group.fetch(:id), group.fetch(:matches).sort_by { |match| match.fetch(:position) }]
end
end
def start_group_positions!(matches_by_group:, group:, positions:)
positions.each do |position|
start_group_match!(match_id: matches_by_group.fetch(group.fetch(:id)).fetch(position).fetch(:id))
end
end
def match_ids_for_positions(matches_by_group:, pairs:)
pairs.map do |group, position|
matches_by_group.fetch(group.fetch(:id)).fetch(position).fetch(:id)
end
end
def expect_match_ids(response, expected_ids)
expect(response[:status]).to eq(200)
expect(response[:json].map { |match| match.fetch(:id) }).to match_array(expected_ids)
end
def expect_started_match_ids(response, expected_ids)
expect_match_ids(response, expected_ids)
expect(response[:json].map { |match| match.fetch(:state) }.uniq).to eq(['in_progress'])
end
def create_playoff_tournament(client:, public:, name_prefix:)
payload = {
name: "#{name_prefix} #{unique_suffix}",

View File

@ -7,29 +7,50 @@ RSpec.describe TournamentGroupMatchStarter do
let(:groups) { tournament.group_stage.groups.sort_by(&:number) }
before do
set_group_match_states(groups[0], %i[finished finished finished in_progress])
groups.drop(1).each do |group|
set_group_match_states(group, %i[finished finished in_progress not_started])
end
groups.each { |group| set_group_match_states(group, Array.new(4, :not_started)) }
end
describe '.upcoming_matches' do
it 'returns only groups whose next unstarted match is at the lowest pending position' do
start_group_positions(groups[0], [0, 1, 2])
start_group_positions(groups[1], [0, 1])
start_group_positions(groups[2], [0, 1])
start_group_positions(groups[3], [0, 1])
matches = described_class.upcoming_matches(tournament)
expect(matches.map(&:group)).to match_array(groups.drop(1))
expect(matches.map(&:position).uniq).to eq([3])
expect(matches.map(&:position).uniq).to eq([2])
end
end
describe '.start_next!' do
it 'starts selected catch-up matches and returns them' do
started_matches = described_class.start_next!(tournament, logger: Logger.new(nil))
it 'starts only a single lagging group before all groups can advance' do
start_group_positions(groups[0], [0, 1])
start_group_positions(groups[1], [0])
start_group_positions(groups[2], [0, 1])
start_group_positions(groups[3], [0, 1])
expect(started_matches.map(&:group)).to match_array(groups.drop(1))
expect(started_matches.map(&:position).uniq).to eq([3])
expect(started_matches.map { |match| match.reload.state }.uniq).to eq(['in_progress'])
expect(groups.first.matches.find_by!(position: 3).reload).to be_in_progress
first_start = described_class.start_next!(tournament, logger: Logger.new(nil))
second_start = described_class.start_next!(tournament, logger: Logger.new(nil))
expect_group_positions(first_start, [[groups[1], 1]])
expect_group_positions(second_start, groups.map { |group| [group, 2] })
end
it 'starts catch-up waves before the next position is started everywhere' do
start_group_positions(groups[0], [0, 1, 2])
start_group_positions(groups[1], [0])
start_group_positions(groups[2], [0, 1, 2])
start_group_positions(groups[3], [0, 1])
first_start = described_class.start_next!(tournament, logger: Logger.new(nil))
second_start = described_class.start_next!(tournament, logger: Logger.new(nil))
third_start = described_class.start_next!(tournament, logger: Logger.new(nil))
expect_group_positions(first_start, [[groups[1], 1]])
expect_group_positions(second_start, [[groups[1], 2], [groups[3], 2]])
expect_group_positions(third_start, groups.map { |group| [group, 3] })
end
end
@ -38,4 +59,15 @@ RSpec.describe TournamentGroupMatchStarter do
match.update!(position: index, state: states.fetch(index))
end
end
def start_group_positions(group, positions)
positions.each do |position|
group.matches.find_by!(position:).update!(state: :in_progress)
end
end
def expect_group_positions(matches, expected_pairs)
expect(matches.map { |match| [match.group, match.position] }).to match_array(expected_pairs)
expect(matches.map { |match| match.reload.state }.uniq).to eq(['in_progress'])
end
end