Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.76% |
179 / 185 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
STVBallot | |
96.76% |
179 / 185 |
|
55.56% |
5 / 9 |
33 | |
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 | |
98.75% |
79 / 80 |
|
0.00% |
0 / 1 |
8 | |||
submitQuestion | |
100.00% |
54 / 54 |
|
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 | |||
getFixedFormatValue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
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\ComboBoxInputWidget; |
8 | use OOUI\DropdownInputWidget; |
9 | use OOUI\FieldLayout; |
10 | use OOUI\FieldsetLayout; |
11 | |
12 | /** |
13 | * A STVBallot class, |
14 | * Currently work in progress see T282015. |
15 | */ |
16 | class STVBallot extends Ballot { |
17 | |
18 | /** @var bool */ |
19 | private $seatsLimit = false; |
20 | |
21 | /** @var int */ |
22 | private $numberOfSeats = 1; |
23 | |
24 | /** |
25 | * Get a list of names of tallying methods, which may be used to produce a |
26 | * result from this ballot type. |
27 | * @return array |
28 | */ |
29 | public static function getTallyTypes(): array { |
30 | return [ |
31 | 'droop-quota' |
32 | ]; |
33 | } |
34 | |
35 | /** @inheritDoc */ |
36 | public static function getCreateDescriptors() { |
37 | $description = parent::getCreateDescriptors(); |
38 | $description['question'] += [ |
39 | 'min-seats' => [ |
40 | 'label-message' => 'securepoll-create-label-seat_count', |
41 | 'type' => 'int', |
42 | 'min' => 1, |
43 | 'validation-callback' => [ |
44 | CreatePage::class, |
45 | 'checkRequired', |
46 | ], |
47 | 'SecurePoll_type' => 'property', |
48 | ], |
49 | 'limit-seats' => [ |
50 | 'label-message' => 'securepoll-create-label-limit-seats_input', |
51 | 'type' => 'check', |
52 | 'hidelabel' => true, |
53 | 'SecurePoll_type' => 'property', |
54 | ], |
55 | ]; |
56 | return $description; |
57 | } |
58 | |
59 | /** |
60 | * @param Question $question |
61 | * @param array $options |
62 | * @return FieldsetLayout |
63 | */ |
64 | public function getQuestionForm( $question, $options ): FieldsetLayout { |
65 | // The formatting to a fixed digit number here is important because |
66 | // `page.vote.stv.js` sorts the form entries alphabetically. |
67 | $name = 'securepoll_q' . $this->getFixedFormatValue( $question->getId() ); |
68 | $fieldset = new FieldsetLayout(); |
69 | $request = $this->getRequest(); |
70 | $this->seatsLimit = $question->getProperty( 'limit-seats' ); |
71 | $this->numberOfSeats = $question->getProperty( 'min-seats' ); |
72 | |
73 | $data = [ |
74 | 'maxSeats' => $this->seatsLimit ? $this->numberOfSeats : count( $options ), |
75 | 'selectedItems' => [] |
76 | ]; |
77 | |
78 | // Because the combobox -> boxmenu -> draggable pipeline doesn't pass along |
79 | // an id (for ux reasons, passing the id would result in exposing the id to |
80 | // the user), save an array of value => id pairs to re-associate the value with |
81 | // the id on form submission. This will transform the user-visible values back |
82 | // into ids the ballot knows how to process. |
83 | $allCandidates = []; |
84 | |
85 | $allOptions = [ |
86 | [ |
87 | 'data' => 0, |
88 | 'label' => $this->msg( 'securepoll-stv-droop-default-value' )->text(), |
89 | ] |
90 | ]; |
91 | $dragAndDropComboOptions = []; |
92 | foreach ( $options as $key => $option ) { |
93 | $formattedKey = $this->getFixedFormatValue( $key ); |
94 | // Restore rankings from request if available |
95 | $selectedVal = $request->getVal( $name . "_opt" . $formattedKey ); |
96 | if ( $selectedVal ) { |
97 | $data[ 'selectedItems' ][] = [ |
98 | 'option' => $name . "_opt" . $formattedKey, |
99 | 'itemKey' => $key |
100 | ]; |
101 | } |
102 | |
103 | $allCandidates[ $option->parseMessageInline( 'text' ) ] = $option->getId(); |
104 | |
105 | // Pass the candidate's name as both the value and the label by setting it |
106 | // as the data attribute. |
107 | // It would be ideal to correctly use the name as the label and the option id as |
108 | // the value but the combobox type will show the value instead of the label |
109 | // in its textarea (eg. selecting a candidate will reveal the option id in the UI). |
110 | // See https://stackoverflow.com/questions/42502268/label-data-mechanism-in-oo-ui-comboboxinputwidget |
111 | $allOptions[] = [ |
112 | 'data' => $option->getId(), |
113 | 'label' => $option->parseMessageInline( 'text' ), |
114 | ]; |
115 | $dragAndDropComboOptions[] = [ |
116 | 'data' => $option->parseMessageInline( 'text' ), |
117 | 'disable' => true, |
118 | 'select' => true, |
119 | ]; |
120 | } |
121 | $data[ 'candidates' ] = $allCandidates; |
122 | |
123 | $numberOfOptions = count( $options ); |
124 | for ( $i = 0; $i < $numberOfOptions; $i++ ) { |
125 | if ( $this->numberOfSeatsReached( $i ) ) { |
126 | break; |
127 | } |
128 | // The formatting to a fixed digit number here is important because |
129 | // `page.vote.stv.js` sorts the form entries alphabetically. |
130 | $formattedKey = $this->getFixedFormatValue( $i ); |
131 | $inputId = "{$name}_opt{$formattedKey}"; |
132 | $widget = new DropdownInputWidget( [ |
133 | 'infusable' => true, |
134 | 'name' => $inputId, |
135 | 'options' => $allOptions, |
136 | 'classes' => [ 'securepoll-stvballot-option-dropdown' ], |
137 | 'value' => $request->getVal( $inputId, '0' ), |
138 | ] ); |
139 | $fieldset->appendContent( new FieldLayout( |
140 | $widget, |
141 | [ |
142 | 'classes' => [ 'securepoll-option-preferential', 'securepoll-option-stv-dropdown' ], |
143 | 'label' => $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 ), |
144 | 'errors' => isset( $this->prevErrorIds[$inputId] ) ? [ |
145 | $this->prevStatus->spGetMessageText( $inputId ) |
146 | ] : null, |
147 | 'align' => 'top', |
148 | ] |
149 | ) ); |
150 | } |
151 | |
152 | $widget = new ComboBoxInputWidget( [ |
153 | 'infusable' => true, |
154 | 'tagName' => $name, |
155 | 'options' => $dragAndDropComboOptions, |
156 | 'autocomplete' => false, |
157 | 'data' => $data, |
158 | 'menu' => [ |
159 | 'filterFromInput' => true |
160 | ] |
161 | ] ); |
162 | |
163 | $fieldset->appendContent( new FieldLayout( |
164 | $widget, |
165 | [ |
166 | 'infusable' => true, |
167 | 'classes' => [ "securepoll-option-preferential", 'securepoll-option-stv-combobox' ], |
168 | 'errors' => isset( $this->prevErrorIds[ $name ] ) ? [ |
169 | $this->prevStatus->spGetMessageText( $name ) |
170 | ] : null, |
171 | 'align' => 'top' |
172 | ] |
173 | ) ); |
174 | |
175 | return $fieldset; |
176 | } |
177 | |
178 | /** |
179 | * @param Question $question |
180 | * @param BallotStatus $status |
181 | * @return string|null |
182 | */ |
183 | public function submitQuestion( $question, $status ): ?string { |
184 | $ok = true; |
185 | // Construct the ranking array |
186 | $options = $question->getOptions(); |
187 | $rankedChoices = []; |
188 | foreach ( $options as $rank => $option ) { |
189 | $formattedId = $this->getFixedFormatValue( $question->getId() ); |
190 | $formattedRank = $this->getFixedFormatValue( $rank ); |
191 | $id = 'securepoll_q' . $formattedId . '_opt' . $formattedRank; |
192 | $rankedChoices[] = $this->getRequest()->getVal( $id ); |
193 | } |
194 | |
195 | // Remove trailing blank options |
196 | $i = count( $rankedChoices ) - 1; |
197 | while ( $i >= 0 ) { |
198 | if ( !$rankedChoices[$i] ) { |
199 | array_pop( $rankedChoices ); |
200 | $i--; |
201 | } else { |
202 | break; |
203 | } |
204 | } |
205 | |
206 | // Check that at least one choice was selected |
207 | if ( !count( $rankedChoices ) ) { |
208 | $status->fatal( 'securepoll-stv-invalid-rank-empty' ); |
209 | $ok = false; |
210 | } |
211 | |
212 | // Check that choices are ranked sequentially |
213 | if ( count( array_filter( $rankedChoices ) ) !== count( $rankedChoices ) ) { |
214 | // Get ids of empty options |
215 | $emptyRanks = []; |
216 | foreach ( $rankedChoices as $i => $choice ) { |
217 | if ( $choice === '0' ) { |
218 | $emptyRanks[] = $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 ); |
219 | $status->spFatal( |
220 | 'securepoll-stv-invalid-input-empty', |
221 | 'securepoll_q' . $question->getId() . '_opt' . $i, |
222 | true |
223 | ); |
224 | } |
225 | } |
226 | $emptyRanks = implode( ', ', $emptyRanks ); |
227 | $status->fatal( 'securepoll-stv-invalid-rank-order', $emptyRanks ); |
228 | $ok = false; |
229 | } |
230 | |
231 | // Check that choices are unique |
232 | $uniqueChoices = array_unique( $rankedChoices ); |
233 | if ( count( $uniqueChoices ) !== count( $rankedChoices ) ) { |
234 | // Get ids of duplicate options |
235 | $duplicateChoiceIds = array_keys( array_diff_assoc( $rankedChoices, $uniqueChoices ) ); |
236 | $duplicateChoices = []; |
237 | foreach ( $duplicateChoiceIds as $id ) { |
238 | if ( $rankedChoices[ $id ] !== '0' ) { |
239 | $duplicateChoices[] = $this->msg( |
240 | 'securepoll-stv-droop-choice-rank', $id + 1 |
241 | ); |
242 | $status->spFatal( |
243 | 'securepoll-stv-invalid-input-duplicate', |
244 | 'securepoll_q' . $question->getId() . '_opt' . $id, |
245 | true |
246 | ); |
247 | } |
248 | } |
249 | // Check against the count to avoid edge case of only multiple empty inputs |
250 | if ( count( $duplicateChoices ) ) { |
251 | $duplicateChoices = implode( ', ', $duplicateChoices ); |
252 | $status->fatal( 'securepoll-stv-invalid-rank-duplicate', $duplicateChoices ); |
253 | } |
254 | $ok = false; |
255 | } |
256 | |
257 | if ( !$ok ) { |
258 | return null; |
259 | } |
260 | |
261 | // Input ok; write the record |
262 | // Q{question id in hexadecimal, padded to 8 chars}-C{choice id in hex, padded}-R{rank in hex, padded}-- |
263 | $record = ''; |
264 | foreach ( $rankedChoices as $rank => $choice ) { |
265 | $record .= $this->packRecord( $question, $choice, $rank ); |
266 | } |
267 | |
268 | return $record; |
269 | } |
270 | |
271 | /** |
272 | * @param Question $question |
273 | * @param string $choice |
274 | * @param int $rank |
275 | * @return string |
276 | */ |
277 | public function packRecord( $question, $choice, $rank ) { |
278 | return sprintf( |
279 | 'Q%08X-C%08X-R%08X--', |
280 | $question->getId(), |
281 | $choice, |
282 | $rank |
283 | ); |
284 | } |
285 | |
286 | /** |
287 | * If the record is valid, return an array of optionIds in ranked order for each questionId |
288 | * @param string $record |
289 | * @return array|bool |
290 | */ |
291 | public function unpackRecord( $record ) { |
292 | $ranks = []; |
293 | $itemLength = 3 * 8 + 7; |
294 | for ( $offset = 0, $len = strlen( $record ); $offset < $len; $offset += $itemLength ) { |
295 | if ( !preg_match( |
296 | '/Q([0-9A-F]{8})-C([0-9A-F]{8})-R([0-9A-F]{8})--/A', |
297 | $record, |
298 | $m, |
299 | 0, |
300 | $offset |
301 | ) |
302 | ) { |
303 | wfDebug( __METHOD__ . ": regex doesn't match\n" ); |
304 | |
305 | return false; |
306 | } |
307 | $qid = intval( base_convert( $m[1], 16, 10 ) ); |
308 | $oid = intval( base_convert( $m[2], 16, 10 ) ); |
309 | $rank = intval( base_convert( $m[3], 16, 10 ) ); |
310 | $ranks[$qid][$rank] = $oid; |
311 | } |
312 | |
313 | return $ranks; |
314 | } |
315 | |
316 | /** |
317 | * @param array $scores |
318 | * @param array $options |
319 | * @return string|string[]|false |
320 | */ |
321 | public function convertScores( $scores, $options = [] ) { |
322 | // TODO: Implement convertScores() method. |
323 | return []; |
324 | } |
325 | |
326 | /** |
327 | * |
328 | * @param int $count |
329 | * @return bool |
330 | */ |
331 | private function numberOfSeatsReached( $count ) { |
332 | if ( $this->seatsLimit && $count >= $this->numberOfSeats ) { |
333 | return true; |
334 | } |
335 | return false; |
336 | } |
337 | |
338 | /** |
339 | * Formats to a fixed digit number (as string). This ensures consistency |
340 | * when sorting alphabetically and doesn't mis-sort IDs into an order |
341 | * like 1, 10, 2, ... |
342 | * |
343 | * @param string|int $value |
344 | * @return string |
345 | */ |
346 | private function getFixedFormatValue( $value ) { |
347 | return sprintf( '%07d', $value ); |
348 | } |
349 | } |