Skip to content

Commit 3ded68a

Browse files
authored
Merge pull request #11836 from neinteractiveliterature/fix-ranked-choice-rerun-restore-order
Fix rerun of moderated ranked choice signup rounds requesting the wrong choices
2 parents 38477d8 + 46a7184 commit 3ded68a

4 files changed

Lines changed: 207 additions & 10 deletions

.rubocop_todo.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ Lint/MissingCopEnableDirective:
3737
Exclude:
3838
- 'app/graphql/types/mutation_type.rb'
3939

40+
# Offense count: 2
41+
Lint/MissingSuper:
42+
Exclude:
43+
- 'app/services/execute_ranked_choice_signup_service.rb'
44+
- 'app/services/rerun_moderated_ranked_choice_signup_round_service.rb'
45+
4046
# Offense count: 1
4147
Lint/NoReturnInBeginEndBlocks:
4248
Exclude:
@@ -50,6 +56,7 @@ Metrics/BlockLength:
5056
- 'config/environments/production.rb'
5157
- 'config/initializers/doorkeeper.rb'
5258
- 'test/presenters/signup_count_presenter_test.rb'
59+
- 'test/services/rerun_moderated_ranked_choice_signup_round_service_test.rb'
5360

5461
# Offense count: 7
5562
# Configuration parameters: CountComments, Max, CountAsOne.
@@ -61,13 +68,27 @@ Metrics/ClassLength:
6168
- 'app/graphql/types/mutation_type.rb'
6269
- 'app/graphql/types/query_type.rb'
6370
- 'app/presenters/signup_count_presenter.rb'
71+
- 'app/services/execute_ranked_choice_signup_service.rb'
6472
- 'test/presenters/signup_count_presenter_test.rb'
6573

74+
# Offense count: 1
75+
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
76+
Metrics/CyclomaticComplexity:
77+
Exclude:
78+
- 'app/services/execute_ranked_choice_signup_service.rb'
79+
6680
# Offense count: 1
6781
# Configuration parameters: Max.
6882
Metrics/ParameterLists:
6983
Exclude:
7084
- 'app/presenters/signup_count_presenter.rb'
85+
- 'app/services/execute_ranked_choice_signup_service.rb'
86+
87+
# Offense count: 1
88+
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
89+
Metrics/PerceivedComplexity:
90+
Exclude:
91+
- 'app/services/execute_ranked_choice_signup_service.rb'
7192

7293
# Offense count: 1
7394
# Configuration parameters: ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros.

app/services/execute_ranked_choice_signup_service.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# frozen_string_literal: true
12
class ExecuteRankedChoiceSignupService < CivilService::Service
23
class Result < CivilService::Result
34
attr_accessor :decision
@@ -46,6 +47,11 @@ def initialize(
4647
end
4748

4849
def inner_call
50+
# Capture this before doing anything else: accepting the choice changes its state, and
51+
# priority is a list position scoped by (user_con_profile, state), so the state change
52+
# renumbers the choice itself as well as the remaining pending choices
53+
after_signup_ranked_choice
54+
4955
skip = skip_reason
5056

5157
decision =
@@ -176,7 +182,14 @@ def do_waitlist
176182
end
177183

178184
def after_signup_ranked_choice
179-
@after_signup_ranked_choice ||=
180-
user_con_profile.signup_ranked_choices.where("priority > ?", signup_ranked_choice.priority).order(:priority).first
185+
return @after_signup_ranked_choice if defined?(@after_signup_ranked_choice)
186+
187+
@after_signup_ranked_choice =
188+
user_con_profile
189+
.signup_ranked_choices
190+
.where(state: "pending")
191+
.where("priority > ?", signup_ranked_choice.priority)
192+
.order(:priority)
193+
.first
181194
end
182195
end

app/services/rerun_moderated_ranked_choice_signup_round_service.rb

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# frozen_string_literal: true
12
class RerunModeratedRankedChoiceSignupRoundService < CivilService::Service
23
include SkippableAdvisoryLock
34

@@ -19,23 +20,38 @@ def inner_call
1920
private
2021

2122
def decisions_to_undo
23+
# Ordered by id so that choices are restored in the order they were executed; that way, if
24+
# multiple decisions for the same user are undone, restore_signup_ranked_choice can count on
25+
# earlier choices already being back in the pending list
2226
@decisions_to_undo ||=
23-
signup_round.ranked_choice_decisions.joins(:signup_request).where(signup_requests: { state: "pending" })
27+
signup_round
28+
.ranked_choice_decisions
29+
.joins(:signup_request)
30+
.where(signup_requests: { state: "pending" })
31+
.order(:id)
2432
end
2533

2634
def undo_decision(decision)
27-
signup_ranked_choice = decision.signup_ranked_choice
28-
signup_request = decision.signup_request
35+
restore_signup_ranked_choice(decision) if decision.signup_ranked_choice
36+
decision.signup_request.destroy!
37+
end
38+
39+
def restore_signup_ranked_choice(decision)
40+
after_signup_ranked_choice = decision.after_signup_ranked_choice
2941
priority =
3042
(
31-
if decision.after_signup_ranked_choice&.state == "pending"
32-
{ after: decision.after_signup_ranked_choice }
33-
else
43+
if after_signup_ranked_choice&.state == "pending"
44+
{ before: after_signup_ranked_choice }
45+
elsif after_signup_ranked_choice
46+
# The choice that followed this one was executed too; if its decision is also being
47+
# undone, it will be re-inserted after this one
3448
:first
49+
else
50+
# Nothing followed this choice when it was executed
51+
:last
3552
end
3653
)
3754

38-
signup_ranked_choice.update!(state: "pending", priority:)
39-
signup_request.destroy!
55+
decision.signup_ranked_choice.update!(state: "pending", priority:)
4056
end
4157
end
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# frozen_string_literal: true
2+
require "test_helper"
3+
4+
describe RerunModeratedRankedChoiceSignupRoundService do
5+
let(:convention) { create(:convention, :with_notification_templates, signup_mode: "moderated") }
6+
let(:signup_round) do
7+
create(:signup_round, convention:, ranked_choice_order: "asc", maximum_event_signups: "1", start: 1.day.ago)
8+
end
9+
let(:one_player_registration_policy) do
10+
RegistrationPolicy.new({ buckets: [{ key: "only_one_player", total_slots: 1, slots_limited: true }] })
11+
end
12+
13+
def create_ranked_choices(user_con_profile, count)
14+
(1..count).map do |i|
15+
event = create(:event, convention:, registration_policy: one_player_registration_policy)
16+
run = create(:run, event:, starts_at: convention.starts_at + (i * 5).hours)
17+
create(:signup_ranked_choice, user_con_profile:, target_run: run)
18+
end
19+
end
20+
21+
def execute_round
22+
ExecuteRankedChoiceSignupRoundService.new(signup_round:, whodunit: nil).call!
23+
end
24+
25+
def rerun_round
26+
max_decision_id = RankedChoiceDecision.maximum(:id) || 0
27+
result = RerunModeratedRankedChoiceSignupRoundService.new(signup_round:, whodunit: nil).call!
28+
assert result.success?
29+
RankedChoiceDecision.where("id > ?", max_decision_id).order(:id).to_a
30+
end
31+
32+
def pending_choice_ids(user_con_profile)
33+
user_con_profile.signup_ranked_choices.where(state: "pending").order(:priority).pluck(:id)
34+
end
35+
36+
it "records the next pending choice as after_signup_ranked_choice when executing" do
37+
user_con_profile = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
38+
choices = create_ranked_choices(user_con_profile, 3)
39+
40+
result = execute_round
41+
42+
signup_decision = result.decisions.find { |decision| decision.decision == "signup" }
43+
assert_equal choices[0], signup_decision.signup_ranked_choice
44+
assert_equal choices[1], signup_decision.after_signup_ranked_choice
45+
end
46+
47+
it "re-requests the same choice for attendees whose requests were still pending" do
48+
rejected_user = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
49+
pending_user = create(:user_con_profile, convention:, lottery_number: 2, ranked_choice_fallback_action: "none")
50+
rejected_user_choices = create_ranked_choices(rejected_user, 2)
51+
pending_user_choices = create_ranked_choices(pending_user, 3)
52+
53+
execute_round
54+
rejected_user_choices[0].reload.result_signup_request.update!(state: "rejected")
55+
original_pending_request = pending_user_choices[0].reload.result_signup_request
56+
57+
rerun_round
58+
59+
# The attendee with the rejected request should advance to their second choice
60+
assert_equal "requested", rejected_user_choices[1].reload.state
61+
assert_equal "pending", rejected_user_choices[1].result_signup_request.state
62+
63+
# The attendee whose request was still pending should have the same choice re-requested
64+
assert_equal "requested", pending_user_choices[0].reload.state
65+
new_request = pending_user_choices[0].result_signup_request
66+
assert_equal "pending", new_request.state
67+
assert_not_equal original_pending_request.id, new_request.id
68+
assert_equal pending_user_choices[0].target_run, new_request.target_run
69+
assert_equal [pending_user_choices[1].id, pending_user_choices[2].id], pending_choice_ids(pending_user)
70+
end
71+
72+
it "does not advance attendees with pending requests across multiple reruns" do
73+
rejected_user = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
74+
pending_user = create(:user_con_profile, convention:, lottery_number: 2, ranked_choice_fallback_action: "none")
75+
rejected_user_choices = create_ranked_choices(rejected_user, 3)
76+
pending_user_choices = create_ranked_choices(pending_user, 3)
77+
78+
execute_round
79+
rejected_user_choices[0].reload.result_signup_request.update!(state: "rejected")
80+
rerun_round
81+
rejected_user_choices[1].reload.result_signup_request.update!(state: "rejected")
82+
rerun_round
83+
84+
assert_equal "requested", rejected_user_choices[2].reload.state
85+
assert_equal "pending", rejected_user_choices[2].result_signup_request.state
86+
87+
assert_equal "requested", pending_user_choices[0].reload.state
88+
assert_equal "pending", pending_user_choices[0].result_signup_request.state
89+
assert_equal pending_user_choices[0].target_run, pending_user_choices[0].result_signup_request.target_run
90+
assert_equal [pending_user_choices[1].id, pending_user_choices[2].id], pending_choice_ids(pending_user)
91+
end
92+
93+
it "restores a choice that was the last pending choice to the end of the list" do
94+
user_con_profile = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
95+
choices = create_ranked_choices(user_con_profile, 2)
96+
create(:signup, run: choices[0].target_run, state: "confirmed")
97+
98+
result = execute_round
99+
original_decisions = result.decisions.select { |decision| decision.user_con_profile == user_con_profile }
100+
assert_equal %w[skip_choice signup], original_decisions.map(&:decision)
101+
102+
rerun_decisions = rerun_round.select { |decision| decision.user_con_profile == user_con_profile }
103+
104+
# The full first choice should still be considered (and skipped) before the second choice
105+
# is re-requested
106+
assert_equal %w[skip_choice signup], rerun_decisions.map(&:decision)
107+
assert_equal choices[0], rerun_decisions[0].signup_ranked_choice
108+
assert_equal choices[1], rerun_decisions[1].signup_ranked_choice
109+
assert_equal [choices[0].id], pending_choice_ids(user_con_profile)
110+
end
111+
112+
it "skips restoring choices that have been deleted" do
113+
user_con_profile = create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
114+
choices = create_ranked_choices(user_con_profile, 2)
115+
116+
execute_round
117+
original_request = choices[0].reload.result_signup_request
118+
choices[0].destroy!
119+
120+
rerun_round
121+
122+
assert_not SignupRequest.exists?(original_request.id)
123+
assert_equal "requested", choices[1].reload.state
124+
assert_equal "pending", choices[1].result_signup_request.state
125+
end
126+
127+
describe "with a round allowing multiple signups" do
128+
let(:signup_round) do
129+
create(:signup_round, convention:, ranked_choice_order: "asc", maximum_event_signups: "2", start: 1.day.ago)
130+
end
131+
132+
it "restores multiple undone choices in their original order" do
133+
user_con_profile =
134+
create(:user_con_profile, convention:, lottery_number: 1, ranked_choice_fallback_action: "none")
135+
choices = create_ranked_choices(user_con_profile, 3)
136+
137+
execute_round
138+
assert_equal(%w[requested requested pending], choices.map { |choice| choice.reload.state })
139+
140+
rerun_decisions = rerun_round
141+
142+
assert_equal [choices[0], choices[1]], rerun_decisions.map(&:signup_ranked_choice)
143+
assert_equal(%w[requested requested pending], choices.map { |choice| choice.reload.state })
144+
assert_equal [choices[2].id], pending_choice_ids(user_con_profile)
145+
end
146+
end
147+
end

0 commit comments

Comments
 (0)