Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
20.33% covered (danger)
20.33%
50 / 246
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
RadioRangeBallot
20.33% covered (danger)
20.33%
50 / 246
0.00% covered (danger)
0.00%
0 / 12
1164.27
0.00% covered (danger)
0.00%
0 / 1
 getTallyTypes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCreateDescriptors
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
2
 getMinMax
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getColumnDirection
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getScoresLeftToRight
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getColumnLabels
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getMessageNames
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 addSign
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getQuestionForm
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
20
 submitQuestion
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
8
 unpackRecord
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 convertScores
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Ballots;
4
5use InvalidArgumentException;
6use MediaWiki\Extension\SecurePoll\Entities\Entity;
7use MediaWiki\Extension\SecurePoll\Entities\Question;
8use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
9use MediaWiki\Extension\SecurePoll\HtmlForm\HTMLFormRadioRangeColumnLabels;
10use MediaWiki\Extension\SecurePoll\Pages\CreatePage;
11use 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 */
29class 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}