Compare commits

..

43 Commits

Author SHA1 Message Date
thibaud-leclere
843009e193 feat: include release year in film hint text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:25:05 +02:00
thibaud-leclere
2e7d7ecf44 fix: register game-config Stimulus controller in bootstrap.js
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:09:08 +02:00
thibaud-leclere
6b514aa87b feat: sort award types by actor count descending
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:05:55 +02:00
thibaud-leclere
51a9f49797 fix: address code review findings
- Guard against empty awardTypeIds array in AwardRepository
- Refactor Stimulus controller to use data-action attributes instead of
  imperative addEventListener (fixes duplicate listener issue)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:10:38 +02:00
thibaud-leclere
b637b725d8 feat: add game config panel UI (template, Stimulus controller, CSS)
Config panel with toggle for watched films, hint type checkboxes,
and multi-select for award types above the start game button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:08:09 +02:00
thibaud-leclere
ba715d69a0 feat: extract game config from POST and pass eligible AwardTypes to template
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:07:04 +02:00
thibaud-leclere
94ff0ced63 feat: GameGridProvider accepts config for hint types, watched-only, and retry logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:06:04 +02:00
thibaud-leclere
9c095a76eb fix: remove actor deletion in AwardTypeRepositoryTest to avoid FK violation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:03:33 +02:00
thibaud-leclere
f291df0fcf feat: add ActorRepository::findOneRandomInWatchedFilms()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:01:57 +02:00
thibaud-leclere
67571e8b33 feat: add AwardTypeRepository::findWithMinActors()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 22:58:52 +02:00
thibaud-leclere
5fbac8359f feat: add AwardRepository::findOneRandomByActorAndTypes() 2026-04-01 22:58:04 +02:00
thibaud-leclere
468b72b419 docs: add game config panel implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:49:16 +02:00
thibaud-leclere
54225ad97b docs: add game configuration panel design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:39:59 +02:00
thibaud-leclere
295bb16ab7 fix: reduce false positives in award detection
Filter SPARQL query to only return entertainment awards (film, TV,
music, theater) and add a canonical award map to normalize variants
(e.g. all Oscar/Academy Award → "Oscar", all Golden Globe → "Golden
Globe"). Non-entertainment awards (orders, medals, honorary degrees)
are excluded both at SPARQL level and via PHP keyword filter.

Also restart messenger container on cache:clear to avoid stale DI
container errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:12:28 +02:00
thibaud-leclere
116d7b409e perf: batch Wikidata SPARQL queries per film instead of per actor
Use a VALUES clause to fetch awards for all actors of a film in a
single SPARQL request, reducing Wikidata API calls from ~20 per film
to 1 and avoiding idle timeout errors from rate limiting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:37:13 +02:00
thibaud-leclere
369893a77e refactor: track import progress per film instead of per batch
Replace batch-level progress (processedBatches/totalBatches) with
film-level progress (processedFilms/totalFilms) for smoother UI updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:30:15 +02:00
thibaud-leclere
8c73a22eff refactor: embed film data directly in batch messages
Avoid re-parsing the entire CSV file in each batch handler by including
the film data in the message payload itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:25:45 +02:00
thibaud-leclere
087b063f1f chore: reorganizing 2026-04-01 19:24:04 +02:00
thibaud-leclere
0e3b17bb7d docs: add project README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:14:31 +02:00
thibaud-leclere
246d6fc740 fix: reorder constructor params in ImportFilmsBatchMessageHandler
Move AwardImporter after ImportRepository to fix autowiring order
in compiled container cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:44:08 +02:00
thibaud-leclere
353ffddeea feat: add popover to confirm abandon 2026-04-01 14:36:41 +02:00
thibaud-leclere
fb13a8819d feat: use DB awards instead of live Wikidata calls for hint generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:29:36 +02:00
thibaud-leclere
0fd0b85b8f feat: import actor awards during film batch import 2026-04-01 14:27:54 +02:00
thibaud-leclere
8aa33ccefc feat: add AwardImporter service with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:27:15 +02:00
thibaud-leclere
d4d2272396 feat: add migration for award_type, award tables and actor.awards_imported
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:24:48 +02:00
thibaud-leclere
6c1e4cb38b feat: add awardsImported flag and awards relation to Actor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:23:56 +02:00
thibaud-leclere
859a5a1067 feat: add Award entity and repository 2026-04-01 14:23:23 +02:00
thibaud-leclere
acc266739d feat: add AwardType entity and repository 2026-04-01 14:22:33 +02:00
thibaud-leclere
76013afb1c docs: add implementation plan for awards in database
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:20 +02:00
thibaud-leclere
d2d211a228 docs: add design spec for persisting awards in database
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:17:04 +02:00
thibaud-leclere
116812b3f8 save movies release date in BDD, remove unused badge, add help to export movie from letterboxd 2026-03-31 22:18:46 +02:00
thibaud-leclere
c5d359bb0c remove unused sync commands (app:sync-actors, app:sync-films)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:55:08 +02:00
thibaud-leclere
ded3d063c6 fix: render hint popover via FloatingPortal to prevent overflow clipping
The popover was invisible because it rendered inside the table's
overflow-x:auto scroll container. FloatingPortal moves it to document.body.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:49:46 +02:00
thibaud-leclere
2e65b2805a feat: add login hint on start page for unauthenticated users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:49:41 +02:00
thibaud-leclere
dba9b985ee feat: rename site title to LtbxdActorle with dual-color branding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:38:26 +02:00
thibaud-leclere
a37ac1debd fix: resolve messenger worker OOM by clearing EntityManager and disabling debug
Clear Doctrine identity map after each film import to prevent memory
accumulation. Run messenger with --no-debug and higher PHP memory_limit
to avoid profiling overhead in dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:34:11 +02:00
thibaud-leclere
6a844542ad feat: replace notifications with import status in profile dropdown
Remove the notification system entirely and show import progress
directly in the user dropdown menu. Block new imports while one
is already running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:34:05 +02:00
thibaud-leclere
3edde1c7db feat: add loading spinner when starting a new game
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:28:08 +02:00
thibaud-leclere
8942e7f608 fix: integrate hint buttons into table for perfect row alignment and sticky scroll
Move hint buttons from a separate flex column into the table as the
first <td> of each row, ensuring pixel-perfect alignment with grid rows.
Use position:sticky with box-shadow to keep hints fixed on the left
while scrolling horizontally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:36:32 +02:00
thibaud-leclere
f6d180474a feat: responsive grid with scrollable area and fixed hint column
- Add CSS variables (--cell, --cell-font, --trigger-h) for responsive cell sizing
- Shrink grid cells at 600px and 420px breakpoints
- Add .page-body wrapper with 16px horizontal padding to prevent edge collisions
- Separate hint column from scrollable grid: hints rendered outside the table in a fixed flex column, only the letter grid scrolls horizontally

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:17:22 +02:00
thibaud-leclere
6cbebb6367 refactor: move WikidataAwardGateway to src/Gateway/WikidataGateway.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:14:20 +02:00
thibaud-leclere
ecfc80c349 style: redesign hint buttons — rectangular pink with white icon, more spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 11:09:57 +02:00
thibaud-leclere
dc5e14531f chore: fix report bug issue template 2026-03-30 23:18:14 +02:00
60 changed files with 4794 additions and 709 deletions

View File

@@ -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

View File

@@ -3,14 +3,6 @@ about: Signaler un bug rencontré dans le jeu
labels: labels:
- "type: bug" - "type: bug"
body: body:
- type: input
id: game_id
attributes:
label: Game ID
description: L'identifiant de la partie concernée
placeholder: "ex: 42"
validations:
required: false
- type: textarea - type: textarea
id: description id: description
attributes: attributes:

View File

@@ -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
View 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) |

8
assets/bootstrap.js vendored
View File

@@ -1,12 +1,16 @@
import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers'; import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers';
import DropdownController from './controllers/dropdown_controller.js'; import DropdownController from './controllers/dropdown_controller.js';
import NotificationsController from './controllers/notifications_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 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('notifications', NotificationsController);
app.register('import-modal', ImportModalController); app.register('import-modal', ImportModalController);
app.register('import-status', ImportStatusController);
app.register('import-help', ImportHelpController);
app.register('game-config', GameConfigController);
// Register React components for {{ react_component() }} Twig function. // 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

View 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;
}
}

View File

@@ -0,0 +1,20 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['overlay'];
open(event) {
event.stopPropagation();
this.overlayTarget.hidden = false;
}
close() {
this.overlayTarget.hidden = true;
}
closeOnBackdrop(event) {
if (event.target === this.overlayTarget) {
this.close();
}
}
}

View File

@@ -43,6 +43,7 @@ export default class extends Controller {
} }
this._showFeedback('Import lancé !', false); this._showFeedback('Import lancé !', false);
document.dispatchEvent(new CustomEvent('import:started'));
setTimeout(() => this.close(), 1500); setTimeout(() => this.close(), 1500);
} catch (e) { } catch (e) {
this._showFeedback('Une erreur est survenue.', true); this._showFeedback('Une erreur est survenue.', true);

View File

@@ -0,0 +1,95 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['item', 'importBtn'];
connect() {
this._poll();
this._interval = setInterval(() => this._poll(), 5000);
this._onImportStarted = () => this._poll();
document.addEventListener('import:started', this._onImportStarted);
}
disconnect() {
clearInterval(this._interval);
document.removeEventListener('import:started', this._onImportStarted);
}
async _poll() {
try {
const response = await fetch('/api/imports/latest');
if (!response.ok) return;
const data = await response.json();
this._update(data);
} catch (e) {
// silently ignore
}
}
_update(data) {
if (!data) {
this._showDefault();
return;
}
const isActive = data.status === 'pending' || data.status === 'processing';
if (isActive) {
this._showActive(data);
} else if (data.status === 'completed') {
this._showCompleted(data);
} else if (data.status === 'failed') {
this._showFailed();
} else {
this._showDefault();
}
}
_showDefault() {
this.importBtnTarget.disabled = false;
this.importBtnTarget.textContent = 'Importer ses films';
this._removeStatus();
}
_showActive(data) {
this.importBtnTarget.disabled = true;
this.importBtnTarget.textContent = 'Import en cours\u2026';
const progress = data.totalFilms > 0
? Math.round((data.processedFilms / data.totalFilms) * 100)
: 0;
this._setStatus(`${progress}% — ${data.processedFilms}/${data.totalFilms} films`, 'active');
}
_showCompleted(data) {
this.importBtnTarget.disabled = false;
this.importBtnTarget.textContent = 'Importer ses films';
const imported = data.totalFilms - data.failedFilms;
this._setStatus(`Dernier import : ${imported}/${data.totalFilms} films`, 'completed');
}
_showFailed() {
this.importBtnTarget.disabled = false;
this.importBtnTarget.textContent = 'Importer ses films';
this._setStatus('Dernier import : échoué', 'failed');
}
_setStatus(text, type) {
let statusEl = this.itemTarget.querySelector('.import-status-text');
if (!statusEl) {
statusEl = document.createElement('span');
statusEl.className = 'import-status-text';
this.itemTarget.appendChild(statusEl);
}
statusEl.textContent = text;
statusEl.className = `import-status-text import-status-${type}`;
}
_removeStatus() {
const statusEl = this.itemTarget.querySelector('.import-status-text');
if (statusEl) statusEl.remove();
}
}

View File

@@ -1,77 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['badge', 'list'];
connect() {
this._poll();
this._interval = setInterval(() => this._poll(), 30000);
}
disconnect() {
clearInterval(this._interval);
}
async _poll() {
try {
const response = await fetch('/api/notifications');
if (!response.ok) return;
const data = await response.json();
this._updateBadge(data.unreadCount);
this._updateTitle(data.unreadCount);
this._updateList(data.notifications);
} catch (e) {
// silently ignore polling errors
}
}
_updateBadge(count) {
if (count > 0) {
this.badgeTarget.textContent = count;
this.badgeTarget.hidden = false;
} else {
this.badgeTarget.hidden = true;
}
}
_updateTitle(count) {
const base = 'Actorle';
document.title = count > 0 ? `(${count}) ${base}` : base;
}
_updateList(notifications) {
if (notifications.length === 0) {
this.listTarget.innerHTML = '<p class="dropdown-empty">Aucune notification</p>';
return;
}
this.listTarget.innerHTML = notifications.map(n => `
<div class="notification-item ${n.read ? '' : 'notification-unread'}">
<p>${this._escapeHtml(n.message)}</p>
<time>${this._formatDate(n.createdAt)}</time>
</div>
`).join('');
}
async markRead() {
await fetch('/api/notifications/read', { method: 'POST' });
this._poll();
}
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
_formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size } from '@floating-ui/react'; import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size, FloatingPortal } from '@floating-ui/react';
const HINT_ICONS = { const HINT_ICONS = {
film: 'fa-solid fa-film', film: 'fa-solid fa-film',
@@ -46,14 +46,16 @@ export default function ActorPopover({ hintType, hintText }) {
<i className={iconClass}></i> <i className={iconClass}></i>
</button> </button>
{isOpen && ( {isOpen && (
<div <FloatingPortal>
ref={refs.setFloating} <div
style={floatingStyles} ref={refs.setFloating}
{...getFloatingProps()} style={floatingStyles}
className="actor-popover" {...getFloatingProps()}
> className="actor-popover"
{hintText} >
</div> {hintText}
</div>
</FloatingPortal>
)} )}
</> </>
); );

View File

@@ -1,41 +1,44 @@
import React from 'react'; import React from 'react';
import GameRow from './GameRow'; import GameRow from './GameRow';
import ActorPopover from './ActorPopover';
export default function GameGrid({ grid, width, middle }) { export default function GameGrid({ grid, width, middle }) {
return ( return (
<table id="actors"> <div className="game-grid-scroll">
<tbody> <table id="actors">
{grid.map((row, rowIndex) => { <tbody>
if (row.separator !== undefined) { {grid.map((row, rowIndex) => {
return ( if (row.separator !== undefined) {
<tr key={rowIndex} className="separator-row"> return (
<td /> <tr key={rowIndex} className="separator-row">
{Array.from({ length: middle }, (_, i) => ( <td className="hint-cell" />
<td key={i} /> {Array.from({ length: middle }, (_, i) => (
))} <td key={i} />
<td className="letter-static separator-char"> ))}
{row.separator === ' ' ? '' : row.separator} <td className="letter-static separator-char">
</td> {row.separator === ' ' ? '' : row.separator}
{Array.from({ length: width - middle }, (_, i) => ( </td>
<td key={middle + 1 + i} /> {Array.from({ length: width - middle }, (_, i) => (
))} <td key={middle + 1 + i} />
</tr> ))}
); </tr>
} );
}
return ( return (
<GameRow <GameRow
key={rowIndex} key={rowIndex}
actorName={row.actorName} actorName={row.actorName}
pos={row.pos} pos={row.pos}
colStart={middle - row.pos} colStart={middle - row.pos}
totalWidth={width} totalWidth={width}
hintType={row.hintType} hintType={row.hintType}
hintText={row.hintText} hintText={row.hintText}
/> />
); );
})} })}
</tbody> </tbody>
</table> </table>
</div>
); );
} }

View File

@@ -29,7 +29,7 @@ export default function GameRow({ actorName, pos, colStart, totalWidth, hintType
return ( return (
<tr> <tr>
<td> <td className="hint-cell">
<ActorPopover hintType={hintType} hintText={hintText} /> <ActorPopover hintType={hintType} hintText={hintText} />
</td> </td>
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => { {Array.from({ length: totalWidth + 1 }, (_, colIndex) => {

View File

@@ -21,6 +21,9 @@
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 10px; --radius-md: 10px;
--radius-lg: 18px; --radius-lg: 18px;
--cell: 38px;
--cell-font: 15px;
--trigger-h: 28px;
} }
body { body {
@@ -31,6 +34,11 @@ body {
margin: 0; margin: 0;
} }
.page-body {
padding: 0 16px;
box-sizing: border-box;
}
/* ── Game grid ── */ /* ── Game grid ── */
#actors { #actors {
@@ -40,18 +48,20 @@ body {
} }
#actors td { #actors td {
width: 38px; width: var(--cell);
height: 38px; height: var(--cell);
padding: 0;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
} }
.letter-input { .letter-input {
width: 38px; width: var(--cell);
height: 38px; height: var(--cell);
text-align: center; text-align: center;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 15px; font-size: var(--cell-font);
font-weight: 700; font-weight: 700;
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -76,11 +86,11 @@ body {
} }
.letter-static { .letter-static {
width: 38px; width: var(--cell);
height: 38px; height: var(--cell);
text-align: center; text-align: center;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-size: 15px; font-size: var(--cell-font);
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
color: var(--text); color: var(--text);
@@ -99,26 +109,24 @@ body {
/* ── Popover ── */ /* ── Popover ── */
.popover-trigger { .popover-trigger {
width: 28px; width: var(--cell);
height: 28px; height: var(--trigger-h);
border-radius: 50%; border-radius: 6px;
border: 1.5px solid var(--border); border: none;
background: var(--surface); background: var(--orange);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: #ffffff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: border-color 0.15s, color 0.15s, background 0.15s; transition: background 0.15s;
box-shadow: 0 1px 3px var(--shadow-warm); box-shadow: 0 1px 3px var(--shadow-warm);
} }
.popover-trigger:hover { .popover-trigger:hover {
border-color: var(--orange); background: var(--orange-mid);
color: var(--orange);
background: var(--surface-tint);
} }
.actor-popover { .actor-popover {
@@ -253,6 +261,10 @@ body {
text-decoration: none; text-decoration: none;
} }
.brand-prefix {
color: var(--text-muted);
}
.navbar-left, .navbar-left,
.navbar-right { .navbar-right {
display: flex; display: flex;
@@ -284,22 +296,6 @@ body {
/* ── Badge ── */ /* ── Badge ── */
.badge {
position: absolute;
top: 2px;
right: 2px;
background: var(--orange);
color: white;
font-size: 10px;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* ── Dropdown ── */ /* ── Dropdown ── */
@@ -355,35 +351,95 @@ body {
margin: 0; margin: 0;
} }
/* ── Notifications ── */ /* ── Dropdown item row ── */
.notifications-list { .dropdown-item-row {
max-height: 300px; display: flex;
overflow-y: auto; align-items: center;
} }
.notification-item { .dropdown-item-row .dropdown-item {
padding: 10px 16px; flex: 1;
border-bottom: 1px solid var(--surface-warm);
} }
.notification-item:last-child { .info-btn {
border-bottom: none; width: 20px;
} height: 20px;
border-radius: 50%;
.notification-item p { border: 1.5px solid var(--text-faint);
margin: 0 0 3px; background: none;
font-size: 13px;
color: var(--text);
}
.notification-item time {
font-size: 11px;
color: var(--text-faint); color: var(--text-faint);
font-size: 12px;
font-weight: 700;
font-style: italic;
font-family: 'Georgia', serif;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 10px;
transition: border-color 0.15s, color 0.15s, background 0.15s;
} }
.notification-unread { .info-btn:hover {
background: var(--surface-warm); border-color: var(--orange);
color: var(--orange);
background: var(--surface-tint);
}
/* ── Help steps ── */
.help-steps {
margin: 0;
padding-left: 20px;
color: var(--text-muted);
font-size: 14px;
line-height: 1.8;
}
.help-steps code {
background: var(--surface-tint);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.help-steps a {
color: var(--orange);
font-weight: 600;
text-decoration: none;
}
.help-steps a:hover {
text-decoration: underline;
}
/* ── Import status ── */
.import-status-item {
display: flex;
flex-direction: column;
}
.import-status-text {
display: block;
padding: 0 12px 8px;
font-size: 11px;
font-weight: 500;
line-height: 1.3;
}
.import-status-active {
color: var(--orange);
}
.import-status-completed {
color: #16a34a;
}
.import-status-failed {
color: #dc2626;
} }
/* ── Modal ── */ /* ── Modal ── */
@@ -513,7 +569,8 @@ body {
/* ── Game card ── */ /* ── Game card ── */
.game-container { .game-container {
max-width: fit-content; width: fit-content;
max-width: 100%;
margin: 56px auto; margin: 56px auto;
padding: 24px 32px 32px; padding: 24px 32px 32px;
background: var(--surface); background: var(--surface);
@@ -523,7 +580,29 @@ body {
} }
.game-container #actors { .game-container #actors {
margin: 16px auto 0; margin: 0;
}
.hint-cell {
padding: 0;
position: sticky;
left: 5px;
z-index: 1;
background: var(--surface);
box-shadow:
-5px 0 0 var(--surface),
5px 0 0 var(--surface),
0 -5px 0 var(--surface),
0 5px 0 var(--surface),
-5px -5px 0 var(--surface),
5px -5px 0 var(--surface),
-5px 5px 0 var(--surface),
5px 5px 0 var(--surface);
}
.game-grid-scroll {
overflow-x: auto;
margin-top: 16px;
} }
/* ── Game actions ── */ /* ── Game actions ── */
@@ -552,18 +631,255 @@ body {
background: #fef2f2; background: #fef2f2;
} }
.abandon-wrapper {
position: relative;
}
.abandon-popover {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 16px;
box-shadow: 0 4px 16px var(--shadow-warm);
z-index: 100;
white-space: nowrap;
}
.abandon-popover.open {
display: block;
}
.abandon-popover-text {
margin: 0 0 10px;
font-size: 13px;
font-weight: 500;
color: var(--text);
text-align: center;
}
.abandon-popover-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.btn-abandon-confirm {
padding: 6px 14px;
background: #dc2626;
color: #fff;
border: none;
border-radius: 100px;
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-abandon-confirm:hover {
background: #b91c1c;
}
.btn-abandon-cancel {
padding: 6px 14px;
background: none;
color: var(--text);
border: 1.5px solid var(--border);
border-radius: 100px;
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.btn-abandon-cancel:hover {
background: var(--surface-hover, #f5f5f5);
border-color: var(--text);
}
.game-start-container { .game-start-container {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: calc(100vh - 64px - 80px); min-height: calc(100vh - 64px - 80px);
} }
.start-login-hint {
margin-top: 16px;
color: var(--text-muted);
font-size: 0.85rem;
}
.start-login-hint a {
color: var(--orange);
font-weight: 600;
text-decoration: none;
}
.start-login-hint a:hover {
text-decoration: underline;
}
.btn-start { .btn-start {
padding: 14px 32px; padding: 14px 32px;
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 {
display: none;
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.15);
border-top-color: #ff6b81;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Game footer ── */ /* ── Game footer ── */
.game-footer { .game-footer {
@@ -590,3 +906,29 @@ body {
color: var(--orange); color: var(--orange);
background: var(--surface-tint); background: var(--surface-tint);
} }
/* ── Responsive grid ── */
@media (max-width: 600px) {
:root {
--cell: 30px;
--cell-font: 12px;
--trigger-h: 22px;
}
.game-container {
padding: 16px 16px 24px;
}
}
@media (max-width: 420px) {
:root {
--cell: 24px;
--cell-font: 10px;
--trigger-h: 18px;
}
#actors {
border-spacing: 3px;
}
}

View File

@@ -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:

View File

@@ -11,6 +11,11 @@ services:
ports: ports:
- "${PORT_80:-80}:80" - "${PORT_80:-80}:80"
messenger:
command: ["php", "-d", "memory_limit=512M", "bin/console", "messenger:consume", "async", "--time-limit=3600", "--memory-limit=256M", "-vv", "--no-debug"]
volumes:
- .:/app
database: database:
ports: ports:
- "0.0.0.0:5432:5432" - "0.0.0.0:5432:5432"

View File

@@ -0,0 +1,881 @@
# Awards en BDD — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Persist actor awards in the database during film import and use them for hint generation instead of live Wikidata calls.
**Architecture:** Two new Doctrine entities (`AwardType`, `Award`) plus a `awardsImported` flag on `Actor`. A new `AwardImporter` service handles Wikidata fetching + pattern-based type resolution during the existing batch import flow. `GameGridGenerator` switches from Wikidata calls to DB queries for award hints.
**Tech Stack:** Symfony 7, Doctrine ORM, PHPUnit 12, PostgreSQL (RANDOM() function used in existing queries)
---
## File Structure
| Action | Path | Responsibility |
|--------|------|---------------|
| Create | `src/Entity/AwardType.php` | Doctrine entity for award types |
| Create | `src/Entity/Award.php` | Doctrine entity for individual awards |
| Create | `src/Repository/AwardTypeRepository.php` | AwardType queries |
| Create | `src/Repository/AwardRepository.php` | Award queries (random by actor) |
| Create | `src/Service/AwardImporter.php` | Orchestrates Wikidata fetch + type resolution + persist |
| Create | `tests/Service/AwardImporterTest.php` | Unit tests for import logic |
| Create | `tests/Service/GameGridGeneratorTest.php` | Unit tests for updated hint generation |
| Modify | `src/Entity/Actor.php` | Add `awardsImported` bool field + `awards` collection |
| Modify | `src/MessageHandler/ImportFilmsBatchMessageHandler.php` | Call `AwardImporter` after actor sync |
| Modify | `src/Service/GameGridGenerator.php` | Replace Wikidata call with DB query |
| Create | `migrations/VersionXXX.php` | Generated by doctrine:migrations:diff |
---
### Task 1: Create `AwardType` entity
**Files:**
- Create: `src/Entity/AwardType.php`
- Create: `src/Repository/AwardTypeRepository.php`
- [ ] **Step 1: Create the entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardTypeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
class AwardType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255)]
private string $pattern;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'awardType')]
private Collection $awards;
public function __construct()
{
$this->awards = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPattern(): string
{
return $this->pattern;
}
public function setPattern(string $pattern): static
{
$this->pattern = $pattern;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
}
```
- [ ] **Step 2: Create the repository**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AwardType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<AwardType> */
class AwardTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AwardType::class);
}
/** @return list<AwardType> */
public function findAll(): array
{
return parent::findAll();
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/AwardType.php src/Repository/AwardTypeRepository.php
git commit -m "feat: add AwardType entity and repository"
```
---
### Task 2: Create `Award` entity
**Files:**
- Create: `src/Entity/Award.php`
- Create: `src/Repository/AwardRepository.php`
- [ ] **Step 1: Create the entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardRepository::class)]
class Award
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private AwardType $awardType;
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private Actor $actor;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(nullable: true)]
private ?int $year = null;
public function getId(): ?int
{
return $this->id;
}
public function getAwardType(): AwardType
{
return $this->awardType;
}
public function setAwardType(AwardType $awardType): static
{
$this->awardType = $awardType;
return $this;
}
public function getActor(): Actor
{
return $this->actor;
}
public function setActor(Actor $actor): static
{
$this->actor = $actor;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getYear(): ?int
{
return $this->year;
}
public function setYear(?int $year): static
{
$this->year = $year;
return $this;
}
}
```
- [ ] **Step 2: Create the repository with `findOneRandomByActor`**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Award;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Award> */
class AwardRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Award::class);
}
public function findOneRandomByActor(int $actorId): ?Award
{
return $this->createQueryBuilder('a')
->andWhere('a.actor = :actorId')
->setParameter('actorId', $actorId)
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/Award.php src/Repository/AwardRepository.php
git commit -m "feat: add Award entity and repository"
```
---
### Task 3: Add `awardsImported` flag and `awards` collection to `Actor`
**Files:**
- Modify: `src/Entity/Actor.php`
- [ ] **Step 1: Add the `awardsImported` column and `awards` OneToMany relation**
Add these properties after the existing `$tmdbId` property:
```php
#[ORM\Column(options: ['default' => false])]
private bool $awardsImported = false;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')]
private Collection $awards;
```
Add `use App\Entity\Award;` import if not already present (it's in the same namespace, so not needed).
In the constructor, add:
```php
$this->awards = new ArrayCollection();
```
Add these methods at the end of the class:
```php
public function isAwardsImported(): bool
{
return $this->awardsImported;
}
public function setAwardsImported(bool $awardsImported): static
{
$this->awardsImported = $awardsImported;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Entity/Actor.php
git commit -m "feat: add awardsImported flag and awards relation to Actor"
```
---
### Task 4: Generate and run the migration
**Files:**
- Create: `migrations/VersionXXX.php` (auto-generated)
- [ ] **Step 1: Generate the migration**
```bash
php bin/console doctrine:migrations:diff
```
Expected: a new migration file is created in `migrations/` with CREATE TABLE for `award_type` and `award`, and ALTER TABLE for `actor` adding `awards_imported`.
- [ ] **Step 2: Review the generated migration**
Open the generated file and verify it contains:
- `CREATE TABLE award_type (id SERIAL, name VARCHAR(255), pattern VARCHAR(255), PRIMARY KEY(id))`
- `CREATE TABLE award (id SERIAL, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255), year INT DEFAULT NULL, PRIMARY KEY(id))` with foreign keys
- `ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false`
- [ ] **Step 3: Run the migration**
```bash
php bin/console doctrine:migrations:migrate --no-interaction
```
Expected: migration executes successfully.
- [ ] **Step 4: Commit**
```bash
git add migrations/
git commit -m "feat: add migration for award_type, award tables and actor.awards_imported"
```
---
### Task 5: Create `AwardImporter` service
**Files:**
- Create: `src/Service/AwardImporter.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/AwardImporterTest.php`:
```php
<?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;
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
php bin/phpunit tests/Service/AwardImporterTest.php
```
Expected: FAIL — `AwardImporter` class not found.
- [ ] **Step 3: Write the implementation**
Create `src/Service/AwardImporter.php`:
```php
<?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;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
```bash
php bin/phpunit tests/Service/AwardImporterTest.php
```
Expected: all 5 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Service/AwardImporter.php tests/Service/AwardImporterTest.php
git commit -m "feat: add AwardImporter service with tests"
```
---
### Task 6: Integrate `AwardImporter` into the import batch handler
**Files:**
- Modify: `src/MessageHandler/ImportFilmsBatchMessageHandler.php`
- [ ] **Step 1: Add `AwardImporter` dependency**
In the constructor, add:
```php
private AwardImporter $awardImporter,
```
Add the import at the top:
```php
use App\Service\AwardImporter;
```
- [ ] **Step 2: Call `AwardImporter` after actor sync**
In the `__invoke` method, after `$this->actorSyncer->syncActorsForMovie($movie);` (line 63) and **before** the existing `$this->em->flush()` (line 77), add:
```php
$this->actorSyncer->syncActorsForMovie($movie);
// Import awards for actors of this movie
foreach ($movie->getActors() as $role) {
$this->awardImporter->importForActor($role->getActor());
}
```
`Movie::getActors()` returns `Collection<MovieRole>`. The `ActorSyncer` persists actors/roles in memory before flush, so the collection is hydrated at this point. The existing `$this->em->flush()` on line 77 will persist both the roles and the new awards in a single flush. The `$this->em->clear()` on line 87 happens after, so all entities are still available.
- [ ] **Step 3: Commit**
```bash
git add src/MessageHandler/ImportFilmsBatchMessageHandler.php
git commit -m "feat: import actor awards during film batch import"
```
---
### Task 7: Update `GameGridGenerator` to use DB for award hints
**Files:**
- Modify: `src/Service/GameGridGenerator.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/GameGridGeneratorTest.php` — test only the hint resolution, not the full game generation (which requires DB):
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Entity\GameRow;
use App\Repository\ActorRepository;
use App\Repository\AwardRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use App\Service\GameGridGenerator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class GameGridGeneratorTest extends TestCase
{
public function testResolveHintTextForAward(): void
{
$awardType = new AwardType();
$awardType->setName('Oscar')->setPattern('Academy Award');
$actor = new Actor();
$actor->setName('Test Actor');
$award = new Award();
$award->setName('Academy Award for Best Actor');
$award->setYear(2020);
$award->setActor($actor);
$award->setAwardType($awardType);
$awardRepository = $this->createMock(AwardRepository::class);
$awardRepository->method('find')->with(42)->willReturn($award);
$generator = new GameGridGenerator(
$this->createMock(ActorRepository::class),
$this->createMock(MovieRoleRepository::class),
$this->createMock(MovieRepository::class),
$awardRepository,
$this->createMock(EntityManagerInterface::class),
);
$row = new GameRow();
$row->setHintType('award');
$row->setHintData('42');
// Use reflection to test the private resolveHintText method
$method = new \ReflectionMethod($generator, 'resolveHintText');
$result = $method->invoke($generator, $row);
$this->assertSame('Academy Award for Best Actor (2020)', $result);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
php bin/phpunit tests/Service/GameGridGeneratorTest.php
```
Expected: FAIL — constructor signature mismatch (no `AwardRepository` param yet).
- [ ] **Step 3: Update `GameGridGenerator`**
Replace `WikidataGateway` with `AwardRepository` in the constructor. Full changes to `src/Service/GameGridGenerator.php`:
**Replace the imports:**
- Remove: `use App\Gateway\WikidataGateway;`
- Add: `use App\Repository\AwardRepository;`
**Replace the constructor:**
```php
public function __construct(
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
private readonly MovieRepository $movieRepository,
private readonly AwardRepository $awardRepository,
private readonly EntityManagerInterface $em,
) {}
```
**Replace the `case 'award':` block in `resolveHint()`** (lines 175-185):
```php
case 'award':
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
if ($award === null) {
return null;
}
return ['type' => 'award', 'data' => (string) $award->getId()];
```
**Replace the `'award'` case in `resolveHintText()`** (line 203):
```php
'award' => $this->resolveAwardHintText((int) $data),
```
**Add this private method:**
```php
private function resolveAwardHintText(int $awardId): ?string
{
$award = $this->awardRepository->find($awardId);
if ($award === null) {
return null;
}
$text = $award->getName();
if ($award->getYear() !== null) {
$text .= ' (' . $award->getYear() . ')';
}
return $text;
}
```
- [ ] **Step 4: Run the tests**
```bash
php bin/phpunit tests/Service/GameGridGeneratorTest.php
```
Expected: PASS.
- [ ] **Step 5: Run all tests**
```bash
php bin/phpunit
```
Expected: all tests PASS.
- [ ] **Step 6: Commit**
```bash
git add src/Service/GameGridGenerator.php tests/Service/GameGridGeneratorTest.php
git commit -m "feat: use DB awards instead of live Wikidata calls for hint generation"
```
---
### Task 8: Manual verification
- [ ] **Step 1: Verify the schema is in sync**
```bash
php bin/console doctrine:schema:validate
```
Expected: schema is in sync with mapping files.
- [ ] **Step 2: Verify the app boots**
```bash
php bin/console cache:clear
```
Expected: no errors.
- [ ] **Step 3: Run all tests one final time**
```bash
php bin/phpunit
```
Expected: all tests PASS.
- [ ] **Step 4: Final commit if any fixes were needed**
Only if adjustments were made during verification.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
# Awards en BDD — Design Spec
## Contexte
Actuellement, les récompenses des acteurs sont récupérées à la volée depuis Wikidata (SPARQL) lors de la génération d'une partie. Elles ne sont pas persistées en base de données, ce qui rend le système fragile (dépendance réseau à chaque partie) et empêche tout filtrage par type de récompense.
## Objectifs
1. Stocker les awards en BDD dès l'import des films
2. Introduire une notion de type de récompense (Oscar, Golden Globe, BAFTA...)
3. Adapter la génération des indices pour piocher en BDD au lieu d'appeler Wikidata
4. Préparer le terrain pour un futur filtrage par type (choix du joueur avant la partie) — hors scope de cette itération
## Modèle de données
### Nouvelle entité : `AwardType`
| Champ | Type | Description |
|-----------|------------|--------------------------------------------------------------------|
| `id` | int (PK) | Auto-increment |
| `name` | string | Nom affiché, ex: "Oscar", "Golden Globe", "BAFTA", "César" |
| `pattern` | string | Préfixe/mot-clé pour le matching sur les noms Wikidata, ex: "Academy Award" |
### Nouvelle entité : `Award`
| Champ | Type | Description |
|-------------|-------------------|--------------------------------------------------------------|
| `id` | int (PK) | Auto-increment |
| `awardType` | ManyToOne → AwardType | Type de la récompense |
| `actor` | ManyToOne → Actor | Acteur récompensé |
| `name` | string | Nom complet Wikidata, ex: "Academy Award for Best Actor" |
| `year` | int (nullable) | Année de la récompense |
### Modification entité `Actor`
| Champ | Type | Description |
|------------------|------|--------------------------------------------------|
| `awardsImported` | bool | `false` par défaut. Passe à `true` après import. |
### Relations
- `Actor` OneToMany → `Award`
- `AwardType` OneToMany → `Award`
## Flux d'import des awards
L'import se greffe sur le batch existant (`ImportFilmsBatchMessageHandler`), après `ActorSyncer::syncActorsForMovie()`.
### Étapes pour chaque acteur du film importé
1. Vérifier `actor.awardsImported`
2. Si `true` → skip
3. Si `false` → appeler `WikidataGateway::getAwards(actor)`
4. Pour chaque award retourné :
- Parcourir les `AwardType` existants et matcher le nom de l'award contre leur `pattern`
- Si un `AwardType` matche → l'utiliser
- Si aucun ne matche → créer un nouvel `AwardType` dynamiquement en extrayant le préfixe commun du nom (ex: "Screen Actors Guild Award for Outstanding Performance..." → type "Screen Actors Guild Award")
- Créer l'entité `Award` (name, year, actor, awardType)
5. Passer `actor.awardsImported = true`
6. Flush
### Gestion d'erreur
Si Wikidata est indisponible ou retourne une erreur :
- Ne **pas** mettre `awardsImported = true`
- L'import du film continue normalement (les awards seront retentés au prochain import contenant cet acteur)
- Log de l'erreur
## Génération des indices (hints)
### Changements dans `GameGridGenerator`
**Avant** : appel à `WikidataGateway::getAwards()` à la volée pour chaque acteur du grid.
**Après** :
1. Pour un hint de type "award", requêter les `Award` en BDD pour l'acteur
2. Si l'acteur a des awards → en choisir un au hasard
3. Si l'acteur n'a pas d'awards → fallback sur les types "film" ou "character" (comportement existant quand Wikidata échouait)
### Stockage du hint
- `GameRow.hintData` stocke l'**ID de l'`Award`** (au lieu d'une string brute comme avant)
- `resolveHintText()` récupère le nom complet + année depuis l'entité `Award`
### Pas de filtrage par `AwardType`
Pour cette itération, on pioche dans tous les awards de l'acteur sans filtrer par type. Le filtrage (choix du joueur : "mode Oscars", "mode Golden Globes"...) sera ajouté ultérieurement.
## Hors scope
- UI de choix du type de récompense avant la partie
- Filtrage des awards par type lors de la génération des indices
- Commande de re-sync des awards pour les acteurs déjà importés

View 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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260331000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop notification table';
}
public function up(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS notification');
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE notification (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES "user"(id),
message VARCHAR(255) NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260331000002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add year column to movie table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE movie ADD year INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE movie DROP COLUMN year');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260401000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add award_type, award tables and actor.awards_imported column';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE award_type (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, pattern VARCHAR(255) NOT NULL)');
$this->addSql('CREATE TABLE award (id SERIAL PRIMARY KEY, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255) NOT NULL, year INT DEFAULT NULL, CONSTRAINT fk_award_award_type FOREIGN KEY (award_type_id) REFERENCES award_type (id), CONSTRAINT fk_award_actor FOREIGN KEY (actor_id) REFERENCES actor (id))');
$this->addSql('CREATE INDEX idx_award_award_type ON award (award_type_id)');
$this->addSql('CREATE INDEX idx_award_actor ON award (actor_id)');
$this->addSql('ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE award');
$this->addSql('DROP TABLE award_type');
$this->addSql('ALTER TABLE actor DROP COLUMN awards_imported');
}
}

View 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');
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Command;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Service\ActorSyncer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-actors')]
readonly class SyncActorsCommand
{
public function __construct(
private ActorSyncer $actorSyncer,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
try {
$output->writeln('Syncing cast for '.$film->getTitle());
$this->actorSyncer->syncActorsForMovie($film);
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
continue;
}
$this->em->flush();
}
return Command::SUCCESS;
}
}

View File

@@ -1,57 +0,0 @@
<?php
namespace App\Command;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-films')]
readonly class SyncFilmsCommands
{
public function __construct(
private LtbxdGateway $ltbxdGateway,
private FilmImporter $filmImporter,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
try {
$ltbxdMovies = $this->ltbxdGateway->parseFile();
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
return Command::FAILURE;
}
$i = 0;
foreach ($ltbxdMovies as $ltbxdMovie) {
try {
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
if ($movie) {
$output->writeln('* Found '.$ltbxdMovie->getName());
}
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
return Command::FAILURE;
}
++$i;
if (0 === $i % 50) {
$this->em->flush();
}
}
$this->em->flush();
$output->writeln('Films synced');
return Command::SUCCESS;
}
}

View File

@@ -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());

View File

@@ -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),
]); ]);
} }

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Entity\Import; use App\Entity\Import;
use App\Entity\User; use App\Entity\User;
use App\Message\ProcessImportMessage; use App\Message\ProcessImportMessage;
use App\Repository\ImportRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -19,14 +20,44 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class ImportController extends AbstractController class ImportController extends AbstractController
{ {
#[Route('/api/imports/latest', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function latest(ImportRepository $importRepository): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$import = $importRepository->findLatestForUser($user);
if (!$import) {
return $this->json(null);
}
return $this->json([
'id' => $import->getId(),
'status' => $import->getStatus(),
'totalFilms' => $import->getTotalFilms(),
'processedFilms' => $import->getProcessedFilms(),
'failedFilms' => $import->getFailedFilms(),
]);
}
#[Route('/api/imports', methods: ['POST'])] #[Route('/api/imports', methods: ['POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function create( public function create(
Request $request, Request $request,
FilesystemOperator $defaultStorage, FilesystemOperator $defaultStorage,
EntityManagerInterface $em, EntityManagerInterface $em,
ImportRepository $importRepository,
MessageBusInterface $bus, MessageBusInterface $bus,
): JsonResponse { ): JsonResponse {
/** @var User $user */
$user = $this->getUser();
if ($importRepository->hasActiveImport($user)) {
return $this->json(['error' => 'Un import est déjà en cours.'], Response::HTTP_CONFLICT);
}
$file = $request->files->get('file'); $file = $request->files->get('file');
if (!$file) { if (!$file) {
@@ -41,9 +72,6 @@ class ImportController extends AbstractController
return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY); return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
/** @var User $user */
$user = $this->getUser();
$import = new Import(); $import = new Import();
$import->setUser($user); $import->setUser($user);
$import->setFilePath('pending'); $import->setFilePath('pending');

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationController extends AbstractController
{
#[Route('/api/notifications', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function index(NotificationRepository $notificationRepository): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$notifications = $notificationRepository->findRecentForUser($user);
$unreadCount = $notificationRepository->countUnreadForUser($user);
return $this->json([
'unreadCount' => $unreadCount,
'notifications' => array_map(fn ($n) => [
'id' => $n->getId(),
'message' => $n->getMessage(),
'read' => $n->isRead(),
'createdAt' => $n->getCreatedAt()->format('c'),
], $notifications),
]);
}
#[Route('/api/notifications/read', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function markRead(NotificationRepository $notificationRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$notificationRepository->markAllReadForUser($user);
return new Response('', Response::HTTP_NO_CONTENT);
}
}

View File

@@ -30,9 +30,17 @@ class Actor
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $tmdbId = null; private ?int $tmdbId = null;
#[ORM\Column(options: ['default' => false])]
private bool $awardsImported = false;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')]
private Collection $awards;
public function __construct() public function __construct()
{ {
$this->movieRoles = new ArrayCollection(); $this->movieRoles = new ArrayCollection();
$this->awards = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -105,4 +113,22 @@ class Actor
return $this; return $this;
} }
public function isAwardsImported(): bool
{
return $this->awardsImported;
}
public function setAwardsImported(bool $awardsImported): static
{
$this->awardsImported = $awardsImported;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
} }

84
src/Entity/Award.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardRepository::class)]
class Award
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private AwardType $awardType;
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private Actor $actor;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(nullable: true)]
private ?int $year = null;
public function getId(): ?int
{
return $this->id;
}
public function getAwardType(): AwardType
{
return $this->awardType;
}
public function setAwardType(AwardType $awardType): static
{
$this->awardType = $awardType;
return $this;
}
public function getActor(): Actor
{
return $this->actor;
}
public function setActor(Actor $actor): static
{
$this->actor = $actor;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getYear(): ?int
{
return $this->year;
}
public function setYear(?int $year): static
{
$this->year = $year;
return $this;
}
}

69
src/Entity/AwardType.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardTypeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
class AwardType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255)]
private string $pattern;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'awardType')]
private Collection $awards;
public function __construct()
{
$this->awards = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPattern(): string
{
return $this->pattern;
}
public function setPattern(string $pattern): static
{
$this->pattern = $pattern;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
}

View File

@@ -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;
} }

View File

@@ -24,6 +24,9 @@ class Movie
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
private ?string $title = null; private ?string $title = null;
#[ORM\Column(nullable: true)]
private ?int $year = null;
/** /**
* @var Collection<int, MovieRole> * @var Collection<int, MovieRole>
*/ */
@@ -76,6 +79,18 @@ class Movie
return $this; return $this;
} }
public function getYear(): ?int
{
return $this->year;
}
public function setYear(?int $year): static
{
$this->year = $year;
return $this;
}
/** /**
* @return Collection<int, MovieRole> * @return Collection<int, MovieRole>
*/ */

View File

@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NotificationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $message = null;
#[ORM\Column(name: 'is_read')]
private bool $read = false;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function isRead(): bool
{
return $this->read;
}
public function setRead(bool $read): static
{
$this->read = $read;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Gateway;
use App\Entity\Actor;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class WikidataGateway
{
private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
public function __construct(
private readonly HttpClientInterface $httpClient,
) {}
/**
* Fetch awards for an actor from Wikidata.
*
* @return list<array{name: string, year: int}>
*/
public function getAwards(Actor $actor): array
{
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, [
'query' => [
'query' => $sparql,
'format' => 'json',
],
'headers' => [
'Accept' => 'application/sparql-results+json',
'User-Agent' => 'LtbxdActorle/1.0',
],
'timeout' => 10,
]);
$data = $response->toArray();
$awards = [];
foreach ($data['results']['bindings'] ?? [] as $binding) {
$actorName = $binding['name']['value'] ?? null;
$awardName = $binding['awardLabel']['value'] ?? null;
$year = $binding['year']['value'] ?? null;
if ($actorName && $awardName && $year) {
$awards[$actorName][] = [
'name' => $awardName,
'year' => (int) substr($year, 0, 4),
];
}
}
return $awards;
}
/**
* @param list<Actor> $actors
*/
private function buildBatchQuery(array $actors): string
{
$values = implode(' ', array_map(function (Actor $actor) {
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actor->getName());
return '"'.$escaped.'"@en';
}, $actors));
return <<<SPARQL
SELECT ?name ?awardLabel ?year WHERE {
VALUES ?name { {$values} }
?person rdfs:label ?name .
?person wdt:P31 wd:Q5 .
?person p:P166 ?awardStatement .
?awardStatement ps:P166 ?award .
?awardStatement pq:P585 ?date .
BIND(YEAR(?date) AS ?year)
# Only keep entertainment awards (film, TV, music, theater, performing arts)
VALUES ?awardSuperclass {
wd:Q4220920 # film award
wd:Q1407443 # television award
wd:Q2235858 # music award
wd:Q15056993 # film festival award
wd:Q15383322 # theater award
wd:Q29461289 # performing arts award
}
?award wdt:P31/wdt:P279* ?awardSuperclass .
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
}
ORDER BY ?name DESC(?year)
SPARQL;
}
}

View File

@@ -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;

View 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;
}
}

View File

@@ -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;
@@ -38,7 +38,8 @@ readonly class FilmImporter
$movie = new Movie() $movie = new Movie()
->setLtbxdRef($ltbxdMovie->getLtbxdRef()) ->setLtbxdRef($ltbxdMovie->getLtbxdRef())
->setTitle($ltbxdMovie->getName()) ->setTitle($ltbxdMovie->getName())
->setTmdbId($tmdbMovie->getId()); ->setTmdbId($tmdbMovie->getId())
->setYear($ltbxdMovie->getYear());
$this->em->persist($movie); $this->em->persist($movie);

View File

@@ -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,
) {} ) {}
} }

View File

@@ -5,16 +5,14 @@ declare(strict_types=1);
namespace App\MessageHandler; namespace App\MessageHandler;
use App\Entity\Import; use App\Entity\Import;
use App\Entity\Notification;
use App\Entity\UserMovie; use App\Entity\UserMovie;
use App\Exception\GatewayException;
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\FilmImporter; use App\Import\AwardImporter;
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;
@@ -23,11 +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 ImportRepository $importRepository, private ImportRepository $importRepository,
private AwardImporter $awardImporter,
private LoggerInterface $logger, private LoggerInterface $logger,
) {} ) {}
@@ -40,71 +37,62 @@ 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,
} );
$userId = $import->getUser()->getId();
$batch = array_slice($ltbxdMovies, $message->offset, $message->limit); $importId = $import->getId();
$user = $import->getUser();
foreach ($batch as $ltbxdMovie) { foreach ($batch as $ltbxdMovie) {
try { try {
$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);
$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(),
'importId' => $import->getId(), 'importId' => $importId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
$this->importRepository->incrementFailedFilms($import); $this->importRepository->incrementFailedFilms($import);
} }
}
$processedBatches = $this->importRepository->incrementProcessedBatches($import); $processedFilms = $this->importRepository->incrementProcessedFilms($import);
if ($processedBatches >= $import->getTotalBatches()) { $this->em->clear();
// Refresh the entity to get updated failedFilms from DB $import = $this->em->getRepository(Import::class)->find($importId);
$this->em->refresh($import);
$import->setStatus(Import::STATUS_COMPLETED); if ($processedFilms >= $import->getTotalFilms()) {
$import->setCompletedAt(new \DateTimeImmutable()); $import->setStatus(Import::STATUS_COMPLETED);
$this->em->flush(); $import->setCompletedAt(new \DateTimeImmutable());
$this->em->flush();
$imported = $import->getTotalFilms() - $import->getFailedFilms(); }
$notification = new Notification();
$notification->setUser($user);
$notification->setMessage(sprintf(
'Import terminé : %d/%d films importés.',
$imported,
$import->getTotalFilms()
));
$this->em->persist($notification);
$this->em->flush();
} }
} }
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\MessageHandler; namespace App\MessageHandler;
use App\Entity\Import; use App\Entity\Import;
use App\Entity\Notification;
use App\Gateway\LtbxdGateway; use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage; use App\Message\ImportFilmsBatchMessage;
use App\Message\ProcessImportMessage; use App\Message\ProcessImportMessage;
@@ -50,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) {
@@ -72,12 +76,6 @@ readonly class ProcessImportMessageHandler
$import->setStatus(Import::STATUS_FAILED); $import->setStatus(Import::STATUS_FAILED);
$this->em->flush(); $this->em->flush();
$notification = new Notification();
$notification->setUser($import->getUser());
$notification->setMessage('L\'import a échoué.');
$this->em->persist($notification);
$this->em->flush();
} }
} }
} }

View File

@@ -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;
@@ -11,21 +11,53 @@ use App\Entity\User;
use App\Repository\ActorRepository; use App\Repository\ActorRepository;
use App\Repository\MovieRepository; use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository; use App\Repository\MovieRoleRepository;
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,
private readonly MovieRoleRepository $movieRoleRepository, private readonly MovieRoleRepository $movieRoleRepository,
private readonly MovieRepository $movieRepository, private readonly MovieRepository $movieRepository,
private readonly WikidataAwardGateway $wikidataAwardGateway, private readonly AwardRepository $awardRepository,
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);
@@ -39,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();
@@ -55,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;
@@ -132,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;
} }
@@ -152,36 +194,37 @@ 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':
try { $award = $this->awardRepository->findOneRandomByActorAndTypes($actorId, $awardTypeIds);
$awards = $this->wikidataAwardGateway->getAwards($rowActor); if ($award === null) {
} catch (\Throwable) {
return null; return null;
} }
if (!empty($awards)) { return ['type' => 'award', 'data' => (string) $award->getId()];
$award = $awards[array_rand($awards)];
return ['type' => 'award', 'data' => $award['name'] . ' (' . $award['year'] . ')'];
}
return null;
} }
return null; return null;
@@ -197,10 +240,40 @@ 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' => $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
{
$award = $this->awardRepository->find($awardId);
if ($award === null) {
return null;
}
$text = $award->getName();
if ($award->getYear() !== null) {
$text .= ' (' . $award->getYear() . ')';
}
return $text;
}
} }

View File

@@ -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();
}
} }

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Award;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Award> */
class AwardRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Award::class);
}
public function findOneRandomByActor(int $actorId): ?Award
{
return $this->createQueryBuilder('a')
->andWhere('a.actor = :actorId')
->setParameter('actorId', $actorId)
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* @param list<int>|null $awardTypeIds null means all types
*/
public function findOneRandomByActorAndTypes(int $actorId, ?array $awardTypeIds): ?Award
{
$qb = $this->createQueryBuilder('a')
->andWhere('a.actor = :actorId')
->setParameter('actorId', $actorId);
if ($awardTypeIds !== null && empty($awardTypeIds)) {
return null;
}
if ($awardTypeIds !== null) {
$qb->andWhere('a.awardType IN (:typeIds)')
->setParameter('typeIds', $awardTypeIds);
}
return $qb
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AwardType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<AwardType> */
class AwardTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AwardType::class);
}
/** @return list<AwardType> */
public function findAll(): array
{
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();
}
}

View File

@@ -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()]
); );
} }
@@ -33,4 +33,27 @@ class ImportRepository extends ServiceEntityRepository
['id' => $import->getId()] ['id' => $import->getId()]
); );
} }
public function findLatestForUser(\App\Entity\User $user): ?Import
{
return $this->createQueryBuilder('i')
->andWhere('i.user = :user')
->setParameter('user', $user)
->orderBy('i.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function hasActiveImport(\App\Entity\User $user): bool
{
return (int) $this->createQueryBuilder('i')
->select('COUNT(i.id)')
->andWhere('i.user = :user')
->andWhere('i.status IN (:statuses)')
->setParameter('user', $user)
->setParameter('statuses', [Import::STATUS_PENDING, Import::STATUS_PROCESSING])
->getQuery()
->getSingleScalarResult() > 0;
}
} }

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
/**
* @return Notification[]
*/
public function findRecentForUser(User $user, int $limit = 20): array
{
return $this->createQueryBuilder('n')
->andWhere('n.user = :user')
->setParameter('user', $user)
->orderBy('n.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countUnreadForUser(User $user): int
{
return $this->count(['user' => $user, 'read' => false]);
}
public function markAllReadForUser(User $user): void
{
$this->createQueryBuilder('n')
->update()
->set('n.read', ':true')
->where('n.user = :user')
->andWhere('n.read = :false')
->setParameter('true', true)
->setParameter('false', false)
->setParameter('user', $user)
->getQuery()
->execute();
}
}

View File

@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class WikidataAwardGateway
{
private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
public function __construct(
private readonly HttpClientInterface $httpClient,
) {}
/**
* Fetch awards for an actor from Wikidata.
*
* @return list<array{name: string, year: int}>
*/
public function getAwards(Actor $actor): array
{
$sparql = $this->buildQuery($actor->getName());
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
'query' => [
'query' => $sparql,
'format' => 'json',
],
'headers' => [
'Accept' => 'application/sparql-results+json',
'User-Agent' => 'LtbxdActorle/1.0',
],
'timeout' => 5,
]);
$data = $response->toArray();
$awards = [];
foreach ($data['results']['bindings'] ?? [] as $binding) {
$name = $binding['awardLabel']['value'] ?? null;
$year = $binding['year']['value'] ?? null;
if ($name && $year) {
$awards[] = [
'name' => $name,
'year' => (int) substr($year, 0, 4),
];
}
}
return $awards;
}
private function buildQuery(string $actorName): string
{
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actorName);
return <<<SPARQL
SELECT ?awardLabel ?year WHERE {
?person rdfs:label "{$escaped}"@en .
?person wdt:P31 wd:Q5 .
?person p:P166 ?awardStatement .
?awardStatement ps:P166 ?award .
?awardStatement pq:P585 ?date .
BIND(YEAR(?date) AS ?year)
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
}
ORDER BY DESC(?year)
SPARQL;
}
}

View File

@@ -1,8 +1,8 @@
{% if app.user %} {% if app.user %}
<div data-controller="import-modal"> <div data-controller="import-modal import-help">
<nav class="navbar" data-controller="notifications"> <nav class="navbar">
<div class="navbar-left"> <div class="navbar-left">
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a> <a href="{{ path('app_homepage') }}" class="navbar-brand"><span class="brand-prefix">Ltbxd</span>Actorle</a>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
{# Gitea repo #} {# Gitea repo #}
@@ -12,25 +12,8 @@
</svg> </svg>
</a> </a>
{# Notifications #}
<div class="navbar-item" data-controller="dropdown">
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="badge" data-notifications-target="badge" hidden></span>
</button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
<div class="dropdown-header">Notifications</div>
<div data-notifications-target="list" class="notifications-list">
<p class="dropdown-empty">Aucune notification</p>
</div>
</div>
</div>
{# User menu #} {# User menu #}
<div class="navbar-item" data-controller="dropdown"> <div class="navbar-item" data-controller="dropdown import-status">
<button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger"> <button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
@@ -38,7 +21,16 @@
</svg> </svg>
</button> </button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden> <div class="dropdown-menu" data-dropdown-target="menu" hidden>
<button class="dropdown-item" data-action="click->import-modal#open">Importer ses films</button> <div class="import-status-item" data-import-status-target="item">
<div class="dropdown-item-row">
<button class="dropdown-item" data-action="click->import-modal#open" data-import-status-target="importBtn">
Importer ses films
</button>
<button class="info-btn" data-action="click->import-help#open" title="Comment exporter depuis Letterboxd">
i
</button>
</div>
</div>
<a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a> <a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a>
</div> </div>
</div> </div>
@@ -64,11 +56,30 @@
</div> </div>
</div> </div>
</div> </div>
{# Help Modal #}
<div class="modal-overlay" data-import-help-target="overlay" data-action="click->import-help#closeOnBackdrop" hidden>
<div class="modal">
<div class="modal-header">
<h2>Comment exporter ses films</h2>
<button class="modal-close" data-action="click->import-help#close">&times;</button>
</div>
<div class="modal-body">
<ol class="help-steps">
<li>Connectez-vous à votre compte <a href="https://letterboxd.com" target="_blank" rel="noopener noreferrer">Letterboxd</a></li>
<li>Allez dans <strong>Settings</strong> (Paramètres)</li>
<li>Cliquez sur l'onglet <strong>Import &amp; Export</strong></li>
<li>Cliquez sur <strong>Export Your Data</strong></li>
<li>Un fichier ZIP sera téléchargé. Décompressez-le.</li>
<li>Utilisez le fichier <code>watched.csv</code> pour l'import.</li>
</ol>
</div>
</div>
</div>
</div> </div>
{% else %} {% else %}
<nav class="navbar"> <nav class="navbar">
<div class="navbar-left"> <div class="navbar-left">
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a> <a href="{{ path('app_homepage') }}" class="navbar-brand"><span class="brand-prefix">Ltbxd</span>Actorle</a>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source"> <a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">

View File

@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Actorle{% endblock %}</title> <title>{% block title %}LtbxdActorle{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -17,6 +17,8 @@
</head> </head>
<body> <body>
{% include '_navbar.html.twig' %} {% include '_navbar.html.twig' %}
{% block body %}{% endblock %} <div class="page-body">
{% block body %}{% endblock %}
</div>
</body> </body>
</html> </html>

View File

@@ -4,10 +4,37 @@
{% if game %} {% if game %}
<div class="game-container"> <div class="game-container">
<div class="game-actions"> <div class="game-actions">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}"> <div class="abandon-wrapper">
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}"> <button type="button" class="btn btn-abandon" id="abandon-trigger">Abandonner</button>
<button type="submit" class="btn btn-abandon">Abandonner</button> <div class="abandon-popover" id="abandon-popover">
</form> <p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
<div class="abandon-popover-actions">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
<button type="submit" class="btn btn-abandon-confirm">Abandonner</button>
</form>
<button type="button" class="btn btn-abandon-cancel" id="abandon-cancel">Non, continuer</button>
</div>
</div>
</div>
<script>
(function() {
var trigger = document.getElementById('abandon-trigger');
var popover = document.getElementById('abandon-popover');
var cancel = document.getElementById('abandon-cancel');
trigger.addEventListener('click', function() {
popover.classList.toggle('open');
});
cancel.addEventListener('click', function() {
popover.classList.remove('open');
});
document.addEventListener('click', function(e) {
if (!popover.contains(e.target) && e.target !== trigger) {
popover.classList.remove('open');
}
});
})();
</script>
</div> </div>
<div {{ react_component('GameGrid', { <div {{ react_component('GameGrid', {
@@ -17,7 +44,7 @@
}) }}></div> }) }}></div>
<div class="game-footer"> <div class="game-footer">
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle/issues/new?template=bug_report.yaml&title={{ ('Bug — Game #' ~ game.id)|url_encode }}" class="btn-bug-report" target="_blank" rel="noopener"> <a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle/issues/new?template=.gitea%2fissue_template%2fbug_report.yaml&title={{ ('Bug — Game #' ~ game.id)|url_encode }}" class="btn-bug-report" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/> <circle cx="8" cy="8" r="7"/>
<line x1="8" y1="5" x2="8" y2="9"/> <line x1="8" y1="5" x2="8" y2="9"/>
@@ -29,10 +56,85 @@
</div> </div>
{% else %} {% else %}
<div class="game-start-container"> <div class="game-start-container">
<form method="post" action="{{ path('app_game_start') }}"> <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>
{% if not app.user %}
<p class="start-login-hint">
<a href="{{ path('app_login') }}">Connectez-vous</a> pour importer vos propres films
</p>
{% endif %}
{% for message in app.flashes('error') %}
<div class="flash-error">{{ message }}</div>
{% endfor %}
<script>
document.getElementById('start-form').addEventListener('submit', function () {
this.style.display = 'none';
document.getElementById('start-loader').style.display = 'block';
});
</script>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Connexion — Actorle{% endblock %} {% block title %}Connexion — LtbxdActorle{% endblock %}
{% block body %} {% block body %}
<div class="auth-container"> <div class="auth-container">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Inscription — Actorle{% endblock %} {% block title %}Inscription — LtbxdActorle{% endblock %}
{% block body %} {% block body %}
<div class="auth-container"> <div class="auth-container">

View 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;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Provider;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Entity\GameRow;
use App\Repository\ActorRepository;
use App\Repository\AwardRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use App\Provider\GameGridProvider;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class GameGridProviderTest extends TestCase
{
public function testResolveHintTextForAward(): void
{
$awardType = new AwardType();
$awardType->setName('Oscar')->setPattern('Academy Award');
$actor = new Actor();
$actor->setName('Test Actor');
$award = new Award();
$award->setName('Academy Award for Best Actor');
$award->setYear(2020);
$award->setActor($actor);
$award->setAwardType($awardType);
$awardRepository = $this->createMock(AwardRepository::class);
$awardRepository->method('find')->with(42)->willReturn($award);
$generator = new GameGridProvider(
$this->createMock(ActorRepository::class),
$this->createMock(MovieRoleRepository::class),
$this->createMock(MovieRepository::class),
$awardRepository,
$this->createMock(EntityManagerInterface::class),
);
$row = new GameRow();
$row->setHintType('award');
$row->setHintData('42');
// Use reflection to test the private resolveHintText method
$method = new \ReflectionMethod($generator, 'resolveHintText');
$result = $method->invoke($generator, $row);
$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);
}
}

View 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());
}
}
}

View 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);
}
}

View 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());
}
}