diff --git a/Business/Marketing/CustomerValue.php b/Business/Marketing/CustomerValue.php index f0f6152f6..e1866d893 100644 --- a/Business/Marketing/CustomerValue.php +++ b/Business/Marketing/CustomerValue.php @@ -27,6 +27,8 @@ final class CustomerValue /** * Simple customer lifetime value * + * Hazard Model, same as $margin * (1 + $discountRate) / (1 + $discountRate - $retentionRate) + * * @param float $margin Margin per period * @param float $retentionRate Rate of remaining customers per period (= average lifetime / (1 + average lifetime)) * @param float $discountRate Cost of capital to discount future revenue diff --git a/Business/Marketing/Metrics.php b/Business/Marketing/Metrics.php index 4bef5e7a9..244beb7bd 100644 --- a/Business/Marketing/Metrics.php +++ b/Business/Marketing/Metrics.php @@ -100,23 +100,21 @@ final class Metrics * * @since 1.0.0 */ - public static function calculateMailingSuccess(float $discountRate, array $purchaseProbability, array $payoffs) : Matrix + public static function calculateMailingSuccessEstimation(float $discountRate, array $purchaseProbability, array $payoffs) : Matrix { $count = \count($purchaseProbability); - $profit = new Matrix($count, $count); + $profit = new Vector($count, 1); $G = Vector::fromArray($payoffs); - $P = [ - $G, - self::createCustomerPurchaseProbabilityMatrix($purchaseProbability), - ]; + $P = self::createCustomerPurchaseProbabilityMatrix($purchaseProbability); + $newP = new IdentityMatrix($count); - for ($i = 0; $i < $count; ++$i) { - if (!isset($P[$i])) { - $P[$i] = $P[$i - 1]->mult($P[$i - 1]); - } + // $i = 0; + $profit = $profit->add($G); - $profit->add($P[$i]->mult($G)->mult(1 / \pow(1 + $discountRate, $i))); + for ($i = 1; $i < $count + 1; ++$i) { + $newP = $newP->mult($P); + $profit = $profit->add($newP->mult($G)->mult(1 / \pow(1 + $discountRate, $i))); } return $profit; @@ -125,6 +123,8 @@ final class Metrics /** * Calculate V of the migration model * + * Pfeifer and Carraway 2000 + * * @param float $discountRate Discount rate * @param array $purchaseProbability Purchase probabilities for different periods * @param array $payoffs Payoff vector (first element = payoff - cost, other elements = -cost, last element = 0) @@ -147,10 +147,12 @@ final class Metrics /** * Calculate the purchase probability of the different purchase states. * + * Pfeifer and Carraway 2000 + * * A customer can either buy in a certain period or not. * Depending on the result he either moves on to the next state (not buying) or returns to the first state (buying). * - * @param int $period Period to evaluate + * @param int $period Period to evaluate (t) * @param array $purchaseProbability Purchase probabilities * * @return Matrix [ @@ -160,12 +162,14 @@ final class Metrics */ public static function migrationModelPurchaseProbability(int $period, array $purchaseProbability) : Matrix { - $matrix = self::createCustomerPurchaseProbabilityMatrix($purchaseProbability); - for ($i = 0; $i < $period; ++$i) { - $matrix = $matrix->mult($matrix); + $matrix = self::createCustomerPurchaseProbabilityMatrix($purchaseProbability); + $newMatrix = clone $matrix; + + for ($i = 0; $i < $period - 1 ; ++$i) { + $newMatrix = $newMatrix->mult($matrix); } - return $matrix; + return $newMatrix; } /** @@ -183,7 +187,7 @@ final class Metrics * p1, 1-p1, 0, * p2, 0, 1-p2, * p3, 0, 1-p3, - * ] where pi = Probability that customer buys in period i + * ] where pi = Probability that customer buys in period i / moves from one state to the next state * * @since 1.0.0 */ @@ -193,9 +197,12 @@ final class Metrics $count = \count($purchaseProbability); for ($i = 0; $i < $count; ++$i) { - $matrix[$i] = []; + $matrix[$i] = \array_fill(0, $count, 0); $matrix[$i][0] = $purchaseProbability[$i]; - $matrix[$i][$i + 1] = 1 - $purchaseProbability[$i]; + + $matrix[$i][ + $i === $count - 1 ? $i : $i + 1 + ] = 1 - $purchaseProbability[$i]; } return Matrix::fromArray($matrix); diff --git a/Event/EventManager.php b/Event/EventManager.php index 260053e5c..0e9cabb18 100644 --- a/Event/EventManager.php +++ b/Event/EventManager.php @@ -144,35 +144,52 @@ final class EventManager implements \Countable } /** - * Trigger event + * Trigger event based on regex for group and/or id * - * @param string $group Name of the event - * @param string $id Sub-requirement for event + * @param string $group Name of the event (can be regex) + * @param string $id Sub-requirement for event (can be regex) * @param mixed $data Data to pass to the callback * - * @return bool returns true on successfully triggering the event, false if the event couldn't be triggered which also includes sub-requirements missing - * - * @todo Orange-Management/phpOMS#241 - * [EventManager] Create an event with a regex id/name and trigger it + * @return bool returns true on successfully triggering ANY event, false if NO event could be triggered which also includes sub-requirements missing * * @since 1.0.0 */ - public function trigger(string $group, string $id = '', $data = null) : bool + public function triggerSimilar(string $group, string $id = '', $data = null) : bool { - if (isset($this->callbacks[$group])) { - return $this->triggerSingleEvent($group, $id, $data); + $groupIsRegex = \stripos($group, '/') === 0; + $idIsRegex = \stripos($id, '/') === 0; + + $groups = []; + if ($groupIsRegex) { + foreach ($this->groups as $groupName => $value) { + if (\preg_match($group, $groupName) === 1) { + $groups[$groupName] = []; + } + } + } else { + $groups[$group] = []; } - $allGroups = \array_keys($this->callbacks); - $result = false; - - foreach ($allGroups as $match) { - if (\preg_match('~^' . $match . '$~', $group) === 1) { - $result = $result || $this->triggerSingleEvent($match, $id, $data); + foreach ($groups as $groupName => $groupValues) { + if ($idIsRegex) { + foreach ($this->groups[$groupName] as $idName => $value) { + if (\preg_match($id, $idName) === 1) { + $groups[$groupName][] = $idName; + } + } + } else { + $groups[$groupName][] = $id; } } - return $result; + $triggerValue = false; + foreach ($groups as $groupName => $ids) { + foreach ($ids as $id) { + $triggerValue = $triggerValue || $this->trigger($groupName, $id, $data); + } + } + + return $triggerValue; } /** @@ -186,8 +203,12 @@ final class EventManager implements \Countable * * @since 1.0.0 */ - private function triggerSingleEvent(string $group, string $id = '', $data = null) : bool + public function trigger(string $group, string $id = '', $data = null) : bool { + if (!isset($this->callbacks[$group])) { + return false; + } + if (isset($this->groups[$group])) { $this->groups[$group][$id] = true; } diff --git a/Log/FileLogger.php b/Log/FileLogger.php index 7c1bab2d3..b460cc181 100644 --- a/Log/FileLogger.php +++ b/Log/FileLogger.php @@ -103,13 +103,9 @@ final class FileLogger implements LoggerInterface $path = \realpath(empty($lpath) ? __DIR__ . '/../../' : $lpath); $this->verbose = $verbose; - if (\is_dir($lpath) || \strpos($lpath, '.') === false) { - $path = \rtrim($path !== false ? $path : $lpath, '/') . '/' . \date('Y-m-d') . '.log'; - } else { - $path = $lpath; - } - - $this->path = $path; + $this->path = \is_dir($lpath) || \strpos($lpath, '.') === false + ? \rtrim($path !== false ? $path : $lpath, '/') . '/' . \date('Y-m-d') . '.log' + : $lpath; } /** @@ -135,11 +131,6 @@ final class FileLogger implements LoggerInterface * * @return FileLogger * - * @todo Orange-Management/phpOMS#248 - * [FileLogger] Create test for getInstance - * Maybe unset the instance static variable first because it might be defined already. - * In order to do this use the `TestUtils` functionality. - * * @since 1.0.0 */ public static function getInstance(string $path = '', bool $verbose = false) : self diff --git a/Math/Matrix/Vector.php b/Math/Matrix/Vector.php index 77bc127a2..5ef6cd74a 100644 --- a/Math/Matrix/Vector.php +++ b/Math/Matrix/Vector.php @@ -35,7 +35,7 @@ final class Vector extends Matrix */ public static function fromArray(array $vector) : self { - $v = new self(); + $v = new self(\count($vector), 1); $v->setMatrixV($vector); return $v; diff --git a/Message/Http/HttpRequest.php b/Message/Http/HttpRequest.php index d7a1d515e..8d946af93 100644 --- a/Message/Http/HttpRequest.php +++ b/Message/Http/HttpRequest.php @@ -196,7 +196,7 @@ final class HttpRequest extends RequestAbstract } $line = \rtrim($lineRaw); - // @codeCoverageIgnoreEnd + if ($line === '') { if (!empty($partInfo['Content-Disposition']['filename'])) { /* Is file */ $tempdir = \sys_get_temp_dir(); @@ -247,6 +247,7 @@ final class HttpRequest extends RequestAbstract $this->files[$name]['error'] = \UPLOAD_ERR_OK; $this->files[$name]['size'] = \filesize($tempname); $this->files[$name]['tmp_name'] = $tempname; + // @codeCoverageIgnoreEnd } elseif ($partInfo !== null) { /* Is variable */ // @codeCoverageIgnoreStart // Tested but coverage doesn't show up @@ -362,7 +363,7 @@ final class HttpRequest extends RequestAbstract $firstLocalComponents = \explode('-', $locals[0]); // @codeCoverageIgnoreEnd - return \strtolower($firstLocalComponents[0]); + return \strtolower($firstLocalComponents[0]); // @codeCoverageIgnore } /** @@ -383,7 +384,7 @@ final class HttpRequest extends RequestAbstract $locals = \stripos($components[0], ',') !== false ? $locals = \explode(',', $components[0]) : $components; // @codeCoverageIgnoreEnd - return \str_replace('-', '_', $locals[0]); + return \str_replace('-', '_', $locals[0]); // @codeCoverageIgnore } /** diff --git a/Uri/UriFactory.php b/Uri/UriFactory.php index 527567b93..1eb4d3967 100644 --- a/Uri/UriFactory.php +++ b/Uri/UriFactory.php @@ -207,7 +207,7 @@ final class UriFactory } if (isset($urlStructure['query'])) { - \parse_str($urlStructure['query'] ?? [], $urlStructure['query']); + \parse_str(\str_replace('?', '&', $urlStructure['query']), $urlStructure['query']); } $escaped = diff --git a/Utils/IO/Zip/Tar.php b/Utils/IO/Zip/Tar.php index ace9a660e..f50e68408 100644 --- a/Utils/IO/Zip/Tar.php +++ b/Utils/IO/Zip/Tar.php @@ -30,10 +30,6 @@ class Tar implements ArchiveInterface { /** * {@inheritdoc} - * - * @todo Orange-Management/phpOMS#250 - * [Tar] Create tar test without destination path/name - * Simply call `Tar::pack([src1, src2, ...], 'output.tar')` */ public static function pack($sources, string $destination, bool $overwrite = false) : bool { diff --git a/Utils/IO/Zip/Zip.php b/Utils/IO/Zip/Zip.php index a7348818e..b5cb0ce06 100644 --- a/Utils/IO/Zip/Zip.php +++ b/Utils/IO/Zip/Zip.php @@ -30,10 +30,6 @@ class Zip implements ArchiveInterface { /** * {@inheritdoc} - * - * @todo Orange-Management/phpOMS#249 - * [Zip] Create zip test without destination path/name - * Simply call `Zip::pack([src1, src2, ...], 'output.zip')` */ public static function pack($sources, string $destination, bool $overwrite = false) : bool { diff --git a/Views/ViewAbstract.php b/Views/ViewAbstract.php index fe166825e..a7c72a310 100644 --- a/Views/ViewAbstract.php +++ b/Views/ViewAbstract.php @@ -219,14 +219,16 @@ abstract class ViewAbstract implements RenderableInterface public function render(...$data) : string { $ob = ''; - $path = __DIR__ . '/../..' . $this->template . '.tpl.php'; - - if (!\file_exists($path)) { - throw new PathException($path); - } try { \ob_start(); + + $path = __DIR__ . '/../..' . $this->template . '.tpl.php'; + + if (!\file_exists($path)) { + throw new PathException($path); + } + /** @noinspection PhpIncludeInspection */ $includeData = include $path; $ob = (string) \ob_get_clean(); @@ -235,7 +237,8 @@ abstract class ViewAbstract implements RenderableInterface $ob = (string) \json_encode($includeData); } } catch (\Throwable $e) { - $ob = ''; // @codeCoverageIgnore + \ob_end_clean(); + $ob = ''; } finally { return $ob; } diff --git a/tests/Business/Marketing/ArticleCorrelationAffinityTest.php b/tests/Business/Marketing/ArticleCorrelationAffinityTest.php index 203beadfc..b30e7d9cd 100644 --- a/tests/Business/Marketing/ArticleCorrelationAffinityTest.php +++ b/tests/Business/Marketing/ArticleCorrelationAffinityTest.php @@ -78,6 +78,19 @@ class ArticleCorrelationAffinityTest extends \PHPUnit\Framework\TestCase $orders = [ ['A' => 1, 'B' => 1, 'C' => 0, 'D' => 0], ['A' => 0, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 1, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 1, 'B' => 0, 'C' => 0, 'D' => 1], + ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0], + ['A' => 1, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 1, 'B' => 0, 'C' => 1, 'D' => 0], + ['A' => 0, 'B' => 0, 'C' => 1, 'D' => 0], + ['A' => 1, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 1, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 0, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 0, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 1, 'B' => 1, 'C' => 0, 'D' => 0], + ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0], + ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0], ]; $aff = new ArticleCorrelationAffinity($orders); diff --git a/tests/Business/Marketing/CustomerValueTest.php b/tests/Business/Marketing/CustomerValueTest.php index 220ab89a9..e6ab0beb4 100644 --- a/tests/Business/Marketing/CustomerValueTest.php +++ b/tests/Business/Marketing/CustomerValueTest.php @@ -35,6 +35,7 @@ class CustomerValueTest extends \PHPUnit\Framework\TestCase self::assertEqualsWithDelta(30000, CustomerValue::getSimpleCLV($margin, $retention, 0.0), 0.1); } + /** * @testdox The monthly recurring revenue (MRR) is correctly calculated * @group framework diff --git a/tests/Business/Marketing/MetricsTest.php b/tests/Business/Marketing/MetricsTest.php index fc8830ca2..ce16df945 100644 --- a/tests/Business/Marketing/MetricsTest.php +++ b/tests/Business/Marketing/MetricsTest.php @@ -33,42 +33,87 @@ class MetricsTest extends \PHPUnit\Framework\TestCase } /** + * @testdox The profit according to Berry can be correctly calculated * @group framework */ public function testBerrysCustomerProfits() : void { - self::markTestIncomplete(); + $acquisitionCost = 30.0; + $customers = 100000; + $ongoingMarketingCustomer = 10; + $revenueCustomer = 100; + $cogsCustomer = 50; + $discountRate = 0.15; + + self::assertEqualsWithDelta( + 4076923.08, + Metrics::getBerrysCustomerProfits($customers, $acquisitionCost, $revenueCustomer, $cogsCustomer, $ongoingMarketingCustomer, $discountRate, 0.5) + , 0.01 + ); } /** + * @testdox The purchase probability of customers can be calculated based on historic information using the migration model * @group framework */ - public function testLifeTimeValue() : void + public function testMigrationModelPurchaseMatrix() : void { - self::markTestIncomplete(); - } - - /** - * @group framework - */ - public function testSimpleRetentionLifeTimeValue() : void - { - self::markTestIncomplete(); - } - - /** - * @group framework - */ - public function testMailingSuccess() : void - { - self::markTestIncomplete(); + // Basis: + // Someone who just bought will buy again = 30% + // Someone who bought two years ago will buy again = 20% + // Someone who bought three years ago will buy again = 5% + + // Result: + // Someone who just bought will have bought in 4 years = 8.2% + // Someone who bought 2 years ago wil have bought in 4 years = 3.7% + // ... + self::assertEqualsWithDelta( + [ + [0.0823, 0.0973, 0.1288, 0.6916], + [0.037, 0.0406, 0.056, 0.8664], + [0.0070, 0.0080, 0.0084, 0.9766], + [0, 0, 0, 1], + ], + Metrics::migrationModelPurchaseProbability( + 4, + [0.3, 0.2, 0.05, 0] + )->toArray(), + 0.01 + ); } /** + * @testdox The CLTV can be calculated using the migration model * @group framework */ public function testMigrationModel() : void { - self::markTestIncomplete(); + // The first element in the migration model result is the CLTV + self::assertEqualsWithDelta( + [[231.08], [57.29], [21.01], [0.0]], + Metrics::migrationModel( + 0.1, + [0.5, 0.2, 0.1, 0], + [100, 0, 0, 0] + )->toArray(), + 0.1 + ); + } + + /** + * @testdox The migration model can be used in order to determin which buying/none-buying customer group should receive a mailing + * @group framework + */ + public function testMailingSuccessEstimation() : void + { + self::assertEqualsWithDelta( + [[49.4], [2.69], [-1.98], [0.0]], + Metrics::calculateMailingSuccessEstimation( + 0.2, + [0.3, 0.2, 0.05, 0], + [36, -4, -4, 0] + )->toArray(), + 0.1 + ); } } diff --git a/tests/Event/EventManagerTest.php b/tests/Event/EventManagerTest.php index 7da1a302c..b899dd2bd 100644 --- a/tests/Event/EventManagerTest.php +++ b/tests/Event/EventManagerTest.php @@ -87,7 +87,7 @@ class EventManagerTest extends \PHPUnit\Framework\TestCase $this->event->addGroup('group', 'id1'); $this->event->addGroup('group', 'id2'); - $this->event->trigger('group', 'id1'); + self::assertFalse($this->event->trigger('group', 'id1')); self::assertTrue($this->event->trigger('group', 'id2')); } @@ -115,6 +115,63 @@ class EventManagerTest extends \PHPUnit\Framework\TestCase self::assertFalse($this->event->trigger('invalid')); } + /** + * @testdox An event can be triggered with group and id regex matches + * @covers phpOMS\Event\EventManager + * @group framework + */ + public function testDispatchSimilarGroupAndId() : void + { + $this->event->attach('group', 'path_to_execute', false, true); + $this->event->addGroup('group', 'id1'); + $this->event->addGroup('group', 'id2'); + + self::assertTrue($this->event->triggerSimilar('/[a-z]+/', '/id\d/')); + } + + /** + * @testdox An event can be triggered with a fixed group definition and id regex matches + * @covers phpOMS\Event\EventManager + * @group framework + */ + public function testDispatchSimilarId() : void + { + $this->event->attach('group', 'path_to_execute', false, true); + $this->event->addGroup('group', 'id1'); + $this->event->addGroup('group', 'id2'); + + self::assertTrue($this->event->triggerSimilar('group', '/id\d/')); + } + + /** + * @testdox An event can be triggered with regex group matches and fixed id definition + * @covers phpOMS\Event\EventManager + * @group framework + */ + public function testDispatchSimilarGroup() : void + { + $this->event->attach('group', 'path_to_execute', false, true); + $this->event->addGroup('group', 'id1'); + $this->event->addGroup('group', 'id2'); + + self::assertFalse($this->event->triggerSimilar('group', 'id1')); + self::assertTrue($this->event->triggerSimilar('group', 'id2')); + } + + /** + * @testdox A invalid regex match will not triggered an event + * @covers phpOMS\Event\EventManager + * @group framework + */ + public function testDispatchSimilarInvalid() : void + { + $this->event->attach('group', 'path_to_execute', false, true); + $this->event->addGroup('group', 'id1'); + $this->event->addGroup('group', 'id2'); + + self::assertFalse($this->event->triggerSimilar('group', '/id\d0/')); + } + /** * @testdox An event can be defined to reset after all conditions and subconditions are met. Then all conditions and sub conditions must be met again before it gets triggered again. * @covers phpOMS\Event\EventManager diff --git a/tests/Log/FileLoggerTest.php b/tests/Log/FileLoggerTest.php index 2593adf46..8df223da9 100644 --- a/tests/Log/FileLoggerTest.php +++ b/tests/Log/FileLoggerTest.php @@ -16,6 +16,7 @@ namespace phpOMS\tests\Log; use phpOMS\Log\FileLogger; use phpOMS\Log\LogLevel; +use phpOMS\Utils\TestUtils; require_once __DIR__ . '/../Autoloader.php'; @@ -76,6 +77,30 @@ class FileLoggerTest extends \PHPUnit\Framework\TestCase self::assertEquals([], $this->log->getByLine()); } + /** + * @testdox The file logger can automatically create a new instance if none exists + * @covers phpOMS\Log\FileLogger + * @group framework + */ + public function testFileLoggerInstance() : void + { + if (\file_exists(__DIR__ . '/named.log')) { + \unlink(__DIR__ . '/named.log'); + } + + $instance = FileLogger::getInstance(__DIR__ . '/named.log', false); + TestUtils::getMember($instance, 'instance', null); + + $log = FileLogger::getInstance(__DIR__ . '/named.log', false); + self::assertInstanceOf(FileLogger::class, $log); + + TestUtils::setMember($instance, 'instance', $instance); + + if (\file_exists(__DIR__ . '/named.log')) { + \unlink(__DIR__ . '/named.log'); + } + } + /** * @testdox A log file for the output can be specified for the file logger * @covers phpOMS\Log\FileLogger diff --git a/tests/Views/ViewTest.php b/tests/Views/ViewTest.php index 70cab4c82..b75aa1792 100644 --- a/tests/Views/ViewTest.php +++ b/tests/Views/ViewTest.php @@ -431,12 +431,10 @@ class ViewTest extends \PHPUnit\Framework\TestCase */ public function testRenderException() : void { - $this->expectException(\phpOMS\System\File\PathException::class); - $view = new View($this->app->l11nManager); $view->setTemplate('something.txt'); - $view->render(); + self::assertEquals('', $view->render()); } /** @@ -446,12 +444,10 @@ class ViewTest extends \PHPUnit\Framework\TestCase */ public function testSerializeException() : void { - $this->expectException(\phpOMS\System\File\PathException::class); - $view = new View($this->app->l11nManager); $view->setTemplate('something.txt'); - $view->serialize(); + self::assertEquals('', $view->serialize()); } /**