Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion Config/module.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<language>en_US</language>
<language>fr_FR</language>
</languages>
<version>3.2.1</version>
<version>3.3.0</version>
<authors>
<author>
<name>Julien Chanséaume</name>
Expand Down
59 changes: 59 additions & 0 deletions Front/RatingFacetSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

/*************************************************************************************/
/* This file is part of the Thelia package. */
/* */
/* Copyright (c) OpenStudio */
/* email : dev@thelia.net */
/* web : http://www.thelia.net */
/* */
/* For the full copyright and license information, please view the LICENSE.txt */
/*************************************************************************************/

namespace Comment\Front;

use Comment\Form\Field\RatingType;
use Comment\Repository\RatingMetaStorageInterface;
use Thelia\Domain\Catalog\Product\ProductRatingSourceInterface;
use Thelia\Model\ConfigQuery;

/**
* Feeds the rating facet of the product listings ("4 stars & up" and so on) with the ratings
* the module collects.
*
* It compares the stored average of the accepted comments, the figure the product card shows
* and the best-rated order sorts on. The facet counts on five stars while a shop can rate on
* another scale (`comment_max_rating`): the threshold is brought to that scale before reading.
*
* Like the best-rated order, nothing here asks whether the module is installed: only an
* activated module reaches the container, so the facet shows exactly where reviews exist.
* On a core without ProductRatingSourceInterface, the module does not register this class.
*/
final readonly class RatingFacetSource implements ProductRatingSourceInterface
{
private const FACET_SCALE = 5;

// MetaData::PRODUCT_KEY, written out as ElementDeletionListener does: the generated base
// models are not loadable by the module's unit tests, which run without a kernel.
private const PRODUCT_REF = 'product';

public function __construct(
private RatingMetaStorageInterface $ratings,
// The scale the shop rates on; read from the configuration when left out.
private ?int $ratingScale = null,
) {
}

public function productIdsRatedAtLeast(float $minimumRating, ?array $amongProductIds = null): array
{
$scale = $this->ratingScale ?? RatingType::scaleFrom(ConfigQuery::read('comment_max_rating'));

return $this->ratings->elementIdsRatedAtLeast(
self::PRODUCT_REF,
$minimumRating * $scale / self::FACET_SCALE,
$amongProductIds,
);
}
}
22 changes: 22 additions & 0 deletions Repository/RatingMetaRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
10 changes: 10 additions & 0 deletions Repository/RatingMetaStorageInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>|null $amongIds the elements to look at; null for every element of the kind
*
* @return list<int>
*/
public function elementIdsRatedAtLeast(string $ref, float $minimumAverage, ?array $amongIds = null): array;
}
22 changes: 22 additions & 0 deletions Tests/Double/InMemoryRatingMetaStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
76 changes: 76 additions & 0 deletions Tests/Unit/Front/RatingFacetSourceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

/*************************************************************************************/
/* This file is part of the Thelia package. */
/* */
/* Copyright (c) OpenStudio */
/* email : dev@thelia.net */
/* web : http://www.thelia.net */
/* */
/* For the full copyright and license information, please view the LICENSE.txt */
/*************************************************************************************/

namespace Comment\Tests\Unit\Front;

use Comment\Front\RatingFacetSource;
use Comment\Tests\Double\InMemoryRatingMetaStorage;
use PHPUnit\Framework\TestCase;

final class RatingFacetSourceTest extends TestCase
{
private InMemoryRatingMetaStorage $ratings;

protected function setUp(): void
{
$this->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<int, float> $averages product id => stored average
*/
private function rate(array $averages): void
{
foreach ($averages as $productId => $average) {
$this->ratings->store('product', $productId, $average, 1);
}
}
}
8 changes: 8 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -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
Loading