Compare commits
20 Commits
353ffddeea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
843009e193 | ||
|
|
2e7d7ecf44 | ||
|
|
6b514aa87b | ||
|
|
51a9f49797 | ||
|
|
b637b725d8 | ||
|
|
ba715d69a0 | ||
|
|
94ff0ced63 | ||
|
|
9c095a76eb | ||
|
|
f291df0fcf | ||
|
|
67571e8b33 | ||
|
|
5fbac8359f | ||
|
|
468b72b419 | ||
|
|
54225ad97b | ||
|
|
295bb16ab7 | ||
|
|
116d7b409e | ||
|
|
369893a77e | ||
|
|
8c73a22eff | ||
|
|
087b063f1f | ||
|
|
0e3b17bb7d | ||
|
|
246d6fc740 |
@@ -1,3 +1,4 @@
|
|||||||
# define your env variables for the test env here
|
# define your env variables for the test env here
|
||||||
KERNEL_CLASS='App\Kernel'
|
KERNEL_CLASS='App\Kernel'
|
||||||
APP_SECRET='$ecretf0rt3st'
|
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
|
symfony\:cache-clear: ## Vide le cache Symfony
|
||||||
docker compose exec app php bin/console cache:clear
|
docker compose exec app php bin/console cache:clear
|
||||||
|
docker compose restart messenger
|
||||||
|
|
||||||
test: ## Lance les tests PHPUnit
|
test: ## Lance les tests PHPUnit
|
||||||
docker compose exec app php bin/phpunit
|
docker compose exec app php bin/phpunit
|
||||||
|
|||||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Actorle
|
||||||
|
|
||||||
|
Un jeu de devinettes inspiré de Wordle, mais autour des acteurs de cinéma. Le joueur doit deviner le nom d'un acteur lettre par lettre, en s'aidant d'indices liés aux films, personnages et récompenses des acteurs.
|
||||||
|
|
||||||
|
## Principe du jeu
|
||||||
|
|
||||||
|
1. Le joueur lance une partie. Le système choisit un acteur aléatoire (filtré par popularité TMDB).
|
||||||
|
2. Pour chaque lettre du nom de l'acteur, un **acteur-indice** est attribué, dont le nom commence par cette lettre.
|
||||||
|
3. Chaque ligne de la grille propose un indice sur l'acteur-indice : un **film** dans lequel il a joué, un **personnage** qu'il a incarné, ou une **récompense** qu'il a reçue.
|
||||||
|
4. Le joueur utilise ces indices pour reconstituer le nom de l'acteur principal.
|
||||||
|
|
||||||
|
Les données proviennent de la filmographie Letterboxd de l'utilisateur : seuls les acteurs issus de ses films regardés sont utilisés pour générer les grilles.
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Couche | Technologies |
|
||||||
|
|------------|-----------------------------------------------------------------|
|
||||||
|
| Backend | PHP 8.4, Symfony 8.0, Doctrine ORM, Symfony Messenger |
|
||||||
|
| Frontend | React 19, Stimulus, Turbo, Vite 6 |
|
||||||
|
| Base | PostgreSQL 16 |
|
||||||
|
| Stockage | FlySystem (S3-compatible) |
|
||||||
|
| Serveur | FrankenPHP (dev), Caddy (prod) |
|
||||||
|
| Infra | Docker Compose |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Controller/ # Contrôleurs (Game, Import, Auth)
|
||||||
|
├── Entity/ # Entités Doctrine (Game, Actor, Movie, Award...)
|
||||||
|
├── Provider/ # Fournisseurs de données (GameGridProvider)
|
||||||
|
├── Import/ # Import de données externes (FilmImporter, ActorSyncer, AwardImporter)
|
||||||
|
├── Repository/ # Requêtes Doctrine
|
||||||
|
├── Gateway/ # Intégrations externes (TMDB, Wikidata, Letterboxd)
|
||||||
|
├── Message/ # Messages async (ProcessImport, ImportFilmsBatch)
|
||||||
|
├── MessageHandler/ # Handlers Symfony Messenger
|
||||||
|
├── EventListener/ # Abandon auto des parties anonymes au login
|
||||||
|
├── Form/ # Formulaires (inscription)
|
||||||
|
└── Model/ # DTOs (TMDB, Letterboxd)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intégrations externes
|
||||||
|
|
||||||
|
- **TMDB API** : recherche de films, récupération des castings et crédits
|
||||||
|
- **Wikidata SPARQL** : récupération des récompenses des acteurs (Oscars, BAFTA, etc.)
|
||||||
|
- **Letterboxd** : import de la filmographie via export CSV
|
||||||
|
|
||||||
|
## Import de films
|
||||||
|
|
||||||
|
1. L'utilisateur exporte son historique depuis Letterboxd (CSV).
|
||||||
|
2. Il upload le fichier via l'interface.
|
||||||
|
3. Un message async découpe l'import en **batchs de 50 films**.
|
||||||
|
4. Chaque batch recherche les films sur TMDB, synchronise les acteurs et importe leurs récompenses depuis Wikidata.
|
||||||
|
5. L'avancement est consultable en temps réel via l'API (`GET /api/imports/latest`).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Docker et Docker Compose
|
||||||
|
- Un token API TMDB
|
||||||
|
|
||||||
|
### Lancement en développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer les conteneurs (app, messenger, database, node)
|
||||||
|
make dev:up
|
||||||
|
|
||||||
|
# Installer les dépendances npm
|
||||||
|
make node:install
|
||||||
|
|
||||||
|
# Exécuter les migrations
|
||||||
|
make db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est accessible sur `http://localhost`.
|
||||||
|
Le serveur Vite (HMR) tourne sur le port `5173`.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Les secrets (token TMDB, etc.) se gèrent via Symfony Secrets :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make symfony:secrets-set NAME="TMDB_API_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables d'environnement principales :
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---------------------------|------------------------------------|
|
||||||
|
| `APP_ENV` | Environnement (`dev` / `prod`) |
|
||||||
|
| `APP_SECRET` | Secret Symfony |
|
||||||
|
| `TMDB_API_TOKEN` | Token API TMDB (via secrets) |
|
||||||
|
| `POSTGRES_HOST` | Hôte PostgreSQL |
|
||||||
|
| `POSTGRES_USER` | Utilisateur PostgreSQL |
|
||||||
|
| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL |
|
||||||
|
| `POSTGRES_DB` | Nom de la base |
|
||||||
|
| `MESSENGER_TRANSPORT_DSN` | DSN du transport Messenger |
|
||||||
|
|
||||||
|
## Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev:up # Démarrer le dev
|
||||||
|
make dev:down # Arrêter le dev
|
||||||
|
make dev:logs # Logs en temps réel
|
||||||
|
make dev:shell # Shell dans le conteneur app
|
||||||
|
make db:migrate # Exécuter les migrations
|
||||||
|
make db:migration # Générer une migration
|
||||||
|
make db:reset # Reset complet de la base
|
||||||
|
make test # Lancer les tests PHPUnit
|
||||||
|
make node:build # Build des assets pour la prod
|
||||||
|
make help # Afficher toutes les commandes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build et push des images vers le registry
|
||||||
|
make docker:build
|
||||||
|
make docker:push
|
||||||
|
|
||||||
|
# Sur le serveur de production
|
||||||
|
make prod:up
|
||||||
|
```
|
||||||
|
|
||||||
|
Les images sont hébergées sur le registry Gitea `git.lclr.dev`.
|
||||||
|
|
||||||
|
## Services Docker
|
||||||
|
|
||||||
|
| Service | Description |
|
||||||
|
|-------------|-----------------------------------------------------|
|
||||||
|
| `app` | Application FrankenPHP (dev) / Caddy (prod) |
|
||||||
|
| `messenger` | Worker Symfony Messenger (consomme la queue `async`) |
|
||||||
|
| `database` | PostgreSQL 16 |
|
||||||
|
| `node` | Vite dev server avec HMR (dev uniquement) |
|
||||||
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 ImportModalController from './controllers/import_modal_controller.js';
|
||||||
import ImportStatusController from './controllers/import_status_controller.js';
|
import ImportStatusController from './controllers/import_status_controller.js';
|
||||||
import ImportHelpController from './controllers/import_help_controller.js';
|
import ImportHelpController from './controllers/import_help_controller.js';
|
||||||
|
import GameConfigController from './controllers/game_config_controller.js';
|
||||||
|
|
||||||
const app = startStimulusApp();
|
const app = startStimulusApp();
|
||||||
app.register('dropdown', DropdownController);
|
app.register('dropdown', DropdownController);
|
||||||
app.register('import-modal', ImportModalController);
|
app.register('import-modal', ImportModalController);
|
||||||
app.register('import-status', ImportStatusController);
|
app.register('import-status', ImportStatusController);
|
||||||
app.register('import-help', ImportHelpController);
|
app.register('import-help', ImportHelpController);
|
||||||
|
app.register('game-config', GameConfigController);
|
||||||
|
|
||||||
// Register React components for {{ react_component() }} Twig function.
|
// Register React components for {{ react_component() }} Twig function.
|
||||||
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,11 +56,11 @@ export default class extends Controller {
|
|||||||
this.importBtnTarget.disabled = true;
|
this.importBtnTarget.disabled = true;
|
||||||
this.importBtnTarget.textContent = 'Import en cours\u2026';
|
this.importBtnTarget.textContent = 'Import en cours\u2026';
|
||||||
|
|
||||||
const progress = data.totalBatches > 0
|
const progress = data.totalFilms > 0
|
||||||
? Math.round((data.processedBatches / data.totalBatches) * 100)
|
? Math.round((data.processedFilms / data.totalFilms) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
this._setStatus(`${progress}% — ${data.totalFilms} films`, 'active');
|
this._setStatus(`${progress}% — ${data.processedFilms}/${data.totalFilms} films`, 'active');
|
||||||
}
|
}
|
||||||
|
|
||||||
_showCompleted(data) {
|
_showCompleted(data) {
|
||||||
|
|||||||
@@ -732,6 +732,140 @@ body {
|
|||||||
font-size: 16px;
|
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 {
|
.start-loader {
|
||||||
display: none;
|
display: none;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ doctrine:
|
|||||||
numeric_functions:
|
numeric_functions:
|
||||||
Random: App\Doctrine\Extension\Random
|
Random: App\Doctrine\Extension\Random
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
dbname: app_test
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
orm:
|
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
|
||||||
30
migrations/Version20260401000002.php
Normal file
30
migrations/Version20260401000002.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260401000002 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add processed_films column to import table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE import ADD processed_films INT NOT NULL DEFAULT 0');
|
||||||
|
$this->addSql('ALTER TABLE import DROP COLUMN total_batches');
|
||||||
|
$this->addSql('ALTER TABLE import DROP COLUMN processed_batches');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE import DROP COLUMN processed_films');
|
||||||
|
$this->addSql('ALTER TABLE import ADD total_batches INT NOT NULL DEFAULT 0');
|
||||||
|
$this->addSql('ALTER TABLE import ADD processed_batches INT NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace App\Controller;
|
|||||||
use App\Entity\Game;
|
use App\Entity\Game;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\GameRepository;
|
use App\Repository\GameRepository;
|
||||||
use App\Service\GameGridGenerator;
|
use App\Provider\GameGridProvider;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -19,7 +19,7 @@ class GameController extends AbstractController
|
|||||||
#[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,
|
||||||
GameGridGenerator $generator,
|
GameGridProvider $generator,
|
||||||
GameRepository $gameRepository,
|
GameRepository $gameRepository,
|
||||||
): Response {
|
): Response {
|
||||||
$this->validateCsrfToken('game_start', $request);
|
$this->validateCsrfToken('game_start', $request);
|
||||||
@@ -42,7 +42,40 @@ class GameController extends AbstractController
|
|||||||
return $this->redirectToRoute('app_homepage');
|
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) {
|
if (!$user) {
|
||||||
$request->getSession()->set('current_game_id', $game->getId());
|
$request->getSession()->set('current_game_id', $game->getId());
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\Game;
|
use App\Entity\Game;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
use App\Repository\GameRepository;
|
use App\Repository\GameRepository;
|
||||||
use App\Service\GameGridGenerator;
|
use App\Provider\GameGridProvider;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -19,7 +20,8 @@ class HomepageController extends AbstractController
|
|||||||
public function index(
|
public function index(
|
||||||
Request $request,
|
Request $request,
|
||||||
GameRepository $gameRepository,
|
GameRepository $gameRepository,
|
||||||
GameGridGenerator $gridGenerator,
|
GameGridProvider $gridGenerator,
|
||||||
|
AwardTypeRepository $awardTypeRepository,
|
||||||
): Response {
|
): Response {
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -42,6 +44,7 @@ class HomepageController extends AbstractController
|
|||||||
if (!$game) {
|
if (!$game) {
|
||||||
return $this->render('homepage/index.html.twig', [
|
return $this->render('homepage/index.html.twig', [
|
||||||
'game' => null,
|
'game' => null,
|
||||||
|
'awardTypes' => $awardTypeRepository->findWithMinActors(5),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,8 @@ class ImportController extends AbstractController
|
|||||||
'id' => $import->getId(),
|
'id' => $import->getId(),
|
||||||
'status' => $import->getStatus(),
|
'status' => $import->getStatus(),
|
||||||
'totalFilms' => $import->getTotalFilms(),
|
'totalFilms' => $import->getTotalFilms(),
|
||||||
|
'processedFilms' => $import->getProcessedFilms(),
|
||||||
'failedFilms' => $import->getFailedFilms(),
|
'failedFilms' => $import->getFailedFilms(),
|
||||||
'processedBatches' => $import->getProcessedBatches(),
|
|
||||||
'totalBatches' => $import->getTotalBatches(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,15 +30,12 @@ class Import
|
|||||||
#[ORM\Column(length: 20)]
|
#[ORM\Column(length: 20)]
|
||||||
private string $status = self::STATUS_PENDING;
|
private string $status = self::STATUS_PENDING;
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
private int $totalBatches = 0;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
private int $processedBatches = 0;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private int $totalFilms = 0;
|
private int $totalFilms = 0;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $processedFilms = 0;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private int $failedFilms = 0;
|
private int $failedFilms = 0;
|
||||||
|
|
||||||
@@ -91,25 +88,14 @@ class Import
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTotalBatches(): int
|
public function getProcessedFilms(): int
|
||||||
{
|
{
|
||||||
return $this->totalBatches;
|
return $this->processedFilms;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTotalBatches(int $totalBatches): static
|
public function setProcessedFilms(int $processedFilms): static
|
||||||
{
|
{
|
||||||
$this->totalBatches = $totalBatches;
|
$this->processedFilms = $processedFilms;
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getProcessedBatches(): int
|
|
||||||
{
|
|
||||||
return $this->processedBatches;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setProcessedBatches(int $processedBatches): static
|
|
||||||
{
|
|
||||||
$this->processedBatches = $processedBatches;
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,23 @@ class WikidataGateway
|
|||||||
*/
|
*/
|
||||||
public function getAwards(Actor $actor): array
|
public function getAwards(Actor $actor): array
|
||||||
{
|
{
|
||||||
$sparql = $this->buildQuery($actor->getName());
|
return $this->getAwardsForActors([$actor])[$actor->getName()] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch awards for multiple actors in a single SPARQL query.
|
||||||
|
*
|
||||||
|
* @param list<Actor> $actors
|
||||||
|
*
|
||||||
|
* @return array<string, list<array{name: string, year: int}>>
|
||||||
|
*/
|
||||||
|
public function getAwardsForActors(array $actors): array
|
||||||
|
{
|
||||||
|
if ([] === $actors) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sparql = $this->buildBatchQuery($actors);
|
||||||
|
|
||||||
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
|
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
|
||||||
'query' => [
|
'query' => [
|
||||||
@@ -33,19 +49,20 @@ class WikidataGateway
|
|||||||
'Accept' => 'application/sparql-results+json',
|
'Accept' => 'application/sparql-results+json',
|
||||||
'User-Agent' => 'LtbxdActorle/1.0',
|
'User-Agent' => 'LtbxdActorle/1.0',
|
||||||
],
|
],
|
||||||
'timeout' => 5,
|
'timeout' => 10,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$data = $response->toArray();
|
$data = $response->toArray();
|
||||||
$awards = [];
|
$awards = [];
|
||||||
|
|
||||||
foreach ($data['results']['bindings'] ?? [] as $binding) {
|
foreach ($data['results']['bindings'] ?? [] as $binding) {
|
||||||
$name = $binding['awardLabel']['value'] ?? null;
|
$actorName = $binding['name']['value'] ?? null;
|
||||||
|
$awardName = $binding['awardLabel']['value'] ?? null;
|
||||||
$year = $binding['year']['value'] ?? null;
|
$year = $binding['year']['value'] ?? null;
|
||||||
|
|
||||||
if ($name && $year) {
|
if ($actorName && $awardName && $year) {
|
||||||
$awards[] = [
|
$awards[$actorName][] = [
|
||||||
'name' => $name,
|
'name' => $awardName,
|
||||||
'year' => (int) substr($year, 0, 4),
|
'year' => (int) substr($year, 0, 4),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -54,21 +71,41 @@ class WikidataGateway
|
|||||||
return $awards;
|
return $awards;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildQuery(string $actorName): string
|
/**
|
||||||
|
* @param list<Actor> $actors
|
||||||
|
*/
|
||||||
|
private function buildBatchQuery(array $actors): string
|
||||||
{
|
{
|
||||||
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actorName);
|
$values = implode(' ', array_map(function (Actor $actor) {
|
||||||
|
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actor->getName());
|
||||||
|
|
||||||
|
return '"'.$escaped.'"@en';
|
||||||
|
}, $actors));
|
||||||
|
|
||||||
return <<<SPARQL
|
return <<<SPARQL
|
||||||
SELECT ?awardLabel ?year WHERE {
|
SELECT ?name ?awardLabel ?year WHERE {
|
||||||
?person rdfs:label "{$escaped}"@en .
|
VALUES ?name { {$values} }
|
||||||
|
?person rdfs:label ?name .
|
||||||
?person wdt:P31 wd:Q5 .
|
?person wdt:P31 wd:Q5 .
|
||||||
?person p:P166 ?awardStatement .
|
?person p:P166 ?awardStatement .
|
||||||
?awardStatement ps:P166 ?award .
|
?awardStatement ps:P166 ?award .
|
||||||
?awardStatement pq:P585 ?date .
|
?awardStatement pq:P585 ?date .
|
||||||
BIND(YEAR(?date) AS ?year)
|
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" . }
|
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
|
||||||
}
|
}
|
||||||
ORDER BY DESC(?year)
|
ORDER BY ?name DESC(?year)
|
||||||
SPARQL;
|
SPARQL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Import;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Movie;
|
use App\Entity\Movie;
|
||||||
231
src/Import/AwardImporter.php
Normal file
231
src/Import/AwardImporter.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Import;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Award;
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use App\Gateway\WikidataGateway;
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
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,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function importForActor(Actor $actor): void
|
||||||
|
{
|
||||||
|
$this->importForActors([$actor]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Actor> $actors
|
||||||
|
*/
|
||||||
|
public function importForActors(array $actors): void
|
||||||
|
{
|
||||||
|
$actorsToFetch = array_filter($actors, fn (Actor $a) => !$a->isAwardsImported());
|
||||||
|
|
||||||
|
if ([] === $actorsToFetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$allAwards = $this->wikidataGateway->getAwardsForActors(array_values($actorsToFetch));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger?->warning('Failed to fetch awards from Wikidata', [
|
||||||
|
'actors' => array_map(fn (Actor $a) => $a->getName(), $actorsToFetch),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownTypes = $this->awardTypeRepository->findAll();
|
||||||
|
|
||||||
|
foreach ($actorsToFetch as $actor) {
|
||||||
|
$wikidataAwards = $allAwards[$actor->getName()] ?? [];
|
||||||
|
|
||||||
|
foreach ($wikidataAwards as $wikidataAward) {
|
||||||
|
if ($this->isExcluded($wikidataAward['name'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
|
||||||
|
|
||||||
|
$award = new Award();
|
||||||
|
$award->setName($wikidataAward['name']);
|
||||||
|
$award->setYear($wikidataAward['year']);
|
||||||
|
$award->setActor($actor);
|
||||||
|
$award->setAwardType($awardType);
|
||||||
|
|
||||||
|
$this->em->persist($award);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actor->setAwardsImported(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AwardType> $knownTypes
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
$newType->setPattern($prefix);
|
||||||
|
|
||||||
|
$this->em->persist($newType);
|
||||||
|
$knownTypes[] = $newType;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// "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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $awardName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Import;
|
||||||
|
|
||||||
use App\Entity\Movie;
|
use App\Entity\Movie;
|
||||||
use App\Exception\GatewayException;
|
use App\Exception\GatewayException;
|
||||||
@@ -6,9 +6,11 @@ namespace App\Message;
|
|||||||
|
|
||||||
readonly class ImportFilmsBatchMessage
|
readonly class ImportFilmsBatchMessage
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{name: string, year: int, ltbxdUri: string, date: string}> $films
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $importId,
|
public int $importId,
|
||||||
public int $offset,
|
public array $films,
|
||||||
public int $limit,
|
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ namespace App\MessageHandler;
|
|||||||
|
|
||||||
use App\Entity\Import;
|
use App\Entity\Import;
|
||||||
use App\Entity\UserMovie;
|
use App\Entity\UserMovie;
|
||||||
use App\Gateway\LtbxdGateway;
|
|
||||||
use App\Message\ImportFilmsBatchMessage;
|
use App\Message\ImportFilmsBatchMessage;
|
||||||
|
use App\Model\Ltbxd\LtbxdMovie;
|
||||||
use App\Repository\ImportRepository;
|
use App\Repository\ImportRepository;
|
||||||
use App\Service\ActorSyncer;
|
use App\Import\ActorSyncer;
|
||||||
use App\Service\AwardImporter;
|
use App\Import\AwardImporter;
|
||||||
use App\Service\FilmImporter;
|
use App\Import\FilmImporter;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use League\Flysystem\FilesystemOperator;
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
@@ -22,12 +21,10 @@ readonly class ImportFilmsBatchMessageHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private FilesystemOperator $defaultStorage,
|
|
||||||
private LtbxdGateway $ltbxdGateway,
|
|
||||||
private FilmImporter $filmImporter,
|
private FilmImporter $filmImporter,
|
||||||
private ActorSyncer $actorSyncer,
|
private ActorSyncer $actorSyncer,
|
||||||
private AwardImporter $awardImporter,
|
|
||||||
private ImportRepository $importRepository,
|
private ImportRepository $importRepository,
|
||||||
|
private AwardImporter $awardImporter,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -40,17 +37,15 @@ readonly class ImportFilmsBatchMessageHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$csvContent = $this->defaultStorage->read($import->getFilePath());
|
$batch = array_map(
|
||||||
$tmpFile = tempnam(sys_get_temp_dir(), 'import_');
|
fn (array $film) => new LtbxdMovie(
|
||||||
file_put_contents($tmpFile, $csvContent);
|
date: new \DateTime($film['date']),
|
||||||
|
name: $film['name'],
|
||||||
try {
|
year: $film['year'],
|
||||||
$ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile);
|
ltbxdUri: $film['ltbxdUri'],
|
||||||
} finally {
|
),
|
||||||
unlink($tmpFile);
|
$message->films,
|
||||||
}
|
);
|
||||||
|
|
||||||
$batch = array_slice($ltbxdMovies, $message->offset, $message->limit);
|
|
||||||
$userId = $import->getUser()->getId();
|
$userId = $import->getUser()->getId();
|
||||||
$importId = $import->getId();
|
$importId = $import->getId();
|
||||||
|
|
||||||
@@ -59,29 +54,26 @@ readonly class ImportFilmsBatchMessageHandler
|
|||||||
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
|
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
|
||||||
if (!$movie) {
|
if (!$movie) {
|
||||||
$this->importRepository->incrementFailedFilms($import);
|
$this->importRepository->incrementFailedFilms($import);
|
||||||
continue;
|
} else {
|
||||||
|
$this->actorSyncer->syncActorsForMovie($movie);
|
||||||
|
|
||||||
|
$actors = array_map(fn ($role) => $role->getActor(), $movie->getActors()->toArray());
|
||||||
|
$this->awardImporter->importForActors($actors);
|
||||||
|
|
||||||
|
$user = $this->em->getReference(\App\Entity\User::class, $userId);
|
||||||
|
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
|
||||||
|
'user' => $user,
|
||||||
|
'movie' => $movie,
|
||||||
|
]);
|
||||||
|
if (!$existingLink) {
|
||||||
|
$userMovie = new UserMovie();
|
||||||
|
$userMovie->setUser($user);
|
||||||
|
$userMovie->setMovie($movie);
|
||||||
|
$this->em->persist($userMovie);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->actorSyncer->syncActorsForMovie($movie);
|
|
||||||
|
|
||||||
// Import awards for actors of this movie
|
|
||||||
foreach ($movie->getActors() as $role) {
|
|
||||||
$this->awardImporter->importForActor($role->getActor());
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->em->getReference(\App\Entity\User::class, $userId);
|
|
||||||
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
|
|
||||||
'user' => $user,
|
|
||||||
'movie' => $movie,
|
|
||||||
]);
|
|
||||||
if (!$existingLink) {
|
|
||||||
$userMovie = new UserMovie();
|
|
||||||
$userMovie->setUser($user);
|
|
||||||
$userMovie->setMovie($movie);
|
|
||||||
$this->em->persist($userMovie);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->flush();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->warning('Failed to import film', [
|
$this->logger->warning('Failed to import film', [
|
||||||
'film' => $ltbxdMovie->getName(),
|
'film' => $ltbxdMovie->getName(),
|
||||||
@@ -91,19 +83,16 @@ readonly class ImportFilmsBatchMessageHandler
|
|||||||
$this->importRepository->incrementFailedFilms($import);
|
$this->importRepository->incrementFailedFilms($import);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$processedFilms = $this->importRepository->incrementProcessedFilms($import);
|
||||||
|
|
||||||
$this->em->clear();
|
$this->em->clear();
|
||||||
$import = $this->em->getRepository(Import::class)->find($importId);
|
$import = $this->em->getRepository(Import::class)->find($importId);
|
||||||
}
|
|
||||||
|
|
||||||
$processedBatches = $this->importRepository->incrementProcessedBatches($import);
|
if ($processedFilms >= $import->getTotalFilms()) {
|
||||||
|
$import->setStatus(Import::STATUS_COMPLETED);
|
||||||
if ($processedBatches >= $import->getTotalBatches()) {
|
$import->setCompletedAt(new \DateTimeImmutable());
|
||||||
// Refresh the entity to get updated failedFilms from DB
|
$this->em->flush();
|
||||||
$this->em->refresh($import);
|
}
|
||||||
|
|
||||||
$import->setStatus(Import::STATUS_COMPLETED);
|
|
||||||
$import->setCompletedAt(new \DateTimeImmutable());
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,18 +49,23 @@ readonly class ProcessImportMessageHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
$totalFilms = count($ltbxdMovies);
|
$totalFilms = count($ltbxdMovies);
|
||||||
$totalBatches = (int) ceil($totalFilms / self::BATCH_SIZE);
|
|
||||||
|
|
||||||
$import->setTotalFilms($totalFilms);
|
$import->setTotalFilms($totalFilms);
|
||||||
$import->setTotalBatches($totalBatches);
|
|
||||||
$import->setStatus(Import::STATUS_PROCESSING);
|
$import->setStatus(Import::STATUS_PROCESSING);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
for ($i = 0; $i < $totalBatches; $i++) {
|
$batches = array_chunk($ltbxdMovies, self::BATCH_SIZE);
|
||||||
|
foreach ($batches as $batch) {
|
||||||
|
$films = array_map(fn ($movie) => [
|
||||||
|
'name' => $movie->getName(),
|
||||||
|
'year' => $movie->getYear(),
|
||||||
|
'ltbxdUri' => $movie->getLtbxdUri(),
|
||||||
|
'date' => $movie->getDate()->format('Y-m-d'),
|
||||||
|
], $batch);
|
||||||
|
|
||||||
$this->bus->dispatch(new ImportFilmsBatchMessage(
|
$this->bus->dispatch(new ImportFilmsBatchMessage(
|
||||||
importId: $import->getId(),
|
importId: $import->getId(),
|
||||||
offset: $i * self::BATCH_SIZE,
|
films: $films,
|
||||||
limit: self::BATCH_SIZE,
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Provider;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Game;
|
use App\Entity\Game;
|
||||||
@@ -14,7 +14,7 @@ use App\Repository\MovieRoleRepository;
|
|||||||
use App\Repository\AwardRepository;
|
use App\Repository\AwardRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
class GameGridGenerator
|
class GameGridProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ActorRepository $actorRepository,
|
private readonly ActorRepository $actorRepository,
|
||||||
@@ -24,9 +24,40 @@ class GameGridGenerator
|
|||||||
private readonly EntityManagerInterface $em,
|
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
|
||||||
{
|
{
|
||||||
$mainActor = $this->actorRepository->findOneRandom(4);
|
$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 = new Game();
|
||||||
$game->setMainActor($mainActor);
|
$game->setMainActor($mainActor);
|
||||||
@@ -40,14 +71,23 @@ class GameGridGenerator
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tryFindActor = 0;
|
$actor = null;
|
||||||
do {
|
for ($try = 0; $try < 5; $try++) {
|
||||||
$actor = $this->actorRepository->findOneRandom(4, $char);
|
if ($watchedOnly && $user !== null) {
|
||||||
++$tryFindActor;
|
$candidate = $this->actorRepository->findOneRandomInWatchedFilms($user, 4, $char);
|
||||||
} while (
|
} else {
|
||||||
in_array($actor->getId(), $usedActors)
|
$candidate = $this->actorRepository->findOneRandom(4, $char);
|
||||||
|| $tryFindActor < 5
|
}
|
||||||
);
|
|
||||||
|
if ($candidate !== null && !in_array($candidate->getId(), $usedActors)) {
|
||||||
|
$actor = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actor === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$usedActors[] = $actor->getId();
|
$usedActors[] = $actor->getId();
|
||||||
|
|
||||||
@@ -56,11 +96,12 @@ class GameGridGenerator
|
|||||||
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
$row->setPosition(strpos(strtolower($actor->getName()), $char));
|
||||||
$row->setRowOrder($rowOrder);
|
$row->setRowOrder($rowOrder);
|
||||||
|
|
||||||
$hint = $this->generateHint($actor);
|
$hint = $this->generateHint($actor, $hintTypes, $awardTypeIds);
|
||||||
if ($hint !== null) {
|
if ($hint === null) {
|
||||||
$row->setHintType($hint['type']);
|
return null; // Every row must have a hint
|
||||||
$row->setHintData($hint['data']);
|
|
||||||
}
|
}
|
||||||
|
$row->setHintType($hint['type']);
|
||||||
|
$row->setHintData($hint['data']);
|
||||||
|
|
||||||
$game->addRow($row);
|
$game->addRow($row);
|
||||||
++$rowOrder;
|
++$rowOrder;
|
||||||
@@ -133,17 +174,17 @@ class GameGridGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a single hint for a row actor.
|
* @param list<string> $allowedTypes
|
||||||
*
|
* @param list<int>|null $awardTypeIds
|
||||||
* @return array{type: string, data: string}|null
|
* @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);
|
shuffle($types);
|
||||||
|
|
||||||
foreach ($types as $type) {
|
foreach ($types as $type) {
|
||||||
$hint = $this->resolveHint($type, $rowActor);
|
$hint = $this->resolveHint($type, $rowActor, $awardTypeIds);
|
||||||
if ($hint !== null) {
|
if ($hint !== null) {
|
||||||
return $hint;
|
return $hint;
|
||||||
}
|
}
|
||||||
@@ -153,27 +194,33 @@ class GameGridGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param list<int>|null $awardTypeIds
|
||||||
* @return array{type: string, data: string}|null
|
* @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) {
|
switch ($type) {
|
||||||
case 'film':
|
case 'film':
|
||||||
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
|
$role = $this->movieRoleRepository->findOneRandomByActor($actorId);
|
||||||
if ($role === null) {
|
if ($role === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];
|
return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];
|
||||||
|
|
||||||
case 'character':
|
case 'character':
|
||||||
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
|
$role = $this->movieRoleRepository->findOneRandomByActor($actorId);
|
||||||
if ($role === null) {
|
if ($role === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ['type' => 'character', 'data' => (string) $role->getId()];
|
return ['type' => 'character', 'data' => (string) $role->getId()];
|
||||||
|
|
||||||
case 'award':
|
case 'award':
|
||||||
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
|
$award = $this->awardRepository->findOneRandomByActorAndTypes($actorId, $awardTypeIds);
|
||||||
if ($award === null) {
|
if ($award === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -193,13 +240,28 @@ class GameGridGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
|
'film' => $this->resolveFilmHintText((int) $data),
|
||||||
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
|
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
|
||||||
'award' => $this->resolveAwardHintText((int) $data),
|
'award' => $this->resolveAwardHintText((int) $data),
|
||||||
default => null,
|
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
|
private function resolveAwardHintText(int $awardId): ?string
|
||||||
{
|
{
|
||||||
$award = $this->awardRepository->find($awardId);
|
$award = $this->awardRepository->find($awardId);
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\UserMovie;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
@@ -38,4 +40,29 @@ class ActorRepository extends ServiceEntityRepository
|
|||||||
->getOneOrNullResult()
|
->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()
|
->getQuery()
|
||||||
->getOneOrNullResult();
|
->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 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ class ImportRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, Import::class);
|
parent::__construct($registry, Import::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function incrementProcessedBatches(Import $import): int
|
public function incrementProcessedFilms(Import $import): int
|
||||||
{
|
{
|
||||||
return (int) $this->getEntityManager()->getConnection()->fetchOne(
|
return (int) $this->getEntityManager()->getConnection()->fetchOne(
|
||||||
'UPDATE import SET processed_batches = processed_batches + 1 WHERE id = :id RETURNING processed_batches',
|
'UPDATE import SET processed_films = processed_films + 1 WHERE id = :id RETURNING processed_films',
|
||||||
['id' => $import->getId()]
|
['id' => $import->getId()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Entity\Actor;
|
|
||||||
use App\Entity\Award;
|
|
||||||
use App\Entity\AwardType;
|
|
||||||
use App\Gateway\WikidataGateway;
|
|
||||||
use App\Repository\AwardTypeRepository;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
readonly class AwardImporter
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private WikidataGateway $wikidataGateway,
|
|
||||||
private AwardTypeRepository $awardTypeRepository,
|
|
||||||
private EntityManagerInterface $em,
|
|
||||||
private ?LoggerInterface $logger = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function importForActor(Actor $actor): void
|
|
||||||
{
|
|
||||||
if ($actor->isAwardsImported()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$wikidataAwards = $this->wikidataGateway->getAwards($actor);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger?->warning('Failed to fetch awards from Wikidata', [
|
|
||||||
'actor' => $actor->getName(),
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$knownTypes = $this->awardTypeRepository->findAll();
|
|
||||||
|
|
||||||
foreach ($wikidataAwards as $wikidataAward) {
|
|
||||||
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
|
|
||||||
|
|
||||||
$award = new Award();
|
|
||||||
$award->setName($wikidataAward['name']);
|
|
||||||
$award->setYear($wikidataAward['year']);
|
|
||||||
$award->setActor($actor);
|
|
||||||
$award->setAwardType($awardType);
|
|
||||||
|
|
||||||
$this->em->persist($award);
|
|
||||||
}
|
|
||||||
|
|
||||||
$actor->setAwardsImported(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<AwardType> $knownTypes
|
|
||||||
*/
|
|
||||||
private function resolveAwardType(string $awardName, array &$knownTypes): AwardType
|
|
||||||
{
|
|
||||||
foreach ($knownTypes as $type) {
|
|
||||||
if (str_contains($awardName, $type->getPattern())) {
|
|
||||||
return $type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$newType = new AwardType();
|
|
||||||
$prefix = $this->extractPrefix($awardName);
|
|
||||||
$newType->setName($prefix);
|
|
||||||
$newType->setPattern($prefix);
|
|
||||||
|
|
||||||
$this->em->persist($newType);
|
|
||||||
$knownTypes[] = $newType;
|
|
||||||
|
|
||||||
return $newType;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
return trim($matches[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $awardName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,8 +56,66 @@
|
|||||||
</div>
|
</div>
|
||||||
{% 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">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
|
<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>
|
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="start-loader" id="start-loader"></div>
|
<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
|
<a href="{{ path('app_login') }}">Connectez-vous</a> pour importer vos propres films
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% for message in app.flashes('error') %}
|
||||||
|
<div class="flash-error">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('start-form').addEventListener('submit', function () {
|
document.getElementById('start-form').addEventListener('submit', function () {
|
||||||
this.style.display = 'none';
|
this.style.display = 'none';
|
||||||
|
|||||||
281
tests/Import/AwardImporterTest.php
Normal file
281
tests/Import/AwardImporterTest.php
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Import;
|
||||||
|
|
||||||
|
use App\Entity\Actor;
|
||||||
|
use App\Entity\Award;
|
||||||
|
use App\Entity\AwardType;
|
||||||
|
use App\Gateway\WikidataGateway;
|
||||||
|
use App\Repository\AwardTypeRepository;
|
||||||
|
use App\Import\AwardImporter;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AwardImporterTest extends TestCase
|
||||||
|
{
|
||||||
|
private AwardImporter $importer;
|
||||||
|
private WikidataGateway&\PHPUnit\Framework\MockObject\MockObject $wikidataGateway;
|
||||||
|
private AwardTypeRepository&\PHPUnit\Framework\MockObject\MockObject $awardTypeRepository;
|
||||||
|
private EntityManagerInterface&\PHPUnit\Framework\MockObject\MockObject $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->wikidataGateway = $this->createMock(WikidataGateway::class);
|
||||||
|
$this->awardTypeRepository = $this->createMock(AwardTypeRepository::class);
|
||||||
|
$this->em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->importer = new AwardImporter(
|
||||||
|
$this->wikidataGateway,
|
||||||
|
$this->awardTypeRepository,
|
||||||
|
$this->em,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSkipsActorWithAwardsAlreadyImported(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag('Already Imported', awardsImported: true);
|
||||||
|
|
||||||
|
$this->wikidataGateway->expects($this->never())->method('getAwardsForActors');
|
||||||
|
|
||||||
|
$this->importer->importForActors([$actor]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testImportsAwardsAndSetsFlag(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||||
|
'Test Actor' => [
|
||||||
|
['name' => 'Academy Award for Best Actor', 'year' => 2020],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingType = new AwardType();
|
||||||
|
$existingType->setName('Oscar')->setPattern('Oscar');
|
||||||
|
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
||||||
|
|
||||||
|
$persisted = [];
|
||||||
|
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||||
|
$persisted[] = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->importer->importForActors([$actor]);
|
||||||
|
|
||||||
|
$this->assertTrue($actor->isAwardsImported());
|
||||||
|
$this->assertCount(1, $persisted);
|
||||||
|
$this->assertInstanceOf(Award::class, $persisted[0]);
|
||||||
|
$this->assertSame('Academy Award for Best Actor', $persisted[0]->getName());
|
||||||
|
$this->assertSame(2020, $persisted[0]->getYear());
|
||||||
|
$this->assertSame($existingType, $persisted[0]->getAwardType());
|
||||||
|
$this->assertSame($actor, $persisted[0]->getActor());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanonicalMapGroupsRelatedAwards(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwardsForActors')->willReturn([
|
||||||
|
'Test Actor' => [
|
||||||
|
['name' => 'Screen Actors Guild Award for Outstanding Performance', 'year' => 2019],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||||
|
|
||||||
|
$persisted = [];
|
||||||
|
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||||
|
$persisted[] = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->importer->importForActors([$actor]);
|
||||||
|
|
||||||
|
$this->assertTrue($actor->isAwardsImported());
|
||||||
|
// Should persist both a new AwardType and the Award
|
||||||
|
$this->assertCount(2, $persisted);
|
||||||
|
|
||||||
|
$newType = $persisted[0];
|
||||||
|
$this->assertInstanceOf(AwardType::class, $newType);
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwardsForActors')
|
||||||
|
->willThrowException(new \RuntimeException('Wikidata timeout'));
|
||||||
|
|
||||||
|
$this->importer->importForActors([$actor]);
|
||||||
|
|
||||||
|
$this->assertFalse($actor->isAwardsImported());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHandlesActorWithNoAwards(): void
|
||||||
|
{
|
||||||
|
$actor = $this->createActorWithFlag('Test Actor', awardsImported: false);
|
||||||
|
|
||||||
|
$this->wikidataGateway->method('getAwardsForActors')->willReturn([]);
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
||||||
|
|
||||||
|
$this->em->expects($this->never())->method('persist');
|
||||||
|
|
||||||
|
$this->importer->importForActors([$actor]);
|
||||||
|
|
||||||
|
$this->assertTrue($actor->isAwardsImported());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBatchImportsMultipleActors(): void
|
||||||
|
{
|
||||||
|
$actor1 = $this->createActorWithFlag('Actor One', awardsImported: false);
|
||||||
|
$actor2 = $this->createActorWithFlag('Actor Two', awardsImported: false);
|
||||||
|
$alreadyImported = $this->createActorWithFlag('Actor Three', awardsImported: true);
|
||||||
|
|
||||||
|
$this->wikidataGateway->expects($this->once())->method('getAwardsForActors')
|
||||||
|
->with($this->callback(fn (array $actors) => 2 === \count($actors)))
|
||||||
|
->willReturn([
|
||||||
|
'Actor One' => [['name' => 'Academy Award for Best Actor', 'year' => 2020]],
|
||||||
|
'Actor Two' => [['name' => 'Golden Globe for Best Actor', 'year' => 2021]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$existingType = new AwardType();
|
||||||
|
$existingType->setName('Oscar')->setPattern('Oscar');
|
||||||
|
|
||||||
|
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
||||||
|
|
||||||
|
$persisted = [];
|
||||||
|
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
||||||
|
$persisted[] = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->importer->importForActors([$actor1, $actor2, $alreadyImported]);
|
||||||
|
|
||||||
|
$this->assertTrue($actor1->isAwardsImported());
|
||||||
|
$this->assertTrue($actor2->isAwardsImported());
|
||||||
|
// 2 Awards + 1 new AwardType (Golden Globe)
|
||||||
|
$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();
|
||||||
|
$actor->setName($name);
|
||||||
|
$actor->setAwardsImported($awardsImported);
|
||||||
|
|
||||||
|
return $actor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Provider;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
use App\Entity\Actor;
|
||||||
use App\Entity\Award;
|
use App\Entity\Award;
|
||||||
@@ -12,11 +12,11 @@ use App\Repository\ActorRepository;
|
|||||||
use App\Repository\AwardRepository;
|
use App\Repository\AwardRepository;
|
||||||
use App\Repository\MovieRepository;
|
use App\Repository\MovieRepository;
|
||||||
use App\Repository\MovieRoleRepository;
|
use App\Repository\MovieRoleRepository;
|
||||||
use App\Service\GameGridGenerator;
|
use App\Provider\GameGridProvider;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class GameGridGeneratorTest extends TestCase
|
class GameGridProviderTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testResolveHintTextForAward(): void
|
public function testResolveHintTextForAward(): void
|
||||||
{
|
{
|
||||||
@@ -35,7 +35,7 @@ class GameGridGeneratorTest extends TestCase
|
|||||||
$awardRepository = $this->createMock(AwardRepository::class);
|
$awardRepository = $this->createMock(AwardRepository::class);
|
||||||
$awardRepository->method('find')->with(42)->willReturn($award);
|
$awardRepository->method('find')->with(42)->willReturn($award);
|
||||||
|
|
||||||
$generator = new GameGridGenerator(
|
$generator = new GameGridProvider(
|
||||||
$this->createMock(ActorRepository::class),
|
$this->createMock(ActorRepository::class),
|
||||||
$this->createMock(MovieRoleRepository::class),
|
$this->createMock(MovieRoleRepository::class),
|
||||||
$this->createMock(MovieRepository::class),
|
$this->createMock(MovieRepository::class),
|
||||||
@@ -53,4 +53,31 @@ class GameGridGeneratorTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame('Academy Award for Best Actor (2020)', $result);
|
$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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Service;
|
|
||||||
|
|
||||||
use App\Entity\Actor;
|
|
||||||
use App\Entity\Award;
|
|
||||||
use App\Entity\AwardType;
|
|
||||||
use App\Gateway\WikidataGateway;
|
|
||||||
use App\Repository\AwardTypeRepository;
|
|
||||||
use App\Service\AwardImporter;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class AwardImporterTest extends TestCase
|
|
||||||
{
|
|
||||||
private AwardImporter $importer;
|
|
||||||
private WikidataGateway&\PHPUnit\Framework\MockObject\MockObject $wikidataGateway;
|
|
||||||
private AwardTypeRepository&\PHPUnit\Framework\MockObject\MockObject $awardTypeRepository;
|
|
||||||
private EntityManagerInterface&\PHPUnit\Framework\MockObject\MockObject $em;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->wikidataGateway = $this->createMock(WikidataGateway::class);
|
|
||||||
$this->awardTypeRepository = $this->createMock(AwardTypeRepository::class);
|
|
||||||
$this->em = $this->createMock(EntityManagerInterface::class);
|
|
||||||
|
|
||||||
$this->importer = new AwardImporter(
|
|
||||||
$this->wikidataGateway,
|
|
||||||
$this->awardTypeRepository,
|
|
||||||
$this->em,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSkipsActorWithAwardsAlreadyImported(): void
|
|
||||||
{
|
|
||||||
$actor = $this->createActorWithFlag(awardsImported: true);
|
|
||||||
|
|
||||||
$this->wikidataGateway->expects($this->never())->method('getAwards');
|
|
||||||
|
|
||||||
$this->importer->importForActor($actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testImportsAwardsAndSetsFlag(): void
|
|
||||||
{
|
|
||||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
|
||||||
|
|
||||||
$this->wikidataGateway->method('getAwards')->willReturn([
|
|
||||||
['name' => 'Academy Award for Best Actor', 'year' => 2020],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$existingType = new AwardType();
|
|
||||||
$existingType->setName('Oscar')->setPattern('Academy Award');
|
|
||||||
|
|
||||||
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
|
|
||||||
|
|
||||||
$persisted = [];
|
|
||||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
|
||||||
$persisted[] = $entity;
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->importer->importForActor($actor);
|
|
||||||
|
|
||||||
$this->assertTrue($actor->isAwardsImported());
|
|
||||||
$this->assertCount(1, $persisted);
|
|
||||||
$this->assertInstanceOf(Award::class, $persisted[0]);
|
|
||||||
$this->assertSame('Academy Award for Best Actor', $persisted[0]->getName());
|
|
||||||
$this->assertSame(2020, $persisted[0]->getYear());
|
|
||||||
$this->assertSame($existingType, $persisted[0]->getAwardType());
|
|
||||||
$this->assertSame($actor, $persisted[0]->getActor());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreatesNewAwardTypeWhenNoPatternMatches(): void
|
|
||||||
{
|
|
||||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
|
||||||
|
|
||||||
$this->wikidataGateway->method('getAwards')->willReturn([
|
|
||||||
['name' => 'Screen Actors Guild Award for Outstanding Performance', 'year' => 2019],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
|
||||||
|
|
||||||
$persisted = [];
|
|
||||||
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
|
|
||||||
$persisted[] = $entity;
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->importer->importForActor($actor);
|
|
||||||
|
|
||||||
$this->assertTrue($actor->isAwardsImported());
|
|
||||||
// Should persist both a new AwardType and the Award
|
|
||||||
$this->assertCount(2, $persisted);
|
|
||||||
|
|
||||||
$newType = $persisted[0];
|
|
||||||
$this->assertInstanceOf(AwardType::class, $newType);
|
|
||||||
$this->assertSame('Screen Actors Guild Award', $newType->getName());
|
|
||||||
$this->assertSame('Screen Actors Guild Award', $newType->getPattern());
|
|
||||||
|
|
||||||
$award = $persisted[1];
|
|
||||||
$this->assertInstanceOf(Award::class, $award);
|
|
||||||
$this->assertSame($newType, $award->getAwardType());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDoesNotSetFlagOnWikidataError(): void
|
|
||||||
{
|
|
||||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
|
||||||
|
|
||||||
$this->wikidataGateway->method('getAwards')
|
|
||||||
->willThrowException(new \RuntimeException('Wikidata timeout'));
|
|
||||||
|
|
||||||
$this->importer->importForActor($actor);
|
|
||||||
|
|
||||||
$this->assertFalse($actor->isAwardsImported());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testHandlesActorWithNoAwards(): void
|
|
||||||
{
|
|
||||||
$actor = $this->createActorWithFlag(awardsImported: false);
|
|
||||||
|
|
||||||
$this->wikidataGateway->method('getAwards')->willReturn([]);
|
|
||||||
$this->awardTypeRepository->method('findAll')->willReturn([]);
|
|
||||||
|
|
||||||
$this->em->expects($this->never())->method('persist');
|
|
||||||
|
|
||||||
$this->importer->importForActor($actor);
|
|
||||||
|
|
||||||
$this->assertTrue($actor->isAwardsImported());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createActorWithFlag(bool $awardsImported): Actor
|
|
||||||
{
|
|
||||||
$actor = new Actor();
|
|
||||||
$actor->setName('Test Actor');
|
|
||||||
$actor->setAwardsImported($awardsImported);
|
|
||||||
|
|
||||||
return $actor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user