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