Skip to content

Commit 884da23

Browse files
committed
Rewrite orderOptions to avoid hanging
Also write tests
1 parent a46dd0c commit 884da23

File tree

2 files changed

+143
-23
lines changed

2 files changed

+143
-23
lines changed

database/poll.go

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package database
22

33
import (
44
"context"
5+
"sort"
56
"time"
67

78
"github.com/sirupsen/logrus"
@@ -181,18 +182,35 @@ func GetClosedVotedPolls(ctx context.Context, userId string) ([]*Poll, error) {
181182
return polls, nil
182183
}
183184

184-
func calculateRankedResult(votesRaw []RankedVote) ([]map[string]int, error) {
185-
// We want to store those that were eliminated
185+
// calculateRankedResult determines a result for a ranked choice vote
186+
// votesRaw is the RankedVote entries that are returned directly from the database
187+
// The algorithm defined in the Constitution as of 26 Nov 2025 is as follows:
188+
//
189+
// > The winning option is selected outright if it gains more than half the votes
190+
// > cast as a first preference. If not, the option with the fewest number of first
191+
// > preference votes is eliminated and their votes move to the second preference
192+
// > marked on the ballots. This process continues until one option has half of the
193+
// > votes cast and is elected.
194+
//
195+
// The return value consists of a list of voting rounds. Each round contains a
196+
// mapping of the vote options to their vote share for that round. If the vote
197+
// is not decided in a given round, there will be a subsequent round with the
198+
// option that had the fewest votes eliminated, and its votes redistributed.
199+
//
200+
// The last entry in this list is the final round, and the option with the most
201+
// votes in this round is the winner. If all options have the same, then it is
202+
// unfortunately a tie, and the vote is not resolvable, as there is no lowest
203+
// option to eliminate.
204+
func calculateRankedResult(ctx context.Context, votesRaw []RankedVote) ([]map[string]int, error) {
205+
// We want to store those that were eliminated so we don't accidentally reinclude them
186206
eliminated := make([]string, 0)
187207
votes := make([][]string, 0)
188208
finalResult := make([]map[string]int, 0)
189209

190210
//change ranked votes from a map (which is unordered) to a slice of votes (which is ordered)
191211
//order is from first preference to last preference
192212
for _, vote := range votesRaw {
193-
temp, cf := context.WithTimeout(context.Background(), 1*time.Second)
194-
optionList := orderOptions(vote.Options, temp)
195-
cf()
213+
optionList := orderOptions(ctx, vote.Options)
196214
votes = append(votes, optionList)
197215
}
198216

@@ -321,7 +339,7 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) {
321339
}
322340
var votesRaw []RankedVote
323341
cursor.All(ctx, &votesRaw)
324-
return calculateRankedResult(votesRaw)
342+
return calculateRankedResult(ctx, votesRaw)
325343
}
326344
return nil, nil
327345
}
@@ -335,21 +353,35 @@ func containsValue(slice []string, value string) bool {
335353
return false
336354
}
337355

338-
func orderOptions(options map[string]int, ctx context.Context) []string {
339-
result := make([]string, 0, len(options))
340-
order := 1
341-
for order <= len(options) {
342-
for option, preference := range options {
343-
select {
344-
case <-ctx.Done():
345-
return make([]string, 0)
346-
default:
347-
if preference == order {
348-
result = append(result, option)
349-
order += 1
350-
}
351-
}
352-
}
356+
// orderOptions takes a RankedVote's options, and returns an ordered list of
357+
// their choices
358+
//
359+
// it's invalid for a vote to list the same number multiple times, the output
360+
// will vary based on the map ordering of the options, and so is not guaranteed
361+
// to be deterministic
362+
//
363+
// ctx is no longer used, as this function is not expected to hang, but remains
364+
// an argument per golang standards
365+
//
366+
// the return values is the option keys, ordered from lowest to highest
367+
func orderOptions(ctx context.Context, options map[string]int) []string {
368+
// Figure out all the ranks they've listed
369+
var ranks []int = make([]int, len(options))
370+
reverse_map := make(map[int]string)
371+
i := 0
372+
for option, rank := range options {
373+
ranks[i] = rank
374+
reverse_map[rank] = option
375+
i += 1
353376
}
354-
return result
377+
378+
sort.Ints(ranks)
379+
380+
// normalise the ranks for counts that don't start at 1
381+
var choices []string = make([]string, len(ranks))
382+
for idx, rank := range ranks {
383+
choices[idx] = reverse_map[rank]
384+
}
385+
386+
return choices
355387
}

database/poll_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package database
22

33
import (
4+
"context"
45
"testing"
6+
"time"
57

68
"github.com/stretchr/testify/assert"
79
)
@@ -128,9 +130,95 @@ func TestResultCalcs(t *testing.T) {
128130
}
129131
for _, test := range tests {
130132
t.Run(test.name, func(t *testing.T) {
131-
results, err := calculateRankedResult(test.votes)
133+
results, err := calculateRankedResult(context.Background(), test.votes)
132134
assert.Equal(t, test.results, results)
133135
assert.Equal(t, test.err, err)
134136
})
135137
}
136138
}
139+
140+
func TestOrderOptions(t *testing.T) {
141+
tests := []struct {
142+
name string
143+
input map[string]int
144+
output []string
145+
}{
146+
{
147+
name: "SimpleOrder",
148+
input: map[string]int{
149+
"one": 1,
150+
"two": 2,
151+
"three": 3,
152+
"four": 4,
153+
},
154+
output: []string{"one", "two", "three", "four"},
155+
},
156+
{
157+
name: "Reversed",
158+
input: map[string]int{
159+
"one": 4,
160+
"two": 3,
161+
"three": 2,
162+
"four": 1,
163+
},
164+
output: []string{"four", "three", "two", "one"},
165+
},
166+
{
167+
name: "HighStart",
168+
input: map[string]int{
169+
"one": 2,
170+
"two": 3,
171+
"three": 4,
172+
"four": 5,
173+
},
174+
output: []string{"one", "two", "three", "four"},
175+
},
176+
{
177+
name: "LowStart",
178+
input: map[string]int{
179+
"one": 0,
180+
"two": 1,
181+
"three": 2,
182+
"four": 3,
183+
},
184+
output: []string{"one", "two", "three", "four"},
185+
},
186+
{
187+
name: "Negative",
188+
input: map[string]int{
189+
"one": -1,
190+
"two": 1,
191+
"three": 2,
192+
"four": 3,
193+
},
194+
output: []string{"one", "two", "three", "four"},
195+
},
196+
{
197+
name: "duplicate, expect bad output",
198+
input: map[string]int{
199+
"one": 0,
200+
"two": 1,
201+
"three": 1,
202+
"four": 2,
203+
},
204+
output: []string{"one", "three", "three", "four"},
205+
},
206+
{
207+
name: "Gap",
208+
input: map[string]int{
209+
"one": 1,
210+
"two": 2,
211+
"three": 4,
212+
"four": 5,
213+
},
214+
output: []string{"one", "two", "three", "four"},
215+
},
216+
}
217+
218+
for _, test := range tests {
219+
t.Run(test.name, func(t *testing.T) {
220+
ctx, _ := context.WithTimeout(context.Background(), time.Second)
221+
assert.Equal(t, test.output, orderOptions(ctx, test.input))
222+
})
223+
}
224+
}

0 commit comments

Comments
 (0)