Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.81% covered (danger)
17.81%
13 / 73
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DumpElection
17.81% covered (danger)
17.81%
13 / 73
0.00% covered (danger)
0.00%
0 / 5
431.77
0.00% covered (danger)
0.00%
0 / 1
 createXMLDump
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 createBLTDump
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 generateBlt
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 createBltVoteRows
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 ensureDoubleQuoted
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\SecurePoll;
4
5use MediaWiki\Extension\SecurePoll\Entities\Election;
6use MediaWiki\Extension\SecurePoll\Entities\Question;
7use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
8
9class 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}