Compare commits
10 Commits
116812b3f8
...
353ffddeea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
353ffddeea | ||
|
|
fb13a8819d | ||
|
|
0fd0b85b8f | ||
|
|
8aa33ccefc | ||
|
|
d4d2272396 | ||
|
|
6c1e4cb38b | ||
|
|
859a5a1067 | ||
|
|
acc266739d | ||
|
|
76013afb1c | ||
|
|
d2d211a228 |
@@ -631,6 +631,78 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
881
docs/superpowers/plans/2026-04-01-awards-bdd.md
Normal file
881
docs/superpowers/plans/2026-04-01-awards-bdd.md
Normal 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.
|
||||
93
docs/superpowers/specs/2026-04-01-awards-bdd-design.md
Normal file
93
docs/superpowers/specs/2026-04-01-awards-bdd-design.md
Normal 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
|
||||
32
migrations/Version20260401000001.php
Normal file
32
migrations/Version20260401000001.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,17 @@ class Actor
|
||||
#[ORM\Column(nullable: true)]
|
||||
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()
|
||||
{
|
||||
$this->movieRoles = new ArrayCollection();
|
||||
$this->awards = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -105,4 +113,22 @@ class Actor
|
||||
|
||||
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
84
src/Entity/Award.php
Normal 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
69
src/Entity/AwardType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Gateway\LtbxdGateway;
|
||||
use App\Message\ImportFilmsBatchMessage;
|
||||
use App\Repository\ImportRepository;
|
||||
use App\Service\ActorSyncer;
|
||||
use App\Service\AwardImporter;
|
||||
use App\Service\FilmImporter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
@@ -25,6 +26,7 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
private LtbxdGateway $ltbxdGateway,
|
||||
private FilmImporter $filmImporter,
|
||||
private ActorSyncer $actorSyncer,
|
||||
private AwardImporter $awardImporter,
|
||||
private ImportRepository $importRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
@@ -62,6 +64,11 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
|
||||
$this->actorSyncer->syncActorsForMovie($movie);
|
||||
|
||||
// Import awards for actors of this movie
|
||||
foreach ($movie->getActors() as $role) {
|
||||
$this->awardImporter->importForActor($role->getActor());
|
||||
}
|
||||
|
||||
$user = $this->em->getReference(\App\Entity\User::class, $userId);
|
||||
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
|
||||
'user' => $user,
|
||||
|
||||
29
src/Repository/AwardRepository.php
Normal file
29
src/Repository/AwardRepository.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
24
src/Repository/AwardTypeRepository.php
Normal file
24
src/Repository/AwardTypeRepository.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
89
src/Service/AwardImporter.php
Normal file
89
src/Service/AwardImporter.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use App\Entity\User;
|
||||
use App\Repository\ActorRepository;
|
||||
use App\Repository\MovieRepository;
|
||||
use App\Repository\MovieRoleRepository;
|
||||
use App\Gateway\WikidataGateway;
|
||||
use App\Repository\AwardRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class GameGridGenerator
|
||||
@@ -20,7 +20,7 @@ class GameGridGenerator
|
||||
private readonly ActorRepository $actorRepository,
|
||||
private readonly MovieRoleRepository $movieRoleRepository,
|
||||
private readonly MovieRepository $movieRepository,
|
||||
private readonly WikidataGateway $wikidataGateway,
|
||||
private readonly AwardRepository $awardRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -173,16 +173,11 @@ class GameGridGenerator
|
||||
return ['type' => 'character', 'data' => (string) $role->getId()];
|
||||
|
||||
case 'award':
|
||||
try {
|
||||
$awards = $this->wikidataGateway->getAwards($rowActor);
|
||||
} catch (\Throwable) {
|
||||
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
|
||||
if ($award === null) {
|
||||
return null;
|
||||
}
|
||||
if (!empty($awards)) {
|
||||
$award = $awards[array_rand($awards)];
|
||||
return ['type' => 'award', 'data' => $award['name'] . ' (' . $award['year'] . ')'];
|
||||
}
|
||||
return null;
|
||||
return ['type' => 'award', 'data' => (string) $award->getId()];
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -200,8 +195,23 @@ class GameGridGenerator
|
||||
return match ($type) {
|
||||
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
|
||||
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
|
||||
'award' => $data,
|
||||
'award' => $this->resolveAwardHintText((int) $data),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,37 @@
|
||||
{% if game %}
|
||||
<div class="game-container">
|
||||
<div class="game-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">Abandonner</button>
|
||||
</form>
|
||||
<div class="abandon-wrapper">
|
||||
<button type="button" class="btn btn-abandon" id="abandon-trigger">Abandonner</button>
|
||||
<div class="abandon-popover" id="abandon-popover">
|
||||
<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 {{ react_component('GameGrid', {
|
||||
|
||||
139
tests/Service/AwardImporterTest.php
Normal file
139
tests/Service/AwardImporterTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
56
tests/Service/GameGridGeneratorTest.php
Normal file
56
tests/Service/GameGridGeneratorTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user