Merge pull request #38 from turniere/ticket/TURNIERE-190

Implement stopping of matches
This commit is contained in:
Daniel Schädler 2019-05-27 14:38:45 +02:00 committed by GitHub
commit e281b1b23f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 433 additions and 126 deletions

View File

@ -5,9 +5,9 @@ addons:
env:
- RAILS_ENV=test
script:
- sonar-scanner
- bundle exec rails db:migrate
- bundle exec rails spec
- sonar-scanner
notifications:
slack:
secure: EDlQKAXSltE2d4vdOtwVdFhPScjFU2rsSSgAGSqV24br4Jb/KihpjracavZW5wnmloiWe0ulj19j7LOtJSCNJOGqeAnct+axNyBRTI+9ctpeBDMHHtiOH9IX2EBsnEBpHdL4gMgOrPFfMoyn+sqbZ7EJgOFU41f/c7X0XUf1QeJ02Gh/uY1+m8Qo0eT9x4u8W+wnCFYCQeTWOB9/4aemkgbELOEDCbLYr5n+HCGK1vi+glmYoyldVr2yQBnbfME2fcNSOb7ytPDzjBI00cdGVhj8e/AMsF84W+Q+U3RIF0zjestQeFp3lPtTcHDt/MRH39MV1fjRaZB4A8+QYrjuECJ6wjzvzXJbGWUjE++6OmbRmszPlkFxXDiiiAe/Vs1NzUr4i7c2aWZhq8Q/6HDwYXx+/OUJY3THpCHjel/PC49s+KZqMrmq53nd6NWSCtZSPCXN/1uqb3m/zUq7i4wSNFirN+9E8reYkEq6GrpG1VwZkpKp9SkjWnd88cgM0JQEpC/dxRrmeI3o+uPRSIXV+RIaGCXIAdWO7eWBIJdpVQNrA4GDjWc+zj0X02qgbn6d6iByFCDtXzB+ognZwmKUnpJ4tF3oh5xv7j6cFw/GNirgThTLwEoXMfC/Q9OmhlYByOsZ+PBApsj0hfs74YXfN753eCglmtOKGqkpRT6kwG8=

View File

@ -13,12 +13,14 @@ class MatchesController < ApplicationController
# PATCH/PUT /matches/1
def update
new_state = match_params['state']
if new_state == 'finished'
# implement logic to move the winning team into the next stage
match_params['state'] = 'team1_won' # or 'team2_won' or 'undecided'
render json: {}, status: :not_implemented
end
if @match.update(match_params)
if new_state == 'finished'
result = PopulateMatchBelowAndSave.call(match: @match) unless @match.group_match?
unless result.success?
render json: { error: 'Moving Team one stage down failed' }, status: :unprocessable_entity
return
end
end
render json: @match
else
render json: @match.errors, status: :unprocessable_entity

View File

@ -38,14 +38,14 @@ class TournamentsController < ApplicationController
if group_stage
groups = organize_teams_in_groups(teams)
# add groups to tournament
result = AddGroupStageToTournamentAndSaveTournamentToDatabase.call(tournament: tournament, groups: groups)
result = AddGroupStageToTournamentAndSave.call(tournament: tournament, groups: groups)
else
# convert teams parameter into Team objects
teams = teams.map(&method(:find_or_create_team))
# associate provided teams with tournament
tournament.teams = teams
# add playoff stage to tournament
result = AddPlayoffsToTournamentAndSaveTournamentToDatabase.call(tournament: tournament)
result = AddPlayoffsToTournamentAndSave.call(tournament: tournament)
end
# validate tournament
unless tournament.valid?

View File

@ -10,7 +10,7 @@ class AddGroupStageToTournament
begin
group_stage = GroupStageService.generate_group_stage(groups)
tournament.stages = [group_stage]
context.tournament = tournament
context.object_to_save = tournament
rescue StandardError
context.fail!
end

View File

@ -12,7 +12,7 @@ class AddPlayoffsToTournament
else
tournament.stages.concat playoff_stages
end
context.tournament = tournament
context.object_to_save = tournament
else
context.fail!
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class PopulateMatchBelow
include Interactor
def call
match = context.match
begin
objects_to_save = PlayoffStageService.populate_match_below(match)
context.object_to_save = objects_to_save
rescue StandardError
context.fail!
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class SaveApplicationRecordObject
include Interactor
def call
Array(context.object_to_save).flatten.each do |object|
context.fail! unless object.save
end
end
end

View File

@ -1,13 +0,0 @@
# frozen_string_literal: true
class SaveTournamentToDatabase
include Interactor
def call
if context.tournament.save
nil
else
context.fail!
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Match < ApplicationRecord
enum state: %i[single_team not_ready not_started in_progress team1_won team2_won undecided]
enum state: %i[single_team not_ready not_started in_progress finished undecided]
belongs_to :stage, optional: true
belongs_to :group, optional: true
@ -19,23 +19,20 @@ class Match < ApplicationRecord
stage ? stage.owner : group.owner
end
private
def winner
return nil unless finished?
return nil if match_scores.first.points == match_scores.second.points
def stage_xor_group
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
end
def evaluate_status
if score_team1 < score_team2
:team2_won
elsif score_team2 < score_team1
:team1_won
else
group_match? ? :undecided : :in_progress
end
match_scores.max_by(&:points).team
end
def group_match?
group.present?
end
private
def stage_xor_group
errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present?
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddGroupStageToTournamentAndSave
include Interactor::Organizer
organize AddGroupStageToTournament, SaveApplicationRecordObject
end

View File

@ -1,7 +0,0 @@
# frozen_string_literal: true
class AddGroupStageToTournamentAndSaveTournamentToDatabase
include Interactor::Organizer
organize AddGroupStageToTournament, SaveTournamentToDatabase
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddPlayoffsToTournamentAndSave
include Interactor::Organizer
organize AddPlayoffsToTournament, SaveApplicationRecordObject
end

View File

@ -1,7 +0,0 @@
# frozen_string_literal: true
class AddPlayoffsToTournamentAndSaveTournamentToDatabase
include Interactor::Organizer
organize AddPlayoffsToTournament, SaveTournamentToDatabase
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class PopulateMatchBelowAndSave
include Interactor::Organizer
organize PopulateMatchBelow, SaveApplicationRecordObject
end

View File

@ -1,73 +1,149 @@
# frozen_string_literal: true
class PlayoffStageService
# Generates the playoff stage given the tournament
#
# @param teams [Array] The teams to generate the playoff stages with
# @return [Array] the generated playoff stages
def self.generate_playoff_stages(teams)
playoffs = []
stage_count = calculate_required_stage_count(teams.size)
# initial_matches are the matches in the first stage; this is the only stage filled with teams from the start on
initial_matches = MatchService.generate_matches(teams)
initial_stage = Stage.new level: stage_count - 1, matches: initial_matches
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.each do |stage|
playoffs << stage
class << self
# Generates the playoff stage given the tournament
#
# @param teams [Array] The teams to generate the playoff stages with
# @return [Array] the generated playoff stages
def generate_playoff_stages(teams)
playoffs = []
stage_count = calculate_required_stage_count(teams.size)
# initial_matches are the matches in the first stage; this is the only stage filled with teams from the start on
initial_matches = MatchService.generate_matches(teams)
initial_stage = Stage.new level: stage_count - 1, matches: initial_matches
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.each do |stage|
playoffs << stage
end
playoffs
end
playoffs
end
# Generates the playoff stage given the tournament
#
# @param tournament [Tournament] The tournament to generate the playoff stages from
# @return [Array] the generated playoff stages
def self.generate_playoff_stages_from_tournament(tournament)
generate_playoff_stages tournament.teams
end
# Generates given number of empty stages
#
# @param stage_count [Integer] number of stages to generate
# @return [Array] the generated stages
def self.generate_stages_with_empty_matches(stage_count)
empty_stages = []
stage_count.times do |i|
stage = Stage.new level: i, matches: generate_empty_matches(2**i)
empty_stages << stage
# Generates the playoff stage given the tournament
#
# @param tournament [Tournament] The tournament to generate the playoff stages from
# @return [Array] the generated playoff stages
def generate_playoff_stages_from_tournament(tournament)
generate_playoff_stages tournament.teams
end
# as we are generating the stages in the wrong order (starting with the lowest number of matches (which is
# the final stage)) they need to be reversed
empty_stages.reverse!
end
# Generates a number of empty matches to fill later stages
#
# @param amount [Integer] the amount of matches to generate
# @return [Array] the generated matches
def self.generate_empty_matches(amount)
matches = []
amount.times do |i|
match = Match.new state: :not_ready, position: i
matches << match
# Generates given number of empty stages
#
# @param stage_count [Integer] number of stages to generate
# @return [Array] the generated stages
def generate_stages_with_empty_matches(stage_count)
empty_stages = []
stage_count.times do |i|
stage = Stage.new level: i, matches: generate_empty_matches(2**i)
empty_stages << stage
end
# as we are generating the stages in the wrong order (starting with the lowest number of matches (which is
# the final stage)) they need to be reversed
empty_stages.reverse!
end
matches
end
# Calculates how many stages are required for given number of teams
#
# @param number_of_teams [Integer] the teams number of teams to calculate amount of stages
# @return [Integer] amount of required stages
def self.calculate_required_stage_count(number_of_teams)
if number_of_teams == 1
1
else
# black voodoo magic
stage_count = Math.log(Utils.next_power_of_two(number_of_teams)) / Math.log(2)
stage_count -= 1 if Utils.po2?(number_of_teams)
stage_count.to_int
# Generates a number of empty matches to fill later stages
#
# @param amount [Integer] the amount of matches to generate
# @return [Array] the generated matches
def generate_empty_matches(amount)
matches = []
amount.times do |i|
match = Match.new state: :not_ready, position: i
matches << match
end
matches
end
# Calculates how many stages are required for given number of teams
#
# @param number_of_teams [Integer] the teams number of teams to calculate amount of stages
# @return [Integer] amount of required stages
def calculate_required_stage_count(number_of_teams)
if number_of_teams == 1
1
else
# black voodoo magic
stage_count = Math.log(Utils.next_power_of_two(number_of_teams)) / Math.log(2)
stage_count -= 1 if Utils.po2?(number_of_teams)
stage_count.to_int
end
end
# Populates the match below given match with the winners of the matches above
#
# @param current_match [Match] The Match which finished, the match below it gets populated
# @return [Array] the objects that changed and need to be saved
def populate_match_below(current_match)
current_stage = current_match.stage
next_stage = current_stage.tournament.stages.find { |s| s.level == current_stage.level - 1 }
# 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)
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
end
private
def find_companion_match(current_position, current_stage)
companion_match_position = current_position.even? ? current_position + 1 : current_position - 1
current_stage.matches.find { |m| m.position == companion_match_position }
end
def assign_correct_match_scores!(match_scores, winners)
case match_scores.size
when 0
# when 0 match_scores are already there we create both of them with the respective winner from above
match_scores = winners.map { |winner| MatchScore.new(team: winner) }
when 1
# when 1 match_score is present, we need to check which team is contained within and add the other team as well
if match_scores.first.team == winners.first
match_scores.push MatchScore.new(team: winners.second)
elsif match_scores.first.team == winners.second
match_scores.push MatchScore.new(team: winners.first)
else
match_scores.first.destroy
match_scores = winners.map { |winner| MatchScore.new(team: winner) }
end
when 2
# when 2 match_scores are present, the teams just get overwritten
match_scores.first.team = winners.first
match_scores.second.team = winners.second
end
match_scores
end
def get_winners_of(companion_match, current_match)
matches = [current_match, companion_match].sort_by(&:position)
matches.map(&:winner)
end
end
end

View File

@ -5,6 +5,9 @@ require 'rails_helper'
RSpec.describe MatchesController, type: :controller do
before do
@match = create(:match, state: :not_started)
@amount_of_stages = 2
@tournament = create(:stage_tournament, stage_count: @amount_of_stages)
@running_playoff_match = @tournament.stages.find_by(level: @amount_of_stages).matches.first
@match.match_scores = create_pair(:match_score)
end
@ -32,7 +35,7 @@ RSpec.describe MatchesController, type: :controller do
let(:invalid_update) do
{
state: 'team1_won'
state: 'finished'
}
end
@ -55,6 +58,48 @@ RSpec.describe MatchesController, type: :controller do
body = deserialize_response response
expect(body[:state]).to eq(valid_update[:state])
end
context 'on a running playoff match' do
let(:finished) do
{
state: 'finished'
}
end
before(:each) do
apply_authentication_headers_for @running_playoff_match.owner
end
before do
@running_playoff_match.match_scores.each_with_index do |ms, i|
ms.points = i
ms.save!
end
put :update, params: { id: @running_playoff_match.to_param }.merge(finished)
@running_playoff_match.reload
end
it 'updates the matches status' do
expect(response).to be_successful
expect(@running_playoff_match.state).to eq(finished[:state])
end
describe 'updates the match below' do
before do
@match_below = @tournament.stages.find_by(level: @amount_of_stages - 1).matches
.find_by(position: @running_playoff_match.position / 2).reload
end
it 'with the right teams' do
expect(@running_playoff_match.winner).to be_a(Team)
expect(@match_below.teams).to include(@running_playoff_match.winner)
end
it 'with the right status' do
expect(@match_below.state).to eq('not_ready')
end
end
end
end
context 'with invalid params' do

View File

@ -27,7 +27,7 @@ FactoryBot.define do
# random number generated by blapplications
match.match_scores.first.points += 1
end
state { :team1_won }
state { :finished }
end
end
@ -51,7 +51,7 @@ FactoryBot.define do
after(:create) do |match, evaluator|
match.match_scores = create_list(:match_score, evaluator.match_scores_count, points: 3)
end
state { :team1_won }
state { :finished }
end
end
end

View File

@ -32,8 +32,14 @@ FactoryBot.define do
:playoff_stage,
level: level,
match_count: -1,
match_type: evaluator.stage_count ? :running_playoff_match : :empty_prepared_playoff_match
match_type: level == evaluator.stage_count ? :running_playoff_match : :empty_prepared_playoff_match
)
tournament.stages.each do |stage|
stage.matches.each_with_index do |match, i|
match.position = i
match.save!
end
end
end
end

View File

@ -10,8 +10,8 @@ RSpec.describe AddGroupStageToTournament do
end
before do
@empty_tournament = create(:stage_tournament, stage_count: 0)
@group_stage_tournament = create(:group_stage_tournament)
@empty_tournament = create(:stageless_tournament)
@group_stage_tournament = create(:group_stage_only_tournament, group_count: 0)
@group_stage = create(:group_stage)
@groups = Hash[1 => create_list(:team, 4), 2 => create_list(:team, 4)].values
end

View File

@ -14,10 +14,10 @@ RSpec.describe AddPlayoffsToTournament do
end
before do
@group_stage_tournament = create(:stage_tournament)
@playoff_stage_tournament = create(:tournament)
@full_tournament = create(:stage_tournament, stage_count: 5)
@stages = create_list(:stage, 5)
@group_stage_tournament = create(:group_stage_only_tournament, group_count: 0)
@playoff_stage_tournament = create(:stageless_tournament)
@full_tournament = create(:dummy_stage_tournament)
@stages = create_list(:stage, 3)
end
context 'PlayoffStageService mocked' do

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
RSpec.describe PopulateMatchBelow do
before do
@match = create(:match)
@objects_to_save = [create(:match), create_list(:match_score, 2)]
end
context 'no exception' do
let(:context) do
PopulateMatchBelow.call(match: @match)
end
before do
allow(PlayoffStageService)
.to receive(:populate_match_below).with(@match)
.and_return(@objects_to_save)
end
it 'succeeds' do
expect(context).to be_a_success
end
it 'provides the objects to save' do
expect(context.object_to_save).to match_array(@objects_to_save)
end
end
context 'exception is thrown' do
let(:context) do
PopulateMatchBelow.call(match: @match)
end
before do
allow(PlayoffStageService)
.to receive(:populate_match_below).with(@match)
.and_throw('This failed :(')
end
it 'fails' do
test = context.failure?
expect(test).to eq(true)
end
end
end

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
RSpec.describe SaveTournamentToDatabase do
RSpec.describe SaveApplicationRecordObject do
before do
@tournament = create(:tournament)
end
context 'save succeeds' do
let(:context) do
SaveTournamentToDatabase.call(tournament: @tournament)
SaveApplicationRecordObject.call(object_to_save: @tournament)
end
before do
expect_any_instance_of(Tournament)
@ -19,13 +19,13 @@ RSpec.describe SaveTournamentToDatabase do
end
it 'provides the tournament' do
expect(context.tournament).to eq(@tournament)
expect(context.object_to_save).to eq(@tournament)
end
end
context 'save fails' do
let(:context) do
SaveTournamentToDatabase.call(tournament: @tournament)
SaveApplicationRecordObject.call(object_to_save: @tournament)
end
before do
expect_any_instance_of(Tournament)

View File

@ -43,6 +43,20 @@ RSpec.describe Match, type: :model do
end
end
context '#winner' do
it 'returns a winner Team for a decided match' do
decided_playoff_match = create(:decided_playoff_match)
winning_team_match_score = decided_playoff_match.match_scores.first
winning_team_match_score.points = 9999
winning_team = winning_team_match_score.team
expect(decided_playoff_match.winner).to be winning_team
end
it 'returns nil for an undecided match' do
expect(create(:undecided_group_match).winner).to be(nil)
end
end
context '#teams' do
before do
@playoff_match = create(:running_playoff_match)

View File

@ -80,4 +80,108 @@ RSpec.describe PlayoffStageService do
end
end
end
describe '#populate_match_below' do
before :each do
@tournament = create(:stage_tournament, stage_count: 2)
@match = @tournament.stages.find { |s| s.level == 2 }.matches.first
@match.state = :finished
@match.match_scores.each_with_index do |ms, i|
ms.points = i
ms.save
end
@match.save
@companion_match = @tournament.stages.find { |s| s.level == 2 }.matches.second
@companion_match.match_scores.each_with_index do |ms, i|
ms.points = i
ms.save
end
@match_to_find = @tournament.stages.find { |s| s.level == 1 }.matches.first
end
context 'match below has no match_scores' do
before do
@match_to_find.match_scores = []
@match_to_find.save
@test = PlayoffStageService.populate_match_below(@match).first
end
it 'finds the correct match and adds two new match_scores to it' do
expect(@match_to_find.teams).to match_array(@match.winner)
end
it 'finds the correct match and changes its state' do
expect(@match_to_find.state).to eq('not_ready')
end
end
context 'match below has one match_score with the winning team' do
before do
@match_to_find.match_scores = create_list(:match_score, 1, team: @match.winner)
@match_to_find.save
@test = PlayoffStageService.populate_match_below(@match).first
end
it 'finds the correct match and adds no match_score' do
expect(@test.teams).to match_array(@match.winner)
end
it 'finds the correct match and changes its state' do
expect(@test.state).to eq('not_ready')
end
end
context 'match below has one match_score with an unknown team' do
before do
@match_to_find.match_scores = create_list(:match_score, 1, team: create(:team), points: 1337)
@match_to_find.save
@test = PlayoffStageService.populate_match_below(@match).first
end
it 'finds the correct match and replaces the match_score' do
expect(@test.teams).to match_array(@match.winner)
expect(@test.match_scores.first.points).to_not be(1337)
end
it 'finds the correct match and changes its state' do
expect(@test.state).to eq('not_ready')
end
end
context 'match below has one match_score with the correct team' do
before do
@match_to_find.match_scores = create_list(:match_score, 1, team: @match.winner, points: 42)
@match_to_find.save
@test = PlayoffStageService.populate_match_below(@match).first
end
it 'finds the correct match and replaces nothing' do
expect(@test.teams).to match_array(@match.winner)
expect(@test.match_scores.first.points).to be(42)
end
it 'finds the correct match and changes its state' do
expect(@test.state).to eq('not_ready')
end
end
context 'match below has two match_scores with the correct teams' do
before do
@companion_match.state = :finished
@companion_match.save
@match_to_find.match_scores = [create(:match_score, team: @match.winner),
create(:match_score, team: @companion_match.winner)]
@match_to_find.save
@test = PlayoffStageService.populate_match_below(@match).first
end
it 'finds the correct match and replaces nothing' do
expect(@test.teams).to match_array([@match.winner, @companion_match.winner])
end
it 'finds the correct match and changes its state' do
expect(@test.state).to eq('not_started')
end
end
end
end