Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.16% covered (danger)
35.16%
32 / 91
18.75% covered (danger)
18.75%
3 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ballot
35.16% covered (danger)
35.16%
32 / 91
18.75% covered (danger)
18.75%
3 / 16
225.68
0.00% covered (danger)
0.00%
0 / 1
 getTallyTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCreateDescriptors
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestionForm
n/a
0 / 0
n/a
0 / 0
0
 getMessageNames
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequest
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getMessageLocalizer
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 msg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserLang
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initRequest
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 submitForm
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 submitQuestion
n/a
0 / 0
n/a
0 / 0
0
 unpackRecord
n/a
0 / 0
n/a
0 / 0
0
 convertRecord
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 convertScores
n/a
0 / 0
n/a
0 / 0
0
 factory
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getForm
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
5.01
 setErrorStatus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 errorLocationIndicator
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 formatStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Ballots;
4
5use InvalidArgumentException;
6use LogicException;
7use MediaWiki\Extension\SecurePoll\Context;
8use MediaWiki\Extension\SecurePoll\Entities\Election;
9use MediaWiki\Extension\SecurePoll\Entities\Entity;
10use MediaWiki\Extension\SecurePoll\Entities\Question;
11use MediaWiki\Language\Language;
12use MediaWiki\Message\Message;
13use MediaWiki\Request\WebRequest;
14use MediaWiki\Status\Status;
15use MessageLocalizer;
16use OOUI\Element;
17use OOUI\FieldsetLayout;
18use OOUI\HtmlSnippet;
19use OOUI\IconWidget;
20
21/**
22 * Parent class for ballot forms. This is the UI component of a voting method.
23 */
24abstract class Ballot {
25    /** @var Election */
26    public $election;
27    /** @var Context */
28    public $context;
29    /** @var string|null */
30    public $currentVote;
31    /** @var true[]|null */
32    public $prevErrorIds;
33    /** @var true[]|null */
34    public $usedErrorIds;
35    /** @var BallotStatus|null */
36    public $prevStatus;
37    /** @var WebRequest|null */
38    private $request;
39    /** @var MessageLocalizer|null */
40    private $messageLocalizer;
41    /** @var Language|null */
42    private $userLang;
43
44    /** @var array<string,class-string<Ballot>> */
45    public const BALLOT_TYPES = [
46        'approval' => ApprovalBallot::class,
47        'preferential' => PreferentialBallot::class,
48        'choose' => ChooseBallot::class,
49        'radio-range' => RadioRangeBallot::class,
50        'radio-range-comment' => RadioRangeCommentBallot::class,
51        'stv' => STVBallot::class,
52    ];
53
54    /**
55     * Get a list of names of tallying methods, which may be used to produce a
56     * result from this ballot type.
57     * @return array
58     */
59    public static function getTallyTypes() {
60        throw new LogicException( "Subclass must override ::getTallyTypes()" );
61    }
62
63    /**
64     * Return descriptors for any properties this type requires for poll
65     * creation, for the election, questions, and options.
66     *
67     * The returned array should have three keys, "election", "question", and
68     * "option", each mapping to an array of HTMLForm descriptors.
69     *
70     * The descriptors should have an additional key, "SecurePoll_type", with
71     * the value being "property" or "message".
72     *
73     * @return array
74     */
75    public static function getCreateDescriptors() {
76        return [
77            'election' => [
78                'shuffle-questions' => [
79                    'label-message' => 'securepoll-create-label-shuffle_questions',
80                    'type' => 'check',
81                    'hidelabel' => true,
82                    'SecurePoll_type' => 'property',
83                ],
84                'shuffle-options' => [
85                    'label-message' => 'securepoll-create-label-shuffle_options',
86                    'type' => 'check',
87                    'hidelabel' => true,
88                    'SecurePoll_type' => 'property',
89                ],
90            ],
91            'question' => [],
92            'option' => [],
93        ];
94    }
95
96    /**
97     * Get the HTML form segment for a single question
98     * @param Question $question
99     * @param array $options Array of options, in the order they should be displayed
100     * @return FieldsetLayout
101     */
102    abstract public function getQuestionForm( $question, $options );
103
104    /**
105     * Get any extra messages that this ballot type uses to render questions.
106     * Used to get the list of translatable messages for TranslatePage.
107     * @param Entity|null $entity
108     * @return array
109     * @see Election::getMessageNames()
110     */
111    public function getMessageNames( ?Entity $entity = null ) {
112        return [];
113    }
114
115    /**
116     * Get the request if it has been set, otherwise throw an exception.
117     *
118     * @return WebRequest
119     */
120    protected function getRequest(): WebRequest {
121        if ( !$this->request ) {
122            throw new LogicException(
123                'Ballot::initRequest() must be called before Ballot::getRequest()' );
124        }
125        return $this->request;
126    }
127
128    /**
129     * Get the MessageLocalizer if it has been set, otherwise throw an exception
130     *
131     * @return MessageLocalizer
132     */
133    private function getMessageLocalizer(): MessageLocalizer {
134        if ( !$this->messageLocalizer ) {
135            throw new LogicException(
136                'Ballot::initRequest() must be called before Ballot::getMessageLocalizer()' );
137        }
138        return $this->messageLocalizer;
139    }
140
141    /**
142     * Get a MediaWiki message. setMessageLocalizer() must have been called.
143     *
144     * This can be used instead of SecurePoll's native message system if the
145     * message does not vary depending on the election, and if there are no
146     * security concerns with allowing people who are not admins of the election
147     * to set the text.
148     *
149     * @param string $key
150     * @param mixed ...$params
151     * @return Message
152     */
153    protected function msg( $key, ...$params ) {
154        return $this->getMessageLocalizer()->msg( $key, ...$params );
155    }
156
157    /**
158     * Get the user language, or throw an exception if it has not been set.
159     * @return Language
160     */
161    protected function getUserLang(): Language {
162        return $this->userLang;
163    }
164
165    /**
166     * Set request dependencies
167     *
168     * @param WebRequest $request
169     * @param MessageLocalizer $localizer
170     * @param Language $userLang
171     */
172    public function initRequest(
173        WebRequest $request,
174        MessageLocalizer $localizer,
175        Language $userLang
176    ) {
177        $this->request = $request;
178        $this->messageLocalizer = $localizer;
179        $this->userLang = $userLang;
180    }
181
182    /**
183     * Called when the form is submitted. This returns a Status object which,
184     * when successful, contains a voting record in the value member. To
185     * preserve voter privacy, voting records should be the same length
186     * regardless of voter choices.
187     * @return Status
188     */
189    public function submitForm() {
190        $questions = $this->election->getQuestions();
191        $record = '';
192        $status = new BallotStatus();
193
194        foreach ( $questions as $question ) {
195            $record .= $this->submitQuestion( $question, $status );
196        }
197        if ( $status->isOK() ) {
198            $status->value = $record;
199        }
200
201        return $status;
202    }
203
204    /**
205     * Construct a string record for a given question, during form submission.
206     *
207     * If there is a problem with the form data, the function should set a
208     * fatal error in the $status object and return null.
209     *
210     * @param Question $question
211     * @param BallotStatus $status
212     * @return string|null
213     */
214    abstract public function submitQuestion( $question, $status );
215
216    /**
217     * Unpack a string record into an array format suitable for the tally type
218     * @param string $record
219     * @return array|bool
220     */
221    abstract public function unpackRecord( $record );
222
223    /**
224     * Convert a record to a string of some kind
225     * @param string $record
226     * @param array $options
227     * @return string[]|false
228     */
229    public function convertRecord( $record, $options = [] ) {
230        $scores = $this->unpackRecord( $record );
231
232        return $this->convertScores( $scores );
233    }
234
235    /**
236     * Convert a score array to a string of some kind
237     * @param array $scores
238     * @param array $options
239     * @return string|string[]|false
240     */
241    abstract public function convertScores( $scores, $options = [] );
242
243    /**
244     * Create a ballot of the given type
245     * @param Context $context
246     * @param string $type
247     * @param Election $election
248     * @return Ballot
249     * @throws InvalidArgumentException
250     */
251    public static function factory( $context, $type, $election ) {
252        if ( !isset( self::BALLOT_TYPES[$type] ) ) {
253            throw new InvalidArgumentException( "Invalid ballot type: $type" );
254        }
255        $class = self::BALLOT_TYPES[$type];
256
257        return new $class( $context, $election );
258    }
259
260    /**
261     * Constructor.
262     * @param Context $context
263     * @param Election $election
264     */
265    public function __construct( $context, $election ) {
266        $this->context = $context;
267        $this->election = $election;
268    }
269
270    /**
271     * Get the HTML for this ballot. <form> tags should not be included,
272     * they will be added by the VotePage.
273     * @param bool|BallotStatus $prevStatus
274     * @return Element[]
275     */
276    public function getForm( $prevStatus = false ) {
277        $questions = $this->election->getQuestions();
278        if ( $this->election->getProperty( 'shuffle-questions' ) ) {
279            shuffle( $questions );
280        }
281        $shuffleOptions = $this->election->getProperty( 'shuffle-options' );
282        $this->setErrorStatus( $prevStatus );
283
284        $itemArray = [];
285        foreach ( $questions as $question ) {
286            $options = $question->getOptions();
287            if ( $shuffleOptions ) {
288                shuffle( $options );
289            }
290
291            $questionForm = $this->getQuestionForm(
292                    $question,
293                    $options
294            );
295            $questionForm->setLabel(
296                new HtmlSnippet( $question->parseMessage( 'text' ) )
297            );
298            $itemArray[] = $questionForm;
299        }
300        if ( $prevStatus ) {
301            $formStatus = new Element( [
302                'content' => new HTMLSnippet(
303                    $this->formatStatus( $prevStatus )
304                ),
305            ] );
306            array_unshift( $itemArray, $formStatus );
307        }
308
309        return $itemArray;
310    }
311
312    /**
313     * @param bool|BallotStatus $status
314     */
315    public function setErrorStatus( $status ) {
316        if ( $status ) {
317            $this->prevErrorIds = $status->getIds();
318            $this->prevStatus = $status;
319        } else {
320            $this->prevErrorIds = [];
321        }
322        $this->usedErrorIds = [];
323    }
324
325    public function errorLocationIndicator( $id ) {
326        if ( !isset( $this->prevErrorIds[$id] ) ) {
327            return '';
328        }
329        $this->usedErrorIds[$id] = true;
330
331        return new IconWidget( [
332            'icon' => 'error',
333            'title' => $this->prevStatus->spGetMessageText( $id ),
334            'id' => "$id-location",
335            'classes' => [ 'securepoll-error-location' ],
336            'flags' => 'error',
337         ] );
338    }
339
340    /**
341     * Convert a BallotStatus object to HTML
342     * @param BallotStatus $status
343     * @return string
344     */
345    public function formatStatus( $status ) {
346        return $status->spGetHTML( $this->usedErrorIds );
347    }
348}