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';
|
import { Controller } from '@hotwired/stimulus';
|
||||||
|
|
||||||
export default class extends Controller {
|
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) {
|
enforceMinOneChecked(event) {
|
||||||
const checked = this.hintTypeTargets.filter((e) => e.checked);
|
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);
|
const allChecked = this.awardTypeTargets.every((el) => el.checked);
|
||||||
this.allAwardsTarget.checked = allChecked;
|
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;
|
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 */
|
/* Toggle switch */
|
||||||
|
|
||||||
.config-toggle {
|
.config-toggle {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
class GameController extends AbstractController
|
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'])]
|
#[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
|
||||||
public function start(
|
public function start(
|
||||||
Request $request,
|
Request $request,
|
||||||
|
|
@ -50,6 +55,8 @@ class GameController extends AbstractController
|
||||||
$config['watchedOnly'] = true;
|
$config['watchedOnly'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$config['popularityRange'] = $this->extractPopularityRange($request);
|
||||||
|
|
||||||
$hintTypes = [];
|
$hintTypes = [];
|
||||||
if ($request->request->getBoolean('hint_film', true)) {
|
if ($request->request->getBoolean('hint_film', true)) {
|
||||||
$hintTypes[] = 'film';
|
$hintTypes[] = 'film';
|
||||||
|
|
@ -126,4 +133,36 @@ class GameController extends AbstractController
|
||||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
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
|
class GameGridProvider
|
||||||
{
|
{
|
||||||
|
private const int DEFAULT_MIN_POPULARITY_BUCKET = 1;
|
||||||
|
private const int DEFAULT_MAX_POPULARITY_BUCKET = 10;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActorRepository $actorRepository,
|
private readonly ActorRepository $actorRepository,
|
||||||
private readonly MovieRoleRepository $movieRoleRepository,
|
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
|
public function generate(?User $user = null, array $config = []): ?Game
|
||||||
{
|
{
|
||||||
$watchedOnly = $config['watchedOnly'] ?? false;
|
$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'];
|
$hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award'];
|
||||||
$awardTypeIds = $config['awardTypeIds'] ?? null;
|
$awardTypeIds = $config['awardTypeIds'] ?? null;
|
||||||
|
|
||||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
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) {
|
if ($game !== null) {
|
||||||
return $game;
|
return $game;
|
||||||
}
|
}
|
||||||
|
|
@ -47,12 +53,12 @@ class GameGridProvider
|
||||||
* @param list<string> $hintTypes
|
* @param list<string> $hintTypes
|
||||||
* @param list<int>|null $awardTypeIds
|
* @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) {
|
if ($watchedOnly && $user !== null) {
|
||||||
$mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4);
|
$mainActor = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket);
|
||||||
} else {
|
} else {
|
||||||
$mainActor = $this->actorRepository->findOneRandom(4);
|
$mainActor = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($mainActor === null) {
|
if ($mainActor === null) {
|
||||||
|
|
@ -74,9 +80,9 @@ class GameGridProvider
|
||||||
$actor = null;
|
$actor = null;
|
||||||
for ($try = 0; $try < 5; $try++) {
|
for ($try = 0; $try < 5; $try++) {
|
||||||
if ($watchedOnly && $user !== null) {
|
if ($watchedOnly && $user !== null) {
|
||||||
$candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
|
$candidate = $this->actorRepository->findOneRandomInWatchedFilmsByPopularityBucketRange($user, $minPopularityBucket, $maxPopularityBucket, $char);
|
||||||
} else {
|
} else {
|
||||||
$candidate = $this->actorRepository->findOneRandom(4, $char);
|
$candidate = $this->actorRepository->findOneRandomByPopularityBucketRange($minPopularityBucket, $maxPopularityBucket, $char);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
|
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,76 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||||
*/
|
*/
|
||||||
class ActorRepository extends ServiceEntityRepository
|
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)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
parent::__construct($registry, Actor::class);
|
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');
|
$qb = $this->createQueryBuilder('o');
|
||||||
$expr = $qb->expr();
|
$expr = $qb->expr();
|
||||||
|
|
||||||
if (!empty($popularity)) {
|
if (null !== $minPopularity) {
|
||||||
$qb->andWhere($expr->gte('o.popularity', ':popularity'))
|
$qb->andWhere($expr->gte('o.popularity', ':minPopularity'))
|
||||||
->setParameter('popularity', $popularity);
|
->setParameter('minPopularity', $minPopularity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $maxPopularity) {
|
||||||
|
$qb->andWhere($expr->lte('o.popularity', ':maxPopularity'))
|
||||||
|
->setParameter('maxPopularity', $maxPopularity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($char)) {
|
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')
|
$qb = $this->createQueryBuilder('a')
|
||||||
->join('a.movieRoles', 'mr')
|
->join('a.movieRoles', 'mr')
|
||||||
|
|
@ -49,9 +106,14 @@ class ActorRepository extends ServiceEntityRepository
|
||||||
->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user')
|
->join(UserMovie::class, 'um', 'WITH', 'um.movie = m AND um.user = :user')
|
||||||
->setParameter('user', $user);
|
->setParameter('user', $user);
|
||||||
|
|
||||||
if (!empty($popularity)) {
|
if (null !== $minPopularity) {
|
||||||
$qb->andWhere('a.popularity >= :popularity')
|
$qb->andWhere('a.popularity >= :minPopularity')
|
||||||
->setParameter('popularity', $popularity);
|
->setParameter('minPopularity', $minPopularity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $maxPopularity) {
|
||||||
|
$qb->andWhere('a.popularity <= :maxPopularity')
|
||||||
|
->setParameter('maxPopularity', $maxPopularity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($char)) {
|
if (!empty($char)) {
|
||||||
|
|
@ -65,4 +127,82 @@ class ActorRepository extends ServiceEntityRepository
|
||||||
->getQuery()
|
->getQuery()
|
||||||
->getOneOrNullResult();
|
->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 %}
|
{% else %}
|
||||||
<div class="game-start-container">
|
<div class="game-start-container">
|
||||||
<form method="post" action="{{ path('app_game_start') }}" id="start-form"
|
<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') }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
||||||
|
|
||||||
<div class="config-panel">
|
<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 %}
|
{% if app.user %}
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<label class="config-toggle">
|
<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\Award;
|
||||||
use App\Entity\AwardType;
|
use App\Entity\AwardType;
|
||||||
use App\Entity\GameRow;
|
use App\Entity\GameRow;
|
||||||
|
use App\Entity\Movie;
|
||||||
|
use App\Entity\MovieRole;
|
||||||
use App\Repository\ActorRepository;
|
use App\Repository\ActorRepository;
|
||||||
use App\Repository\AwardRepository;
|
use App\Repository\AwardRepository;
|
||||||
use App\Repository\MovieRepository;
|
use App\Repository\MovieRepository;
|
||||||
|
|
@ -80,4 +82,75 @@ class GameGridProviderTest extends TestCase
|
||||||
|
|
||||||
$this->assertNull($result);
|
$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();
|
$this->em->flush();
|
||||||
|
|
||||||
for ($i = 0; $i < 10; $i++) {
|
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->assertNotNull($result);
|
||||||
$this->assertSame($watchedActor->getId(), $result->getId());
|
$this->assertSame($watchedActor->getId(), $result->getId());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue