Completed
Pull Request — master (#6)
by Thomas
02:30
created

Experiment::getRandomInt()   B

Complexity

Conditions 6
Paths 15

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 9.7518

Importance

Changes 0
Metric Value
dl 0
loc 25
ccs 9
cts 17
cp 0.5294
rs 8.439
c 0
b 0
f 0
cc 6
eloc 15
nc 15
nop 2
crap 9.7518
1
<?php
2
/**
3
 * InnoCraft Ltd - We are the makers of Piwik Analytics, the leading open source analytics platform.
4
 *
5
 * @link https://www.innocraft.com
6
 * @license https://www.gnu.org/licenses/lgpl-3.0.en.html LGPL v3.0
7
 */
8
9
namespace InnoCraft\Experiments;
10
11
use InnoCraft\Experiments\Filters\DefaultFilters;
12
use InnoCraft\Experiments\Filters\FilterInterface;
13
use InnoCraft\Experiments\Storage\Cookie;
14
use InnoCraft\Experiments\Storage\StorageInterface;
15
use InnoCraft\Experiments\Variations\VariationInterface;
16
use InvalidArgumentException;
17
use InnoCraft\Experiments\Variations\StandardVariation;
18
use Exception;
19
20
/**
21
 * Lets you create a new experiment to run an A/B test or a split test.
22
 */
23
class Experiment {
24
25
    /**
26
     * Defines the name of the original version.
27
     */
28
    const ORIGINAL_VARIATION_NAME = 'original';
29
30
    /**
31
     * Instead of the word 'original', one can also set '0' to mark a variation as the original version.
32
     */
33
    const ORIGINAL_VARIATION_ID = '0';
34
35
    /**
36
     * Is returned by {@link getActivatedVariation()} when no variation should be activated.
37
     */
38
    const DO_NOT_TRIGGER = null;
39
40
    /**
41
     * @var int|string
42
     */
43
    private $name;
44
45
    /**
46
     * @var Variations
47
     */
48
    private $variations;
49
50
    /**
51
     * @var FilterInterface
52
     */
53
    private $filter;
54
55
    /**
56
     * @var StorageInterface
57
     */
58
    private $storage;
59
60
    /**
61
     * Creates a new experiment
62
     *
63
     * @param string $experimentNameOrId Can be any experiment name or an id of the experiment (eg as given by A/B Testing for Piwik)
64
     * @param array|VariationInterface[] $variations
65
     * @param array $config
66
     */
67 23
    public function __construct($experimentNameOrId, $variations, $config = [])
68
    {
69 23
        if (!isset($experimentNameOrId) || $experimentNameOrId === false || $experimentNameOrId === '') {
70 3
            throw new InvalidArgumentException('no experimentNameOrId given');
71
        }
72
73 20
        $this->name = $experimentNameOrId;
74
75 20
        if ($variations instanceof Variations) {
76 2
            $this->variations = $variations;
77 2
        } else {
78 18
            $this->variations = new Variations($experimentNameOrId, $variations);
79
80
            // in Piwik A/B Testing there is always an original variation, we need to force the existence here.
81
            // if you do not want to have this behaviour, instead pass an instance of Variations.
82 18
            if (!$this->variations->exists(Experiment::ORIGINAL_VARIATION_NAME)
83 18
                && !$this->variations->exists(Experiment::ORIGINAL_VARIATION_ID)) {
84 16
                $this->variations->addVariation(new StandardVariation(['name' => Experiment::ORIGINAL_VARIATION_NAME]));
85 16
            }
86
        }
87
88 20 View Code Duplication
        if (isset($config['storage']) && $config['storage'] instanceof StorageInterface) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
89 19
            $this->storage = $config['storage'];
90 20
        } elseif (isset($config['storage'])) {
91 1
            throw new InvalidArgumentException('storage needs to be an instance of StorageInterface');
92
        } else {
93
            $this->storage = new Cookie();
94
        }
95
96 19 View Code Duplication
        if (isset($config['filter']) && $config['filter'] instanceof FilterInterface) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
97 15
            $this->filter = $config['filter'];
98 19
        } elseif (isset($config['filter'])) {
99 1
            throw new InvalidArgumentException('filter config needs to be an instance of FilterInterface');
100
        } else {
101 3
            $this->filter = new DefaultFilters($this->name, $this->storage, $config);
102
        }
103 18
    }
104
105
    /**
106
     * Get the name of this experiment.
107
     *
108
     * @return int|string
109
     */
110 7
    public function getExperimentName()
111
    {
112 7
        return $this->name;
113
    }
114
115
    /**
116
     * Forces the activation of the given variation name.
117
     *
118
     * @param string $variationName
119
     */
120 4
    public function forceVariationName($variationName)
121
    {
122 4
        $this->storage->set('experiment', $this->name, $variationName);
123 4
    }
124
125
    /**
126
     * Detect whether any variation, including the original version, should be activated.
127
     *
128
     * Returns true if a variation should and will be activated when calling eg {@link getActivatedVariation()}
129
     * or {@link run()}, false if no variation will be activated.
130
     *
131
     * @return bool
132
     */
133
    public function shouldTrigger()
134
    {
135
        return $this->getActivatedVariation() !== self::DO_NOT_TRIGGER;
136
    }
137
138
    /**
139
     * Get the activated variation for this experiment, or null if no variation was activated because of a set filter.
140
     * For example when the user does not take part in the experiment or when a scheduled date prevents the activation
141
     * of a variation. Returns the activated variation if no filter "blocked" it. On the first request, a variation
142
     * will be randomly chosen unless it was forced by {@link forceVariationName()}. On all subsequent requests
143
     * it will reuse the variation that was activated on the first request.
144
     *
145
     * @return VariationInterface|null
146
     */
147 7
    public function getActivatedVariation()
148
    {
149 7
        if (!$this->filter->shouldTrigger()) {
150 1
            return self::DO_NOT_TRIGGER;
151
        }
152
153 6
        $variationName = $this->storage->get('experiment', $this->name);
154
155 6
        if (($variationName || $variationName === '0' || $variationName === 0) && $this->variations->exists($variationName)) {
156 4
            return $this->variations->get($variationName);
157
        }
158
159 3
        $variation = $this->variations->selectRandomVariation();
160
161 3
        if ($variation) {
162 2
            $this->forceVariationName($variation->getName());
163
164 2
            return $variation;
165
        }
166
167
        // when no variation exists
168 1
        return self::DO_NOT_TRIGGER;
169
    }
170
171
    /**
172
     * Get the set filter.
173
     *
174
     * @return FilterInterface
175
     */
176 3
    public function getFilter()
177
    {
178 3
        return $this->filter;
179
    }
180
181
    /**
182
     * Get the set variations.
183
     *
184
     * @return Variations
185
     */
186 6
    public function getVariations()
187
    {
188 6
        return $this->variations;
189
    }
190
191
    /**
192
     * Get the set storage.
193
     * @return Cookie|StorageInterface
194
     */
195 4
    public function getStorage()
196
    {
197 4
        return $this->storage;
198
    }
199
200
    /**
201
     * Tracks the activation of a variation using for example the Piwik Tracker. This lets Piwik know which variation
202
     * was activated and should be used if you track your application using the Piwik Tracker server side. If you are
203
     * usually tracking using the JavaScript Tracker, have a look at {@link getTrackingScript()}.
204
     *
205
     * @param \stdClass|\PiwikTracker $tracker   The passed object needs to implement a `doTrackEvent` method accepting
206
     *                                           three parameters $category, $action, $name
207
     */
208 3
    public function trackVariationActivation($tracker)
209
    {
210
        // we do not use an interface here for simplicity so it is not needed to use an adapter or something
211
        // for Piwik tracker
212 3
        if ($tracker && method_exists($tracker, 'doTrackEvent')) {
213 1
            $variation = $this->getActivatedVariation();
214
215 1
            if ($variation === self::DO_NOT_TRIGGER) {
216
                return;
217
            }
218
219
            // eg PiwikTracker
220 1
            $tracker->doTrackEvent('abtesting', $this->getExperimentName(), $variation->getName());
221 1
        } else {
222 2
            throw new InvalidArgumentException('The given tracker does not implement the doTrackEvent method');
223
        }
224 1
    }
225
226
    /**
227
     * Returns the JavaScript tracking code that you can echo in your website to let Piwik know which variation was
228
     * activated server side.
229
     *
230
     * Do not pass variables from $_GET or $_POST etc. Make sure to escape the variables before passing them
231
     * to this method as you would otherwise risk an XSS.
232
     *
233
     * @param string $experimentName  ExperimentName and VariationName needs to be passed cause we do not yet have a way
234
     *                                here to properly escape it to prevent XSS.
235
     * @param string $variationName
236
     * @return string  The Piwik tracking code including the `<script>` elements and _paq.push().
237
     */
238 1
    public function getTrackingScript($experimentName, $variationName)
239
    {
240 1
        return sprintf('<script type="text/javascript">_paq.push(["AbTesting::enter", {experiment: "%s", variation: "%s"}]);</script>', $experimentName, $variationName);
241
    }
242
243
    /**
244
     * Generates a random integer by using the best method available.
245
     *
246
     * @param int $min Minimum value
247
     * @param int $max Maximum value
248
     * @return int|null
249
     */
250 18
    public static function getRandomInt($min = 0, $max = 999999)
251
    {
252 18
        $val = null;
253
254 18
        if (function_exists('random_int')) {
255
            try {
256
                if (!isset($max)) {
257
                    $max = PHP_INT_MAX;
258
                }
259
                $val = random_int($min, $max);
260
            } catch (Exception $e) {
261
                // eg if no crypto source is available
262
                $val = null;
263
            }
264
        }
265
266 18
        if (!isset($val)) {
267 18
            if (function_exists('mt_rand')) {
268 18
                $val = mt_rand($min, $max);
269 18
            } else {
270
                $val = rand($min, $max);
271
            }
272 18
        }
273 18
        return $val;
274
    }
275
}
276