diff --git a/Comment.php b/Comment.php index 1293b52..d4e92dc 100755 --- a/Comment.php +++ b/Comment.php @@ -28,6 +28,7 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ServicesConfigurator; use Thelia\Core\Install\Database; use Thelia\Core\Translation\Translator; +use Thelia\Domain\Catalog\Product\ProductRatingSourceInterface; use Thelia\Model\ConfigQuery; use Thelia\Model\Lang; use Thelia\Model\LangQuery; @@ -402,7 +403,13 @@ public static function configureServices(ServicesConfigurator $servicesConfigura $servicesConfigurator->load(self::getModuleCode().'\\', __DIR__) // Tests/ is part of the tree load() walks: a test double implementing an // autoconfigured interface would otherwise be registered as a real service. - ->exclude([__DIR__.'/I18n/*', __DIR__.'/Tests/*']) + ->exclude([ + __DIR__.'/I18n/*', + __DIR__.'/Tests/*', + // The rating facet arrived with Thelia 3.3: on an older core its interface + // does not exist, and the class implementing it could not be loaded. + ...(interface_exists(ProductRatingSourceInterface::class) ? [] : [__DIR__.'/Front/RatingFacetSource.php']), + ]) ->autowire(true) ->autoconfigure(true); diff --git a/Config/module.xml b/Config/module.xml index bf79ac7..7d5af5c 100755 --- a/Config/module.xml +++ b/Config/module.xml @@ -13,7 +13,7 @@ en_US fr_FR - 3.2.1 + 3.3.0 Julien Chanséaume diff --git a/Front/RatingFacetSource.php b/Front/RatingFacetSource.php new file mode 100644 index 0000000..eada72f --- /dev/null +++ b/Front/RatingFacetSource.php @@ -0,0 +1,59 @@ +ratingScale ?? RatingType::scaleFrom(ConfigQuery::read('comment_max_rating')); + + return $this->ratings->elementIdsRatedAtLeast( + self::PRODUCT_REF, + $minimumRating * $scale / self::FACET_SCALE, + $amongProductIds, + ); + } +} diff --git a/Repository/RatingMetaRepository.php b/Repository/RatingMetaRepository.php index 67ff989..380d50f 100644 --- a/Repository/RatingMetaRepository.php +++ b/Repository/RatingMetaRepository.php @@ -32,4 +32,26 @@ public function clear(string $ref, int $refId): void ->filterByElementId($refId) ->delete(); } + + /** + * Compares the stored average with the cast RatingMetaMemo::averageExpression() orders on, + * so that the rating facet and the best-rated order read the same figure. + */ + public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array + { + $query = MetaDataQuery::create() + ->filterByMetaKey(Comment::META_KEY_RATING) + ->filterByElementKey($ref) + ->where('CAST(MetaData.Value AS DECIMAL(10, 2)) >= ?', (string) $minimumAverage, \PDO::PARAM_STR); + + if ($amongIds !== null) { + if ($amongIds === []) { + return []; + } + + $query->filterByElementId($amongIds, Criteria::IN); + } + + return array_map('intval', $query->select(['ElementId'])->find()->getData()); + } } diff --git a/Repository/RatingMetaStorageInterface.php b/Repository/RatingMetaStorageInterface.php index ff5c3e4..ce6dd81 100644 --- a/Repository/RatingMetaStorageInterface.php +++ b/Repository/RatingMetaStorageInterface.php @@ -28,4 +28,14 @@ public function store(string $ref, int $refId, float $average, int $count): void * keeps showing an average computed from comments that are gone. */ public function clear(string $ref, int $refId): void; + + /** + * The elements of a kind whose stored average reaches $minimumAverage, on the scale the + * module rates on. + * + * @param list|null $amongIds the elements to look at; null for every element of the kind + * + * @return list + */ + public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array; } diff --git a/Tests/Double/InMemoryRatingMetaStorage.php b/Tests/Double/InMemoryRatingMetaStorage.php index 107e0a9..8fa602c 100644 --- a/Tests/Double/InMemoryRatingMetaStorage.php +++ b/Tests/Double/InMemoryRatingMetaStorage.php @@ -49,4 +49,26 @@ public function read(string $ref, int $refId): array { return $this->values[$ref.'|'.$refId] ?? ['average' => null, 'count' => 0]; } + + public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array + { + $ids = []; + + foreach ($this->values as $key => $value) { + [$storedRef, $refId] = explode('|', $key); + $refId = (int) $refId; + + if ($storedRef !== $ref || $value['average'] === null || $value['average'] < $minimumAverage) { + continue; + } + + if ($amongIds !== null && !\in_array($refId, $amongIds, true)) { + continue; + } + + $ids[] = $refId; + } + + return $ids; + } } diff --git a/Tests/Unit/Front/RatingFacetSourceTest.php b/Tests/Unit/Front/RatingFacetSourceTest.php new file mode 100644 index 0000000..0463fe6 --- /dev/null +++ b/Tests/Unit/Front/RatingFacetSourceTest.php @@ -0,0 +1,76 @@ +ratings = new InMemoryRatingMetaStorage(); + } + + public function testAThresholdKeepsTheProductsWhoseStoredAverageReachesIt(): void + { + $this->rate([1 => 4.6, 2 => 4.0, 3 => 3.99, 4 => 2.0]); + + $source = new RatingFacetSource($this->ratings, ratingScale: 5); + + self::assertSame([1, 2], $source->productIdsRatedAtLeast(4.0)); + self::assertSame([1, 2, 3, 4], $source->productIdsRatedAtLeast(2.0)); + } + + public function testTheThresholdIsBroughtToTheScaleTheShopRatesOn(): void + { + // Out of ten, "4 stars & up" means an average of 8 or more. + $this->rate([1 => 9.0, 2 => 8.0, 3 => 7.5]); + + $source = new RatingFacetSource($this->ratings, ratingScale: 10); + + self::assertSame([1, 2], $source->productIdsRatedAtLeast(4.0)); + } + + public function testOnlyTheProductsAskedAboutAreAnswered(): void + { + $this->rate([1 => 5.0, 2 => 5.0, 3 => 5.0]); + + $source = new RatingFacetSource($this->ratings, ratingScale: 5); + + self::assertSame([1, 3], $source->productIdsRatedAtLeast(4.0, [1, 3, 9])); + self::assertSame([], $source->productIdsRatedAtLeast(4.0, [])); + } + + public function testTheRatingsOfOtherElementsAreNotProductRatings(): void + { + $this->ratings->store('content', 1, 5.0, 3); + + self::assertSame([], (new RatingFacetSource($this->ratings, ratingScale: 5))->productIdsRatedAtLeast(2.0)); + } + + /** + * @param array $averages product id => stored average + */ + private function rate(array $averages): void + { + foreach ($averages as $productId => $average) { + $this->ratings->store('product', $productId, $average, 1); + } + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..a7dec84 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,8 @@ +# Product rating source contract of a core newer than the published one: Comment::configureServices() wires +# Front/RatingFacetSource.php only when interface_exists() finds it. Kept out of the report so that PHPStan runs +# against the published core; an entry the core catches up with is not reported. +parameters: + # PHPStan cannot ignore a class that implements an unknown interface: the class is left out of the analysis instead (still scanned, so its name resolves). + excludePaths: + analyse: + - Front/RatingFacetSource.php