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