Add configurable game start filters
This commit is contained in:
parent
0698589d5b
commit
15760ebc08
9 changed files with 618 additions and 18 deletions
|
|
@ -1,7 +1,26 @@
|
|||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['hintType', 'awardSection', 'allAwards', 'awardType'];
|
||||
static values = {
|
||||
bucketSize: { type: Number, default: 20 },
|
||||
};
|
||||
|
||||
static targets = [
|
||||
'hintType',
|
||||
'awardSection',
|
||||
'allAwards',
|
||||
'awardType',
|
||||
'popularityMinInput',
|
||||
'popularityMaxInput',
|
||||
'popularityValue',
|
||||
'popularityFill',
|
||||
'popularitySummary',
|
||||
];
|
||||
|
||||
connect() {
|
||||
this.toggleAwardSection();
|
||||
this.syncPopularityRange();
|
||||
}
|
||||
|
||||
enforceMinOneChecked(event) {
|
||||
const checked = this.hintTypeTargets.filter((e) => e.checked);
|
||||
|
|
@ -30,4 +49,64 @@ export default class extends Controller {
|
|||
const allChecked = this.awardTypeTargets.every((el) => el.checked);
|
||||
this.allAwardsTarget.checked = allChecked;
|
||||
}
|
||||
|
||||
syncPopularityRange(event) {
|
||||
if (
|
||||
!this.hasPopularityMinInputTarget
|
||||
|| !this.hasPopularityMaxInputTarget
|
||||
|| !this.hasPopularityValueTarget
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let min = Number(this.popularityMinInputTarget.value);
|
||||
let max = Number(this.popularityMaxInputTarget.value);
|
||||
|
||||
if (event?.target === this.popularityMinInputTarget && min > max) {
|
||||
max = min;
|
||||
this.popularityMaxInputTarget.value = String(max);
|
||||
} else if (event?.target === this.popularityMaxInputTarget && max < min) {
|
||||
min = max;
|
||||
this.popularityMinInputTarget.value = String(min);
|
||||
} else if (min > max) {
|
||||
[min, max] = [max, min];
|
||||
this.popularityMinInputTarget.value = String(min);
|
||||
this.popularityMaxInputTarget.value = String(max);
|
||||
}
|
||||
|
||||
this.popularityValueTarget.textContent = `${min}-${max}`;
|
||||
|
||||
if (this.hasPopularityFillTarget) {
|
||||
const left = ((min - 1) / 9) * 100;
|
||||
const right = ((10 - max) / 9) * 100;
|
||||
this.popularityFillTarget.style.left = `${left}%`;
|
||||
this.popularityFillTarget.style.right = `${right}%`;
|
||||
}
|
||||
|
||||
if (this.hasPopularitySummaryTarget) {
|
||||
this.popularitySummaryTarget.textContent =
|
||||
this.buildPopularitySummary(min, max);
|
||||
}
|
||||
}
|
||||
|
||||
buildPopularitySummary(min, max) {
|
||||
const bucketSize = this.bucketSizeValue;
|
||||
|
||||
if (min === 1 && max === 10) {
|
||||
return 'Tous les acteurs.';
|
||||
}
|
||||
|
||||
const startRank = ((10 - max) * bucketSize) + 1;
|
||||
const endRank = min === 1 ? null : ((10 - min + 1) * bucketSize);
|
||||
|
||||
if (max === 10) {
|
||||
return `Top ${(11 - min) * bucketSize} des acteurs les plus populaires.`;
|
||||
}
|
||||
|
||||
if (endRank === null) {
|
||||
return `A partir du rang ${startRank} en popularite.`;
|
||||
}
|
||||
|
||||
return `Rangs ${startRank} a ${endRank} en popularite.`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -788,6 +788,128 @@ body {
|
|||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.config-range {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-range-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-range-value {
|
||||
min-width: 28px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-tint);
|
||||
color: var(--orange);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.config-range-input {
|
||||
width: 100%;
|
||||
accent-color: var(--orange);
|
||||
}
|
||||
|
||||
.config-dual-range {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-dual-range-track,
|
||||
.config-dual-range-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.config-dual-range-track {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.config-dual-range-fill {
|
||||
background: linear-gradient(90deg, var(--orange-light), var(--orange));
|
||||
}
|
||||
|
||||
.config-dual-range .config-range-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
pointer-events: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.config-dual-range .config-range-input::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.config-dual-range .config-range-input::-moz-range-track {
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.config-dual-range .config-range-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: -6px;
|
||||
border: 2px solid var(--orange);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 2px 8px var(--shadow-warm);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.config-dual-range .config-range-input::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--orange);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 2px 8px var(--shadow-warm);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.config-range-input-min {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.config-range-input-max {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.config-range-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.config-help {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
|
||||
.config-toggle {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||
|
||||
class GameController extends AbstractController
|
||||
{
|
||||
private const int DEFAULT_MIN_POPULARITY_BUCKET = 1;
|
||||
private const int DEFAULT_MAX_POPULARITY_BUCKET = 10;
|
||||
private const int MIN_POPULARITY_BUCKET = 1;
|
||||
private const int MAX_POPULARITY_BUCKET = 10;
|
||||
|
||||
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||
public function start(
|
||||
Request $request,
|
||||
|
|
@ -50,6 +55,8 @@ class GameController extends AbstractController
|
|||
$config['watchedOnly'] = true;
|
||||
}
|
||||
|
||||
$config['popularityRange'] = $this->extractPopularityRange($request);
|
||||
|
||||
$hintTypes = [];
|
||||
if ($request->request->getBoolean('hint_film', true)) {
|
||||
$hintTypes[] = 'film';
|
||||
|
|
@ -126,4 +133,36 @@ class GameController extends AbstractController
|
|||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{minBucket: int, maxBucket: int}
|
||||
*/
|
||||
private function extractPopularityRange(Request $request): array
|
||||
{
|
||||
$minBucket = $this->extractPopularityBucket($request->request->get('popularity_min_bucket'), self::DEFAULT_MIN_POPULARITY_BUCKET);
|
||||
$maxBucket = $this->extractPopularityBucket($request->request->get('popularity_max_bucket'), self::DEFAULT_MAX_POPULARITY_BUCKET);
|
||||
|
||||
if ($minBucket > $maxBucket) {
|
||||
[$minBucket, $maxBucket] = [$maxBucket, $minBucket];
|
||||
}
|
||||
|
||||
return [
|
||||
'minBucket' => $minBucket,
|
||||
'maxBucket' => $maxBucket,
|
||||
];
|
||||
}
|
||||
|
||||
private function extractPopularityBucket(mixed $rawValue, int $default): int
|
||||
{
|
||||
if (!is_scalar($rawValue) || $rawValue === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$bucket = filter_var((string) $rawValue, FILTER_VALIDATE_INT);
|
||||
if ($bucket === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $bucket));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||
|
||||
class GameGridProvider
|
||||
{
|
||||
private const int DEFAULT_MIN_POPULARITY_BUCKET = 1;
|
||||
private const int DEFAULT_MAX_POPULARITY_BUCKET = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly ActorRepository $actorRepository,
|
||||
private readonly MovieRoleRepository $movieRoleRepository,
|
||||
|
|
@ -25,16 +28,19 @@ class GameGridProvider
|
|||
) {}
|
||||
|
||||
/**
|
||||
* @param array{watchedOnly?: bool, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
|
||||
* @param array{watchedOnly?: bool, popularityRange?: array{minBucket?: int, maxBucket?: int}, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
|
||||
*/
|
||||
public function generate(?User $user = null, array $config = []): ?Game
|
||||
{
|
||||
$watchedOnly = $config['watchedOnly'] ?? false;
|
||||
$popularityRange = $config['popularityRange'] ?? [];
|
||||
$minPopularityBucket = $popularityRange['minBucket'] ?? self::DEFAULT_MIN_POPULARITY_BUCKET;
|
||||
$maxPopularityBucket = $popularityRange['maxBucket'] ?? self::DEFAULT_MAX_POPULARITY_BUCKET;
|
||||
$hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award'];
|
||||
$awardTypeIds = $config['awardTypeIds'] ?? null;
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds);
|
||||
$game = $this->tryGenerate($user, $watchedOnly, (int) $minPopularityBucket, (int) $maxPopularityBucket, $hintTypes, $awardTypeIds);
|
||||
if ($game !== null) {
|
||||
return $game;
|
||||
}
|
||||
|
|
@ -47,12 +53,12 @@ class GameGridProvider
|
|||
* @param list<string> $hintTypes
|
||||
* @param list<int>|null $awardTypeIds
|
||||
*/
|
||||
private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game
|
||||
private function tryGenerate(?User $user, bool $watchedOnly, int $minPopularityBucket, int $maxPopularityBucket, array $hintTypes, ?array $awardTypeIds): ?Game
|
||||
{
|
||||
if ($watchedOnly && $user !== null) {
|
||||
$mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4);
|
||||
$mainActor = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket);
|
||||
} else {
|
||||
$mainActor = $this->actorRepository->findOneRandom(4);
|
||||
$mainActor = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket);
|
||||
}
|
||||
|
||||
if ($mainActor === null) {
|
||||
|
|
@ -74,9 +80,9 @@ class GameGridProvider
|
|||
$actor = null;
|
||||
for ($try = 0; $try < 5; $try++) {
|
||||
if ($watchedOnly && $user !== null) {
|
||||
$candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
|
||||
$candidate = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket, $char);
|
||||
} else {
|
||||
$candidate = $this->actorRepository->findOneRandom(4, $char);
|
||||
$candidate = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket, $char);
|
||||
}
|
||||
|
||||
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
|
||||
|
|
|
|||
|
|
@ -13,19 +13,76 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||
*/
|
||||
class ActorRepository extends ServiceEntityRepository
|
||||
{
|
||||
private const int MIN_POPULARITY_BUCKET = 1;
|
||||
private const int MAX_POPULARITY_BUCKET = 10;
|
||||
private const int POPULARITY_BUCKET_SIZE = 20;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Actor::class);
|
||||
}
|
||||
|
||||
public function findOneRandom(?float $popularity = null, ?string $char = null): Actor
|
||||
public function findOneRandomByPopularityBucketRange(int $minBucket, int $maxBucket, ?string $char = null): ?Actor
|
||||
{
|
||||
$actorId = $this->findRandomActorIdByPopularityBucketRange(
|
||||
<<<'SQL'
|
||||
SELECT a.id, a.name, a.popularity
|
||||
FROM actor a
|
||||
WHERE a.popularity IS NOT NULL
|
||||
SQL,
|
||||
[],
|
||||
$minBucket,
|
||||
$maxBucket,
|
||||
$char,
|
||||
);
|
||||
|
||||
if ($actorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actor = $this->find($actorId);
|
||||
|
||||
return $actor instanceof Actor ? $actor : null;
|
||||
}
|
||||
|
||||
public function findOneRandomInWatchedFilmsByPopularityBucketRange(User $user, int $minBucket, int $maxBucket, ?string $char = null): ?Actor
|
||||
{
|
||||
$actorId = $this->findRandomActorIdByPopularityBucketRange(
|
||||
<<<'SQL'
|
||||
SELECT DISTINCT a.id, a.name, a.popularity
|
||||
FROM actor a
|
||||
JOIN movie_role mr ON mr.actor_id = a.id
|
||||
JOIN user_movie um ON um.movie_id = mr.movie_id AND um.user_id = :userId
|
||||
WHERE a.popularity IS NOT NULL
|
||||
SQL,
|
||||
['userId' => $user->getId()],
|
||||
$minBucket,
|
||||
$maxBucket,
|
||||
$char,
|
||||
);
|
||||
|
||||
if ($actorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actor = $this->find($actorId);
|
||||
|
||||
return $actor instanceof Actor ? $actor : null;
|
||||
}
|
||||
|
||||
public function findOneRandom(?float $minPopularity = null, ?float $maxPopularity = null, ?string $char = null): ?Actor
|
||||
{
|
||||
$qb = $this->createQueryBuilder('o');
|
||||
$expr = $qb->expr();
|
||||
|
||||
if (!empty($popularity)) {
|
||||
$qb->andWhere($expr->gte('o.popularity', ':popularity'))
|
||||
->setParameter('popularity', $popularity);
|
||||
if (null !== $minPopularity) {
|
||||
$qb->andWhere($expr->gte('o.popularity', ':minPopularity'))
|
||||
->setParameter('minPopularity', $minPopularity);
|
||||
}
|
||||
|
||||
if (null !== $maxPopularity) {
|
||||
$qb->andWhere($expr->lte('o.popularity', ':maxPopularity'))
|
||||
->setParameter('maxPopularity', $maxPopularity);
|
||||
}
|
||||
|
||||
if (!empty($char)) {
|
||||
|
|
@ -41,7 +98,7 @@ class ActorRepository extends ServiceEntityRepository
|
|||
;
|
||||
}
|
||||
|
||||
public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor
|
||||
public function findOneRandomInWatchedFilms(User $user, ?float $minPopularity = null, ?float $maxPopularity = null, ?string $char = null): ?Actor
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->join('a.movieRoles', 'mr')
|
||||
|
|
@ -49,9 +106,14 @@ class ActorRepository extends ServiceEntityRepository
|
|||
->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user')
|
||||
->setParameter('user', $user);
|
||||
|
||||
if (!empty($popularity)) {
|
||||
$qb->andWhere('a.popularity >= :popularity')
|
||||
->setParameter('popularity', $popularity);
|
||||
if (null !== $minPopularity) {
|
||||
$qb->andWhere('a.popularity >= :minPopularity')
|
||||
->setParameter('minPopularity', $minPopularity);
|
||||
}
|
||||
|
||||
if (null !== $maxPopularity) {
|
||||
$qb->andWhere('a.popularity <= :maxPopularity')
|
||||
->setParameter('maxPopularity', $maxPopularity);
|
||||
}
|
||||
|
||||
if (!empty($char)) {
|
||||
|
|
@ -65,4 +127,82 @@ class ActorRepository extends ServiceEntityRepository
|
|||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
private function findRandomActorIdByPopularityBucketRange(
|
||||
string $baseSql,
|
||||
array $params,
|
||||
int $minBucket,
|
||||
int $maxBucket,
|
||||
?string $char = null,
|
||||
): ?int
|
||||
{
|
||||
[$minBucket, $maxBucket] = $this->normalizePopularityBuckets($minBucket, $maxBucket);
|
||||
[$startRank, $endRank] = $this->resolvePopularityRankBounds($minBucket, $maxBucket);
|
||||
|
||||
$sql = <<<'SQL'
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
base.id,
|
||||
base.name,
|
||||
ROW_NUMBER() OVER (ORDER BY base.popularity DESC, base.id DESC) AS popularity_rank
|
||||
FROM (
|
||||
SQL;
|
||||
$sql .= "\n" . $baseSql . "\n";
|
||||
$sql .= <<<'SQL'
|
||||
) AS base
|
||||
)
|
||||
SELECT ranked.id
|
||||
FROM ranked
|
||||
WHERE ranked.popularity_rank >= :startRank
|
||||
SQL;
|
||||
|
||||
$queryParams = [
|
||||
...$params,
|
||||
'startRank' => $startRank,
|
||||
];
|
||||
|
||||
if ($endRank !== null) {
|
||||
$sql .= "\nAND ranked.popularity_rank <= :endRank";
|
||||
$queryParams['endRank'] = $endRank;
|
||||
}
|
||||
|
||||
if ($char !== null && $char !== '') {
|
||||
$sql .= "\nAND LOWER(ranked.name) LIKE LOWER(:name)";
|
||||
$queryParams['name'] = '%' . $char . '%';
|
||||
}
|
||||
|
||||
$sql .= "\nORDER BY RANDOM()\nLIMIT 1";
|
||||
|
||||
$result = $this->getEntityManager()->getConnection()->fetchOne($sql, $queryParams);
|
||||
|
||||
return $result === false ? null : (int) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: int}
|
||||
*/
|
||||
private function normalizePopularityBuckets(int $minBucket, int $maxBucket): array
|
||||
{
|
||||
$minBucket = max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $minBucket));
|
||||
$maxBucket = max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $maxBucket));
|
||||
|
||||
if ($minBucket > $maxBucket) {
|
||||
[$minBucket, $maxBucket] = [$maxBucket, $minBucket];
|
||||
}
|
||||
|
||||
return [$minBucket, $maxBucket];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: ?int}
|
||||
*/
|
||||
private function resolvePopularityRankBounds(int $minBucket, int $maxBucket): array
|
||||
{
|
||||
$startRank = ((self::MAX_POPULARITY_BUCKET - $maxBucket) * self::POPULARITY_BUCKET_SIZE) + 1;
|
||||
$endRank = $minBucket === self::MIN_POPULARITY_BUCKET
|
||||
? null
|
||||
: ((self::MAX_POPULARITY_BUCKET - $minBucket + 1) * self::POPULARITY_BUCKET_SIZE);
|
||||
|
||||
return [$startRank, $endRank];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,10 +65,55 @@
|
|||
{% else %}
|
||||
<div class="game-start-container">
|
||||
<form method="post" action="{{ path('app_game_start') }}" id="start-form"
|
||||
data-controller="game-config">
|
||||
data-controller="game-config"
|
||||
data-game-config-bucket-size-value="20">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
||||
|
||||
<div class="config-panel">
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">Acteurs</div>
|
||||
<div class="config-range">
|
||||
<span class="config-range-label">Tranche de popularité</span>
|
||||
<span class="config-range-value" data-game-config-target="popularityValue">1-10</span>
|
||||
</div>
|
||||
<div class="config-dual-range">
|
||||
<div class="config-dual-range-track"></div>
|
||||
<div class="config-dual-range-fill" data-game-config-target="popularityFill"></div>
|
||||
<input
|
||||
id="min-popularity"
|
||||
type="range"
|
||||
name="popularity_min_bucket"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
value="1"
|
||||
class="config-range-input config-range-input-min"
|
||||
data-game-config-target="popularityMinInput"
|
||||
data-action="input->game-config#syncPopularityRange"
|
||||
>
|
||||
<input
|
||||
id="max-popularity"
|
||||
type="range"
|
||||
name="popularity_max_bucket"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
value="10"
|
||||
class="config-range-input config-range-input-max"
|
||||
data-game-config-target="popularityMaxInput"
|
||||
data-action="input->game-config#syncPopularityRange"
|
||||
>
|
||||
</div>
|
||||
<div class="config-range-scale" aria-hidden="true">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
<p class="config-help" data-game-config-target="popularitySummary">
|
||||
Tous les acteurs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if app.user %}
|
||||
<div class="config-section">
|
||||
<label class="config-toggle">
|
||||
|
|
|
|||
96
tests/Controller/GameControllerTest.php
Normal file
96
tests/Controller/GameControllerTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Controller\GameController;
|
||||
use App\Entity\Game;
|
||||
use App\Provider\GameGridProvider;
|
||||
use App\Repository\GameRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class GameControllerTest extends TestCase
|
||||
{
|
||||
public function testStartPassesConfiguredPopularityRangeToGenerator(): void
|
||||
{
|
||||
$game = new Game();
|
||||
$idProperty = new \ReflectionProperty(Game::class, 'id');
|
||||
$idProperty->setValue($game, 42);
|
||||
|
||||
$generator = $this->createMock(GameGridProvider::class);
|
||||
$generator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with(
|
||||
null,
|
||||
$this->callback(function (array $config): bool {
|
||||
$this->assertSame([
|
||||
'minBucket' => 5,
|
||||
'maxBucket' => 8,
|
||||
], $config['popularityRange']);
|
||||
$this->assertSame(['film', 'character', 'award'], $config['hintTypes']);
|
||||
$this->assertArrayNotHasKey('watchedOnly', $config);
|
||||
|
||||
return true;
|
||||
})
|
||||
)
|
||||
->willReturn($game);
|
||||
|
||||
$controller = new class extends GameController {
|
||||
protected function isCsrfTokenValid(string $id, ?string $token): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$controller->setContainer($this->createContainer());
|
||||
|
||||
$request = Request::create('/game/start', 'POST', [
|
||||
'_token' => 'test-token',
|
||||
'popularity_min_bucket' => '5',
|
||||
'popularity_max_bucket' => '8',
|
||||
]);
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
|
||||
$response = $controller->start(
|
||||
$request,
|
||||
$generator,
|
||||
$this->createStub(GameRepository::class),
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertSame('/', $response->getTargetUrl());
|
||||
$this->assertSame(42, $request->getSession()->get('current_game_id'));
|
||||
}
|
||||
|
||||
private function createContainer(): ContainerInterface
|
||||
{
|
||||
$router = $this->createStub(UrlGeneratorInterface::class);
|
||||
$router->method('generate')->willReturn('/');
|
||||
|
||||
$container = $this->createStub(ContainerInterface::class);
|
||||
$container->method('has')->willReturnCallback(static fn (string $id): bool => match ($id) {
|
||||
'router' => true,
|
||||
'security.token_storage' => false,
|
||||
default => false,
|
||||
});
|
||||
$container->method('get')->willReturnCallback(static fn (string $id) => match ($id) {
|
||||
'router' => $router,
|
||||
default => null,
|
||||
});
|
||||
|
||||
return $container;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ use App\Entity\Actor;
|
|||
use App\Entity\Award;
|
||||
use App\Entity\AwardType;
|
||||
use App\Entity\GameRow;
|
||||
use App\Entity\Movie;
|
||||
use App\Entity\MovieRole;
|
||||
use App\Repository\ActorRepository;
|
||||
use App\Repository\AwardRepository;
|
||||
use App\Repository\MovieRepository;
|
||||
|
|
@ -80,4 +82,75 @@ class GameGridProviderTest extends TestCase
|
|||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGenerateUsesConfiguredMinPopularity(): void
|
||||
{
|
||||
$mainActor = $this->createActorWithId(10, 'AB');
|
||||
$firstRowActor = $this->createActorWithId(11, 'Anna');
|
||||
$secondRowActor = $this->createActorWithId(12, 'Bob');
|
||||
|
||||
$calls = [];
|
||||
$actors = [$mainActor, $firstRowActor, $secondRowActor];
|
||||
|
||||
$actorRepository = $this->createMock(ActorRepository::class);
|
||||
$actorRepository
|
||||
->method('findOneRandomByPopularityBucketRange')
|
||||
->willReturnCallback(function (int $minBucket, int $maxBucket, ?string $char = null) use (&$calls, &$actors) {
|
||||
$calls[] = [$minBucket, $maxBucket, $char];
|
||||
|
||||
return array_shift($actors);
|
||||
});
|
||||
|
||||
$movieRoleRepository = $this->createMock(MovieRoleRepository::class);
|
||||
$movieRoleRepository
|
||||
->method('findOneRandomByActor')
|
||||
->willReturnCallback(function (int $actorId): MovieRole {
|
||||
$movie = new Movie();
|
||||
$movie->setTitle('Film ' . $actorId);
|
||||
|
||||
$role = new MovieRole();
|
||||
$role->setCharacter('Character ' . $actorId);
|
||||
$role->setMovie($movie);
|
||||
|
||||
return $role;
|
||||
});
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects($this->once())->method('persist');
|
||||
$em->expects($this->once())->method('flush');
|
||||
|
||||
$generator = new GameGridProvider(
|
||||
$actorRepository,
|
||||
$movieRoleRepository,
|
||||
$this->createMock(MovieRepository::class),
|
||||
$this->createMock(AwardRepository::class),
|
||||
$em,
|
||||
);
|
||||
|
||||
$game = $generator->generate(config: [
|
||||
'popularityRange' => [
|
||||
'minBucket' => 5,
|
||||
'maxBucket' => 10,
|
||||
],
|
||||
'hintTypes' => ['film'],
|
||||
]);
|
||||
|
||||
$this->assertNotNull($game);
|
||||
$this->assertSame([
|
||||
[5, 10, null],
|
||||
[5, 10, 'a'],
|
||||
[5, 10, 'b'],
|
||||
], $calls);
|
||||
}
|
||||
|
||||
private function createActorWithId(int $id, string $name): Actor
|
||||
{
|
||||
$actor = new Actor();
|
||||
$actor->setName($name);
|
||||
|
||||
$idProperty = new \ReflectionProperty(Actor::class, 'id');
|
||||
$idProperty->setValue($actor, $id);
|
||||
|
||||
return $actor;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class ActorRepositoryTest extends KernelTestCase
|
|||
$this->em->flush();
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$result = $this->repo->findOneRandomInWatchedFilms($user, 0, 'w');
|
||||
$result = $this->repo->findOneRandomInWatchedFilms($user, null, null, 'w');
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($watchedActor->getId(), $result->getId());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue