Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
17.81% |
13 / 73 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
DumpElection | |
17.81% |
13 / 73 |
|
0.00% |
0 / 5 |
431.77 | |
0.00% |
0 / 1 |
createXMLDump | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
3.07 | |||
createBLTDump | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
generateBlt | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
72 | |||
createBltVoteRows | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
72 | |||
ensureDoubleQuoted | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll; |
4 | |
5 | use MediaWiki\Extension\SecurePoll\Entities\Election; |
6 | use MediaWiki\Extension\SecurePoll\Entities\Question; |
7 | use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException; |
8 | |
9 | class DumpElection { |
10 | |
11 | private const MAX_BLT_NAME_LENGTH = 20; |
12 | |
13 | /** |
14 | * @param Election $election |
15 | * @param array $confOptions |
16 | * @param bool $withVotes |
17 | * |
18 | * Election conf xml options are: |
19 | * - jump: boolean |
20 | * - langs: array |
21 | * - private: boolean |
22 | * |
23 | * @return string |
24 | * @throws InvalidDataException |
25 | */ |
26 | public static function createXMLDump( $election, $confOptions = [], $withVotes = true ) { |
27 | $confXml = $election->getConfXml( $confOptions ); |
28 | $dump = "<SecurePoll>\n<election>\n$confXml"; |
29 | |
30 | if ( $withVotes ) { |
31 | $status = $election->dumpVotesToCallback( static function ( $election, $row ) use ( &$dump ) { |
32 | $dump .= "<vote>\n" . htmlspecialchars( rtrim( $row->vote_record ) ) . "\n</vote>\n"; |
33 | } ); |
34 | |
35 | if ( !$status->isOK() ) { |
36 | throw new InvalidDataException( $status->getWikiText() ); |
37 | } |
38 | } |
39 | |
40 | $dump .= "</election>\n</SecurePoll>\n"; |
41 | |
42 | return $dump; |
43 | } |
44 | |
45 | /** |
46 | * @param Election $election |
47 | * |
48 | * @return string |
49 | * @throws InvalidDataException |
50 | */ |
51 | public static function createBLTDump( $election ) { |
52 | if ( $election->ballotType !== 'stv' ) { |
53 | throw new InvalidDataException( wfMessage( 'securepoll-dump-blt-error-not-stv' )->plain() ); |
54 | } |
55 | |
56 | $questions = $election->getQuestions(); |
57 | if ( count( $questions ) !== 1 ) { |
58 | throw new InvalidDataException( wfMessage( 'securepoll-dump-blt-error-multiple-questions' )->plain() ); |
59 | } |
60 | |
61 | return self::generateBlt( $questions[0], $election ); |
62 | } |
63 | |
64 | /** |
65 | * Dump in BLT format. |
66 | * Reference: https://svn.apache.org/repos/asf/steve/trunk/stv_background/meekm.pdf |
67 | * |
68 | * @param Question $question |
69 | * @param Election $election |
70 | * |
71 | * @return string |
72 | * @throws InvalidDataException |
73 | */ |
74 | private static function generateBlt( $question, $election ) { |
75 | $title = $election->title; |
76 | |
77 | // Limit title to 20 characters |
78 | if ( $title && strlen( $title ) > self::MAX_BLT_NAME_LENGTH ) { |
79 | $title = substr( $title, 0, self::MAX_BLT_NAME_LENGTH ); |
80 | } |
81 | $title = self::ensureDoubleQuoted( $title ); |
82 | |
83 | $questionId = $question->getId(); |
84 | $candidates = []; |
85 | foreach ( $question->getOptions() as $option ) { |
86 | $candidates[$option->getId()] = self::ensureDoubleQuoted( $option->getMessage( 'text' ) ); |
87 | |
88 | // Limit candidate name to 20 characters |
89 | if ( strlen( $candidates[$option->getId()] ) > self::MAX_BLT_NAME_LENGTH ) { |
90 | $candidates[$option->getId()] = substr( $candidates[$option->getId()], 0, self::MAX_BLT_NAME_LENGTH ); |
91 | } |
92 | } |
93 | // Create candidate number mapping list |
94 | $candidateNumberMapping = []; |
95 | for ( $i = 0; $i < count( $candidates ); $i++ ) { |
96 | $candidateNumberMapping[array_keys( $candidates )[$i]] = $i + 1; |
97 | } |
98 | |
99 | $availableSeats = (int)$question->getProperty( 'min-seats' ); |
100 | $numberOfCandidates = count( $candidates ); |
101 | $voteRows = self::createBltVoteRows( $election, $questionId, $candidateNumberMapping ); |
102 | |
103 | // $voteRows can be empty if vote has no valid vote records. |
104 | // @phan-suppress-next-line MediaWikiNoEmptyIfDefined |
105 | if ( empty( $voteRows ) ) { |
106 | throw new InvalidDataException( wfMessage( 'securepoll-dump-blt-error-no-votes' )->plain() ); |
107 | } |
108 | |
109 | // Create BLT format |
110 | $blt = "$numberOfCandidates $availableSeats\n"; |
111 | $blt .= implode( "\n", $voteRows ); |
112 | $blt .= "\n0\n"; |
113 | foreach ( $candidateNumberMapping as $candidateId => $number ) { |
114 | $blt .= "$candidates[$candidateId]\n"; |
115 | } |
116 | $blt .= "$title\n"; |
117 | |
118 | return $blt; |
119 | } |
120 | |
121 | /** |
122 | * Converts database vote records to BLT format. |
123 | * Result example of one row: |
124 | * |
125 | * 2 2 4 3 0 |
126 | * |
127 | * 2 voters put |
128 | * candidate 2 first, |
129 | * candidate 4 second, |
130 | * candidate 3 third, |
131 | * and no more. |
132 | * Each such list must end with a zero. |
133 | * |
134 | * @param Election $election |
135 | * @param int $questionId |
136 | * @param array $candidateNumberMapping |
137 | * |
138 | * @return array |
139 | */ |
140 | private static function createBltVoteRows( $election, $questionId, $candidateNumberMapping ) { |
141 | $ballot = $election->getBallot(); |
142 | |
143 | // Unpack all records into ranked votes. |
144 | // Votes are in random order. |
145 | $records = []; |
146 | $election->dumpVotesToCallback( static function ( $election, $row ) use ( $questionId, $ballot, &$records ) { |
147 | $voteRecord = $row->vote_record; |
148 | |
149 | // Sometimes the vote record is an JSON string with key "vote" in database |
150 | $jsonVoteRecord = json_decode( $voteRecord ); |
151 | if ( $jsonVoteRecord && isset( $jsonVoteRecord->vote ) ) { |
152 | $voteRecord = $jsonVoteRecord->vote; |
153 | } |
154 | |
155 | $record = $ballot->unpackRecord( $voteRecord ); |
156 | |
157 | if ( !isset( $record[$questionId] ) ) { |
158 | return; |
159 | } |
160 | |
161 | $records[] = $record[$questionId]; |
162 | } ); |
163 | |
164 | // Convert ranked votes to BLT format |
165 | $bltVoteRows = []; |
166 | foreach ( $records as $record ) { |
167 | $bltVoteRow = implode( " ", array_map( static function ( $optionId ) use ( $candidateNumberMapping ) { |
168 | // Sometimes the vote record is already the candidate number instead of the option ID |
169 | if ( isset( $candidateNumberMapping[$optionId] ) ) { |
170 | return $candidateNumberMapping[$optionId]; |
171 | } |
172 | |
173 | return $optionId; |
174 | }, $record ) ); |
175 | |
176 | // Each row must end with a zero |
177 | $bltVoteRow .= " 0"; |
178 | |
179 | // Count the number of times each equal row appears |
180 | if ( !isset( $bltVoteRows[$bltVoteRow] ) ) { |
181 | $bltVoteRows[$bltVoteRow] = 1; |
182 | |
183 | continue; |
184 | } |
185 | |
186 | $bltVoteRows[$bltVoteRow]++; |
187 | } |
188 | |
189 | // Put the number of appearances at the front of each row |
190 | foreach ( $bltVoteRows as $voteRow => $count ) { |
191 | $bltVoteRows[$voteRow] = "$count $voteRow"; |
192 | } |
193 | |
194 | return array_values( $bltVoteRows ); |
195 | } |
196 | |
197 | private static function ensureDoubleQuoted( $string ) { |
198 | if ( !$string ) { |
199 | return ""; |
200 | } |
201 | |
202 | // Check if the string is already enclosed in double quotes |
203 | if ( strlen( $string ) >= 2 && $string[0] === '"' && $string[strlen( $string ) - 1] === '"' ) { |
204 | return $string; |
205 | } |
206 | |
207 | // If not, add double quotes around the string |
208 | return '"' . $string . '"'; |
209 | } |
210 | } |