Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
20.33% |
50 / 246 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
RadioRangeBallot | |
20.33% |
50 / 246 |
|
0.00% |
0 / 12 |
1164.27 | |
0.00% |
0 / 1 |
getTallyTypes | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getCreateDescriptors | |
0.00% |
0 / 59 |
|
0.00% |
0 / 1 |
2 | |||
getMinMax | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
getColumnDirection | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
getScoresLeftToRight | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getColumnLabels | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
getMessageNames | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
42 | |||
addSign | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getQuestionForm | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
20 | |||
submitQuestion | |
97.73% |
43 / 44 |
|
0.00% |
0 / 1 |
8 | |||
unpackRecord | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
56 | |||
convertScores | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Ballots; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Extension\SecurePoll\Entities\Entity; |
7 | use MediaWiki\Extension\SecurePoll\Entities\Question; |
8 | use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException; |
9 | use MediaWiki\Extension\SecurePoll\HtmlForm\HTMLFormRadioRangeColumnLabels; |
10 | use MediaWiki\Extension\SecurePoll\Pages\CreatePage; |
11 | use MediaWiki\Parser\Sanitizer; |
12 | use OOUI\Element; |
13 | use OOUI\FieldsetLayout; |
14 | use OOUI\HtmlSnippet; |
15 | use OOUI\RadioInputWidget; |
16 | use OOUI\Tag; |
17 | |
18 | /** |
19 | * A ballot form for range voting where the number of allowed responses is small, |
20 | * allowing a radio button table interface and histogram tallying. |
21 | * |
22 | * Election properties: |
23 | * must-answer-all |
24 | * |
25 | * Question properties: |
26 | * min-score |
27 | * max-score |
28 | * column-label-msgs |
29 | * column-order |
30 | * |
31 | * Question messages: |
32 | * column-1, column0, column+1, etc. |
33 | */ |
34 | class RadioRangeBallot extends Ballot { |
35 | /** @var string[]|null */ |
36 | public $columnLabels; |
37 | /** @var int[]|null */ |
38 | public $minMax; |
39 | |
40 | /** @inheritDoc */ |
41 | public static function getTallyTypes() { |
42 | return [ |
43 | 'plurality', |
44 | 'histogram-range' |
45 | ]; |
46 | } |
47 | |
48 | /** @inheritDoc */ |
49 | public static function getCreateDescriptors() { |
50 | $ret = parent::getCreateDescriptors(); |
51 | $ret['election'] += [ |
52 | 'must-answer-all' => [ |
53 | 'label-message' => 'securepoll-create-label-must_answer_all', |
54 | 'type' => 'check', |
55 | 'hidelabel' => true, |
56 | 'SecurePoll_type' => 'property', |
57 | ], |
58 | ]; |
59 | $ret['question'] += [ |
60 | 'min-score' => [ |
61 | 'label-message' => 'securepoll-create-label-min_score', |
62 | 'type' => 'int', |
63 | 'validation-callback' => [ |
64 | CreatePage::class, |
65 | 'checkRequired', |
66 | ], |
67 | 'SecurePoll_type' => 'property', |
68 | ], |
69 | 'max-score' => [ |
70 | 'label-message' => 'securepoll-create-label-max_score', |
71 | 'type' => 'int', |
72 | 'validation-callback' => [ |
73 | CreatePage::class, |
74 | 'checkRequired', |
75 | ], |
76 | 'SecurePoll_type' => 'property', |
77 | ], |
78 | 'default-score' => [ |
79 | 'label-message' => 'securepoll-create-label-default_score', |
80 | 'type' => 'int', |
81 | 'SecurePoll_type' => 'property', |
82 | ], |
83 | 'column-order' => [ |
84 | 'label-message' => 'securepoll-create-label-column_order', |
85 | 'type' => 'select', |
86 | 'options-messages' => [ |
87 | 'securepoll-create-option-column_order-asc' => 'asc', |
88 | 'securepoll-create-option-column_order-desc' => 'desc', |
89 | ], |
90 | 'SecurePoll_type' => 'property', |
91 | ], |
92 | 'column-label-msgs' => [ |
93 | 'label-message' => 'securepoll-create-label-column_label_msgs', |
94 | 'type' => 'check', |
95 | 'hidelabel' => true, |
96 | 'SecurePoll_type' => 'property', |
97 | ], |
98 | 'column-messages' => [ |
99 | 'hide-if' => [ |
100 | '!==', |
101 | 'column-label-msgs', |
102 | '1' |
103 | ], |
104 | 'class' => HTMLFormRadioRangeColumnLabels::class, |
105 | 'SecurePoll_type' => 'messages', |
106 | ], |
107 | ]; |
108 | |
109 | return $ret; |
110 | } |
111 | |
112 | /** |
113 | * @param Question $question |
114 | * @return array |
115 | * @throws InvalidDataException |
116 | */ |
117 | public function getMinMax( $question ) { |
118 | $min = intval( $question->getProperty( 'min-score' ) ); |
119 | $max = intval( $question->getProperty( 'max-score' ) ); |
120 | if ( $max <= $min ) { |
121 | throw new InvalidDataException( __METHOD__ . ': min/max not configured' ); |
122 | } |
123 | |
124 | return [ |
125 | $min, |
126 | $max |
127 | ]; |
128 | } |
129 | |
130 | /** |
131 | * @param Question $question |
132 | * @return int |
133 | * @throws InvalidDataException |
134 | */ |
135 | public function getColumnDirection( $question ) { |
136 | $order = $question->getProperty( 'column-order' ); |
137 | if ( !$order ) { |
138 | return 1; |
139 | } elseif ( preg_match( '/^asc/i', $order ) ) { |
140 | return 1; |
141 | } elseif ( preg_match( '/^desc/i', $order ) ) { |
142 | return -1; |
143 | } else { |
144 | throw new InvalidDataException( __METHOD__ . ': column-order configured incorrectly' ); |
145 | } |
146 | } |
147 | |
148 | /** |
149 | * @param Question $question |
150 | * @return array |
151 | */ |
152 | public function getScoresLeftToRight( $question ) { |
153 | $incr = $this->getColumnDirection( $question ); |
154 | [ $min, $max ] = $this->getMinMax( $question ); |
155 | if ( $incr > 0 ) { |
156 | $left = $min; |
157 | $right = $max; |
158 | } else { |
159 | $left = $max; |
160 | $right = $min; |
161 | } |
162 | |
163 | return range( $left, $right ); |
164 | } |
165 | |
166 | /** |
167 | * @param Question $question |
168 | * @return array |
169 | */ |
170 | public function getColumnLabels( $question ) { |
171 | // list( $min, $max ) = $this->getMinMax( $question ); |
172 | $labels = []; |
173 | $useMessageLabels = $question->getProperty( 'column-label-msgs' ); |
174 | $scores = $this->getScoresLeftToRight( $question ); |
175 | if ( $useMessageLabels ) { |
176 | foreach ( $scores as $score ) { |
177 | $signedScore = $this->addSign( $question, $score ); |
178 | $labels[$score] = $question->parseMessageInline( "column$signedScore" ); |
179 | } |
180 | } else { |
181 | foreach ( $scores as $score ) { |
182 | $labels[$score] = $this->getUserLang()->formatNum( $score ); |
183 | } |
184 | } |
185 | |
186 | return $labels; |
187 | } |
188 | |
189 | /** @inheritDoc */ |
190 | public function getMessageNames( ?Entity $entity = null ) { |
191 | if ( $entity === null || $entity->getType() !== 'question' ) { |
192 | return []; |
193 | } |
194 | if ( !$entity->getProperty( 'column-label-msgs' ) ) { |
195 | return []; |
196 | } |
197 | $msgs = []; |
198 | if ( !$entity instanceof Question ) { |
199 | $class = get_class( $entity ); |
200 | throw new InvalidArgumentException( |
201 | "Expecting instance of Question, got $class instead" |
202 | ); |
203 | } |
204 | [ $min, $max ] = $this->getMinMax( $entity ); |
205 | for ( $score = $min; $score <= $max; $score++ ) { |
206 | $signedScore = $this->addSign( $entity, $score ); |
207 | $msgs[] = "column$signedScore"; |
208 | } |
209 | |
210 | return $msgs; |
211 | } |
212 | |
213 | /** |
214 | * @param Question $question |
215 | * @param int|string $score |
216 | * @return string |
217 | */ |
218 | public function addSign( $question, $score ) { |
219 | [ $min, ] = $this->getMinMax( $question ); |
220 | if ( $min < 0 && $score > 0 ) { |
221 | return "+$score"; |
222 | } |
223 | |
224 | return $score; |
225 | } |
226 | |
227 | /** |
228 | * @param Question $question |
229 | * @param array $options |
230 | * @return FieldsetLayout |
231 | */ |
232 | public function getQuestionForm( $question, $options ) { |
233 | $name = 'securepoll_q' . $question->getId(); |
234 | $labels = $this->getColumnLabels( $question ); |
235 | |
236 | $table = new Tag( 'table' ); |
237 | $table->addClasses( [ 'securepoll-ballot-table' ] ); |
238 | |
239 | $thead = new Tag( 'thead' ); |
240 | $table->appendContent( $thead ); |
241 | $tr = new Tag( 'tr' ); |
242 | $tr->appendContent( new Tag( 'th' ) ); |
243 | foreach ( $labels as $lab ) { |
244 | $tr->appendContent( ( new Tag( 'th' ) )->appendContent( $lab ) ); |
245 | } |
246 | $thead->appendContent( $tr ); |
247 | $tbody = new Tag( 'tbody' ); |
248 | $table->appendContent( $tbody ); |
249 | |
250 | $defaultScore = $question->getProperty( 'default-score' ); |
251 | |
252 | foreach ( $options as $option ) { |
253 | $optionHTML = $option->parseMessageInline( 'text' ); |
254 | $optionId = $option->getId(); |
255 | $inputId = "{$name}_opt{$optionId}"; |
256 | $oldValue = $this->getRequest()->getVal( $inputId, $defaultScore ); |
257 | |
258 | $tr = ( new Tag( 'tr' ) )->addClasses( [ 'securepoll-ballot-row', $inputId ] ); |
259 | $tr->appendContent( |
260 | ( new Tag( 'td' ) ) |
261 | ->appendContent( new HtmlSnippet( $optionHTML ) ) |
262 | ); |
263 | foreach ( $labels as $score => $label ) { |
264 | $tr->appendContent( ( new Tag( 'td' ) )->appendContent( |
265 | new RadioInputWidget( [ |
266 | 'name' => $inputId, |
267 | 'value' => $score, |
268 | 'selected' => !strcmp( $oldValue, $score ), |
269 | 'title' => Sanitizer::stripAllTags( $label ), |
270 | ] ) |
271 | ) ); |
272 | } |
273 | $tr->appendContent( |
274 | ( new Tag( 'td' ) ) |
275 | ->addClasses( [ 'securepoll-ballot-optlabel' ] ) |
276 | ->appendContent( new HtmlSnippet( $this->errorLocationIndicator( $inputId ) . "" ) ) |
277 | ); |
278 | $tbody->appendContent( $tr ); |
279 | } |
280 | return new FieldsetLayout( [ |
281 | 'items' => [ new Element( [ 'content' => [ $table ] ] ) ], |
282 | 'classes' => [ $name ] |
283 | ] ); |
284 | } |
285 | |
286 | /** |
287 | * @param Question $question |
288 | * @param BallotStatus $status |
289 | * @return string |
290 | */ |
291 | public function submitQuestion( $question, $status ) { |
292 | $options = $question->getOptions(); |
293 | $record = ''; |
294 | $ok = true; |
295 | [ $min, $max ] = $this->getMinMax( $question ); |
296 | $defaultScore = $question->getProperty( 'default-score' ); |
297 | foreach ( $options as $option ) { |
298 | $id = 'securepoll_q' . $question->getId() . '_opt' . $option->getId(); |
299 | $score = $this->getRequest()->getVal( $id ); |
300 | |
301 | if ( is_numeric( $score ) ) { |
302 | if ( $score < $min || $score > $max ) { |
303 | $status->spFatal( |
304 | 'securepoll-invalid-score', |
305 | $id, |
306 | false, |
307 | $this->getUserLang()->formatNum( $min ), |
308 | $this->getUserLang()->formatNum( $max ) |
309 | ); |
310 | $ok = false; |
311 | continue; |
312 | } |
313 | |
314 | $score = intval( $score ); |
315 | } elseif ( strval( $score ) === '' ) { |
316 | if ( $this->election->getProperty( 'must-answer-all' ) ) { |
317 | $status->spFatal( 'securepoll-unanswered-options', $id, false ); |
318 | $ok = false; |
319 | continue; |
320 | } |
321 | |
322 | $score = $defaultScore; |
323 | } else { |
324 | $status->spFatal( |
325 | 'securepoll-invalid-score', |
326 | $id, |
327 | false, |
328 | $this->getUserLang()->formatNum( $min ), |
329 | $this->getUserLang()->formatNum( $max ) |
330 | ); |
331 | $ok = false; |
332 | continue; |
333 | } |
334 | $record .= sprintf( |
335 | 'Q%08X-A%08X-S%+011d--', |
336 | $question->getId(), |
337 | $option->getId(), |
338 | $score |
339 | ); |
340 | } |
341 | if ( $ok ) { |
342 | return $record; |
343 | } else { |
344 | return ''; |
345 | } |
346 | } |
347 | |
348 | /** |
349 | * @param string $record |
350 | * @return array|bool |
351 | */ |
352 | public function unpackRecord( $record ) { |
353 | $scores = []; |
354 | $itemLength = 8 + 8 + 11 + 7; |
355 | $questions = []; |
356 | foreach ( $this->election->getQuestions() as $question ) { |
357 | $questions[$question->getId()] = $question; |
358 | } |
359 | for ( $offset = 0, $len = strlen( $record ); $offset < $len; $offset += $itemLength ) { |
360 | if ( !preg_match( |
361 | '/Q([0-9A-F]{8})-A([0-9A-F]{8})-S([+-][0-9]{10})--/A', |
362 | $record, |
363 | $m, |
364 | 0, |
365 | $offset |
366 | ) ) { |
367 | wfDebug( __METHOD__ . ": regex doesn't match\n" ); |
368 | return false; |
369 | } |
370 | |
371 | $qid = intval( base_convert( $m[1], 16, 10 ) ); |
372 | $oid = intval( base_convert( $m[2], 16, 10 ) ); |
373 | $score = intval( $m[3] ); |
374 | if ( !isset( $questions[$qid] ) ) { |
375 | wfDebug( __METHOD__ . ": invalid question ID\n" ); |
376 | return false; |
377 | } |
378 | |
379 | [ $min, $max ] = $this->getMinMax( $questions[$qid] ); |
380 | if ( $score < $min || $score > $max ) { |
381 | wfDebug( __METHOD__ . ": score out of range\n" ); |
382 | return false; |
383 | } |
384 | |
385 | $scores[$qid][$oid] = $score; |
386 | } |
387 | |
388 | return $scores; |
389 | } |
390 | |
391 | /** @inheritDoc */ |
392 | public function convertScores( $scores, $params = [] ) { |
393 | $result = []; |
394 | foreach ( $this->election->getQuestions() as $question ) { |
395 | $qid = $question->getId(); |
396 | if ( !isset( $scores[$qid] ) ) { |
397 | return false; |
398 | } |
399 | $s = ''; |
400 | $qscores = $scores[$qid]; |
401 | ksort( $qscores ); |
402 | $first = true; |
403 | foreach ( $qscores as $score ) { |
404 | if ( $first ) { |
405 | $first = false; |
406 | } else { |
407 | $s .= ', '; |
408 | } |
409 | $s .= $score; |
410 | } |
411 | $result[$qid] = $s; |
412 | } |
413 | |
414 | return $result; |
415 | } |
416 | } |