Issues (300)

src/Expectation.php (3 issues)

1
<?php
2
namespace Kahlan;
3
4
use Throwable;
5
use Exception;
6
use Kahlan\Analysis\Debugger;
7
use Kahlan\Analysis\Inspector;
8
use Kahlan\Code\Code;
9
use Kahlan\Code\TimeoutException;
10
use Kahlan\Block\Specification;
11
12
use Closure;
13
14
/**
15
 * Class Expectation
16
 *
17
 * @method Matcher\ToBe toBe(mixed $expected) passes if actual === expected
18
 * @method Matcher\ToEqual toEqual(mixed $expected) passes if actual == expected
19
 * @method Matcher\ToBeTruthy toBeTruthy() passes if actual is truthy
20
 * @method Matcher\ToBeFalsy toBeFalsy() passes if actual is falsy
21
 * @method Matcher\ToBeFalsy toBeEmpty() passes if actual is falsy
22
 * @method Matcher\ToBeNull toBeNull() passes if actual is null
23
 * @method Matcher\ToBeA toBeA(string $expected) passes if actual is of the expected type
24
 * @method Matcher\ToBeA toBeAn(string $expected) passes if actual is of the expected type (toBeA alias)
25
 * @method Matcher\ToBeAnInstanceOf toBeAnInstanceOf(string $expected) passes if actual is an instance of expected
26
 * @method Matcher\ToHaveLength toHaveLength(int $expected) passes if actual has the expected length
27
 * @method Matcher\ToContain toContain(mixed $expected) passes if actual contain the expected value
28
 * @method Matcher\ToContainKey toContainKey(mixed $expected) passes if actual contain the expected key
29
 * @method Matcher\ToContainKey toContainKeys(mixed $expected) passes if actual contain the expected keys (toContainKey alias)
30
 * @method Matcher\ToBeCloseTo toBeCloseTo(float $expected, int $precision) passes if actual is close to expected in some precision
31
 * @method Matcher\ToBeGreaterThan toBeGreaterThan(mixed $expected) passes if actual if greater than expected
32
 * @method Matcher\ToBeLessThan toBeLessThan(mixed $expected) passes if actual is less than expected
33
 * @method Matcher\ToThrow toThrow(mixed $expected = null) passes if actual throws the expected exception
34
 * @method Matcher\ToMatch toMatch(string $expected) passes if actual matches the expected regexp
35
 * @method Matcher\ToEcho toEcho(string $expected) passes if actual echoes the expected string
36
 * @method Matcher\ToMatchEcho toMatchEcho(string $expected) passes if actual echoes matches the expected string
37
 * @method Matcher\ToReceive toReceive(string $expected) passes if the expected method as been called on actual
38
 * @method Exception toReceiveNext(string $expected) passes if the expected method as been called on actual after some other method
39
 *
40
 * @property Expectation $not
41
 */
42
class Expectation
43
{
44
    /**
45
     * Indicates whether the block has been processed or not.
46
     *
47
     * @var boolean
48
     */
49
    protected $_processed = false;
50
51
    /**
52
     * Deferred expectation.
53
     *
54
     * @var array
55
     */
56
    protected $_deferred = null;
57
58
    /**
59
     * Stores the success value.
60
     *
61
     * @var boolean
62
     */
63
    protected $_passed = null;
64
65
    /**
66
     * The result logs.
67
     *
68
     * @var array
69
     */
70
    protected $_logs = [];
71
72
    /**
73
     * The current value to test.
74
     *
75
     * @var mixed
76
     */
77
    protected $_actual = null;
78
79
    /**
80
     * If `true`, the result of the test will be inverted.
81
     *
82
     * @var boolean
83
     */
84
    protected $_not = false;
85
86
    /**
87
     * The timeout value.
88
     *
89
     * @var integer
90
     */
91
    protected $_timeout = 0;
92
93
    /**
94
     * The delegated handler.
95
     *
96
     * @var callable
97
     */
98
    protected $_handler = null;
99
100
    /**
101
     * The supported exception type.
102
     *
103
     * @var string
104
     */
105
    protected $_type;
106
107
    /**
108
     * Constructor.
109
     *
110
     * @param array $config The config array. Options are:
111
     *                       -`'actual'`  _mixed_   : the actual value.
112
     *                       -`'timeout'` _integer_ : the timeout value.
113
     *                       Or:
114
     *                       -`'handler'` _Closure_ : a delegated handler to execute.
115
     *                       -`'type'`    _string_  : delegated handler supported exception type.
116
     *
117
     * @return Expectation
118
     */
119
    public function __construct($config = [])
120
    {
121
        $defaults = [
122
            'actual'  => null,
123
            'handler' => null,
124
            'type'    => 'Exception',
125
            'timeout' => 0
126 694
        ];
127 694
        $config += $defaults;
128
129 694
        $this->_actual = $config['actual'];
130 694
        $this->_handler = $config['handler'];
131 694
        $this->_type = $config['type'];
132 694
        $this->_timeout = $config['timeout'];
133
    }
134
135
    /**
136
     * Returns the actual value.
137
     *
138
     * @return boolean
139
     */
140
    public function actual()
141
    {
142 2
        return $this->_actual;
143
    }
144
145
    /**
146
     * Returns the not value.
147
     *
148
     * @return boolean
149
     */
150
    public function not()
151
    {
152 4
        return $this->_not;
153
    }
154
155
    /**
156
     * Returns the logs.
157
     */
158
    public function logs()
159
    {
160 692
        return $this->_logs;
161
    }
162
163
    /**
164
     * Returns the deferred expectations.
165
     *
166
     * @return array
167
     */
168
    public function deferred()
169
    {
170 2
        return $this->_deferred;
171
    }
172
173
    /**
174
     * Returns the timeout value.
175
     */
176
    public function timeout()
177
    {
178 694
        return $this->_timeout;
179
    }
180
181
    /**
182
     * Calls a registered matcher.
183
     *
184
     * @param  string  $matcherName The name of the matcher.
185
     * @param  array   $args        The arguments to pass to the matcher.
186
     * @return boolean
187
     */
188
    public function __call($matcherName, $args)
189
    {
190 694
        $result = true;
191 694
        $spec = $this->_actual;
192 694
        $this->_passed = true;
193
194
        $closure = function () use ($spec, $matcherName, $args, &$actual, &$result) {
195
            if ($spec instanceof Specification) {
196 8
                $spec->reset();
197 8
                $spec->process($result);
198 8
                $expectation = $spec->expect($result, 0)->__call($matcherName, $args);
199 8
                $this->_logs = $spec->logs();
200 8
                $this->_passed = $spec->passed() && $expectation->passed();
201 8
                return $this->_passed;
202
            } else {
203 694
                $actual = $spec;
204 694
                array_unshift($args, $actual);
205 694
                $matcher = $this->_matcher($matcherName, $actual);
206 694
                $result = call_user_func_array($matcher . '::match', $args);
207
            }
208 694
            return is_object($result) || $result === !$this->_not;
209
        };
210
211
        try {
212 694
            $this->_spin($closure);
213
        } catch (TimeoutException $e) {
214
        }
215
216
        if ($spec instanceof Specification) {
217
            if ($exception = $spec->log()->exception()) {
218
                throw $exception;
219
            }
220 8
            return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type Kahlan\Expectation which is incompatible with the documented return type boolean.
Loading history...
221
        }
222
223 694
        array_unshift($args, $actual);
224 694
        $matcher = $this->_matcher($matcherName, $actual);
225 694
        $data = Inspector::parameters($matcher, 'match', $args);
226 694
        $report = compact('matcherName', 'matcher', 'data');
227
228
        if (!is_object($result)) {
229 675
            $report['description'] = $report['matcher']::description();
230 675
            $this->_log($result, $report);
231 675
            return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type Kahlan\Expectation which is incompatible with the documented return type boolean.
Loading history...
232
        }
233
        $this->_deferred = $report + [
234
            'instance' => $result, 'not' => $this->_not,
235 50
        ];
236
237 50
        return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result returns the type object which is incompatible with the documented return type boolean.
Loading history...
238
    }
239
240
    /**
241
     * Returns a compatible matcher class name according to a passed actual value.
242
     *
243
     * @param  string $matcherName The name of the matcher.
244
     * @param  mixed  $actual      The actual value.
245
     * @return string              A matcher class name.
246
     */
247
    public function _matcher($matcherName, $actual)
248
    {
249
        $target = null;
250 2
        if (!Matcher::exists($matcherName, true)) {
251
            throw new Exception("Unexisting matcher attached to `'{$matcherName}'`.");
252
        }
253 694
254
        $matcher = null;
255
256
        foreach (Matcher::get($matcherName, true) as $target => $value) {
257 694
            if (!$target) {
258 694
                $matcher = $value;
259
                continue;
260
            }
261 2
            if ($actual instanceof $target) {
262
                $matcher = $value;
263
            }
264
        }
265
266 2
        if (!$matcher) {
267
            throw new Exception("Unexisting matcher attached to `'{$matcherName}'` for `{$target}`.");
268
        }
269 694
270
        return $matcher;
271
    }
272
273
    /**
274
     * Processes the expectation.
275
     *
276
     * @return mixed
277
     */
278
    protected function _process()
279
    {
280 4
        if (is_callable($this->_handler)) {
281
            return $this->_processDelegated();
282 692
        }
283
        $spec = $this->_actual;
284
285 692
        if (!$spec instanceof Specification || $spec->passed() !== null) {
286
            return $this;
287
        }
288
289 4
        $closure = function () use ($spec) {
290 4
            $spec->reset();
291 4
            $spec->process($result);
292 4
            $this->_logs = $spec->logs();
293
            return $this->_passed = $spec->passed();
294
        };
295
296 4
        try {
297
            $this->_spin($closure);
298
        } catch (TimeoutException $e) {
299
        }
300
        if ($exception = $spec->log()->exception()) {
301
            throw $exception;
302
        }
303 4
304 4
        $this->_passed = $spec->passed() && ($this->_passed ?? true);
305
        return $this;
306
    }
307
308
    /**
309
     * Processes the expectation.
310
     *
311
     * @return mixed
312
     */
313
    protected function _processDelegated()
314 4
    {
315
        $exception = null;
316
317 4
        try {
318
            call_user_func($this->_handler);
319 2
        } catch (Throwable|Exception $e) {
320
            $exception = $e;
321
        }
322
323
        if (!$exception) {
324
            $this->_logs[] = ['type' => 'passed'];
325 4
            $this->_passed = true;
326 4
            return $this;
327 4
        }
328
329
        $this->_passed = false;
330 2
331
        if (!$exception instanceof $this->_type) {
332
            throw $exception;
333 2
        }
334
335
        $this->_logs[] = [
336
            'type' => 'failed',
337
            'data' => [
338
                'external' => true,
339
                'description' => $exception->getMessage()
340
            ],
341
            'backtrace' => Debugger::normalize($exception->getTrace())
342
        ];
343 2
        return $this;
344 2
    }
345
346
    /**
347
     * Runs the expectation.
348
     *
349
     * @param Closure $closure The closure to run/spin.
350
     */
351
    protected function _spin($closure)
352
    {
353
        $timeout = $this->timeout();
354 694
        if ($timeout <= 0) {
355
            $closure();
356 694
        } else {
357
            Code::spin($closure, $timeout);
358 6
        }
359
    }
360
361
    /**
362
     * Resolves deferred matchers.
363
     */
364
    protected function _resolve()
365
    {
366
        if (!$this->_deferred) {
367
            return;
368 673
        }
369
        $data = $this->_deferred;
370 48
371
        $instance = $data['instance'];
372 48
        $this->_not = $data['not'];
373 48
        $boolean = $instance->resolve();
374 48
        $data['description'] = $instance->description();
375 48
        $data['backtrace'] = $instance->backtrace();
376 48
        $this->_log($boolean, $data);
377 48
378
        $this->_deferred = null;
379 48
    }
380
381
    /**
382
     * Logs a result.
383
     *
384
     * @param  boolean $boolean Set `true` for success and `false` for failure.
385
     * @param  array   $data    Test details array.
386
     * @return boolean
387
     */
388
    protected function _log($boolean, $data = [])
389
    {
390
        $not = $this->_not;
391 694
        $pass = $not ? !$boolean : $boolean;
392 694
        if ($pass) {
393
            $data['type'] = 'passed';
394 694
        } else {
395
            $data['type'] = 'failed';
396 12
            $this->_passed = false;
397 12
        }
398
399
        $description = $data['description'];
400 694
        if (is_array($description)) {
401
            $data['data'] = $description['data'];
402 219
            $data['description'] = $description['description'];
403 219
        }
404
        $data += ['backtrace' => Debugger::backtrace()];
405 694
        $data['not'] = $not;
406 694
407
        $this->_logs[] = $data;
408 694
        $this->_not = false;
409 694
        return $boolean;
410 694
    }
411
412
    /**
413
     * Magic getter, if called with `'not'` invert the `_not` attribute.
414
     *
415
     * @param string
416
     */
417
    public function __get($name)
418
    {
419
        if ($name !== 'not') {
420
            throw new Exception("Unsupported attribute `{$name}`.");
421 2
        }
422
        $this->_not = !$this->_not;
423 106
        return $this;
424 106
    }
425
426
    /**
427
     * Run the expectation.
428
     *
429
     * @return boolean Returns `true` if passed, `false` otherwise.
430
     */
431
    public function process()
432
    {
433
        if (!$this->_processed) {
434
            $this->_process();
435 692
            $this->_resolve();
436 692
        }
437
        $this->_processed = true;
438 692
        return $this->_passed !== false;
439 692
    }
440
441
    /**
442
     * Returns `true`/`false` if test passed or not, `false` if not and `null` if not runned.
443
     *
444
     * @return boolean.
445
     */
446
    public function passed()
447
    {
448
        return $this->_passed;
449 6
    }
450
451
    /**
452
     * Clears the instance.
453
     */
454
    public function clear()
455
    {
456
        $this->_actual = null;
457 2
        $this->_passed = null;
458 2
        $this->_processed = null;
459 2
        $this->_not = false;
460 2
        $this->_timeout = 0;
461 2
        $this->_logs = [];
462 2
        $this->_deferred = null;
463 2
    }
464
}
465