Passed
Pull Request — master (#99)
by
unknown
02:46
created

FeatureContext   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 388
Duplicated Lines 0 %

Importance

Changes 20
Bugs 3 Features 2
Metric Value
wmc 56
eloc 151
c 20
b 3
f 2
dl 0
loc 388
rs 5.5199

How to fix   Complexity   

Complex Class

Complex classes like FeatureContext often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FeatureContext, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * PHP Billing Library
4
 *
5
 * @link      https://github.com/hiqdev/php-billing
6
 * @package   php-billing
7
 * @license   BSD-3-Clause
8
 * @copyright Copyright (c) 2017-2020, HiQDev (http://hiqdev.com/)
9
 */
10
11
namespace hiqdev\php\billing\tests\behat\bootstrap;
12
13
use Behat\Behat\Context\Context;
14
use Behat\Behat\Tester\Exception\PendingException;
15
use Cache\Adapter\PHPArray\ArrayCachePool;
16
use Closure;
17
use DateTimeImmutable;
18
use Exception;
19
use hiqdev\php\billing\action\Action;
20
use hiqdev\php\billing\action\UsageInterval;
21
use hiqdev\php\billing\charge\Charge;
22
use hiqdev\php\billing\charge\ChargeInterface;
23
use hiqdev\php\billing\customer\Customer;
24
use hiqdev\php\billing\formula\FormulaEngine;
25
use hiqdev\php\billing\plan\Plan;
26
use hiqdev\php\billing\price\MoneyBuilder;
27
use hiqdev\php\billing\price\PriceHelper;
28
use hiqdev\php\billing\price\ProgressivePrice;
29
use hiqdev\php\billing\price\ProgressivePriceThreshold;
30
use hiqdev\php\billing\price\ProgressivePriceThresholdList;
31
use hiqdev\php\billing\sale\Sale;
32
use hiqdev\php\billing\price\SinglePrice;
33
use hiqdev\php\billing\target\Target;
34
use hiqdev\php\billing\tests\support\order\SimpleBilling;
35
use hiqdev\php\billing\type\Type;
36
use hiqdev\php\units\Quantity;
37
use hiqdev\php\units\Unit;
38
use Money\Currencies\ISOCurrencies;
39
use Money\Currency;
40
use Money\Money;
41
use Money\Parser\DecimalMoneyParser;
42
use NumberFormatter;
43
use PHPUnit\Framework\Assert;
44
use ReflectionClass;
45
46
/**
47
 * Defines application features from the specific context.
48
 */
49
class FeatureContext implements Context
50
{
51
    protected $engine;
52
53
    /** @var Customer */
54
    protected $customer;
55
    /**
56
     * @var \hiqdev\php\billing\price\PriceInterface|\hiqdev\php\billing\charge\FormulaChargeModifierTrait
57
     *
58
     * TODO: FormulaChargeModifierTrait::setFormula() must be moved to interface
59
     */
60
    protected $price;
61
62
    /** @var string */
63
    protected $formula;
64
65
    /**
66
     * @var \hiqdev\php\billing\action\ActionInterface|\hiqdev\php\billing\action\AbstractAction
67
     */
68
    protected $action;
69
    /**
70
     * @var ChargeInterface[]
71
     */
72
    protected $charges;
73
74
    /** @var \Money\MoneyParser */
75
    protected $moneyParser;
76
77
    /** @var string */
78
    protected $expectedError;
79
80
    /**
81
     * Initializes context.
82
     */
83
    public function __construct()
84
    {
85
        error_reporting(E_ALL & ~E_DEPRECATED);
86
        date_default_timezone_set('UTC');
87
        $this->customer = new Customer(null, 'somebody');
88
        $this->moneyParser = new DecimalMoneyParser(new ISOCurrencies());
89
        $this->plan = new Plan(null, 'plan', $this->customer);
90
        $this->sale = new Sale(null, Target::any(), $this->customer, $this->plan, new DateTimeImmutable('2000-01-01'));
91
        $this->billing = SimpleBilling::fromSale($this->sale);
92
    }
93
94
    /**
95
     * @Given /(\S+) (\S+) price is ([0-9.]+) (\w+) per (\w+)(?: includes ([\d.]+))?/
96
     */
97
    public function priceIs($target, $type, $sum, $currency, $unit, $quantity = 0)
98
    {
99
        $type = Type::anyId($type);
100
        $target = new Target(Target::ANY, $target);
101
        $quantity = Quantity::create($unit, $quantity);
102
        $sum = $this->moneyParser->parse($sum, new Currency($currency));
103
        $this->setPrice(new SinglePrice(null, $type, $target, null, $quantity, $sum));
104
    }
105
106
    protected array $progressivePrice = [];
107
    /**
108
     * @Given /(\S+) progressive price for (\S+) is +(\S+) (\S+) per (\S+) (\S+) (\S+) (\S+)$/
109
     */
110
    public function progressivePrice($target, $type, $price, $currency, $unit, $sign, $quantity, $perUnit): void
111
    {
112
        if (empty($this->progressivePrice[$type])) {
113
            $this->progressivePrice[$type] = [
114
                'target' => $target,
115
                'price' => $price,
116
                'currency' => $currency,
117
                'prepaid' => $quantity,
118
                'unit' => $unit,
119
                'thresholds' => [],
120
            ];
121
        } else {
122
            $this->progressivePrice[$type]['thresholds'][] = [
123
                'price' => $price,
124
                'currency' => $currency,
125
                'unit' => $unit,
126
                'quantity' => $quantity,
127
            ];
128
        }
129
    }
130
131
    /**
132
     * @Given /^build progressive price/
133
     */
134
    public function buildProgressivePrices()
135
    {
136
        $i = 0;
137
        foreach ($this->progressivePrice as $type => $price) {
138
            $type = Type::anyId($type);
139
            $target = new Target(Target::ANY, $price['target']);
140
            $quantity = Quantity::create($price['unit'], $price['prepaid']);
141
            if ($i++ === 0) {
142
                $price['price'] *= 100;
143
            }
144
            $money = new Money($price['price'], new Currency($price['currency']));
145
            $thresholds = ProgressivePriceThresholdList::fromScalarsArray($price['thresholds']);
146
            $price = new ProgressivePrice(null, $type, $target, $quantity, $money, $thresholds);
147
            $this->setPrice($price);
148
        }
149
    }
150
151
    /**
152
     * @Given /sale close time is ([0-9.-]+)?/
153
     */
154
    public function setActionCloseTime($closeTime): void
155
    {
156
        if ($closeTime === null) {
157
            return;
158
        }
159
160
        $this->sale->close(new DateTimeImmutable($closeTime));
161
    }
162
163
    /**
164
     * @Given /sale time is (.+)$/
165
     */
166
    public function setSaleTime($time): void
167
    {
168
        $ref = new ReflectionClass($this->sale);
169
        $prop = $ref->getProperty('time');
170
        $prop->setAccessible(true);
171
        $prop->setValue($this->sale, new DateTimeImmutable($time));
172
        $prop->setAccessible(false);
173
    }
174
175
    private function setPrice($price)
176
    {
177
        $this->price = $price;
178
        $ref = new ReflectionClass($this->plan);
179
        $prop = $ref->getProperty('prices');
180
        $prop->setAccessible(true);
181
        $prop->setValue($this->plan, [$price]);
182
    }
183
184
    /**
185
     * @Given /action is (\S+) ([\w_,]+)(?: ([0-9.]+) (\S+))?(?: in (.+))?/
186
     */
187
    public function actionIs(string $target, string $type, float $amount, string $unit, ?string $date = null): void
188
    {
189
        $type = Type::anyId($type);
190
        $target = new Target(Target::ANY, $target);
191
        $time = new DateTimeImmutable($date);
192
        $fractionOfMonth = 1;
193
        if ($this->sale->getCloseTime() instanceof DateTimeImmutable) {
194
            $fractionOfMonth = $this->getFractionOfMonth(
195
                $time, $time, $this->sale->getCloseTime()
196
            );
197
            if ($type->getName() !== 'overuse') {
198
                // Overuses should be prepared in the test case
199
                $amount = $amount * $fractionOfMonth;
200
            }
201
        }
202
        $quantity = Quantity::create($unit, $amount);
203
204
        $this->action = new Action(
205
            null,
206
            $type,
207
            $target,
208
            $quantity,
209
            $this->customer,
210
            $time,
211
            null,
212
            null,
213
            null,
214
            $fractionOfMonth
215
        );
216
    }
217
218
    private function getFractionOfMonth(DateTimeImmutable $month, DateTimeImmutable $startTime, DateTimeImmutable $endTime): float
219
    {
220
        try {
221
            return UsageInterval::withinMonth($month, $startTime, $endTime)->ratioOfMonth();
222
        } catch (\InvalidArgumentException $e) {
223
            return 1;
224
        }
225
    }
226
227
    /**
228
     * @Given /formula is (.+)/
229
     */
230
    public function formulaIs(string $formula): void
231
    {
232
        $this->formula = $formula;
233
    }
234
235
    /**
236
     * @Given /formula continues (.+)/
237
     */
238
    public function formulaContinues(string $formula): void
239
    {
240
        $this->formula .= "\n" . $formula;
241
    }
242
243
    protected function getFormulaEngine()
244
    {
245
        if ($this->engine === null) {
246
            $this->engine = new FormulaEngine(new ArrayCachePool());
247
        }
248
249
        return $this->engine;
250
    }
251
252
    /**
253
     * @When /action date is (.+)/
254
     * @throws Exception
255
     */
256
    public function actionDateIs(string $date): void
257
    {
258
        $this->action->setTime(new DateTimeImmutable($date));
259
    }
260
261
    /**
262
     * @Given /^client rejected service at ?(.+?)$/
263
     */
264
    public function actionCloseDateIs(?string $close_date): void
265
    {
266
        $close_date = trim($close_date);
267
        if (empty($close_date)) {
268
            return;
269
        }
270
271
        $this->sale->close(new DateTimeImmutable($close_date));
272
    }
273
274
    /**
275
     * @Then /^error is$/m
276
     */
277
    public function multilineErrorIs(\Behat\Gherkin\Node\PyStringNode $value)
278
    {
279
        $this->expectedError = $value->getRaw();
280
    }
281
282
    /**
283
     * @Then /^error is (.+)$/
284
     *
285
     * @param string $error
286
     */
287
    public function errorIs($error): void
288
    {
289
        $this->expectedError = $error;
290
    }
291
292
    /**
293
     * @Then /^(\w+) charge is ?(?: with ?)?$/
294
     */
295
    public function emptyCharge(string $numeral): void
296
    {
297
        $this->chargeIs($numeral);
298
    }
299
300
    /**
301
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3})(?: for ([\d.]+)? (\w+)?)?(?: with (.+)?)?$/
302
     */
303
    public function chargeWithSum($numeral, $type = null, $sum = null, $currency = null, $qty = null, $unit = null, $events = null): void
304
    {
305
        $this->chargeIs($numeral, $type, $sum, $currency, null, $qty, $unit, $events);
306
    }
307
308
    /**
309
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3}) reason ([\w]+)(?: with (.+)?)?$/
310
     */
311
    public function chargeWithReason($numeral, $type = null, $sum = null, $currency = null, $reason = null, $events = null): void
312
    {
313
        $this->chargeIs($numeral, $type, $sum, $currency, $reason, null, null, $events);
314
    }
315
316
    public function chargeIs($numeral, $type = null, $sum = null, $currency = null, $reason = null, $qty = null, $unit = null, $events = null): void
317
    {
318
        $no = $this->ensureNo($numeral);
319
        if ($no === 0) {
320
            $this->calculatePrice();
321
        }
322
        $this->assertCharge($this->charges[$no] ?? null, $type, $sum, $currency, $reason, $qty, $unit, $events);
323
    }
324
325
    /**
326
     * @When /^calculating charges$/
327
     */
328
    public function calculatePrice(): void
329
    {
330
        $this->expectError(function () {
331
            if ($this->formula !== null) {
332
                $this->price->setModifier($this->getFormulaEngine()->build($this->formula));
333
            }
334
            $this->charges = $this->billing->calculateCharges($this->action);
335
        });
336
    }
337
338
    public function expectError(Closure $closure): void
339
    {
340
        try {
341
            call_user_func($closure);
342
        } catch (Exception $e) {
343
            if ($this->isExpectedError($e)) {
344
                $this->expectedError = null;
345
            } else {
346
                throw $e;
347
            }
348
        }
349
        if ($this->expectedError) {
350
            throw new Exception('failed receive expected exception');
351
        }
352
    }
353
354
    protected function isExpectedError(Exception $e): bool
355
    {
356
        return str_starts_with($e->getMessage(), $this->expectedError);
357
    }
358
359
    /**
360
     * @param ChargeInterface|Charge|null $charge
361
     * @param string|null $type
362
     * @param string|null $sum
363
     * @param string|null $currency
364
     * @param string|null $reason
365
     * @param string|null $qty
366
     * @param string|null $unit
367
     * @param string|null $events
368
     */
369
    public function assertCharge($charge, $type, $sum, $currency, $reason, $qty, $unit, $events): void
370
    {
371
        if (empty($type) && empty($sum) && empty($currency)) {
372
            is_null($charge) || Assert::assertSame('0', $charge->getSum()->getAmount());
373
            return;
374
        }
375
        Assert::assertInstanceOf(Charge::class, $charge);
376
        Assert::assertSame($type, $this->normalizeType($charge->getType()->getName()), sprintf(
377
            'Charge type %s does not match expected %s', $type, $this->normalizeType($charge->getType()->getName())
378
        ));
379
        $money = $this->moneyParser->parse($sum, new Currency($currency));
380
        Assert::assertTrue($money->equals($charge->getSum()), sprintf(
381
            'Charge sum %s does not match expected %s', $charge->getSum()->getAmount(), $money->getAmount()
382
        ));
383
        if ($reason !== null) {
384
            Assert::assertSame($reason, $charge->getComment(),
385
                sprintf('Charge comment %s does not match expected %s', $charge->getComment(), $reason)
386
            );
387
        }
388
        if ($qty !== null && $unit !== null) {
389
            Assert::assertEqualsWithDelta($qty, $charge->getUsage()->getQuantity(), 1e-7,
390
                sprintf('Charge quantity "%s" does not match expected "%s"', $charge->getUsage()->getQuantity(), $qty)
391
            );
392
            Assert::assertSame($unit, $charge->getUsage()->getUnit()->getName(),
393
                sprintf('Charge unit "%s" does not match expected "%s"', $charge->getUsage()->getUnit()->getName(), $unit)
394
            );
395
        }
396
        if ($events !== null) {
397
            $storedEvents = $charge->releaseEvents();
398
            foreach (array_map('trim', explode(',', $events)) as $eventClass) {
399
                foreach ($storedEvents as $storedEvent) {
400
                    $eventReflection = new \ReflectionObject($storedEvent);
401
                    if ($eventReflection->getShortName() === $eventClass) {
402
                        continue 2;
403
                    }
404
                }
405
406
                Assert::fail(sprintf('Event of class %s is not present is charge', $eventClass));
407
            }
408
        } else {
409
            Assert::assertEmpty($charge->releaseEvents(), 'Failed asserting that charge does not have events');
410
        }
411
    }
412
413
    private function normalizeType($string): string
414
    {
415
        switch ($string) {
416
            case 'discount,discount':
417
                return 'discount';
418
            case 'monthly,leasing':
419
                return 'leasing';
420
            case 'monthly,installment':
421
                return 'installment';
422
            default:
423
                return $string;
424
        }
425
    }
426
427
    private function ensureNo(string $numeral): int
428
    {
429
        $formatter = new NumberFormatter('en_EN', NumberFormatter::SPELLOUT);
430
        $result = $formatter->parse($numeral);
431
        if ($result === false) {
432
            throw new Exception("Wrong numeral '$numeral'");
433
        }
434
435
        return --$result;
436
    }
437
438
    /**
439
     * @Given /^progressive price calculation steps are (.*)$/
440
     */
441
    public function progressivePriceCalculationStepsAre($explanation)
442
    {
443
        if (!$this->price instanceof ProgressivePrice) {
444
            throw new Exception('Price is not progressive');
445
        }
446
447
        $traces = array_map(fn($trace) => $trace->toShortString(), $this->price->getCalculationTraces());
448
        $billed = implode(' + ', $traces);
449
        Assert::assertSame($explanation, $billed, 'Progressive price calculation steps mismatch. Expected: ' . $explanation . ', got: ' . $billed);
450
    }
451
}
452