Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.74% |
135 / 141 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
STVBallot | |
95.74% |
135 / 141 |
|
50.00% |
4 / 8 |
29 | |
0.00% |
0 / 1 |
getTallyTypes | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getCreateDescriptors | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
getQuestionForm | |
97.44% |
38 / 39 |
|
0.00% |
0 / 1 |
5 | |||
submitQuestion | |
100.00% |
52 / 52 |
|
100.00% |
1 / 1 |
14 | |||
packRecord | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
unpackRecord | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 | |||
convertScores | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
numberOfSeatsReached | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Ballots; |
4 | |
5 | use MediaWiki\Extension\SecurePoll\Entities\Question; |
6 | use MediaWiki\Extension\SecurePoll\Pages\CreatePage; |
7 | use OOUI\DropdownInputWidget; |
8 | use OOUI\FieldLayout; |
9 | use OOUI\FieldsetLayout; |
10 | |
11 | /** |
12 | * A STVBallot class, |
13 | * Currently work in progress see T282015. |
14 | */ |
15 | class STVBallot extends Ballot { |
16 | |
17 | /** @var bool */ |
18 | private $seatsLimit = false; |
19 | |
20 | /** @var int */ |
21 | private $numberOfSeats = 1; |
22 | |
23 | /** |
24 | * Get a list of names of tallying methods, which may be used to produce a |
25 | * result from this ballot type. |
26 | * @return array |
27 | */ |
28 | public static function getTallyTypes(): array { |
29 | return [ |
30 | 'droop-quota' |
31 | ]; |
32 | } |
33 | |
34 | public static function getCreateDescriptors() { |
35 | $description = parent::getCreateDescriptors(); |
36 | $description['question'] += [ |
37 | 'min-seats' => [ |
38 | 'label-message' => 'securepoll-create-label-seat_count', |
39 | 'type' => 'int', |
40 | 'min' => 1, |
41 | 'validation-callback' => [ |
42 | CreatePage::class, |
43 | 'checkRequired', |
44 | ], |
45 | 'SecurePoll_type' => 'property', |
46 | ], |
47 | 'limit-seats' => [ |
48 | 'label-message' => 'securepoll-create-label-limit-seats_input', |
49 | 'type' => 'check', |
50 | 'hidelabel' => true, |
51 | 'SecurePoll_type' => 'property', |
52 | ], |
53 | ]; |
54 | return $description; |
55 | } |
56 | |
57 | /** |
58 | * @param Question $question |
59 | * @param array $options |
60 | * @return FieldsetLayout |
61 | */ |
62 | public function getQuestionForm( $question, $options ): FieldsetLayout { |
63 | $name = 'securepoll_q' . $question->getId(); |
64 | $fieldset = new FieldsetLayout(); |
65 | $request = $this->getRequest(); |
66 | $this->seatsLimit = $question->getProperty( 'limit-seats' ); |
67 | $this->numberOfSeats = $question->getProperty( 'min-seats' ); |
68 | |
69 | $allOptions = [ |
70 | [ |
71 | 'data' => 0, |
72 | 'label' => $this->msg( 'securepoll-stv-droop-default-value' )->text(), |
73 | ] |
74 | ]; |
75 | foreach ( $options as $option ) { |
76 | $allOptions[] = [ |
77 | 'data' => $option->getId(), |
78 | 'label' => $option->parseMessageInline( 'text' ), |
79 | ]; |
80 | } |
81 | $numberOfOptions = count( $options ); |
82 | for ( $i = 0; $i < $numberOfOptions; $i++ ) { |
83 | if ( $this->numberOfSeatsReached( $i ) ) { |
84 | break; |
85 | } |
86 | $inputId = "{$name}_opt{$i}"; |
87 | $widget = new DropdownInputWidget( [ |
88 | 'infusable' => true, |
89 | 'name' => $inputId, |
90 | 'options' => $allOptions, |
91 | 'classes' => [ 'securepoll-stvballot-option-dropdown' ], |
92 | 'value' => $request->getVal( $inputId, '0' ), |
93 | ] ); |
94 | $fieldset->appendContent( new FieldLayout( |
95 | $widget, |
96 | [ |
97 | 'classes' => [ 'securepoll-option-preferential' ], |
98 | 'label' => $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 ), |
99 | 'errors' => isset( $this->prevErrorIds[$inputId] ) ? [ |
100 | $this->prevStatus->spGetMessageText( $inputId ) |
101 | ] : null, |
102 | 'align' => 'top', |
103 | ] |
104 | ) ); |
105 | } |
106 | |
107 | return $fieldset; |
108 | } |
109 | |
110 | /** |
111 | * @param Question $question |
112 | * @param BallotStatus $status |
113 | * @return string|null |
114 | */ |
115 | public function submitQuestion( $question, $status ): ?string { |
116 | $ok = true; |
117 | // Construct the ranking array |
118 | $options = $question->getOptions(); |
119 | $rankedChoices = []; |
120 | foreach ( $options as $rank => $option ) { |
121 | $id = 'securepoll_q' . $question->getId() . '_opt' . $rank; |
122 | $rankedChoices[] = $this->getRequest()->getVal( $id ); |
123 | } |
124 | |
125 | // Remove trailing blank options |
126 | $i = count( $rankedChoices ) - 1; |
127 | while ( $i >= 0 ) { |
128 | if ( !$rankedChoices[$i] ) { |
129 | array_pop( $rankedChoices ); |
130 | $i--; |
131 | } else { |
132 | break; |
133 | } |
134 | } |
135 | |
136 | // Check that at least one choice was selected |
137 | if ( !count( $rankedChoices ) ) { |
138 | $status->fatal( 'securepoll-stv-invalid-rank-empty' ); |
139 | $ok = false; |
140 | } |
141 | |
142 | // Check that choices are ranked sequentially |
143 | if ( count( array_filter( $rankedChoices ) ) !== count( $rankedChoices ) ) { |
144 | // Get ids of empty options |
145 | $emptyRanks = []; |
146 | foreach ( $rankedChoices as $i => $choice ) { |
147 | if ( $choice === '0' ) { |
148 | $emptyRanks[] = $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 ); |
149 | $status->spFatal( |
150 | 'securepoll-stv-invalid-input-empty', |
151 | 'securepoll_q' . $question->getId() . '_opt' . $i, |
152 | true |
153 | ); |
154 | } |
155 | } |
156 | $emptyRanks = implode( ', ', $emptyRanks ); |
157 | $status->fatal( 'securepoll-stv-invalid-rank-order', $emptyRanks ); |
158 | $ok = false; |
159 | } |
160 | |
161 | // Check that choices are unique |
162 | $uniqueChoices = array_unique( $rankedChoices ); |
163 | if ( count( $uniqueChoices ) !== count( $rankedChoices ) ) { |
164 | // Get ids of duplicate options |
165 | $duplicateChoiceIds = array_keys( array_diff_assoc( $rankedChoices, $uniqueChoices ) ); |
166 | $duplicateChoices = []; |
167 | foreach ( $duplicateChoiceIds as $id ) { |
168 | if ( $rankedChoices[ $id ] !== '0' ) { |
169 | $duplicateChoices[] = $this->msg( |
170 | 'securepoll-stv-droop-choice-rank', $id + 1 |
171 | ); |
172 | $status->spFatal( |
173 | 'securepoll-stv-invalid-input-duplicate', |
174 | 'securepoll_q' . $question->getId() . '_opt' . $id, |
175 | true |
176 | ); |
177 | } |
178 | } |
179 | // Check against the count to avoid edge case of only multiple empty inputs |
180 | if ( count( $duplicateChoices ) ) { |
181 | $duplicateChoices = implode( ', ', $duplicateChoices ); |
182 | $status->fatal( 'securepoll-stv-invalid-rank-duplicate', $duplicateChoices ); |
183 | } |
184 | $ok = false; |
185 | } |
186 | |
187 | if ( !$ok ) { |
188 | return null; |
189 | } |
190 | |
191 | // Input ok; write the record |
192 | // Q{question id in hexadecimal, padded to 8 chars}-C{choice id in hex, padded}-R{rank in hex, padded}-- |
193 | $record = ''; |
194 | foreach ( $rankedChoices as $rank => $choice ) { |
195 | $record .= $this->packRecord( $question, $choice, $rank ); |
196 | } |
197 | |
198 | return $record; |
199 | } |
200 | |
201 | public function packRecord( $question, $choice, $rank ) { |
202 | return sprintf( |
203 | 'Q%08X-C%08X-R%08X--', |
204 | $question->getId(), |
205 | $choice, |
206 | $rank |
207 | ); |
208 | } |
209 | |
210 | /** |
211 | * If the record is valid, return an array of optionIds in ranked order for each questionId |
212 | * @param string $record |
213 | * @return array|bool |
214 | */ |
215 | public function unpackRecord( $record ) { |
216 | $ranks = []; |
217 | $itemLength = 3 * 8 + 7; |
218 | for ( $offset = 0, $len = strlen( $record ); $offset < $len; $offset += $itemLength ) { |
219 | if ( !preg_match( |
220 | '/Q([0-9A-F]{8})-C([0-9A-F]{8})-R([0-9A-F]{8})--/A', |
221 | $record, |
222 | $m, |
223 | 0, |
224 | $offset |
225 | ) |
226 | ) { |
227 | wfDebug( __METHOD__ . ": regex doesn't match\n" ); |
228 | |
229 | return false; |
230 | } |
231 | $qid = intval( base_convert( $m[1], 16, 10 ) ); |
232 | $oid = intval( base_convert( $m[2], 16, 10 ) ); |
233 | $rank = intval( base_convert( $m[3], 16, 10 ) ); |
234 | $ranks[$qid][$rank] = $oid; |
235 | } |
236 | |
237 | return $ranks; |
238 | } |
239 | |
240 | /** |
241 | * @param array $scores |
242 | * @param array $options |
243 | * @return string|string[]|false |
244 | */ |
245 | public function convertScores( $scores, $options = [] ) { |
246 | // TODO: Implement convertScores() method. |
247 | return []; |
248 | } |
249 | |
250 | /** |
251 | * |
252 | * @param int $count |
253 | * @return bool |
254 | */ |
255 | private function numberOfSeatsReached( $count ) { |
256 | if ( $this->seatsLimit && $count >= $this->numberOfSeats ) { |
257 | return true; |
258 | } |
259 | return false; |
260 | } |
261 | } |