Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.03% covered (warning)
77.03%
57 / 74
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConfigurationValidator
77.03% covered (warning)
77.03%
57 / 74
66.67% covered (warning)
66.67%
8 / 12
40.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setMessageLocalizer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateIdentifier
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateTitle
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
6.00
 validateRequiredField
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateInteger
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 validateFieldIsArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 validateArrayMaxSize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 validateMessages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validateTaskMessages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 validateTopicMessages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sortTopics
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\ConfigurationLoader;
4
5use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
6use GrowthExperiments\NewcomerTasks\Topic\Topic;
7use MediaWiki\Collation\CollationFactory;
8use MediaWiki\Title\MalformedTitleException;
9use MediaWiki\Title\TitleParser;
10use Message;
11use MessageLocalizer;
12use StatusValue;
13
14/**
15 * Helper class for validating task type / topic / etc. configuration.
16 */
17class ConfigurationValidator {
18
19    /** @var MessageLocalizer */
20    private $messageLocalizer;
21
22    /** @var CollationFactory */
23    private $collationFactory;
24
25    /** @var TitleParser */
26    private $titleParser;
27
28    /**
29     * @param MessageLocalizer $messageLocalizer
30     * @param CollationFactory $collationFactory
31     * @param TitleParser $titleParser
32     */
33    public function __construct(
34        MessageLocalizer $messageLocalizer,
35        CollationFactory $collationFactory,
36        TitleParser $titleParser
37    ) {
38        $this->messageLocalizer = $messageLocalizer;
39        $this->collationFactory = $collationFactory;
40        $this->titleParser = $titleParser;
41    }
42
43    /**
44     * Inject the message localizer.
45     * @param MessageLocalizer $messageLocalizer
46     * @internal To be used by ResourceLoader callbacks only.
47     * @note This is an ugly hack. Normal requests use the global RequestContext as a localizer,
48     *   which is a bit of a kitchen sink, but conceptually can be thought of as a service.
49     *   ResourceLoader provides the ResourceLoaderContext, which is not global and can only be
50     *   obtained by code directly invoked by ResourceLoader. The ConfigurationLoader depends
51     *   on whichever of the two is available, so the localizer cannot be injected in the service
52     *   wiring file, and a factory would not make sense conceptually (there should never be
53     *   multiple configuration loaders). So we provide this method so that the ResourceLoader
54     *   callback can finish the dependency injection.
55     */
56    public function setMessageLocalizer( MessageLocalizer $messageLocalizer ): void {
57        $this->messageLocalizer = $messageLocalizer;
58    }
59
60    /**
61     * Validate a task or topic ID
62     * @param string $id
63     * @return StatusValue
64     */
65    public function validateIdentifier( $id ) {
66        return preg_match( '/^[a-z\d\-]+$/', $id )
67            ? StatusValue::newGood()
68            : StatusValue::newFatal( 'growthexperiments-homepage-suggestededits-config-invalidid', $id );
69    }
70
71    /**
72     * @param mixed $title Page title. Must be a string (but at the PHP level we need to allow
73     *   any type, so we can handle errors via status objects).
74     * @return StatusValue
75     */
76    public function validateTitle( $title ) {
77        if ( !is_string( $title ) ) {
78            if ( !is_scalar( $title ) ) {
79                $title = '[' . gettype( $title ) . ']';
80            }
81            return StatusValue::newFatal( 'growthexperiments-homepage-suggestededits-config-invalidtitle',
82                $title );
83        }
84        try {
85            $this->titleParser->parseTitle( $title );
86        } catch ( MalformedTitleException $e ) {
87            return StatusValue::newFatal( 'growthexperiments-homepage-suggestededits-config-invalidtitle',
88                $title );
89        }
90        return StatusValue::newGood();
91    }
92
93    /**
94     * Verify that a required field is present.
95     * @param string $field Configuration field name
96     * @param array $config Configuration
97     * @param string $taskTypeId Task type ID, for better error reporting
98     * @return StatusValue
99     */
100    public function validateRequiredField( $field, $config, $taskTypeId ) {
101        return isset( $config[$field] )
102            ? StatusValue::newGood()
103            : StatusValue::newFatal( 'growthexperiments-homepage-suggestededits-config-missingfield',
104                $field, $taskTypeId );
105    }
106
107    /**
108     * Verify that a field is an integer, and optionally within some bounds. The field doesn't have
109     * to exist.
110     * @param array $config Configuration
111     * @param string $field Configuration field name
112     * @param string $taskTypeId Task type ID, for better error reporting
113     * @param int|null $min Minimum value
114     * @return StatusValue
115     */
116    public function validateInteger(
117        array $config, string $field, string $taskTypeId, int $min = null
118    ) {
119        if ( !array_key_exists( $field, $config ) ) {
120            return StatusValue::newGood();
121        }
122        $value = $config[$field];
123        if ( !is_int( $value ) ) {
124            return StatusValue::newFatal( 'growthexperiments-homepage-suggestededits-config-notinteger',
125                $field, $taskTypeId );
126        } elseif ( $min !== null && $value < $min ) {
127            return StatusValue::newFatal( 'growthexperiments-homepage-suggestededits-config-toosmall',
128                $field, $taskTypeId, $min );
129        }
130        return StatusValue::newGood();
131    }
132
133    /**
134     * Verify that a field exists and is a non-associative array.
135     *
136     * @param string $field Configuration field name
137     * @param array $config Configuration
138     * @param string $taskTypeId Task type ID, for better error reporting.
139     * @return StatusValue
140     */
141    public function validateFieldIsArray( string $field, array $config, string $taskTypeId ): StatusValue {
142        $status = StatusValue::newGood();
143        $status->merge( $this->validateRequiredField( $field, $config, $taskTypeId ) );
144        if ( $status->isOK() ) {
145            if ( !is_array( $config[$field] ) || array_values( $config[$field] ) !== $config[$field] ) {
146                $status->fatal(
147                    'growthexperiments-homepage-suggestededits-config-fieldarray', $taskTypeId, $field
148                );
149            }
150        }
151        return $status;
152    }
153
154    /**
155     * Verify that an array doesn't exceed an allowed maximum size.
156     *
157     * @param int $maxSize
158     * @param array $config Configuration
159     * @param string $taskTypeId Task type ID, for better error reporting.
160     * @param string $field Configuration field name, for better error reporting.
161     * @return StatusValue
162     */
163    public function validateArrayMaxSize( int $maxSize, array $config, string $taskTypeId, string $field ) {
164        $status = StatusValue::newGood();
165        if ( count( $config ) > $maxSize ) {
166            $status->fatal( 'growthexperiments-homepage-suggestededits-config-arraymaxsize',
167                $taskTypeId, $field, Message::numParam( $maxSize ) );
168        }
169        return $status;
170    }
171
172    /**
173     * For a given list of messages, verifies that they all exist.
174     * @param Message[] $messages
175     * @param string $field Field name where the missing message was defined (e.g. ID of the task).
176     * @return StatusValue
177     */
178    public function validateMessages( array $messages, string $field ) {
179        $status = StatusValue::newGood();
180        foreach ( $messages as $msg ) {
181            if ( !$msg->exists() ) {
182                $status->fatal( 'growthexperiments-homepage-suggestededits-config-missingmessage',
183                    $msg->getKey(), $field );
184            }
185        }
186        return $status;
187    }
188
189    /**
190     * Ensure that all messages used by the task type exist.
191     * @param TaskType $taskType
192     * @return StatusValue
193     */
194    public function validateTaskMessages( TaskType $taskType ) {
195        return $this->validateMessages( [
196            $taskType->getName( $this->messageLocalizer ),
197            $taskType->getDescription( $this->messageLocalizer ),
198            $taskType->getShortDescription( $this->messageLocalizer ),
199            $taskType->getTimeEstimate( $this->messageLocalizer )
200        ], $taskType->getId() );
201    }
202
203    /**
204     * Ensure that all messages used by the topic exist.
205     * @param Topic $topic
206     * @return StatusValue
207     */
208    public function validateTopicMessages( Topic $topic ) {
209        $messages = [ $topic->getName( $this->messageLocalizer ) ];
210        if ( $topic->getGroupId() ) {
211            $messages[] = $topic->getGroupName( $this->messageLocalizer );
212        }
213        return $this->validateMessages( $messages, $topic->getId() );
214    }
215
216    /**
217     * Sorts topics in-place, based on the group configuration and alphabetically within that.
218     * @param Topic[] &$topics
219     * @param string[] $groups
220     */
221    public function sortTopics( array &$topics, $groups ) {
222        if ( !$topics ) {
223            return;
224        }
225
226        $collation = $this->collationFactory->getCategoryCollation();
227
228        usort( $topics, function ( Topic $left, Topic $right ) use ( $groups, $collation ) {
229            $leftGroup = $left->getGroupId();
230            $rightGroup = $right->getGroupId();
231            if ( $leftGroup !== $rightGroup ) {
232                return array_search( $leftGroup, $groups, true ) - array_search( $rightGroup, $groups, true );
233            }
234
235            $leftSortKey = $collation->getSortKey(
236                $left->getName( $this->messageLocalizer )->text() );
237            $rightSortKey = $collation->getSortKey(
238                $right->getName( $this->messageLocalizer )->text() );
239            return strcmp( $leftSortKey, $rightSortKey );
240        } );
241    }
242
243}