Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.74% |
135 / 141 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
STVBallot | |
95.74% |
135 / 141 |
|
50.00% |
4 / 8 |
29 | |
0.00% |
0 / 1 |
getTallyTypes | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getCreateDescriptors | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
getQuestionForm | |
97.44% |
38 / 39 |
|
0.00% |
0 / 1 |
5 | |||
submitQuestion | |
100.00% |
52 / 52 |
|
100.00% |
1 / 1 |
14 | |||
packRecord | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
unpackRecord | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 | |||
convertScores | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
numberOfSeatsReached | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Ballots; |
4 | |
5 | use MediaWiki\Extension\SecurePoll\Entities\Question; |
6 | use MediaWiki\Extension\SecurePoll\Pages\CreatePage; |
7 | use OOUI\DropdownInputWidget; |
8 | use OOUI\FieldsetLayout; |
9 | |
10 | /** |
11 | * A STVBallot class, |
12 | * Currently work in progress see T282015. |
13 | */ |
14 | class 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 | } |