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