Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.74% covered (success)
95.74%
135 / 141
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
STVBallot
95.74% covered (success)
95.74%
135 / 141
50.00% covered (danger)
50.00%
4 / 8
29
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
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
5
 submitQuestion
100.00% covered (success)
100.00%
52 / 52
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
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Ballots;
4
5use MediaWiki\Extension\SecurePoll\Entities\Question;
6use MediaWiki\Extension\SecurePoll\Pages\CreatePage;
7use OOUI\DropdownInputWidget;
8use OOUI\FieldsetLayout;
9
10/**
11 * A STVBallot class,
12 * Currently work in progress see T282015.
13 */
14class STVBallot extends Ballot {
15
16    /** @var bool */
17    private $seatsLimit = false;
18
19    /** @var int */
20    private $numberOfSeats = 1;
21
22    /**
23     * Get a list of names of tallying methods, which may be used to produce a
24     * result from this ballot type.
25     * @return array
26     */
27    public static function getTallyTypes(): array {
28        return [
29            'droop-quota'
30        ];
31    }
32
33    public static function getCreateDescriptors() {
34        $description = parent::getCreateDescriptors();
35        $description['question'] += [
36            'min-seats' => [
37                'label-message' => 'securepoll-create-label-seat_count',
38                'type' => 'int',
39                'min' => 1,
40                'validation-callback' => [
41                    CreatePage::class,
42                    'checkRequired',
43                ],
44                'SecurePoll_type' => 'property',
45            ],
46            'limit-seats' => [
47                'label-message' => 'securepoll-create-label-limit-seats_input',
48                'type' => 'check',
49                'hidelabel' => true,
50                'SecurePoll_type' => 'property',
51            ],
52        ];
53        return $description;
54    }
55
56    /**
57     * @param Question $question
58     * @param array $options
59     * @return FieldsetLayout
60     */
61    public function getQuestionForm( $question, $options ): FieldsetLayout {
62        $name = 'securepoll_q' . $question->getId();
63        $fieldset = new \OOUI\FieldsetLayout();
64        $request = $this->getRequest();
65        $this->seatsLimit = $question->getProperty( 'limit-seats' );
66        $this->numberOfSeats = $question->getProperty( 'min-seats' );
67
68        $allOptions = [
69            [
70                'data' => 0,
71                'label' => $this->msg( 'securepoll-stv-droop-default-value' )->text(),
72            ]
73        ];
74        foreach ( $options as $option ) {
75            $allOptions[] = [
76                'data' => $option->getId(),
77                'label' => $option->parseMessageInline( 'text' ),
78            ];
79        }
80        $numberOfOptions = count( $options );
81        for ( $i = 0; $i < $numberOfOptions; $i++ ) {
82            if ( $this->numberOfSeatsReached( $i ) ) {
83                break;
84            }
85            $inputId = "{$name}_opt{$i}";
86            $widget = new DropdownInputWidget( [
87                'infusable' => true,
88                'name' => $inputId,
89                'options' => $allOptions,
90                'classes' => [ 'securepoll-stvballot-option-dropdown' ],
91                'value' => $request->getVal( $inputId, '0' ),
92            ] );
93            $fieldset->appendContent( new \OOUI\FieldLayout(
94                $widget,
95                [
96                    'classes' => [ 'securepoll-option-preferential' ],
97                    'label' => $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 ),
98                    'errors' => isset( $this->prevErrorIds[$inputId] ) ? [
99                        $this->prevStatus->spGetMessageText( $inputId )
100                        ] : null,
101                    'align' => 'top',
102                ]
103            ) );
104        }
105
106        return $fieldset;
107    }
108
109    /**
110     * @param Question $question
111     * @param BallotStatus $status
112     * @return string|null
113     */
114    public function submitQuestion( $question, $status ): ?string {
115        $ok = true;
116        // Construct the ranking array
117        $options = $question->getOptions();
118        $rankedChoices = [];
119        foreach ( $options as $rank => $option ) {
120            $id = 'securepoll_q' . $question->getId() . '_opt' . $rank;
121            $rankedChoices[] = $this->getRequest()->getVal( $id );
122        }
123
124        // Remove trailing blank options
125        $i = count( $rankedChoices ) - 1;
126        while ( $i >= 0 ) {
127            if ( !$rankedChoices[$i] ) {
128                array_pop( $rankedChoices );
129                $i--;
130            } else {
131                break;
132            }
133        }
134
135        // Check that at least one choice was selected
136        if ( !count( $rankedChoices ) ) {
137            $status->fatal( 'securepoll-stv-invalid-rank-empty' );
138            $ok = false;
139        }
140
141        // Check that choices are ranked sequentially
142        if ( count( array_filter( $rankedChoices ) ) !== count( $rankedChoices ) ) {
143            // Get ids of empty options
144            $emptyRanks = [];
145            foreach ( $rankedChoices as $i => $choice ) {
146                if ( $choice === '0' ) {
147                    $emptyRanks[] = $this->msg( 'securepoll-stv-droop-choice-rank', $i + 1 );
148                    $status->spFatal(
149                        'securepoll-stv-invalid-input-empty',
150                        'securepoll_q' . $question->getId() . '_opt' . $i,
151                        true
152                    );
153                }
154            }
155            $emptyRanks = implode( ', ', $emptyRanks );
156            $status->fatal( 'securepoll-stv-invalid-rank-order', $emptyRanks );
157            $ok = false;
158        }
159
160        // Check that choices are unique
161        $uniqueChoices = array_unique( $rankedChoices );
162        if ( count( $uniqueChoices ) !== count( $rankedChoices ) ) {
163            // Get ids of duplicate options
164            $duplicateChoiceIds = array_keys( array_diff_assoc( $rankedChoices, $uniqueChoices ) );
165            $duplicateChoices = [];
166            foreach ( $duplicateChoiceIds as $id ) {
167                if ( $rankedChoices[ $id ] !== '0' ) {
168                    $duplicateChoices[] = $this->msg(
169                        'securepoll-stv-droop-choice-rank', $id + 1
170                    );
171                    $status->spFatal(
172                        'securepoll-stv-invalid-input-duplicate',
173                        'securepoll_q' . $question->getId() . '_opt' . $id,
174                        true
175                    );
176                }
177            }
178            // Check against the count to avoid edge case of only multiple empty inputs
179            if ( count( $duplicateChoices ) ) {
180                $duplicateChoices = implode( ', ', $duplicateChoices );
181                $status->fatal( 'securepoll-stv-invalid-rank-duplicate', $duplicateChoices );
182            }
183            $ok = false;
184        }
185
186        if ( !$ok ) {
187            return null;
188        }
189
190        // Input ok; write the record
191        // Q{question id in hexadecimal, padded to 8 chars}-C{choice id in hex, padded}-R{rank in hex, padded}--
192        $record = '';
193        foreach ( $rankedChoices as $rank => $choice ) {
194            $record .= $this->packRecord( $question, $choice, $rank );
195        }
196
197        return $record;
198    }
199
200    public function packRecord( $question, $choice, $rank ) {
201        return sprintf(
202            'Q%08X-C%08X-R%08X--',
203            $question->getId(),
204            $choice,
205            $rank
206        );
207    }
208
209    /**
210     * If the record is valid, return an array of optionIds in ranked order for each questionId
211     * @param string $record
212     * @return array|bool
213     */
214    public function unpackRecord( $record ) {
215        $ranks = [];
216        $itemLength = 3 * 8 + 7;
217        for ( $offset = 0, $len = strlen( $record ); $offset < $len; $offset += $itemLength ) {
218            if ( !preg_match(
219                '/Q([0-9A-F]{8})-C([0-9A-F]{8})-R([0-9A-F]{8})--/A',
220                $record,
221                $m,
222                0,
223                $offset
224            )
225            ) {
226                wfDebug( __METHOD__ . ": regex doesn't match\n" );
227
228                return false;
229            }
230            $qid = intval( base_convert( $m[1], 16, 10 ) );
231            $oid = intval( base_convert( $m[2], 16, 10 ) );
232            $rank = intval( base_convert( $m[3], 16, 10 ) );
233            $ranks[$qid][$rank] = $oid;
234        }
235
236        return $ranks;
237    }
238
239    /**
240     * @param array $scores
241     * @param array $options
242     * @return string|string[]|false
243     */
244    public function convertScores( $scores, $options = [] ) {
245        // TODO: Implement convertScores() method.
246        return [];
247    }
248
249    /**
250     *
251     * @param int $count
252     * @return bool
253     */
254    private function numberOfSeatsReached( $count ) {
255        if ( $this->seatsLimit && $count >= $this->numberOfSeats ) {
256            return true;
257        }
258        return false;
259    }
260}