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