131 lines
3.5 KiB
Ruby
131 lines
3.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class DiscoursePoll::RankedChoice
|
|
MAX_ROUNDS = 50
|
|
|
|
def self.outcome(poll_id)
|
|
options = PollOption.where(poll_id: poll_id).map { |hash| { id: hash.digest, html: hash.html } }
|
|
|
|
ballot = []
|
|
|
|
#Fetch all votes for the poll in a single query
|
|
votes =
|
|
PollVote
|
|
.where(poll_id: poll_id)
|
|
.select(:user_id, :poll_option_id, :rank)
|
|
.order(:user_id, :rank)
|
|
.includes(:poll_option) # Eager load poll options
|
|
# Group votes by user_id
|
|
votes_by_user = votes.group_by(&:user_id)
|
|
# Build the ballot
|
|
votes_by_user.each do |user_id, user_votes|
|
|
ballot_paper =
|
|
user_votes.select { |vote| vote.rank > 0 }.map { |vote| vote.poll_option.digest }
|
|
ballot << ballot_paper
|
|
end
|
|
|
|
DiscoursePoll::RankedChoice.run(ballot, options) if ballot.length > 0
|
|
end
|
|
|
|
def self.run(starting_votes, options)
|
|
current_votes = starting_votes
|
|
round_activity = []
|
|
potential_winners = []
|
|
round = 0
|
|
while round < MAX_ROUNDS
|
|
round += 1
|
|
|
|
# Count the first place votes for each candidate
|
|
tally = tally_votes(current_votes)
|
|
|
|
max_votes = tally.values.max
|
|
|
|
# Find the candidate(s) with the most votes
|
|
potential_winners = find_potential_winners(tally, max_votes)
|
|
|
|
# Check for a majority and return if found
|
|
if majority_check(tally, potential_winners, max_votes)
|
|
majority_candidate = enrich(potential_winners.keys.first, options)
|
|
|
|
round_activity << { round: round, majority: majority_candidate, eliminated: nil }
|
|
|
|
return(
|
|
{
|
|
tied: false,
|
|
tied_candidates: nil,
|
|
winner: true,
|
|
winning_candidate: majority_candidate,
|
|
round_activity: round_activity,
|
|
}
|
|
)
|
|
end
|
|
|
|
# Find the candidate(s) with the least votes
|
|
losers = identify_losers(tally)
|
|
|
|
# Remove the candidate with the least votes
|
|
current_votes.each { |vote| vote.reject! { |candidate| losers.include?(candidate) } }
|
|
|
|
losers = losers.map { |loser| enrich(loser, options) }
|
|
|
|
round_activity << { round: round, majority: nil, eliminated: losers }
|
|
|
|
all_empty = current_votes.all? { |arr| arr.empty? }
|
|
|
|
if all_empty
|
|
return(
|
|
{
|
|
tied: true,
|
|
tied_candidates: losers,
|
|
winner: nil,
|
|
winning_candidate: nil,
|
|
round_activity: round_activity,
|
|
}
|
|
)
|
|
end
|
|
end
|
|
|
|
potential_winners =
|
|
potential_winners.keys.map { |potential_winner| enrich(potential_winner, options) }
|
|
|
|
{
|
|
tied: true,
|
|
tied_candidates: potential_winners,
|
|
winner: nil,
|
|
winning_candidate: nil,
|
|
round_activity: round_activity,
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def self.tally_votes(current_votes)
|
|
tally = Hash.new(0)
|
|
current_votes.each do |vote|
|
|
vote.each { |candidate| tally[candidate] = 0 unless tally.has_key?(candidate) }
|
|
end
|
|
current_votes.each { |vote| tally[vote.first] += 1 if vote.first }
|
|
tally
|
|
end
|
|
|
|
def self.find_potential_winners(tally, max_votes)
|
|
tally.select { |k, v| v == max_votes }
|
|
end
|
|
|
|
def self.majority_check(tally, potential_winners, max_votes)
|
|
total_votes = tally.values.sum
|
|
|
|
max_votes > total_votes / 2 || potential_winners.count == 1
|
|
end
|
|
|
|
def self.identify_losers(tally)
|
|
min_votes = tally.values.min
|
|
|
|
tally.select { |k, v| v == min_votes }.keys
|
|
end
|
|
|
|
def self.enrich(digest, options)
|
|
{ digest: digest, html: options.find { |option| option[:id] == digest }[:html] }
|
|
end
|
|
end
|