Compare commits
14 Commits
116d7b409e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
843009e193 | ||
|
|
2e7d7ecf44 | ||
|
|
6b514aa87b | ||
|
|
51a9f49797 | ||
|
|
b637b725d8 | ||
|
|
ba715d69a0 | ||
|
|
94ff0ced63 | ||
|
|
9c095a76eb | ||
|
|
f291df0fcf | ||
|
|
67571e8b33 | ||
|
|
5fbac8359f | ||
|
|
468b72b419 | ||
|
|
54225ad97b | ||
|
|
295bb16ab7 |
@@ -1,3 +1,4 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
POSTGRES_DB=app_test
|
||||
|
||||
1
Makefile
1
Makefile
@@ -60,6 +60,7 @@ php\:console: ## Lance bin/console avec arguments (ex: make php:console -- cache
|
||||
|
||||
symfony\:cache-clear: ## Vide le cache Symfony
|
||||
docker compose exec app php bin/console cache:clear
|
||||
docker compose restart messenger
|
||||
|
||||
test: ## Lance les tests PHPUnit
|
||||
docker compose exec app php bin/phpunit
|
||||
|
||||
2
assets/bootstrap.js
vendored
2
assets/bootstrap.js
vendored
@@ -3,12 +3,14 @@ import DropdownController from './controllers/dropdown_controller.js';
|
||||
import ImportModalController from './controllers/import_modal_controller.js';
|
||||
import ImportStatusController from './controllers/import_status_controller.js';
|
||||
import ImportHelpController from './controllers/import_help_controller.js';
|
||||
import GameConfigController from './controllers/game_config_controller.js';
|
||||
|
||||
const app = startStimulusApp();
|
||||
app.register('dropdown', DropdownController);
|
||||
app.register('import-modal', ImportModalController);
|
||||
app.register('import-status', ImportStatusController);
|
||||
app.register('import-help', ImportHelpController);
|
||||
app.register('game-config', GameConfigController);
|
||||
|
||||
// Register React components for {{ react_component() }} Twig function.
|
||||
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
||||
|
||||
33
assets/controllers/game_config_controller.js
Normal file
33
assets/controllers/game_config_controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['hintType', 'awardSection', 'allAwards', 'awardType'];
|
||||
|
||||
enforceMinOneChecked(event) {
|
||||
const checked = this.hintTypeTargets.filter((e) => e.checked);
|
||||
if (checked.length === 0) {
|
||||
event.target.checked = true;
|
||||
}
|
||||
this.toggleAwardSection();
|
||||
}
|
||||
|
||||
toggleAwardSection() {
|
||||
const awardChecked = this.hintTypeTargets.find(
|
||||
(el) => el.name === 'hint_award'
|
||||
)?.checked;
|
||||
|
||||
this.awardSectionTarget.style.display = awardChecked ? '' : 'none';
|
||||
}
|
||||
|
||||
toggleAllAwards() {
|
||||
const checked = this.allAwardsTarget.checked;
|
||||
this.awardTypeTargets.forEach((el) => {
|
||||
el.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
syncAllAwards() {
|
||||
const allChecked = this.awardTypeTargets.every((el) => el.checked);
|
||||
this.allAwardsTarget.checked = allChecked;
|
||||
}
|
||||
}
|
||||
@@ -732,6 +732,140 @@ body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ── Game config panel ── */
|
||||
|
||||
.config-panel {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.config-section + .config-section {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-section-subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
|
||||
.config-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-toggle-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
appearance: none;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
background: var(--border);
|
||||
border-radius: 100px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch:checked {
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
.toggle-switch:checked::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Hint type checkboxes */
|
||||
|
||||
.config-hint-types {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--orange);
|
||||
}
|
||||
|
||||
/* Award type list */
|
||||
|
||||
.config-award-types {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.config-award-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.config-award-list .config-checkbox {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.config-award-list .config-checkbox:first-child {
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Flash messages */
|
||||
|
||||
.flash-error {
|
||||
margin-top: 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.start-loader {
|
||||
display: none;
|
||||
width: 48px;
|
||||
|
||||
@@ -33,6 +33,11 @@ doctrine:
|
||||
numeric_functions:
|
||||
Random: App\Doctrine\Extension\Random
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
dbname: app_test
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
|
||||
1176
docs/superpowers/plans/2026-04-01-game-config-panel.md
Normal file
1176
docs/superpowers/plans/2026-04-01-game-config-panel.md
Normal file
File diff suppressed because it is too large
Load Diff
119
docs/superpowers/specs/2026-04-01-game-config-panel-design.md
Normal file
119
docs/superpowers/specs/2026-04-01-game-config-panel-design.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Game Configuration Panel
|
||||
|
||||
## Summary
|
||||
|
||||
Add a configuration panel above the "Commencer une partie" button on the homepage, allowing players to customize game generation parameters before starting.
|
||||
|
||||
## Parameters
|
||||
|
||||
### Films vus (toggle on/off)
|
||||
|
||||
- **Visible only** for authenticated users
|
||||
- Default: off
|
||||
- When enabled: all actors in the grid (main actor + hint actors) must appear in at least one film marked as watched by the user
|
||||
- When disabled: no filtering, any actor can appear
|
||||
|
||||
### Paramètres des indices (checkboxes)
|
||||
|
||||
Three checkboxes controlling which hint types can appear in the grid:
|
||||
|
||||
- **Film** (default: checked) — hint shows a film the actor appeared in
|
||||
- **Rôle** (default: checked) — hint shows a character name the actor played
|
||||
- **Récompense** (default: checked) — hint shows an award the actor received
|
||||
|
||||
**Constraint:** at least one must remain checked. The UI prevents unchecking the last one.
|
||||
|
||||
### Récompenses (multi-select checkboxes)
|
||||
|
||||
- **Visible only** when "Récompense" is checked
|
||||
- Lists all `AwardType` entities that have 5+ distinct actors in the database
|
||||
- A "Toutes les récompenses" option at the top that toggles all
|
||||
- Default: all checked (= "Toutes" checked)
|
||||
- Unchecking an individual item unchecks "Toutes"; rechecking all rechecks "Toutes"
|
||||
- Selected AwardType IDs are sent as `award_types[]` in the form POST
|
||||
|
||||
## UI Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Films vus [ toggle ] │ ← auth users only
|
||||
├─────────────────────────────────┤
|
||||
│ Paramètres des indices │
|
||||
│ │
|
||||
│ ☑ Film ☑ Rôle ☑ Récompense│
|
||||
│ │
|
||||
│ Récompenses : │ ← visible if Récompense checked
|
||||
│ ┌───────────────────────┐ │
|
||||
│ │ ☑ Toutes les récomp. │ │
|
||||
│ │ ☑ Academy Awards │ │
|
||||
│ │ ☑ Golden Globes │ │
|
||||
│ │ ☑ César │ │
|
||||
│ │ ... │ │
|
||||
│ └───────────────────────┘ │
|
||||
├─────────────────────────────────┤
|
||||
│ [ Commencer une partie ] │
|
||||
│ │
|
||||
│ Connectez-vous pour importer...│
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Form Fields
|
||||
|
||||
All parameters are fields within the existing start game `<form>`:
|
||||
|
||||
| Field name | Type | Value |
|
||||
|---|---|---|
|
||||
| `watched_only` | hidden + checkbox | `"1"` if checked, absent otherwise |
|
||||
| `hint_film` | checkbox | `"1"` if checked |
|
||||
| `hint_character` | checkbox | `"1"` if checked |
|
||||
| `hint_award` | checkbox | `"1"` if checked |
|
||||
| `award_types[]` | checkbox (multiple) | Array of AwardType IDs |
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### HomepageController
|
||||
|
||||
- Query `AwardTypeRepository::findWithMinActors(5)` to get eligible AwardType list
|
||||
- Pass to template for rendering the multi-select
|
||||
|
||||
### GameController (POST /game/start)
|
||||
|
||||
- Extract config parameters from the Request
|
||||
- Pass config to `GameGridProvider::generate()`
|
||||
|
||||
### GameGridProvider::generate()
|
||||
|
||||
Receives a config array/object with:
|
||||
|
||||
- `watchedOnly` (bool) — filter actors to those in user's watched films
|
||||
- `hintTypes` (array) — subset of `['film', 'character', 'award']` based on checked boxes
|
||||
- `awardTypeIds` (array|null) — list of selected AwardType IDs, null = all
|
||||
|
||||
Generation logic changes:
|
||||
|
||||
- **Actor selection:** if `watchedOnly` is true, only pick actors with a MovieRole in a film watched by the authenticated user
|
||||
- **Hint selection:** only use hint types present in `hintTypes`
|
||||
- **Award hints:** only pick awards whose AwardType ID is in `awardTypeIds`
|
||||
- **Retry mechanism:** if generation fails (cannot find a valid hint for every row), retry up to 5 times. After 5 failures, redirect to homepage with a flash error message explaining the grid could not be generated with the chosen parameters.
|
||||
|
||||
### AwardTypeRepository
|
||||
|
||||
New method: `findWithMinActors(int $minActors): array` — returns AwardType entities having at least `$minActors` distinct actors.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### Stimulus controller: `game-config`
|
||||
|
||||
Registered on the config panel container. Handles:
|
||||
|
||||
1. **Award section visibility:** show/hide the AwardType checkbox list when "Récompense" is toggled
|
||||
2. **Minimum one hint type:** prevent unchecking the last remaining hint checkbox
|
||||
3. **"Toutes les récompenses" toggle:** check/uncheck all AwardType checkboxes; sync state when individual items change
|
||||
|
||||
### CSS (in app.css)
|
||||
|
||||
- **Toggle switch:** CSS-only using `appearance: none` on checkbox, `::before` pseudo-element for the sliding circle, `--orange` when active, `--border` when inactive
|
||||
- **Hint checkboxes:** `accent-color: var(--orange)`
|
||||
- **AwardType list:** border `var(--border)`, `border-radius: var(--radius-sm)`, `max-height: 150px`, `overflow-y: auto`
|
||||
- **Section labels:** `color: var(--text-muted)`, `font-size: 13px`, `text-transform: uppercase`
|
||||
- No new CSS or JS files — everything in existing `app.css` and a new Stimulus controller file
|
||||
@@ -42,7 +42,40 @@ class GameController extends AbstractController
|
||||
return $this->redirectToRoute('app_homepage');
|
||||
}
|
||||
|
||||
$game = $generator->generate($user);
|
||||
// Build config from form parameters
|
||||
$config = [];
|
||||
|
||||
if ($user && $request->request->getBoolean('watched_only')) {
|
||||
$config['watchedOnly'] = true;
|
||||
}
|
||||
|
||||
$hintTypes = [];
|
||||
if ($request->request->getBoolean('hint_film', true)) {
|
||||
$hintTypes[] = 'film';
|
||||
}
|
||||
if ($request->request->getBoolean('hint_character', true)) {
|
||||
$hintTypes[] = 'character';
|
||||
}
|
||||
if ($request->request->getBoolean('hint_award', true)) {
|
||||
$hintTypes[] = 'award';
|
||||
}
|
||||
if (empty($hintTypes)) {
|
||||
$hintTypes = ['film', 'character', 'award'];
|
||||
}
|
||||
$config['hintTypes'] = $hintTypes;
|
||||
|
||||
/** @var list<string> $awardTypeIds */
|
||||
$awardTypeIds = $request->request->all('award_types');
|
||||
if (!empty($awardTypeIds) && in_array('award', $hintTypes)) {
|
||||
$config['awardTypeIds'] = array_map('intval', $awardTypeIds);
|
||||
}
|
||||
|
||||
$game = $generator->generate($user, $config);
|
||||
|
||||
if ($game === null) {
|
||||
$this->addFlash('error', 'Impossible de générer une grille avec ces paramètres. Essayez avec des critères moins restrictifs.');
|
||||
return $this->redirectToRoute('app_homepage');
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
$request->getSession()->set('current_game_id', $game->getId());
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Game;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AwardTypeRepository;
|
||||
use App\Repository\GameRepository;
|
||||
use App\Provider\GameGridProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -20,6 +21,7 @@ class HomepageController extends AbstractController
|
||||
Request $request,
|
||||
GameRepository $gameRepository,
|
||||
GameGridProvider $gridGenerator,
|
||||
AwardTypeRepository $awardTypeRepository,
|
||||
): Response {
|
||||
/** @var User|null $user */
|
||||
$user = $this->getUser();
|
||||
@@ -42,6 +44,7 @@ class HomepageController extends AbstractController
|
||||
if (!$game) {
|
||||
return $this->render('homepage/index.html.twig', [
|
||||
'game' => null,
|
||||
'awardTypes' => $awardTypeRepository->findWithMinActors(5),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,18 @@ class WikidataGateway
|
||||
?awardStatement ps:P166 ?award .
|
||||
?awardStatement pq:P585 ?date .
|
||||
BIND(YEAR(?date) AS ?year)
|
||||
|
||||
# Only keep entertainment awards (film, TV, music, theater, performing arts)
|
||||
VALUES ?awardSuperclass {
|
||||
wd:Q4220920 # film award
|
||||
wd:Q1407443 # television award
|
||||
wd:Q2235858 # music award
|
||||
wd:Q15056993 # film festival award
|
||||
wd:Q15383322 # theater award
|
||||
wd:Q29461289 # performing arts award
|
||||
}
|
||||
?award wdt:P31/wdt:P279* ?awardSuperclass .
|
||||
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
|
||||
}
|
||||
ORDER BY ?name DESC(?year)
|
||||
|
||||
@@ -14,6 +14,76 @@ use Psr\Log\LoggerInterface;
|
||||
|
||||
readonly class AwardImporter
|
||||
{
|
||||
/**
|
||||
* Canonical award name => keywords to match (case-insensitive).
|
||||
* Checked in order — first match wins.
|
||||
*/
|
||||
private const AWARD_MAP = [
|
||||
'Oscar' => ['Academy Award', 'Oscar'],
|
||||
'Golden Globe' => ['Golden Globe', 'Golden Globes'],
|
||||
'BAFTA' => ['BAFTA', 'British Academy Film Award', 'British Academy Television Award', 'British Academy Games Award'],
|
||||
'César' => ['César'],
|
||||
'SAG' => ['Screen Actors Guild'],
|
||||
'Emmy' => ['Emmy Award', 'Primetime Emmy'],
|
||||
'Tony' => ['Tony Award', 'Tony award'],
|
||||
'Grammy' => ['Grammy'],
|
||||
'Cannes' => ['Festival de Cannes', 'Cannes', "Palme d'or", "Caméra d'or"],
|
||||
'Sundance' => ['Sundance'],
|
||||
'Berlinale' => ['Berlinale', 'Berliner Bär', "Ours d'argent", "Ours d'or"],
|
||||
'Mostra de Venise' => ['Mostra', 'Venice Film Festival', 'Coupe Volpi', "Lion d'or"],
|
||||
'Saturn' => ['Saturn Award'],
|
||||
'MTV' => ['MTV Movie', 'MTV Video'],
|
||||
"Critics' Choice" => ["Critics' Choice"],
|
||||
'Independent Spirit' => ['Independent Spirit'],
|
||||
'Annie' => ['Annie Award'],
|
||||
'Goya' => ['prix Goya', 'Goya Award'],
|
||||
'Laurence Olivier' => ['Laurence Olivier', 'Olivier Award'],
|
||||
'David di Donatello' => ['David di Donatello'],
|
||||
'Gotham' => ['Gotham Award', 'Gotham Independent'],
|
||||
'NAACP Image' => ['NAACP Image'],
|
||||
"People's Choice" => ["People's Choice"],
|
||||
'Teen Choice' => ['Teen Choice'],
|
||||
'BET' => ['BET Award', 'BET Her', 'BET YoungStars'],
|
||||
'Black Reel' => ['Black Reel'],
|
||||
'National Board of Review' => ['National Board of Review'],
|
||||
'New York Film Critics Circle' => ['New York Film Critics Circle'],
|
||||
'Los Angeles Film Critics' => ['Los Angeles Film Critics'],
|
||||
'San Sebastián' => ['Donostia', 'San Sebastián'],
|
||||
'Sitges' => ['Sitges'],
|
||||
'Satellite' => ['Satellite Award'],
|
||||
'Lucille Lortel' => ['Lucille Lortel'],
|
||||
'Golden Raspberry' => ['Golden Raspberry', 'Razzie'],
|
||||
'Drama Desk' => ['Drama Desk'],
|
||||
'Genie' => ['Genie Award'],
|
||||
'European Film Award' => ['prix du cinéma européen', 'European Film Award'],
|
||||
'AACTA' => ['AACTA'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Keywords indicating non-entertainment awards (case-insensitive).
|
||||
* These slip through even with the SPARQL filter.
|
||||
*/
|
||||
private const EXCLUDED_KEYWORDS = [
|
||||
// National orders and decorations
|
||||
'chevalier', 'officier', 'commandeur', 'compagnon',
|
||||
'ordre du', 'ordre de', 'order of the',
|
||||
'grand-croix', 'grand officier', 'grand cordon',
|
||||
'Knight Bachelor', 'Knight Commander',
|
||||
'croix d\'',
|
||||
// Honorary degrees and memberships
|
||||
'honoris causa',
|
||||
'membre de l\'', 'membre de la', 'membre honoraire', 'membre associé', 'membre élu',
|
||||
'Fellow of', 'fellow de',
|
||||
// Scholarships
|
||||
'bourse ',
|
||||
// Medals (military, scientific, etc.)
|
||||
'médaille', 'Medal',
|
||||
// Other non-entertainment
|
||||
'Time 100', '100 Women', 'All-NBA',
|
||||
'étoile du Hollywood Walk of Fame',
|
||||
'allée des célébrités',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private WikidataGateway $wikidataGateway,
|
||||
private AwardTypeRepository $awardTypeRepository,
|
||||
@@ -54,6 +124,10 @@ readonly class AwardImporter
|
||||
$wikidataAwards = $allAwards[$actor->getName()] ?? [];
|
||||
|
||||
foreach ($wikidataAwards as $wikidataAward) {
|
||||
if ($this->isExcluded($wikidataAward['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
|
||||
|
||||
$award = new Award();
|
||||
@@ -74,12 +148,33 @@ readonly class AwardImporter
|
||||
*/
|
||||
private function resolveAwardType(string $awardName, array &$knownTypes): AwardType
|
||||
{
|
||||
// 1. Try canonical map first
|
||||
$canonicalName = $this->findCanonicalName($awardName);
|
||||
|
||||
if (null !== $canonicalName) {
|
||||
foreach ($knownTypes as $type) {
|
||||
if ($type->getName() === $canonicalName) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
$newType = new AwardType();
|
||||
$newType->setName($canonicalName);
|
||||
$newType->setPattern($canonicalName);
|
||||
$this->em->persist($newType);
|
||||
$knownTypes[] = $newType;
|
||||
|
||||
return $newType;
|
||||
}
|
||||
|
||||
// 2. Fall back to existing pattern matching
|
||||
foreach ($knownTypes as $type) {
|
||||
if (str_contains($awardName, $type->getPattern())) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create new type with prefix extraction
|
||||
$newType = new AwardType();
|
||||
$prefix = $this->extractPrefix($awardName);
|
||||
$newType->setName($prefix);
|
||||
@@ -91,10 +186,43 @@ readonly class AwardImporter
|
||||
return $newType;
|
||||
}
|
||||
|
||||
private function findCanonicalName(string $awardName): ?string
|
||||
{
|
||||
$normalized = mb_strtolower($awardName);
|
||||
|
||||
foreach (self::AWARD_MAP as $canonical => $keywords) {
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($normalized, mb_strtolower($keyword))) {
|
||||
return $canonical;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isExcluded(string $awardName): bool
|
||||
{
|
||||
$normalized = mb_strtolower($awardName);
|
||||
|
||||
foreach (self::EXCLUDED_KEYWORDS as $keyword) {
|
||||
if (str_contains($normalized, mb_strtolower($keyword))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function extractPrefix(string $awardName): string
|
||||
{
|
||||
// Extract text before " for " or " pour " (common patterns in award names)
|
||||
if (preg_match('/^(.+?)\s+(?:for|pour)\s+/i', $awardName, $matches)) {
|
||||
// "X for Y", "X pour Y", "X du Y", "X de la Y", "X de l'Y", "X des Y"
|
||||
if (preg_match('/^(.+?)\s+(?:for|pour|du|de la|de l\'|des)\s+/iu', $awardName, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
// "... festival de cinéma de X" or "... festival de X"
|
||||
if (preg_match('/festival\s+(?:de\s+(?:cinéma\s+de\s+)?)?(.+?)$/iu', $awardName, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,40 @@ class GameGridProvider
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function generate(?User $user = null): Game
|
||||
/**
|
||||
* @param array{watchedOnly?: bool, hintTypes?: list<string>, awardTypeIds?: list<int>|null} $config
|
||||
*/
|
||||
public function generate(?User $user = null, array $config = []): ?Game
|
||||
{
|
||||
$watchedOnly = $config['watchedOnly'] ?? false;
|
||||
$hintTypes = $config['hintTypes'] ?? ['film', 'character', 'award'];
|
||||
$awardTypeIds = $config['awardTypeIds'] ?? null;
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$game = $this->tryGenerate($user, $watchedOnly, $hintTypes, $awardTypeIds);
|
||||
if ($game !== null) {
|
||||
return $game;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $hintTypes
|
||||
* @param list<int>|null $awardTypeIds
|
||||
*/
|
||||
private function tryGenerate(?User $user, bool $watchedOnly, array $hintTypes, ?array $awardTypeIds): ?Game
|
||||
{
|
||||
if ($watchedOnly && $user !== null) {
|
||||
$mainActor = $this->actorRepository->findOneRandomInWatchedFilms($user, 4);
|
||||
} else {
|
||||
$mainActor = $this->actorRepository->findOneRandom(4);
|
||||
}
|
||||
|
||||
if ($mainActor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$game = new Game();
|
||||
$game->setMainActor($mainActor);
|
||||
@@ -40,14 +71,23 @@ class GameGridProvider
|
||||
continue;
|
||||
}
|
||||
|
||||
$tryFindActor = 0;
|
||||
do {
|
||||
$actor = $this->actorRepository->findOneRandom(4, $char);
|
||||
++$tryFindActor;
|
||||
} while (
|
||||
in_array($actor->getId(), $usedActors)
|
||||
|| $tryFindActor < 5
|
||||
);
|
||||
$actor = null;
|
||||
for ($try = 0; $try < 5; $try++) {
|
||||
if ($watchedOnly && $user !== null) {
|
||||
$candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
|
||||
} else {
|
||||
$candidate = $this->actorRepository->findOneRandom(4, $char);
|
||||
}
|
||||
|
||||
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
|
||||
$actor = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($actor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$usedActors[] = $actor->getId();
|
||||
|
||||
@@ -56,11 +96,12 @@ class GameGridProvider
|
||||
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||
$row->setRowOrder($rowOrder);
|
||||
|
||||
$hint = $this->generateHint($actor);
|
||||
if ($hint !== null) {
|
||||
$hint = $this->generateHint($actor, $hintTypes, $awardTypeIds);
|
||||
if ($hint === null) {
|
||||
return null; // Every row must have a hint
|
||||
}
|
||||
$row->setHintType($hint['type']);
|
||||
$row->setHintData($hint['data']);
|
||||
}
|
||||
|
||||
$game->addRow($row);
|
||||
++$rowOrder;
|
||||
@@ -133,17 +174,17 @@ class GameGridProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single hint for a row actor.
|
||||
*
|
||||
* @param list<string> $allowedTypes
|
||||
* @param list<int>|null $awardTypeIds
|
||||
* @return array{type: string, data: string}|null
|
||||
*/
|
||||
private function generateHint(Actor $rowActor): ?array
|
||||
private function generateHint(Actor $rowActor, array $allowedTypes = ['film', 'character', 'award'], ?array $awardTypeIds = null): ?array
|
||||
{
|
||||
$types = ['film', 'character', 'award'];
|
||||
$types = $allowedTypes;
|
||||
shuffle($types);
|
||||
|
||||
foreach ($types as $type) {
|
||||
$hint = $this->resolveHint($type, $rowActor);
|
||||
$hint = $this->resolveHint($type, $rowActor, $awardTypeIds);
|
||||
if ($hint !== null) {
|
||||
return $hint;
|
||||
}
|
||||
@@ -153,27 +194,33 @@ class GameGridProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int>|null $awardTypeIds
|
||||
* @return array{type: string, data: string}|null
|
||||
*/
|
||||
private function resolveHint(string $type, Actor $rowActor): ?array
|
||||
private function resolveHint(string $type, Actor $rowActor, ?array $awardTypeIds = null): ?array
|
||||
{
|
||||
$actorId = $rowActor->getId();
|
||||
if ($actorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'film':
|
||||
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
|
||||
$role = $this->movieRoleRepository->findOneRandomByActor($actorId);
|
||||
if ($role === null) {
|
||||
return null;
|
||||
}
|
||||
return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];
|
||||
|
||||
case 'character':
|
||||
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
|
||||
$role = $this->movieRoleRepository->findOneRandomByActor($actorId);
|
||||
if ($role === null) {
|
||||
return null;
|
||||
}
|
||||
return ['type' => 'character', 'data' => (string) $role->getId()];
|
||||
|
||||
case 'award':
|
||||
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
|
||||
$award = $this->awardRepository->findOneRandomByActorAndTypes($actorId, $awardTypeIds);
|
||||
if ($award === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -193,13 +240,28 @@ class GameGridProvider
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
|
||||
'film' => $this->resolveFilmHintText((int) $data),
|
||||
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
|
||||
'award' => $this->resolveAwardHintText((int) $data),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveFilmHintText(int $movieId): ?string
|
||||
{
|
||||
$movie = $this->movieRepository->find($movieId);
|
||||
if ($movie === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = $movie->getTitle();
|
||||
if ($movie->getYear() !== null) {
|
||||
$title .= ' (' . $movie->getYear() . ')';
|
||||
}
|
||||
|
||||
return $title;
|
||||
}
|
||||
|
||||
private function resolveAwardHintText(int $awardId): ?string
|
||||
{
|
||||
$award = $this->awardRepository->find($awardId);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserMovie;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -38,4 +40,29 @@ class ActorRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findOneRandomInWatchedFilms(User $user, ?float $popularity = null, ?string $char = null): ?Actor
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->join('a.movieRoles', 'mr')
|
||||
->join('mr.movie', 'm')
|
||||
->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 (!empty($char)) {
|
||||
$qb->andWhere('LOWER(a.name) LIKE LOWER(:name)')
|
||||
->setParameter('name', '%' . $char . '%');
|
||||
}
|
||||
|
||||
return $qb
|
||||
->orderBy('RANDOM()')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,29 @@ class AwardRepository extends ServiceEntityRepository
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int>|null $awardTypeIds null means all types
|
||||
*/
|
||||
public function findOneRandomByActorAndTypes(int $actorId, ?array $awardTypeIds): ?Award
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->andWhere('a.actor = :actorId')
|
||||
->setParameter('actorId', $actorId);
|
||||
|
||||
if ($awardTypeIds !== null && empty($awardTypeIds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($awardTypeIds !== null) {
|
||||
$qb->andWhere('a.awardType IN (:typeIds)')
|
||||
->setParameter('typeIds', $awardTypeIds);
|
||||
}
|
||||
|
||||
return $qb
|
||||
->orderBy('RANDOM()')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,17 @@ class AwardTypeRepository extends ServiceEntityRepository
|
||||
{
|
||||
return parent::findAll();
|
||||
}
|
||||
|
||||
/** @return list<AwardType> */
|
||||
public function findWithMinActors(int $minActors): array
|
||||
{
|
||||
return $this->createQueryBuilder('at')
|
||||
->join('at.awards', 'a')
|
||||
->groupBy('at.id')
|
||||
->having('COUNT(DISTINCT a.actor) >= :minActors')
|
||||
->setParameter('minActors', $minActors)
|
||||
->orderBy('COUNT(DISTINCT a.actor)', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,66 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<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">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
||||
|
||||
<div class="config-panel">
|
||||
{% if app.user %}
|
||||
<div class="config-section">
|
||||
<label class="config-toggle">
|
||||
<span class="config-toggle-label">Films vus</span>
|
||||
<input type="checkbox" name="watched_only" value="1" class="toggle-switch">
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">Paramètres des indices</div>
|
||||
<div class="config-hint-types">
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" name="hint_film" value="1" checked
|
||||
data-game-config-target="hintType"
|
||||
data-action="change->game-config#enforceMinOneChecked">
|
||||
Film
|
||||
</label>
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" name="hint_character" value="1" checked
|
||||
data-game-config-target="hintType"
|
||||
data-action="change->game-config#enforceMinOneChecked">
|
||||
Rôle
|
||||
</label>
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" name="hint_award" value="1" checked
|
||||
data-game-config-target="hintType"
|
||||
data-action="change->game-config#enforceMinOneChecked">
|
||||
Récompense
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-award-types" data-game-config-target="awardSection">
|
||||
<div class="config-section-subtitle">Récompenses</div>
|
||||
<div class="config-award-list">
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" checked
|
||||
data-game-config-target="allAwards"
|
||||
data-action="change->game-config#toggleAllAwards">
|
||||
Toutes les récompenses
|
||||
</label>
|
||||
{% for awardType in awardTypes %}
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" name="award_types[]"
|
||||
value="{{ awardType.id }}" checked
|
||||
data-game-config-target="awardType"
|
||||
data-action="change->game-config#syncAllAwards">
|
||||
{{ awardType.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
|
||||
</form>
|
||||
<div class="start-loader" id="start-loader"></div>
|
||||
@@ -66,6 +124,11 @@
|
||||
<a href="{{ path('app_login') }}">Connectez-vous</a> pour importer vos propres films
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% for message in app.flashes('error') %}
|
||||
<div class="flash-error">{{ message }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<script>
|
||||
document.getElementById('start-form').addEventListener('submit', function () {
|
||||
this.style.display = 'none';
|
||||
|
||||
@@ -53,7 +53,7 @@ class AwardImporterTest extends TestCase
|
||||
]);
|
||||
|
||||
$existingType = new AwardType();
|
||||
$existingType->setName('Oscar')->setPattern('Academy Award');
|
||||
$existingType->setName('Oscar')->setPattern('Oscar');
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
||||
|
||||
@@ -73,7 +73,7 @@ class AwardImporterTest extends TestCase
|
||||
$this->assertSame($actor, $persisted[0]->getActor());
|
||||
}
|
||||
|
||||
public function testCreatesNewAwardTypeWhenNoPatternMatches(): void
|
||||
public function testCanonicalMapGroupsRelatedAwards(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
@@ -98,14 +98,67 @@ class AwardImporterTest extends TestCase
|
||||
|
||||
$newType = $persisted[0];
|
||||
$this->assertInstanceOf(AwardType::class, $newType);
|
||||
$this->assertSame('Screen Actors Guild Award', $newType->getName());
|
||||
$this->assertSame('Screen Actors Guild Award', $newType->getPattern());
|
||||
$this->assertSame('SAG', $newType->getName());
|
||||
$this->assertSame('SAG', $newType->getPattern());
|
||||
|
||||
$award = $persisted[1];
|
||||
$this->assertInstanceOf(Award::class, $award);
|
||||
$this->assertSame($newType, $award->getAwardType());
|
||||
}
|
||||
|
||||
public function testFallsBackToExtractPrefixWhenNotInCanonicalMap(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||
'Test Actor' => [
|
||||
['name' => 'Bambi for Best Film', 'year' => 2019],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||
|
||||
$persisted = [];
|
||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
$newType = $persisted[0];
|
||||
$this->assertInstanceOf(AwardType::class, $newType);
|
||||
$this->assertSame('Bambi', $newType->getName());
|
||||
}
|
||||
|
||||
public function testExcludesNonEntertainmentAwards(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||
'Test Actor' => [
|
||||
['name' => 'chevalier de la Légion d\'honneur', 'year' => 2015],
|
||||
['name' => 'docteur honoris causa', 'year' => 2018],
|
||||
['name' => 'bourse Rhodes', 'year' => 2010],
|
||||
['name' => 'Oscar du meilleur acteur', 'year' => 2020],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||
|
||||
$persisted = [];
|
||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
// Only the Oscar should be persisted (1 AwardType + 1 Award)
|
||||
$this->assertCount(2, $persisted);
|
||||
$this->assertInstanceOf(AwardType::class, $persisted[0]);
|
||||
$this->assertSame('Oscar', $persisted[0]->getName());
|
||||
$this->assertInstanceOf(Award::class, $persisted[1]);
|
||||
}
|
||||
|
||||
public function testDoesNotSetFlagOnWikidataError(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
@@ -146,7 +199,7 @@ class AwardImporterTest extends TestCase
|
||||
]);
|
||||
|
||||
$existingType = new AwardType();
|
||||
$existingType->setName('Oscar')->setPattern('Academy Award');
|
||||
$existingType->setName('Oscar')->setPattern('Oscar');
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
||||
|
||||
@@ -163,6 +216,60 @@ class AwardImporterTest extends TestCase
|
||||
$this->assertCount(3, $persisted);
|
||||
}
|
||||
|
||||
public function testExtractPrefixHandlesFrenchPatterns(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||
'Test Actor' => [
|
||||
['name' => 'Bodil du meilleur acteur', 'year' => 2019],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||
|
||||
$persisted = [];
|
||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
$newType = $persisted[0];
|
||||
$this->assertInstanceOf(AwardType::class, $newType);
|
||||
$this->assertSame('Bodil', $newType->getName());
|
||||
}
|
||||
|
||||
public function testCanonicalMapReusesExistingType(): void
|
||||
{
|
||||
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||
|
||||
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||
'Test Actor' => [
|
||||
['name' => 'oscar du meilleur acteur', 'year' => 2020],
|
||||
['name' => 'Oscar de la meilleure actrice', 'year' => 2021],
|
||||
],
|
||||
]);
|
||||
|
||||
$existingOscar = new AwardType();
|
||||
$existingOscar->setName('Oscar')->setPattern('Oscar');
|
||||
|
||||
$this->awardTypeRepository->method('findAll')->willReturn([$existingOscar]);
|
||||
|
||||
$persisted = [];
|
||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||
$persisted[] = $entity;
|
||||
});
|
||||
|
||||
$this->importer->importForActors([$actor]);
|
||||
|
||||
// Both awards should reuse the same "Oscar" type — only 2 Awards persisted, no new AwardType
|
||||
$this->assertCount(2, $persisted);
|
||||
$this->assertContainsOnlyInstancesOf(Award::class, $persisted);
|
||||
$this->assertSame($existingOscar, $persisted[0]->getAwardType());
|
||||
$this->assertSame($existingOscar, $persisted[1]->getAwardType());
|
||||
}
|
||||
|
||||
private function createActorWithFlag(string $name, bool $awardsImported): Actor
|
||||
{
|
||||
$actor = new Actor();
|
||||
|
||||
@@ -53,4 +53,31 @@ class GameGridProviderTest extends TestCase
|
||||
|
||||
$this->assertSame('Academy Award for Best Actor (2020)', $result);
|
||||
}
|
||||
|
||||
public function testGenerateHintRespectsAllowedTypes(): void
|
||||
{
|
||||
$movieRoleRepo = $this->createMock(MovieRoleRepository::class);
|
||||
$movieRoleRepo->method('findOneRandomByActor')->willReturn(null);
|
||||
|
||||
$awardRepo = $this->createMock(AwardRepository::class);
|
||||
$awardRepo->method('findOneRandomByActor')->willReturn(null);
|
||||
$awardRepo->method('findOneRandomByActorAndTypes')->willReturn(null);
|
||||
|
||||
$generator = new GameGridProvider(
|
||||
$this->createMock(ActorRepository::class),
|
||||
$movieRoleRepo,
|
||||
$this->createMock(MovieRepository::class),
|
||||
$awardRepo,
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
);
|
||||
|
||||
$actor = new Actor();
|
||||
$actor->setName('Test');
|
||||
|
||||
// Only allow 'award' type, but no awards exist → should return null
|
||||
$method = new \ReflectionMethod($generator, 'generateHint');
|
||||
$result = $method->invoke($generator, $actor, ['award'], null);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
}
|
||||
|
||||
82
tests/Repository/ActorRepositoryTest.php
Normal file
82
tests/Repository/ActorRepositoryTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Movie;
|
||||
use App\Entity\MovieRole;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserMovie;
|
||||
use App\Repository\ActorRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class ActorRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private ActorRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->repo = self::getContainer()->get(ActorRepository::class);
|
||||
}
|
||||
|
||||
public function testFindOneRandomInWatchedFilmsReturnsOnlyWatchedActors(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setEmail('test-watched-' . uniqid() . '@example.com');
|
||||
$user->setPassword('test');
|
||||
$this->em->persist($user);
|
||||
|
||||
$watchedActor = new Actor();
|
||||
$watchedActor->setName('Watched Actor');
|
||||
$watchedActor->setPopularity(10.0);
|
||||
$this->em->persist($watchedActor);
|
||||
|
||||
$movie = new Movie();
|
||||
$movie->setTmdbId(99990);
|
||||
$movie->setLtbxdRef('watched-test');
|
||||
$movie->setTitle('Watched Film');
|
||||
$this->em->persist($movie);
|
||||
|
||||
$role = new MovieRole();
|
||||
$role->setActor($watchedActor);
|
||||
$role->setMovie($movie);
|
||||
$role->setCharacter('Hero');
|
||||
$this->em->persist($role);
|
||||
|
||||
$userMovie = new UserMovie();
|
||||
$userMovie->setUser($user);
|
||||
$userMovie->setMovie($movie);
|
||||
$this->em->persist($userMovie);
|
||||
|
||||
$unwatchedActor = new Actor();
|
||||
$unwatchedActor->setName('Unwatched Actor');
|
||||
$unwatchedActor->setPopularity(10.0);
|
||||
$this->em->persist($unwatchedActor);
|
||||
|
||||
$movie2 = new Movie();
|
||||
$movie2->setTmdbId(99991);
|
||||
$movie2->setLtbxdRef('unwatched-test');
|
||||
$movie2->setTitle('Unwatched Film');
|
||||
$this->em->persist($movie2);
|
||||
|
||||
$role2 = new MovieRole();
|
||||
$role2->setActor($unwatchedActor);
|
||||
$role2->setMovie($movie2);
|
||||
$role2->setCharacter('Villain');
|
||||
$this->em->persist($role2);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$result = $this->repo->findOneRandomInWatchedFilms($user, 0, 'w');
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($watchedActor->getId(), $result->getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
63
tests/Repository/AwardRepositoryTest.php
Normal file
63
tests/Repository/AwardRepositoryTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Award;
|
||||
use App\Entity\AwardType;
|
||||
use App\Repository\AwardRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class AwardRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private AwardRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->repo = self::getContainer()->get(AwardRepository::class);
|
||||
}
|
||||
|
||||
public function testFindOneRandomByActorAndTypesFiltersCorrectly(): void
|
||||
{
|
||||
$actor = new Actor();
|
||||
$actor->setName('Award Actor Test');
|
||||
$this->em->persist($actor);
|
||||
|
||||
$oscarType = new AwardType();
|
||||
$oscarType->setName('Oscar')->setPattern('oscar');
|
||||
$this->em->persist($oscarType);
|
||||
|
||||
$globeType = new AwardType();
|
||||
$globeType->setName('Golden Globe')->setPattern('globe');
|
||||
$this->em->persist($globeType);
|
||||
|
||||
$oscar = new Award();
|
||||
$oscar->setName('Best Actor Oscar');
|
||||
$oscar->setActor($actor);
|
||||
$oscar->setAwardType($oscarType);
|
||||
$this->em->persist($oscar);
|
||||
|
||||
$globe = new Award();
|
||||
$globe->setName('Best Actor Globe');
|
||||
$globe->setActor($actor);
|
||||
$globe->setAwardType($globeType);
|
||||
$this->em->persist($globe);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$result = $this->repo->findOneRandomByActorAndTypes($actor->getId(), [$oscarType->getId()]);
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('Best Actor Oscar', $result->getName());
|
||||
}
|
||||
|
||||
$result = $this->repo->findOneRandomByActorAndTypes($actor->getId(), null);
|
||||
$this->assertNotNull($result);
|
||||
}
|
||||
}
|
||||
67
tests/Repository/AwardTypeRepositoryTest.php
Normal file
67
tests/Repository/AwardTypeRepositoryTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Award;
|
||||
use App\Entity\AwardType;
|
||||
use App\Repository\AwardTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class AwardTypeRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private AwardTypeRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->repo = self::getContainer()->get(AwardTypeRepository::class);
|
||||
|
||||
// Clean award data (order matters for FK constraints)
|
||||
$this->em->createQuery('DELETE FROM App\Entity\Award')->execute();
|
||||
$this->em->createQuery('DELETE FROM App\Entity\AwardType')->execute();
|
||||
}
|
||||
|
||||
public function testFindWithMinActorsFiltersCorrectly(): void
|
||||
{
|
||||
$smallType = new AwardType();
|
||||
$smallType->setName('Small Award')->setPattern('small');
|
||||
$this->em->persist($smallType);
|
||||
|
||||
$bigType = new AwardType();
|
||||
$bigType->setName('Big Award')->setPattern('big');
|
||||
$this->em->persist($bigType);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$actor = new Actor();
|
||||
$actor->setName("Actor $i");
|
||||
$this->em->persist($actor);
|
||||
|
||||
$award = new Award();
|
||||
$award->setName("Big Award $i");
|
||||
$award->setActor($actor);
|
||||
$award->setAwardType($bigType);
|
||||
$this->em->persist($award);
|
||||
|
||||
if ($i < 3) {
|
||||
$awardSmall = new Award();
|
||||
$awardSmall->setName("Small Award $i");
|
||||
$awardSmall->setActor($actor);
|
||||
$awardSmall->setAwardType($smallType);
|
||||
$this->em->persist($awardSmall);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$result = $this->repo->findWithMinActors(5);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Big Award', $result[0]->getName());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user