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;
12use OOUI\Element;
13use OOUI\FieldsetLayout;
14use OOUI\HtmlSnippet;
15use OOUI\RadioInputWidget;
16use 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 */
34class 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}