Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.45% covered (warning)
87.45%
216 / 247
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
HtmlFormatter
87.45% covered (warning)
87.45%
216 / 247
28.57% covered (danger)
28.57%
2 / 7
29.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 formatPreamble
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
5
 formatRoundsPreamble
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 formatRound
94.37% covered (success)
94.37%
134 / 142
0.00% covered (danger)
0.00%
0 / 1
16.05
 formatForNumParams
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCandidateName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastEliminated
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Talliers\STVFormatter;
4
5use MediaWiki\Context\RequestContext;
6use MediaWiki\Output\OutputPage;
7use OOUI\HtmlSnippet;
8use OOUI\PanelLayout;
9use OOUI\Tag;
10
11class HtmlFormatter implements STVFormatter {
12
13    protected const DISPLAY_PRECISION = 6;
14
15    /**
16     * Number of seats to be filled.
17     * @var int
18     */
19    protected $seats;
20
21    /**
22     * An array of results, gets populated per round
23     * holds current round, both elected and eliminated candidate and the total votes per round.
24     * @var array[]
25     */
26    protected $resultsLog;
27
28    /**
29     * An array of vote combinations keyed by underscore-delimited
30     * and ranked options. Each vote has a rank array (which allows
31     * index-1 access to each ranked option) and a count
32     * @var array
33     */
34    protected $rankedVotes;
35
36    /**
37     * An array of all candidates in the election.
38     * @var array<int, string>
39     */
40    protected $candidates = [];
41
42    /**
43     *
44     * @param array $resultLogs
45     * @param array $rankedVotes
46     * @param int $seats
47     * @param array $candidates
48     */
49    public function __construct( $resultLogs, $rankedVotes, $seats, $candidates ) {
50        $context = RequestContext::getMain();
51        OutputPage::setupOOUI(
52                strtolower( $context->getSkin()->getSkinName() ),
53                $context->getLanguage()->getDir()
54        );
55
56        $this->resultsLog = $resultLogs;
57        $this->seats = $seats;
58        $this->rankedVotes = $rankedVotes;
59        $this->candidates = $candidates;
60    }
61
62    public function formatPreamble( array $elected, array $eliminated ) {
63        // Generate overview of elected candidates
64        $electionSummary = new PanelLayout( [
65            'expanded' => false,
66            'content' => [],
67        ] );
68        $electionSummary->appendContent(
69            ( new Tag( 'h2' ) )->appendContent(
70                wfMessage( 'securepoll-stv-result-election-elected-header' )
71            )
72        );
73
74        $totalVotes = array_reduce( $this->rankedVotes, static function ( $count, $ballot ) {
75            return $count + $ballot['count'];
76        }, 0 );
77        $electionSummary->appendContent(
78            ( new Tag( 'p' ) )->appendContent(
79                wfMessage( 'securepoll-stv-election-paramters' )
80                    ->numParams(
81                        $this->seats,
82                        count( $this->candidates ),
83                        $totalVotes
84                    )
85            )
86        );
87
88        $electedList = ( new Tag( 'ol' ) )->addClasses( [ 'election-summary-elected-list' ] );
89        for ( $i = 0; $i < $this->seats; $i++ ) {
90            if ( isset( $elected[$i] ) ) {
91                $currentCandidate = $elected[$i];
92                $electedList->appendContent(
93                    ( new Tag( 'li' ) )->appendContent(
94                        $this->getCandidateName( $currentCandidate )
95                    )
96                );
97            } else {
98                $eliminated = $this->getLastEliminated( $this->resultsLog['rounds'] );
99                $formattedEliminated = implode( ', ', $eliminated );
100                $electedList->appendContent(
101                    ( new Tag( 'li' ) )->appendContent(
102                        ( new Tag( 'i' ) )->appendContent(
103                            wfMessage(
104                                'securepoll-stv-result-elected-list-unfilled-seat',
105                                $formattedEliminated
106                            )
107                        )
108                    )
109                );
110            }
111        }
112        $electionSummary->appendContent( $electedList );
113
114        // Generate overview of eliminated candidates
115        $electionSummary->appendContent(
116            ( new Tag( 'h2' ) )->appendContent(
117                wfMessage( 'securepoll-stv-result-election-eliminated-header' )
118            )
119        );
120        $eliminatedList = ( new Tag( 'ul' ) );
121        foreach ( $eliminated as $eliminatedCandidate ) {
122            $eliminatedList->appendContent(
123                ( new Tag( 'li' ) )->appendContent(
124                    $this->getCandidateName( $eliminatedCandidate )
125                )
126            );
127        }
128
129        // If any candidates weren't eliminated from the rounds (as a result of seats filling)
130        // Output them to the eliminated list after all of the round-eliminated candidates
131        foreach (
132            array_diff(
133                array_keys( $this->candidates ),
134                array_merge( $elected, $eliminated )
135        ) as $remainingCandidate ) {
136            $eliminatedList->appendContent(
137                ( new Tag( 'li' ) )->appendContent(
138                    $this->getCandidateName( $remainingCandidate )
139                )
140            );
141        }
142
143        $electionSummary->appendContent( $eliminatedList );
144        return $electionSummary;
145    }
146
147    public function formatRoundsPreamble() {
148        $electionRounds = new PanelLayout( [
149            'expanded' => false,
150            'content' => [],
151        ] );
152
153        $electionRounds->appendContent(
154            ( new Tag( 'h2' ) )->appendContent(
155                wfMessage( 'securepoll-stv-result-election-rounds-header' )
156            )
157        );
158
159        // Help text
160        $electionRounds->appendContent(
161            ( new Tag( 'p' ) )->appendContent(
162                new HtmlSnippet( wfMessage( 'securepoll-stv-help-text' )->parse() )
163            )
164        );
165        return $electionRounds;
166    }
167
168    public function formatRound() {
169        // Generate rounds table
170        $table = new Tag( 'table' );
171        $table->addClasses( [
172            'wikitable'
173        ] );
174
175        // thead
176        $table->appendContent(
177            ( new Tag( 'thead' ) )->appendContent( ( new Tag( 'tr' ) )->appendContent(
178                ( new Tag( 'th' ) )->appendContent(
179                    wfMessage( 'securepoll-stv-result-election-round-number-table-heading' )
180                ),
181                ( new Tag( 'th' ) )->appendContent(
182                    wfMessage( 'securepoll-stv-result-election-tally-table-heading' )
183                ),
184                ( new Tag( 'th' ) )->appendContent(
185                    wfMessage( 'securepoll-stv-result-election-result-table-heading' )
186                )
187            ) )
188        );
189
190        // tbody
191        $previouslyElected = [];
192        $previouslyEliminated = [];
193
194        $tbody = new Tag( 'tbody' );
195        foreach ( $this->resultsLog['rounds'] as $round ) {
196            $tr = new Tag( 'tr' );
197
198            // Round number
199            $tr->appendContent(
200                ( new Tag( 'td' ) )->appendContent(
201                    $round['round']
202                )
203            );
204
205            // Sort rankings before listing them
206            uksort( $round['rankings'], static function ( $aKey, $bKey ) use ( $round ) {
207                $a = $round['rankings'][$aKey];
208                $b = $round['rankings'][$bKey];
209                if ( $a['total'] === $b['total'] ) {
210                    // ascending sort
211                    return $aKey <=> $bKey;
212                }
213                // descending sort
214                return $b['total'] <=> $a['total'];
215            } );
216
217            $tally = ( new Tag( 'ol' ) )->addClasses( [ 'round-summary-tally-list' ] );
218            $votesTransferred = false;
219            foreach ( $round['rankings'] as $currentCandidate => $rank ) {
220                $content = $lineItem = ( new Tag( 'li' ) );
221
222                // Was the candidate eliminated this round?
223                $candidateEliminatedThisRound = in_array( $currentCandidate, $round['eliminated'] );
224
225                if ( $candidateEliminatedThisRound ) {
226                    $content = new Tag( 's' );
227                    $lineItem->appendContent( $content );
228                }
229
230                $name = $this->getCandidateName( $currentCandidate );
231                $nameContent = ( new Tag( 'span' ) )
232                    ->appendContent( wfMessage( 'securepoll-stv-result-candidate', $name ) )
233                    ->addClasses( [ 'round-summary-candidate-name' ] );
234                $nameContent->appendContent( ' ' );
235
236                $content->appendContent( $nameContent );
237
238                $candidateState = ( new Tag( 'span' ) )->addClasses( [ 'round-summary-candidate-votes' ] );
239
240                // Only show candidates who haven't been eliminated by this round
241                if ( in_array( $currentCandidate, $previouslyEliminated ) ) {
242                    continue;
243                }
244
245                $roundedVotes = round( $rank['votes'], self::DISPLAY_PRECISION );
246                $roundedTotal = round( $rank['total'], self::DISPLAY_PRECISION );
247
248                // Rounding doesn't guarantee accurate display. One value may be rounded up/down and another one
249                // left as-is, resulting in a discrepency of 1E-6
250                // Calculating the earned votes post-rounding simulates how earned votes are calculated by
251                // the algorithm and ensures that our display shows accurate math
252                $roundedEarned = round( $roundedTotal - $roundedVotes, self::DISPLAY_PRECISION );
253
254                $formattedVotes = $this->formatForNumParams( $roundedVotes );
255                $formattedTotal = $this->formatForNumParams( $roundedTotal );
256
257                // We select the votes-gain/-votes-surplus message based on the sign of
258                // $roundedEarned. However, those messages expect its absolute value.
259                $formattedEarned = $this->formatForNumParams( abs( $roundedEarned ) );
260
261                // Round 1 should just show the initial votes and is guaranteed to neither elect nor eliminate
262                $contentRound = '';
263                if ( $round['round'] === 1 ) {
264                    $contentRound = wfMessage( 'securepoll-stv-result-votes-no-change' )
265                        ->numParams( $formattedTotal );
266                } elseif ( $roundedEarned > 0 ) {
267                    $contentRound = wfMessage( 'securepoll-stv-result-votes-gain' )
268                        ->numParams(
269                            $formattedVotes,
270                            $formattedEarned,
271                            $formattedTotal
272                        );
273                    $votesTransferred = true;
274                } elseif ( $roundedEarned < 0 ) {
275                    $contentRound = wfMessage( 'securepoll-stv-result-votes-surplus' )
276                        ->numParams(
277                            $formattedVotes,
278                            $formattedEarned,
279                            $formattedTotal
280                        );
281                    $votesTransferred = true;
282                } else {
283                    $contentRound = wfMessage( 'securepoll-stv-result-votes-no-change' )
284                        ->numParams( $formattedTotal );
285                }
286                $candidateState->appendContent( $contentRound );
287
288                if ( in_array( $currentCandidate, $round['elected'] ) ) {
289                    $content->addClasses( [ 'round-candidate-elected' ] );
290
291                    // Mark the candidate as having been previously elected (for display purposes only).
292                    $previouslyElected[] = $currentCandidate;
293                } elseif ( in_array( $currentCandidate, $previouslyElected ) ) {
294                    $content->addClasses( [ 'previously-elected' ] );
295                    $formattedParams = $this->formatForNumParams( $round['keepFactors'][$currentCandidate] );
296                    $candidateState
297                        ->appendContent( ' ' )
298                        ->appendContent(
299                            wfMessage( 'securepoll-stv-result-round-keep-factor' )
300                            ->numParams( $formattedParams )
301                        );
302                } elseif ( $candidateEliminatedThisRound ) {
303                    // Mark the candidate as having been previously eliminated (for display purposes only).
304                    $previouslyEliminated[] = $currentCandidate;
305                }
306
307                $content->appendContent( $candidateState );
308
309                $tally->appendContent( $lineItem );
310            }
311            $tr->appendContent(
312                ( new Tag( 'td' ) )->appendContent(
313                    $tally
314                )
315            );
316
317            // Result
318            $roundResults = new Tag( 'td' );
319
320            // Quota
321            $roundResults->appendContent(
322                wfMessage( 'securepoll-stv-result-round-quota' )
323                    ->numParams( $this->formatForNumParams( $round['quota'] ) )
324            );
325            $roundResults->appendContent( new Tag( 'br' ) );
326
327            // Elected
328            if ( count( $round['elected'] ) ) {
329                $electCandidates = array_map( [ $this, 'getCandidateName' ], $round['elected'] );
330                $formattedElectCandidates = implode(
331                    ', ',
332                    $electCandidates
333                );
334                $roundResults
335                    ->appendContent(
336                        wfMessage(
337                            'securepoll-stv-result-round-elected',
338                            $formattedElectCandidates
339                        )
340                    )
341                    ->appendContent( new Tag( 'br' ) );
342            }
343
344            // Eliminated
345            if ( $round['eliminated'] !== null && count( $round['eliminated'] ) > 0 ) {
346                $eliminatedCandidates = array_map( [ $this, 'getCandidateName' ], $round['eliminated'] );
347                $formattedElimCandidates = implode( ', ', $eliminatedCandidates );
348                $roundResults
349                    ->appendContent(
350                        wfMessage(
351                            'securepoll-stv-result-round-eliminated',
352                            $formattedElimCandidates
353                        )
354                    )
355                    ->appendContent( new Tag( 'br' ) );
356            }
357
358            // Votes transferred
359            if ( $votesTransferred ) {
360                $roundResults
361                    ->appendContent( wfMessage( 'securepoll-stv-votes-transferred' ) )
362                    ->appendContent( new Tag( 'br' ) );
363            }
364
365            $tr->appendContent(
366                $roundResults
367            );
368
369            $tbody->appendContent( $tr );
370            $table->appendContent( $tbody );
371        }
372        return $table;
373    }
374
375    /**
376     * Prep numbers in advance to round before display.
377     *
378     * There's a lot to unpack here:
379     * 1. Check if the value is an integer by transforming it into a 6-precision string
380     *    representation and ensuring it's in the format x.000000
381     * 2. If it's an integer, set it to the rounded value so that numParams will display an integer
382     *    and not a floated value like 0.000000
383     * 3. If it's not an integer, force it into the decimal representation before passing it
384     *    to numParams. Not doing this will pass the number in scientific notation (eg. 1E-6)
385     *    which has the tendency to become 0 somewhere in the number formatting pipeline
386     *
387     * @param float $n
388     * @return string|float
389     */
390    protected function formatForNumParams( float $n ) {
391        $formatted = number_format( $n, self::DISPLAY_PRECISION, '.', '' );
392        if ( preg_match( '/\.0+$/', $formatted ) ) {
393            return round( $n, self::DISPLAY_PRECISION );
394        }
395        return $formatted;
396    }
397
398    /**
399     * Given a candidate id, return the candidate name
400     * @param int $id
401     * @return string
402     */
403    protected function getCandidateName( $id ) {
404        return $this->candidates[$id];
405    }
406
407    /**
408     * Given the rounds of an election, return the last set
409     * of eliminated candidates by their candidate name
410     * @param array $rounds
411     * @return string[]
412     */
413    protected function getLastEliminated( $rounds ) {
414        $eliminationRounds = array_filter( $rounds, static function ( $round ) {
415            return $round['eliminated'];
416        } );
417        if ( $eliminationRounds ) {
418            $eliminated = array_pop( $eliminationRounds )['eliminated'];
419            return array_map( static function ( $candidateId ) {
420                return $candidateId;
421            }, $eliminated );
422        }
423        return [];
424    }
425}