Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.76% covered (success)
96.76%
179 / 185
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
STVBallot
96.76% covered (success)
96.76%
179 / 185
55.56% covered (warning)
55.56%
5 / 9
33
0.00% covered (danger)
0.00%
0 / 1
 getTallyTypes
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getCreateDescriptors
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 getQuestionForm
98.75% covered (success)
98.75%
79 / 80
0.00% covered (danger)
0.00%
0 / 1
8
 submitQuestion
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
14
 packRecord
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 unpackRecord
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 convertScores
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 numberOfSeatsReached
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 getFixedFormatValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Ballots;
4
5use MediaWiki\Extension\SecurePoll\Entities\Question;
6use MediaWiki\Extension\SecurePoll\Pages\CreatePage;
7use OOUI\ComboBoxInputWidget;
8use OOUI\DropdownInputWidget;
9use OOUI\FieldLayout;
10use OOUI\FieldsetLayout;
11
12/**
13 * A STVBallot class,
14 * Currently work in progress see T282015.
15 */
16class STVBallot extends Ballot {
17
18    /** @var bool */
19    private $seatsLimit = false;
20
21    /** @var int */
22    private $numberOfSeats = 1;
23
24    /**
25     * Get a list of names of tallying methods, which may be used to produce a
26     * result from this ballot type.
27     * @return array
28     */
29    public static function getTallyTypes(): array {
30        return [
31            'droop-quota'
32        ];
33    }
34
35    /** @inheritDoc */
36    public static function getCreateDescriptors() {
37        $description = parent::getCreateDescriptors();
38        $description['question'] += [
39            'min-seats' => [
40                'label-message' => 'securepoll-create-label-seat_count',
41                'type' => 'int',
42                'min' => 1,
43                'validation-callback' => [
44                    CreatePage::class,
45                    'checkRequired',
46                ],
47                'SecurePoll_type' => 'property',
48            ],
49            'limit-seats' => [
50                'label-message' => 'securepoll-create-label-limit-seats_input',
51                'type' => 'check',
52                'hidelabel' => true,
53                'SecurePoll_type' => 'property',
54            ],
55        ];
56        return $description;
57    }
58
59    /**
60     * @param Question $question
61     * @param array $options
62     * @return FieldsetLayout
63     */
64    public function getQuestionForm( $question, $options ): FieldsetLayout {
65        // The formatting to a fixed digit number here is important because
66        // `page.vote.stv.js` sorts the form entries alphabetically.
67        $name = 'securepoll_q' . $this->getFixedFormatValue( $question->getId() );
68        $fieldset = new FieldsetLayout();
69        $request = $this->getRequest();
70        $this->seatsLimit = $question->getProperty( 'limit-seats' );
71        $this->numberOfSeats = $question->getProperty( 'min-seats' );
72
73        $data = [
74            'maxSeats' => $this->seatsLimit ? $this->numberOfSeats : count( $options ),
75            'selectedItems' => []
76        ];
77
78        // Because the combobox -> boxmenu -> draggable pipeline doesn't pass along
79        // an id (for ux reasons, passing the id would result in exposing the id to
80        // the user), save an array of value => id pairs to re-associate the value with
81        // the id on form submission. This will transform the user-visible values back
82        // into ids the ballot knows how to process.
83        $allCandidates = [];
84
85        $allOptions = [
86            [
87                'data' => 0,
88                'label' => $this->msg( 'securepoll-stv-droop-default-value' )->text(),
89            ]
90        ];
91        $dragAndDropComboOptions = [];
92        foreach ( $options as $key => $option ) {
93            $formattedKey = $this->getFixedFormatValue( $key );
94            // Restore rankings from request if available
95            $selectedVal = $request->getVal( $name . "_opt" . $formattedKey );
96            if ( $selectedVal ) {
97                $data[ 'selectedItems' ][] = [
98                    'option' => $name . "_opt" . $formattedKey,
99                    'itemKey' => $key
100                ];
101            }
102
103            $allCandidates[ $option->parseMessageInline( 'text' ) ] = $option->getId();
104
105            // Pass the candidate's name as both the value and the label by setting it
106            // as the data attribute.
107            // It would be ideal to correctly use the name as the label and the option id as
108            // the value but the combobox type will show the value instead of the label
109            // in its textarea (eg. selecting a candidate will reveal the option id in the UI).
110            // See https://stackoverflow.com/questions/42502268/label-data-mechanism-in-oo-ui-comboboxinputwidget
111            $allOptions[] = [
112                'data' => $option->getId(),
113                'label' => $option->parseMessageInline( 'text' ),
114            ];
115            $dragAndDropComboOptions[] = [
116                'data' => $option->parseMessageInline( 'text' ),
117                'disable' => true,
118                'select' => true,
119            ];
120        }
121        $data[ 'candidates' ] = $allCandidates;
122
123        $numberOfOptions = count( $options );
124        for ( $i = 0; $i < $numberOfOptions; $i++ ) {
125            if ( $this->numberOfSeatsReached( $i ) ) {
126                break;
127            }
128            // The formatting to a fixed digit number here is important because
129            // `page.vote.stv.js` sorts the form entries alphabetically.
130            $formattedKey = $this->getFixedFormatValue( $i );
131            $inputId = "{$name}_opt{$formattedKey}";
132            $widget = new DropdownInputWidget( [
133                'infusable' => true,
134                'name' => $inputId,
135                'options' => $allOptions,
136                'classes' => [ 'securepoll-stvballot-option-dropdown' ],
137                'value' => $request->getVal( $inputId, '0' ),
138            ] );
139            $fieldset->appendContent( new FieldLayout(
140                $widget,
141                [
142                    'classes' => [ 'securepoll-option-preferential', 'securepoll-option-stv-dropdown' ],
143                    'label' => $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 ),
144                    'errors' => isset( $this->prevErrorIds[$inputId] ) ? [
145                        $this->prevStatus->spGetMessageText( $inputId )
146                        ] : null,
147                    'align' => 'top',
148                ]
149            ) );
150        }
151
152        $widget = new ComboBoxInputWidget( [
153            'infusable' => true,
154            'tagName' => $name,
155            'options' => $dragAndDropComboOptions,
156            'autocomplete' => false,
157            'data' => $data,
158            'menu' => [
159                'filterFromInput' => true
160            ]
161        ] );
162
163        $fieldset->appendContent( new FieldLayout(
164            $widget,
165            [
166                'infusable' => true,
167                'classes' => [ "securepoll-option-preferential", 'securepoll-option-stv-combobox' ],
168                'errors' => isset( $this->prevErrorIds[ $name ] ) ? [
169                    $this->prevStatus->spGetMessageText( $name )
170                    ] : null,
171                'align' => 'top'
172            ]
173        ) );
174
175        return $fieldset;
176    }
177
178    /**
179     * @param Question $question
180     * @param BallotStatus $status
181     * @return string|null
182     */
183    public function submitQuestion( $question, $status ): ?string {
184        $ok = true;
185        // Construct the ranking array
186        $options = $question->getOptions();
187        $rankedChoices = [];
188        foreach ( $options as $rank => $option ) {
189            $formattedId = $this->getFixedFormatValue( $question->getId() );
190            $formattedRank = $this->getFixedFormatValue( $rank );
191            $id = 'securepoll_q' . $formattedId . '_opt' . $formattedRank;
192            $rankedChoices[] = $this->getRequest()->getVal( $id );
193        }
194
195        // Remove trailing blank options
196        $i = count( $rankedChoices ) - 1;
197        while ( $i >= 0 ) {
198            if ( !$rankedChoices[$i] ) {
199                array_pop( $rankedChoices );
200                $i--;
201            } else {
202                break;
203            }
204        }
205
206        // Check that at least one choice was selected
207        if ( !count( $rankedChoices ) ) {
208            $status->fatal( 'securepoll-stv-invalid-rank-empty' );
209            $ok = false;
210        }
211
212        // Check that choices are ranked sequentially
213        if ( count( array_filter( $rankedChoices ) ) !== count( $rankedChoices ) ) {
214            // Get ids of empty options
215            $emptyRanks = [];
216            foreach ( $rankedChoices as $i => $choice ) {
217                if ( $choice === '0' ) {
218                    $emptyRanks[] = $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 );
219                    $status->spFatal(
220                        'securepoll-stv-invalid-input-empty',
221                        'securepoll_q' . $question->getId() . '_opt' . $i,
222                        true
223                    );
224                }
225            }
226            $emptyRanks = implode( ', ', $emptyRanks );
227            $status->fatal( 'securepoll-stv-invalid-rank-order', $emptyRanks );
228            $ok = false;
229        }
230
231        // Check that choices are unique
232        $uniqueChoices = array_unique( $rankedChoices );
233        if ( count( $uniqueChoices ) !== count( $rankedChoices ) ) {
234            // Get ids of duplicate options
235            $duplicateChoiceIds = array_keys( array_diff_assoc( $rankedChoices, $uniqueChoices ) );
236            $duplicateChoices = [];
237            foreach ( $duplicateChoiceIds as $id ) {
238                if ( $rankedChoices[ $id ] !== '0' ) {
239                    $duplicateChoices[] = $this->msg(
240                        'securepoll-stv-droop-choice-rank', $id + 1
241                    );
242                    $status->spFatal(
243                        'securepoll-stv-invalid-input-duplicate',
244                        'securepoll_q' . $question->getId() . '_opt' . $id,
245                        true
246                    );
247                }
248            }
249            // Check against the count to avoid edge case of only multiple empty inputs
250            if ( count( $duplicateChoices ) ) {
251                $duplicateChoices = implode( ', ', $duplicateChoices );
252                $status->fatal( 'securepoll-stv-invalid-rank-duplicate', $duplicateChoices );
253            }
254            $ok = false;
255        }
256
257        if ( !$ok ) {
258            return null;
259        }
260
261        // Input ok; write the record
262        // Q{question id in hexadecimal, padded to 8 chars}-C{choice id in hex, padded}-R{rank in hex, padded}--
263        $record = '';
264        foreach ( $rankedChoices as $rank => $choice ) {
265            $record .= $this->packRecord( $question, $choice, $rank );
266        }
267
268        return $record;
269    }
270
271    /**
272     * @param Question $question
273     * @param string $choice
274     * @param int $rank
275     * @return string
276     */
277    public function packRecord( $question, $choice, $rank ) {
278        return sprintf(
279            'Q%08X-C%08X-R%08X--',
280            $question->getId(),
281            $choice,
282            $rank
283        );
284    }
285
286    /**
287     * If the record is valid, return an array of optionIds in ranked order for each questionId
288     * @param string $record
289     * @return array|bool
290     */
291    public function unpackRecord( $record ) {
292        $ranks = [];
293        $itemLength = 3 * 8 + 7;
294        for ( $offset = 0, $len = strlen( $record ); $offset < $len; $offset += $itemLength ) {
295            if ( !preg_match(
296                '/Q([0-9A-F]{8})-C([0-9A-F]{8})-R([0-9A-F]{8})--/A',
297                $record,
298                $m,
299                0,
300                $offset
301            )
302            ) {
303                wfDebug( __METHOD__ . ": regex doesn't match\n" );
304
305                return false;
306            }
307            $qid = intval( base_convert( $m[1], 16, 10 ) );
308            $oid = intval( base_convert( $m[2], 16, 10 ) );
309            $rank = intval( base_convert( $m[3], 16, 10 ) );
310            $ranks[$qid][$rank] = $oid;
311        }
312
313        return $ranks;
314    }
315
316    /**
317     * @param array $scores
318     * @param array $options
319     * @return string|string[]|false
320     */
321    public function convertScores( $scores, $options = [] ) {
322        // TODO: Implement convertScores() method.
323        return [];
324    }
325
326    /**
327     *
328     * @param int $count
329     * @return bool
330     */
331    private function numberOfSeatsReached( $count ) {
332        if ( $this->seatsLimit && $count >= $this->numberOfSeats ) {
333            return true;
334        }
335        return false;
336    }
337
338    /**
339     * Formats to a fixed digit number (as string). This ensures consistency
340     * when sorting alphabetically and doesn't mis-sort IDs into an order
341     * like 1, 10, 2, ...
342     *
343     * @param string|int $value
344     * @return string
345     */
346    private function getFixedFormatValue( $value ) {
347        return sprintf( '%07d', $value );
348    }
349}