Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.88% covered (danger)
37.88%
25 / 66
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
NewcomerTasksUserOptionsLookup
37.88% covered (danger)
37.88%
25 / 66
43.75% covered (danger)
43.75%
7 / 16
277.48
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getTaskTypeFilter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTopics
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTopicsMatchMode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getTopicFilterWithoutFallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 areLinkRecommendationsEnabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 areImageRecommendationsEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 areSectionImageRecommendationsEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filterTaskTypes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultTaskTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConversionMap
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 convertTaskTypes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 filterNonExistentTaskTypes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getJsonListOption
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getStringOption
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 shouldUserSeeAllTaskTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\NewcomerTasks;
4
5use GrowthExperiments\ExperimentUserManager;
6use GrowthExperiments\HomepageModules\SuggestedEdits;
7use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
8use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy;
9use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
10use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler;
11use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
12use GrowthExperiments\VariantHooks;
13use MediaWiki\Config\Config;
14use MediaWiki\User\Options\UserOptionsLookup;
15use MediaWiki\User\UserIdentity;
16
17/**
18 * Retrieves user settings related to newcomer tasks.
19 */
20class NewcomerTasksUserOptionsLookup {
21
22    /**
23     * This property isn't used, but we want to preserve the ability to run A/B tests where
24     * user options depend on the user's experiment group.
25     * @var ExperimentUserManager
26     */
27    private $experimentUserManager;
28
29    /** @var UserOptionsLookup */
30    private $userOptionsLookup;
31
32    /** @var Config */
33    private $config;
34
35    /** @var ConfigurationLoader */
36    private $configurationLoader;
37
38    /**
39     * @param ExperimentUserManager $experimentUserManager
40     * @param UserOptionsLookup $userOptionsLookup
41     * @param Config $config
42     * @param ConfigurationLoader $configurationLoader
43     */
44    public function __construct(
45        ExperimentUserManager $experimentUserManager,
46        UserOptionsLookup $userOptionsLookup,
47        Config $config,
48        ConfigurationLoader $configurationLoader
49    ) {
50        $this->experimentUserManager = $experimentUserManager;
51        $this->userOptionsLookup = $userOptionsLookup;
52        $this->config = $config;
53        $this->configurationLoader = $configurationLoader;
54    }
55
56    /**
57     * Get user's task types given their preferences and community configuration.
58     * @param UserIdentity $user
59     * @return string[] A list of task type IDs, or the default task types when the user
60     * has no preference set.
61     * @see \GrowthExperiments\NewcomerTasks\TaskType\TaskType::getId()
62     */
63    public function getTaskTypeFilter( UserIdentity $user ): array {
64        $taskTypes = $this->getJsonListOption( $user, SuggestedEdits::TASKTYPES_PREF );
65        // Filter out invalid task types for the user and use defaults based on user options.
66        if ( !$taskTypes ) {
67            $taskTypes = $this->getDefaultTaskTypes( $user );
68        }
69        return $this->filterNonExistentTaskTypes( $this->convertTaskTypes( $taskTypes, $user ) );
70    }
71
72    /**
73     * Get the given user's topic preferences.
74     * @param UserIdentity $user
75     * @return string[] A list of topic IDs, or an empty array when the user has
76     *   no preference set (This is method is meant to be compatible with TaskSuggester
77     *   which takes an empty array as "no filtering".)
78     * @see \GrowthExperiments\NewcomerTasks\Topic\Topic::getId()
79     */
80    public function getTopics( UserIdentity $user ): array {
81        return $this->getTopicFilterWithoutFallback( $user ) ?? [];
82    }
83
84    /**
85     * Get the given user's topic mode preference.
86     * @param UserIdentity $user
87     * @return string A string representing the topic match mode.
88     * One of ( 'AND', 'OR').
89     * @see SearchStrategy::TOPIC_MATCH_MODES
90     */
91    public function getTopicsMatchMode( UserIdentity $user ): string {
92        $matchMode = $this->getStringOption( $user, SuggestedEdits::TOPICS_MATCH_MODE_PREF ) ??
93            SearchStrategy::TOPIC_MATCH_MODE_OR;
94        if ( $matchMode === SearchStrategy::TOPIC_MATCH_MODE_AND &&
95            !$this->config->get( 'GETopicsMatchModeEnabled' ) ) {
96            $matchMode = SearchStrategy::TOPIC_MATCH_MODE_OR;
97        }
98        return $matchMode;
99    }
100
101    /**
102     * Get the given user's topic preferences without a fallback to empty array.
103     * @param UserIdentity $user
104     * @return string[]|null A list of topic IDs, an empty array when the user has
105     *   no preference set, or null if preference wasn't set or is invalid.
106     */
107    public function getTopicFilterWithoutFallback( UserIdentity $user ): ?array {
108        return $this->getJsonListOption( $user, SuggestedEdits::getTopicFiltersPref( $this->config ) );
109    }
110
111    /**
112     * Check if link recommendations are enabled. When true, the link-recommendation task type
113     * should be made available to the user and the links task type hidden.
114     * @note This has to be equivalent to areLinkRecommendationsEnabled in TaskTypesAbFilter.js
115     * @return bool
116     */
117    public function areLinkRecommendationsEnabled( UserIdentity $user ): bool {
118        if ( $this->experimentUserManager->isUserInVariant( $user,
119            VariantHooks::VARIANT_NO_LINK_RECOMMENDATION ) ) {
120            // Disabled by a variant (T377787)
121            return false;
122        }
123
124        return $this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' )
125               && $this->config->get( 'GELinkRecommendationsFrontendEnabled' )
126               && array_key_exists( LinkRecommendationTaskTypeHandler::TASK_TYPE_ID,
127                   $this->configurationLoader->getTaskTypes() );
128    }
129
130    /**
131     * Check if image recommendations are enabled. When true, the image-recommendation task type
132     * should be made available to the user.
133     * @note This has to be equivalent to areImageRecommendationsEnabled in TaskTypesAbFilter.js
134     * @return bool
135     */
136    public function areImageRecommendationsEnabled(): bool {
137        return $this->config->get( 'GENewcomerTasksImageRecommendationsEnabled' )
138            && array_key_exists( ImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
139                $this->configurationLoader->getTaskTypes() );
140    }
141
142    /**
143     * Check if section-level image recommendations are enabled. When true, the
144     * section-image-recommendation task type should be made available to the user.
145     * @note This has to be equivalent to areSectionImageRecommendationsEnabled in TaskTypesAbFilter.js
146     * @param UserIdentity $user
147     * @return bool
148     */
149    public function areSectionImageRecommendationsEnabled( UserIdentity $user ): bool {
150        return $this->config->get( 'GENewcomerTasksSectionImageRecommendationsEnabled' )
151            && array_key_exists( SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
152                $this->configurationLoader->getTaskTypes() );
153    }
154
155    /**
156     * Remove task types which the user is not supposed to see, given the link recommendation
157     * configuration and community configuration.
158     * @param string[] $taskTypes Task types IDs.
159     * @param UserIdentity $user
160     * @return string[] Filtered task types IDs. Array keys are not preserved.
161     */
162    public function filterTaskTypes( array $taskTypes, UserIdentity $user ): array {
163        $conversionMap = $this->getConversionMap( $user );
164        $taskTypes = array_filter( $taskTypes,
165            fn ( $taskTypeId ) => !array_key_exists( $taskTypeId, $conversionMap )
166        );
167        return $this->filterNonExistentTaskTypes( $taskTypes );
168    }
169
170    /**
171     * Get default task types when the user has no stored preference.
172     * @param UserIdentity $user
173     * @return string[]
174     */
175    private function getDefaultTaskTypes( UserIdentity $user ): array {
176        // This doesn't do anything useful right now, but we want to preserve the ability
177        // to determine the default task types dynamically for A/B testing.
178        return SuggestedEdits::DEFAULT_TASK_TYPES;
179    }
180
181    /**
182     * Get mapping of task types which the user is not supposed to see to a similar task type
183     * or false (meaning nothing should be shown instead).
184     * Identical to TaskTypesAbFilter.getConversionMap().
185     * @param UserIdentity $user
186     * @return array A map of old task type ID => new task type ID or false.
187     * @phan-return array<string,string|false>
188     */
189    private function getConversionMap( UserIdentity $user ): array {
190        $map = [];
191        if ( $this->areLinkRecommendationsEnabled( $user ) ) {
192            $map += [ 'links' => LinkRecommendationTaskTypeHandler::TASK_TYPE_ID ];
193        } else {
194            $map += [ LinkRecommendationTaskTypeHandler::TASK_TYPE_ID => 'links' ];
195        }
196        if ( !$this->areImageRecommendationsEnabled() ) {
197            $map += [ ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ];
198        }
199        if ( !$this->areSectionImageRecommendationsEnabled( $user ) ) {
200            $map += [ SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ];
201        }
202        return $map;
203    }
204
205    /**
206     * Convert task types which the user is not supposed to see, given the link recommendation
207     * configuration, to the closest task type available to them.
208     * @param string[] $taskTypes Task types IDs.
209     * @param UserIdentity $user
210     * @return string[] Converted task types IDs. Array keys are not preserved.
211     */
212    private function convertTaskTypes( array $taskTypes, UserIdentity $user ): array {
213        $map = $this->getConversionMap( $user );
214        $taskTypes = array_map( static function ( string $taskType ) use ( $map ) {
215            return $map[$taskType] ?? $taskType;
216        }, $taskTypes );
217        return array_unique( array_filter( $taskTypes ) );
218    }
219
220    /**
221     * Remove task types which have been disabled via community configuration.
222     * @param string[] $taskTypesToFilter Task types IDs.
223     * @return string[] Filtered task types IDs.
224     */
225    private function filterNonExistentTaskTypes( array $taskTypesToFilter ) {
226        $allTaskTypes = $this->configurationLoader->getTaskTypes();
227        return array_values( array_filter( $taskTypesToFilter,
228            static function ( $taskTypeId ) use ( $allTaskTypes ) {
229                return array_key_exists( $taskTypeId, $allTaskTypes );
230            }
231        ) );
232    }
233
234    /**
235     * Read a user preference that is a list of strings.
236     * @param UserIdentity $user
237     * @param string $pref
238     * @return array|null User preferences as a list of strings, or null of the preference was
239     *   missing or invalid.
240     */
241    private function getJsonListOption( UserIdentity $user, string $pref ) {
242        $stored = $this->userOptionsLookup->getOption( $user, $pref );
243        if ( $stored ) {
244            $stored = json_decode( $stored, true );
245        }
246        // sanity check
247        if ( !is_array( $stored ) || array_filter( $stored, 'is_string' ) !== $stored ) {
248            return null;
249        }
250        return $stored;
251    }
252
253    /**
254     * Read a user preference that is a string.
255     * @param UserIdentity $user
256     * @param string $pref
257     * @return string|null User preference as a string, or null if the preference is invalid
258     */
259    private function getStringOption( UserIdentity $user, string $pref ) {
260        $stored = $this->userOptionsLookup->getOption( $user, $pref );
261
262        if ( !is_string( $stored ) ) {
263            return null;
264        }
265        return $stored;
266    }
267
268    /**
269     * Check if the user is an internal pseudo-user who should see all task types.
270     * @param UserIdentity $user
271     * @return bool
272     */
273    private function shouldUserSeeAllTaskTypes( UserIdentity $user ) {
274        return $user->getId() === 0 && $user->getName() === SuggestionsInfo::USER;
275    }
276
277}