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    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}