diff --git a/src/Domain/Round/IFSCRoundCategory.php b/src/Domain/Athlete/IFSCAthleteGender.php similarity index 74% rename from src/Domain/Round/IFSCRoundCategory.php rename to src/Domain/Athlete/IFSCAthleteGender.php index 526fecc..51cedec 100644 --- a/src/Domain/Round/IFSCRoundCategory.php +++ b/src/Domain/Athlete/IFSCAthleteGender.php @@ -5,9 +5,9 @@ * @link https://github.com/nicoSWD * @author Nicolas Oelgart */ -namespace SportClimbing\IfscCalendar\Domain\Round; +namespace SportClimbing\IfscCalendar\Domain\Athlete; -enum IFSCRoundCategory: string +enum IFSCAthleteGender: string { case MEN = 'men'; case WOMEN = 'women'; diff --git a/src/Domain/Round/IFSCRound.php b/src/Domain/Round/IFSCRound.php index 1c1ddd6..679acf4 100644 --- a/src/Domain/Round/IFSCRound.php +++ b/src/Domain/Round/IFSCRound.php @@ -8,12 +8,13 @@ namespace SportClimbing\IfscCalendar\Domain\Round; use DateTimeImmutable; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDisciplines; use SportClimbing\IfscCalendar\Domain\Stream\LiveStream; final readonly class IFSCRound { - /** @param IFSCRoundCategory[] $categories */ + /** @param IFSCAthleteGender[] $categories */ public function __construct( public string $name, public array $categories, diff --git a/src/Domain/Round/IFSCSameStreamRoundsMerger.php b/src/Domain/Round/IFSCSameStreamRoundsMerger.php index 388cb66..734c249 100644 --- a/src/Domain/Round/IFSCSameStreamRoundsMerger.php +++ b/src/Domain/Round/IFSCSameStreamRoundsMerger.php @@ -8,6 +8,7 @@ namespace SportClimbing\IfscCalendar\Domain\Round; use DateTimeImmutable; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\Tags\IFSCTagsParser; final readonly class IFSCSameStreamRoundsMerger @@ -107,7 +108,7 @@ private function buildMergedName(array $rounds): string /** * @param IFSCRound[] $rounds - * @return IFSCRoundCategory[] + * @return IFSCAthleteGender[] */ private function mergedCategories(array $rounds): array { @@ -121,7 +122,7 @@ private function mergedCategories(array $rounds): array } } - usort($categories, static fn (IFSCRoundCategory $a, IFSCRoundCategory $b) => $a->value <=> $b->value); + usort($categories, static fn (IFSCAthleteGender $a, IFSCAthleteGender $b) => $a->value <=> $b->value); return $categories; } diff --git a/src/Domain/StartList/IFSCStartListGenerator.php b/src/Domain/StartList/IFSCStartListGenerator.php index 15986ac..dfd1f2d 100644 --- a/src/Domain/StartList/IFSCStartListGenerator.php +++ b/src/Domain/StartList/IFSCStartListGenerator.php @@ -8,15 +8,16 @@ namespace SportClimbing\IfscCalendar\Domain\StartList; use Closure; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthlete; use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteException; use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteService; use SportClimbing\IfscCalendar\Domain\Ranking\IFSCAthleteRankingCalculator; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; final readonly class IFSCStartListGenerator { - private const int LIST_MAX_SIZE = 40; + private const int PER_GENDER_MAX = 20; public function __construct( private IFSCStartListProviderInterface $startListProvider, @@ -37,13 +38,8 @@ public function buildStartList(int $eventId): IFSCStartListResult $athlete = $this->athleteService->fetchAthlete($starter->athleteId); $starter->score = $this->rankingCalculator->calculateScore($athlete); $starter->photoUrl = $athlete->photoUrl; - $starter->instagram = $athlete->instagram; - - $starter->category = match ($athlete->gender) { - 'male' => IFSCRoundCategory::MEN, - 'female' => IFSCRoundCategory::WOMEN, - default => null, - }; + $starter->instagram = $this->normalizeInstagram($athlete->instagram); + $starter->gender = $this->getGender($athlete); $startList[] = $starter; } @@ -51,11 +47,46 @@ public function buildStartList(int $eventId): IFSCStartListResult usort($startList, $this->sortByScore()); return new IFSCStartListResult( - starters: array_slice($startList, 0, self::LIST_MAX_SIZE), + starters: $this->selectTopByGender($startList), total: count($startList), ); } + /** + * @param IFSCStarter[] $startList + * @return IFSCStarter[] + */ + private function selectTopByGender(array $startList): array + { + $men = $this->filterByGender($startList, IFSCAthleteGender::MEN); + $women = $this->filterByGender($startList, IFSCAthleteGender::WOMEN); + + $selectedMen = $this->selectTopFromPool($men, array_slice($women, self::PER_GENDER_MAX)); + $selectedWomen = $this->selectTopFromPool($women, array_slice($men, self::PER_GENDER_MAX)); + + $result = array_merge($selectedMen, $selectedWomen); + usort($result, $this->sortByScore()); + + return $result; + } + + /** + * @param IFSCStarter[] $pool + * @param IFSCStarter[] $fillPool + * @return IFSCStarter[] + */ + private function selectTopFromPool(array $pool, array $fillPool): array + { + $selected = array_slice($pool, 0, self::PER_GENDER_MAX); + $shortfall = self::PER_GENDER_MAX - count($selected); + + if ($shortfall > 0) { + $selected = array_merge($selected, array_slice($fillPool, 0, $shortfall)); + } + + return $selected; + } + private function sortByScore(): Closure { return static function (IFSCStarter $athlete1, IFSCStarter $athlete2): int { @@ -69,6 +100,39 @@ private function sortByScore(): Closure }; } + /** @return IFSCStarter[] */ + public function filterByGender(array $startList, IFSCAthleteGender $gender): array + { + return array_values(array_filter($startList, fn (IFSCStarter $starter): bool => $starter->gender === $gender)); + } + + private function normalizeInstagram(?string $instagram): ?string + { + if ($instagram === null || $instagram === '') { + return null; + } + + if (str_contains($instagram, 'instagram.com/')) { + preg_match('~instagram\.com/([^/?#]+)~', $instagram, $matches); + return $matches[1] ?? null; + } + + return ltrim($instagram, '@'); + } + + /** + * @param IFSCAthlete $athlete + * @return IFSCAthleteGender|null + */ + private function getGender(IFSCAthlete $athlete): ?IFSCAthleteGender + { + return match ($athlete->gender) { + 'male' => IFSCAthleteGender::MEN, + 'female' => IFSCAthleteGender::WOMEN, + default => null, + }; + } + /** * @return IFSCStarter[] * @throws IFSCStartListException diff --git a/src/Domain/StartList/IFSCStarter.php b/src/Domain/StartList/IFSCStarter.php index da0f20a..3f8b82a 100644 --- a/src/Domain/StartList/IFSCStarter.php +++ b/src/Domain/StartList/IFSCStarter.php @@ -7,17 +7,19 @@ */ namespace SportClimbing\IfscCalendar\Domain\StartList; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; final class IFSCStarter { - public ?IFSCRoundCategory $category = null; - + /** @param IFSCDiscipline[] $disciplines */ public function __construct( public readonly int $athleteId, public readonly string $firstName, public readonly string $lastName, public readonly string $country, + public ?IFSCAthleteGender $gender = null, + public readonly array $disciplines = [], public float $score = 0, public ?string $photoUrl = null, public ?string $instagram = null, diff --git a/src/Domain/Tags/IFSCParsedTags.php b/src/Domain/Tags/IFSCParsedTags.php index 9da9cca..b68c191 100644 --- a/src/Domain/Tags/IFSCParsedTags.php +++ b/src/Domain/Tags/IFSCParsedTags.php @@ -9,7 +9,7 @@ use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; use SportClimbing\IfscCalendar\Domain\Event\IFSCEventTagsRegex as Tag; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundKind; final readonly class IFSCParsedTags @@ -29,8 +29,8 @@ ]; private const array CATEGORIES = [ - IFSCRoundCategory::WOMEN->value => Tag::WOMEN, - IFSCRoundCategory::MEN->value => Tag::MEN, + IFSCAthleteGender::WOMEN->value => Tag::WOMEN, + IFSCAthleteGender::MEN->value => Tag::MEN, ]; /** @param Tag[] $tags */ @@ -75,20 +75,20 @@ public function getRoundKind(): ?IFSCRoundKind return null; } - /** @return IFSCRoundCategory[] */ + /** @return IFSCAthleteGender[] */ public function getCategories(): array { $categories = []; foreach (self::CATEGORIES as $name => $tag) { if ($this->hasTag($tag)) { - $categories[] = IFSCRoundCategory::from($name); + $categories[] = IFSCAthleteGender::from($name); } } if (empty($categories)) { - $categories[] = IFSCRoundCategory::WOMEN; - $categories[] = IFSCRoundCategory::MEN; + $categories[] = IFSCAthleteGender::WOMEN; + $categories[] = IFSCAthleteGender::MEN; } return $categories; diff --git a/src/Infrastructure/Calendar/ICalCalendar.php b/src/Infrastructure/Calendar/ICalCalendar.php index 7ab94a2..4641340 100644 --- a/src/Infrastructure/Calendar/ICalCalendar.php +++ b/src/Infrastructure/Calendar/ICalCalendar.php @@ -23,6 +23,7 @@ use Eluceo\iCal\Domain\ValueObject\Uri; use Exception; use SportClimbing\IfscCalendar\Domain\Calendar\IFSCCalendarGeneratorInterface; +use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; use SportClimbing\IfscCalendar\Domain\Event\IFSCEvent; use SportClimbing\IfscCalendar\Domain\Round\IFSCRound; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStarter; @@ -223,13 +224,42 @@ private function buildDescription(IFSCEvent $event, ?IFSCRound $round = null): s /** @return IFSCStarter[] */ private function getFilteredStartList(IFSCEvent $event, ?IFSCRound $round): array { - if (!$round || empty($round->categories)) { + if (!$round) { + return $event->startList; + } + + if (empty($round->categories) && empty($round->disciplines->all())) { return $event->startList; } return array_filter( $event->startList, - fn (IFSCStarter $athlete): bool => $athlete->category === null || in_array($athlete->category, $round->categories, strict: true) + fn (IFSCStarter $athlete): bool => + $this->matchesRoundCategory($athlete, $round) && + $this->matchesRoundDiscipline($athlete, $round) + ); + } + + private function matchesRoundCategory(IFSCStarter $athlete, IFSCRound $round): bool + { + if (empty($round->categories)) { + return true; + } + + return $athlete->gender === null || in_array($athlete->gender, $round->categories, strict: true); + } + + private function matchesRoundDiscipline(IFSCStarter $athlete, IFSCRound $round): bool + { + $roundDisciplines = $round->disciplines->all(); + + if (empty($roundDisciplines)) { + return true; + } + + return array_any( + $athlete->disciplines, + static fn (IFSCDiscipline $discipline): bool => in_array($discipline, $roundDisciplines, strict: true), ); } diff --git a/src/Infrastructure/Calendar/JsonCalendar.php b/src/Infrastructure/Calendar/JsonCalendar.php index 192650e..c5c0a08 100644 --- a/src/Infrastructure/Calendar/JsonCalendar.php +++ b/src/Infrastructure/Calendar/JsonCalendar.php @@ -14,7 +14,7 @@ use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; use SportClimbing\IfscCalendar\Domain\Event\IFSCEvent; use SportClimbing\IfscCalendar\Domain\Round\IFSCRound; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStarter; use Override; @@ -99,9 +99,11 @@ private function formatStarters(array $starters): array 'athlete_id' => $starter->athleteId, 'first_name' => $starter->firstName, 'last_name' => $starter->lastName, + 'gender' => $starter->gender, 'country' => $starter->country, 'photo_url' => $starter->photoUrl, - 'instagram' => $this->normalizeInstagram($starter->instagram), + 'instagram' => $starter->instagram, + 'disciplines' => $this->buildStarterDisciplines($starter), ]; return array_map($format, $starters); @@ -137,7 +139,7 @@ private function buildDisciplines(IFSCRound $round): array /** @return string[] */ private function buildCategories(IFSCRound $round): array { - return array_map(static fn (IFSCRoundCategory $category): string => $category->value, $round->categories); + return array_map(static fn (IFSCAthleteGender $category): string => $category->value, $round->categories); } private function countryName(string $countryCode): string @@ -157,22 +159,14 @@ private function countryName(string $countryCode): string return \Locale::getDisplayRegion("und-{$isoCode}", 'en'); } - private function normalizeInstagram(?string $instagram): ?string + private function formatDate(DateTimeInterface $dateTime): string { - if ($instagram === null || $instagram === '') { - return null; - } - - if (str_contains($instagram, 'instagram.com/')) { - preg_match('~instagram\.com/([^/?#]+)~', $instagram, $matches); - return $matches[1] ?? null; - } - - return ltrim($instagram, '@'); + return $dateTime->format(DateTimeInterface::RFC3339); } - private function formatDate(DateTimeInterface $dateTime): string + /** @return string[] */ + private function buildStarterDisciplines(IFSCStarter $starter): array { - return $dateTime->format(DateTimeInterface::RFC3339); + return array_map(static fn (IFSCDiscipline $discipline): string => $discipline->value, $starter->disciplines); } } diff --git a/src/Infrastructure/StartList/ApiStartListProvider.php b/src/Infrastructure/StartList/ApiStartListProvider.php index 63ffc08..3cdb231 100644 --- a/src/Infrastructure/StartList/ApiStartListProvider.php +++ b/src/Infrastructure/StartList/ApiStartListProvider.php @@ -7,6 +7,8 @@ */ namespace SportClimbing\IfscCalendar\Infrastructure\StartList; +use Closure; +use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStarter; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListException; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListProviderInterface; @@ -81,6 +83,7 @@ private function createStarterFromAthletePayload(object $athlete): ?IFSCStarter firstName: $firstName, lastName: $this->normalizeLastName($lastName), country: $country, + disciplines: $this->extractDisciplines($athlete), ); } @@ -117,12 +120,44 @@ private function createStartersFromSquadPayload(object $athlete): array firstName: $firstName, lastName: $this->normalizeLastName($lastName), country: $country, + disciplines: $this->extractDisciplines($athlete), ); } return $starters; } + /** @return IFSCDiscipline[] */ + private function extractDisciplines(object $athlete): array + { + $disciplines = []; + + foreach ($athlete->d_cats as $dCat) { + $status = isset($dCat->status) && is_string($dCat->status) + ? IFSCStartListStatus::tryFrom($dCat->status) + : null; + + if ($status === IFSCStartListStatus::NOT_ATTENDING_STATUS) { + continue; + } + + $discipline = $this->parseDisciplineFromName($dCat->name); + + if ($discipline !== null) { + $disciplines[] = $discipline; + } + } + + return array_unique($disciplines, SORT_REGULAR); + } + + private function parseDisciplineFromName(string $name): ?IFSCDiscipline + { + $firstWord = array_first(explode(' ', $name)); + + return IFSCDiscipline::tryFrom(strtolower($firstWord)); + } + private function athleteShouldBeIncluded(object $athlete): bool { if (!isset($athlete->d_cats) || !is_array($athlete->d_cats)) { diff --git a/tests/unit/Domain/Round/IFSCSameStreamRoundsMergerTest.php b/tests/unit/Domain/Round/IFSCSameStreamRoundsMergerTest.php index a864f9b..d02f474 100644 --- a/tests/unit/Domain/Round/IFSCSameStreamRoundsMergerTest.php +++ b/tests/unit/Domain/Round/IFSCSameStreamRoundsMergerTest.php @@ -11,7 +11,7 @@ use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDisciplines; use SportClimbing\IfscCalendar\Domain\Round\IFSCRound; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundKind; use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundNameNormalizer; use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundStatus; @@ -37,14 +37,14 @@ protected function setUp(): void { $stream = new LiveStream(url: 'https://youtu.be/abc123'); - $mens = $this->makeRound("Men's Lead Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 14:00', '2025-05-10 15:30'); - $womens = $this->makeRound("Women's Lead Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:30', '2025-05-10 17:00'); + $mens = $this->makeRound("Men's Lead Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 14:00', '2025-05-10 15:30'); + $womens = $this->makeRound("Women's Lead Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:30', '2025-05-10 17:00'); $result = $this->merger->merge([$mens, $womens]); $this->assertCount(1, $result); $this->assertSame("Men's & Women's Lead Final", $result[0]->name); - $this->assertSame([IFSCRoundCategory::MEN, IFSCRoundCategory::WOMEN], $result[0]->categories); + $this->assertSame([IFSCAthleteGender::MEN, IFSCAthleteGender::WOMEN], $result[0]->categories); $this->assertSame($mens->startTime, $result[0]->startTime); $this->assertSame('2025-05-10T17:00:00+00:00', $result[0]->endTime->format(DATE_RFC3339)); $this->assertSame('https://youtu.be/abc123', $result[0]->liveStream->url); @@ -54,14 +54,14 @@ protected function setUp(): void { $stream = new LiveStream(url: 'https://youtu.be/abc123'); - $womens = $this->makeRound("Women's Boulder Qualification", [IFSCRoundCategory::WOMEN], IFSCRoundKind::QUALIFICATION, $stream, '2025-05-10 09:00', '2025-05-10 12:00'); - $mens = $this->makeRound("Men's Boulder Qualification", [IFSCRoundCategory::MEN], IFSCRoundKind::QUALIFICATION, $stream, '2025-05-10 12:00', '2025-05-10 15:00'); + $womens = $this->makeRound("Women's Boulder Qualification", [IFSCAthleteGender::WOMEN], IFSCRoundKind::QUALIFICATION, $stream, '2025-05-10 09:00', '2025-05-10 12:00'); + $mens = $this->makeRound("Men's Boulder Qualification", [IFSCAthleteGender::MEN], IFSCRoundKind::QUALIFICATION, $stream, '2025-05-10 12:00', '2025-05-10 15:00'); $result = $this->merger->merge([$womens, $mens]); $this->assertCount(1, $result); $this->assertSame("Men's & Women's Boulder Qualification", $result[0]->name); - $this->assertSame([IFSCRoundCategory::MEN, IFSCRoundCategory::WOMEN], $result[0]->categories); + $this->assertSame([IFSCAthleteGender::MEN, IFSCAthleteGender::WOMEN], $result[0]->categories); } #[Test] public function rounds_with_different_stream_urls_are_not_merged(): void @@ -69,8 +69,8 @@ protected function setUp(): void $streamA = new LiveStream(url: 'https://youtu.be/aaa'); $streamB = new LiveStream(url: 'https://youtu.be/bbb'); - $mens = $this->makeRound("Men's Lead Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $streamA, '2025-05-10 14:00', '2025-05-10 15:30'); - $womens = $this->makeRound("Women's Lead Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $streamB, '2025-05-10 15:30', '2025-05-10 17:00'); + $mens = $this->makeRound("Men's Lead Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $streamA, '2025-05-10 14:00', '2025-05-10 15:30'); + $womens = $this->makeRound("Women's Lead Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $streamB, '2025-05-10 15:30', '2025-05-10 17:00'); $result = $this->merger->merge([$mens, $womens]); @@ -81,8 +81,8 @@ protected function setUp(): void { $noStream = new LiveStream(); - $mens = $this->makeRound("Men's Lead Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $noStream, '2025-05-10 14:00', '2025-05-10 15:30'); - $womens = $this->makeRound("Women's Lead Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $noStream, '2025-05-10 15:30', '2025-05-10 17:00'); + $mens = $this->makeRound("Men's Lead Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $noStream, '2025-05-10 14:00', '2025-05-10 15:30'); + $womens = $this->makeRound("Women's Lead Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $noStream, '2025-05-10 15:30', '2025-05-10 17:00'); $result = $this->merger->merge([$mens, $womens]); @@ -93,8 +93,8 @@ protected function setUp(): void { $stream = new LiveStream(url: 'https://youtu.be/abc123'); - $qual = $this->makeRound("Men's Lead Qualification", [IFSCRoundCategory::MEN], IFSCRoundKind::QUALIFICATION, $stream, '2025-05-10 09:00', '2025-05-10 12:00'); - $final = $this->makeRound("Women's Lead Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:00', '2025-05-10 17:00'); + $qual = $this->makeRound("Men's Lead Qualification", [IFSCAthleteGender::MEN], IFSCRoundKind::QUALIFICATION, $stream, '2025-05-10 09:00', '2025-05-10 12:00'); + $final = $this->makeRound("Women's Lead Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:00', '2025-05-10 17:00'); $result = $this->merger->merge([$qual, $final]); @@ -105,8 +105,8 @@ protected function setUp(): void { $stream = new LiveStream(url: 'https://youtu.be/abc123'); - $lead = $this->makeRound("Men's Lead Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 14:00', '2025-05-10 15:30', IFSCDiscipline::LEAD); - $boulder = $this->makeRound("Women's Boulder Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:30', '2025-05-10 17:00', IFSCDiscipline::BOULDER); + $lead = $this->makeRound("Men's Lead Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 14:00', '2025-05-10 15:30', IFSCDiscipline::LEAD); + $boulder = $this->makeRound("Women's Boulder Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:30', '2025-05-10 17:00', IFSCDiscipline::BOULDER); $result = $this->merger->merge([$lead, $boulder]); @@ -117,8 +117,8 @@ protected function setUp(): void { $stream = new LiveStream(url: 'https://youtu.be/abc123'); - $mens = $this->makeRound("Men's Lead Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 14:00', '2025-05-10 15:30', status: IFSCRoundStatus::PROVISIONAL); - $womens = $this->makeRound("Women's Lead Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:30', '2025-05-10 17:00', status: IFSCRoundStatus::CONFIRMED); + $mens = $this->makeRound("Men's Lead Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 14:00', '2025-05-10 15:30', status: IFSCRoundStatus::PROVISIONAL); + $womens = $this->makeRound("Women's Lead Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $stream, '2025-05-10 15:30', '2025-05-10 17:00', status: IFSCRoundStatus::CONFIRMED); $result = $this->merger->merge([$mens, $womens]); @@ -131,9 +131,9 @@ protected function setUp(): void $otherStream = new LiveStream(url: 'https://youtu.be/other'); $noStream = new LiveStream(); - $mens = $this->makeRound("Men's Lead Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $sharedStream, '2025-05-10 14:00', '2025-05-10 15:30'); - $womens = $this->makeRound("Women's Lead Final", [IFSCRoundCategory::WOMEN], IFSCRoundKind::FINAL, $sharedStream, '2025-05-10 15:30', '2025-05-10 17:00'); - $speed = $this->makeRound("Men's Speed Final", [IFSCRoundCategory::MEN], IFSCRoundKind::FINAL, $otherStream, '2025-05-10 18:00', '2025-05-10 19:00'); + $mens = $this->makeRound("Men's Lead Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $sharedStream, '2025-05-10 14:00', '2025-05-10 15:30'); + $womens = $this->makeRound("Women's Lead Final", [IFSCAthleteGender::WOMEN], IFSCRoundKind::FINAL, $sharedStream, '2025-05-10 15:30', '2025-05-10 17:00'); + $speed = $this->makeRound("Men's Speed Final", [IFSCAthleteGender::MEN], IFSCRoundKind::FINAL, $otherStream, '2025-05-10 18:00', '2025-05-10 19:00'); $boulderQual = $this->makeRound("Boulder Qualification", [], IFSCRoundKind::QUALIFICATION, $noStream, '2025-05-09 09:00', '2025-05-09 13:00'); $result = $this->merger->merge([$mens, $womens, $speed, $boulderQual]); diff --git a/tests/unit/Domain/StartList/IFSCStartListGeneratorTest.php b/tests/unit/Domain/StartList/IFSCStartListGeneratorTest.php index 010a1bf..5b80896 100644 --- a/tests/unit/Domain/StartList/IFSCStartListGeneratorTest.php +++ b/tests/unit/Domain/StartList/IFSCStartListGeneratorTest.php @@ -12,13 +12,14 @@ use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteResult; use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteService; use SportClimbing\IfscCalendar\Domain\Ranking\IFSCAthleteRankingCalculator; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStarter; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListGenerator; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListProviderInterface; use Override; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; final class IFSCStartListGeneratorTest extends TestCase { @@ -29,9 +30,9 @@ final class IFSCStartListGeneratorTest extends TestCase #[Override] public function fetchStartListForEvent(int $eventId): array { return [ - new IFSCStarter(athleteId: 3, firstName: 'C', lastName: 'Last', country: 'USA'), - new IFSCStarter(athleteId: 1, firstName: 'A', lastName: 'Last', country: 'USA'), - new IFSCStarter(athleteId: 2, firstName: 'B', lastName: 'Last', country: 'USA'), + new IFSCStarter(athleteId: 3, firstName: 'C', lastName: 'Last', country: 'USA', disciplines: []), + new IFSCStarter(athleteId: 1, firstName: 'A', lastName: 'Last', country: 'USA', disciplines: []), + new IFSCStarter(athleteId: 2, firstName: 'B', lastName: 'Last', country: 'USA', disciplines: []), ]; } }; @@ -62,14 +63,14 @@ final class IFSCStartListGeneratorTest extends TestCase /** * @param IFSCAthleteResult[] $results */ - private function athlete(int $athleteId, string $photoUrl, array $results): IFSCAthlete + private function athlete(int $athleteId, string $photoUrl, array $results, string $gender = 'female'): IFSCAthlete { return new IFSCAthlete( id: $athleteId, firstName: "Athlete {$athleteId}", lastName: 'Test', birthday: null, - gender: 'female', + gender: $gender, personalStory: null, federation: null, country: 'USA', @@ -117,9 +118,9 @@ private function worldCupResult(int $rank, int $eventId): IFSCAthleteResult $this->assertSame([3, 1, 2], $athleteProvider->requestedAthleteIds); $this->assertSame([1, 2, 3], array_map(static fn (IFSCStarter $starter): int => $starter->athleteId, $startList)); - $this->assertSame(IFSCRoundCategory::WOMEN, $startList[0]->category); - $this->assertSame(IFSCRoundCategory::WOMEN, $startList[1]->category); - $this->assertSame(IFSCRoundCategory::WOMEN, $startList[2]->category); + $this->assertSame(IFSCAthleteGender::WOMEN, $startList[0]->gender); + $this->assertSame(IFSCAthleteGender::WOMEN, $startList[1]->gender); + $this->assertSame(IFSCAthleteGender::WOMEN, $startList[2]->gender); $this->assertSame('https://photos/1', $startList[0]->photoUrl); $this->assertSame('https://photos/2', $startList[1]->photoUrl); @@ -127,4 +128,313 @@ private function worldCupResult(int $rank, int $eventId): IFSCAthleteResult $this->assertGreaterThan($startList[1]->score, $startList[0]->score); $this->assertGreaterThan($startList[2]->score, $startList[1]->score); } + + #[Test] public function top_20_of_each_gender_selected_when_both_have_enough(): void + { + $menIds = range(1, 25); + $womenIds = range(26, 50); + + $startListProvider = $this->createProvider($menIds, $womenIds); + $athleteProvider = $this->createAthleteProvider($menIds, $womenIds); + + $generator = new IFSCStartListGenerator( + startListProvider: $startListProvider, + athleteService: new IFSCAthleteService($athleteProvider), + rankingCalculator: new IFSCAthleteRankingCalculator(), + ); + + $result = $generator->buildStartList(999); + $startList = $result->starters; + + $this->assertSame(50, $result->total); + $this->assertCount(40, $startList); + + $menInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::MEN); + $womenInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::WOMEN); + + $this->assertCount(20, $menInList); + $this->assertCount(20, $womenInList); + + // Top 20 men (lowest IDs = best ranks) should be in the list + $menAthleteIds = array_map(static fn (IFSCStarter $s): int => $s->athleteId, array_values($menInList)); + foreach (range(1, 20) as $id) { + $this->assertContains($id, $menAthleteIds); + } + // Men 21-25 should NOT be in the list + foreach (range(21, 25) as $id) { + $this->assertNotContains($id, $menAthleteIds); + } + + // Top 20 women should be in the list + $womenAthleteIds = array_map(static fn (IFSCStarter $s): int => $s->athleteId, array_values($womenInList)); + foreach (range(26, 45) as $id) { + $this->assertContains($id, $womenAthleteIds); + } + // Women 46-50 should NOT be in the list + foreach (range(46, 50) as $id) { + $this->assertNotContains($id, $womenAthleteIds); + } + } + + #[Test] public function men_shortfall_filled_by_extra_women(): void + { + $menIds = range(1, 10); + $womenIds = range(11, 40); + + $startListProvider = $this->createProvider($menIds, $womenIds); + $athleteProvider = $this->createAthleteProvider($menIds, $womenIds); + + $generator = new IFSCStartListGenerator( + startListProvider: $startListProvider, + athleteService: new IFSCAthleteService($athleteProvider), + rankingCalculator: new IFSCAthleteRankingCalculator(), + ); + + $result = $generator->buildStartList(999); + $startList = $result->starters; + + $this->assertSame(40, $result->total); + $this->assertCount(40, $startList); + + $menInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::MEN); + $womenInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::WOMEN); + + // Only 10 men exist, so all 10 should be included + $this->assertCount(10, $menInList); + // 30 women should fill the rest + $this->assertCount(30, $womenInList); + + // All 10 men should be present + $menAthleteIds = array_map(static fn (IFSCStarter $s): int => $s->athleteId, array_values($menInList)); + foreach (range(1, 10) as $id) { + $this->assertContains($id, $menAthleteIds); + } + // All 30 women should be present (no women left out) + $womenAthleteIds = array_map(static fn (IFSCStarter $s): int => $s->athleteId, array_values($womenInList)); + foreach (range(11, 40) as $id) { + $this->assertContains($id, $womenAthleteIds); + } + } + + #[Test] public function women_shortfall_filled_by_extra_men(): void + { + $menIds = range(1, 30); + $womenIds = range(31, 40); + + $startListProvider = $this->createProvider($menIds, $womenIds); + $athleteProvider = $this->createAthleteProvider($menIds, $womenIds); + + $generator = new IFSCStartListGenerator( + startListProvider: $startListProvider, + athleteService: new IFSCAthleteService($athleteProvider), + rankingCalculator: new IFSCAthleteRankingCalculator(), + ); + + $result = $generator->buildStartList(999); + $startList = $result->starters; + + $this->assertSame(40, $result->total); + $this->assertCount(40, $startList); + + $menInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::MEN); + $womenInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::WOMEN); + + // 30 men (top 20 + 10 filling women shortfall) + $this->assertCount(30, $menInList); + // Only 10 women exist, all included + $this->assertCount(10, $womenInList); + + $womenAthleteIds = array_map(static fn (IFSCStarter $s): int => $s->athleteId, array_values($womenInList)); + foreach (range(31, 40) as $id) { + $this->assertContains($id, $womenAthleteIds); + } + } + + #[Test] public function both_genders_below_20_returns_all_available(): void + { + $menIds = range(1, 8); + $womenIds = range(9, 15); + + $startListProvider = $this->createProvider($menIds, $womenIds); + $athleteProvider = $this->createAthleteProvider($menIds, $womenIds); + + $generator = new IFSCStartListGenerator( + startListProvider: $startListProvider, + athleteService: new IFSCAthleteService($athleteProvider), + rankingCalculator: new IFSCAthleteRankingCalculator(), + ); + + $result = $generator->buildStartList(999); + $startList = $result->starters; + + $this->assertSame(15, $result->total); + $this->assertCount(15, $startList); + + $menInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::MEN); + $womenInList = array_filter($startList, fn (IFSCStarter $s): bool => $s->gender === IFSCAthleteGender::WOMEN); + + $this->assertCount(8, $menInList); + $this->assertCount(7, $womenInList); + } + + #[Test] public function null_category_athletes_are_excluded(): void + { + $startListProvider = new class () implements IFSCStartListProviderInterface { + /** @return IFSCStarter[] */ + #[Override] public function fetchStartListForEvent(int $eventId): array + { + return [ + new IFSCStarter(athleteId: 1, firstName: 'Male', lastName: 'A', country: 'USA', disciplines: []), + new IFSCStarter(athleteId: 2, firstName: 'Null', lastName: 'B', country: 'USA', disciplines: []), + new IFSCStarter(athleteId: 3, firstName: 'Female', lastName: 'C', country: 'USA', disciplines: []), + ]; + } + }; + + $athleteProvider = new class () implements IFSCAthleteProviderInterface { + #[Override] public function fetchAthlete(int $athleteId): IFSCAthlete + { + return new IFSCAthlete( + id: $athleteId, + firstName: "Athlete {$athleteId}", + lastName: 'Test', + birthday: null, + gender: match ($athleteId) { + 1 => 'male', + 2 => 'other', + 3 => 'female', + }, + personalStory: null, + federation: null, + country: 'USA', + flagUrl: 'https://flags/USA.png', + city: null, + age: null, + height: null, + instagram: null, + nickname: null, + spokenLanguages: null, + photoUrl: null, + actionPhotoUrl: null, + disciplinePodiums: [], + worldChampionshipsDisciplinePodiums: [], + continentalChampionshipsDisciplinePodiums: [], + allResults: [ + new IFSCAthleteResult( + season: '2025', + rank: $athleteId, // Different ranks for different scores + discipline: 'boulder', + eventName: "IFSC World Cup {$athleteId}", + eventId: $athleteId, + dCat: 7, + date: '2025-06-01', + categoryName: 'Women', + resultUrl: "/api/v1/events/{$athleteId}/result/7", + ), + ], + cupRankings: [], + ); + } + }; + + $generator = new IFSCStartListGenerator( + startListProvider: $startListProvider, + athleteService: new IFSCAthleteService($athleteProvider), + rankingCalculator: new IFSCAthleteRankingCalculator(), + ); + + $result = $generator->buildStartList(999); + $startList = $result->starters; + + $this->assertSame(3, $result->total); + + $includedIds = array_map(static fn (IFSCStarter $s): int => $s->athleteId, $startList); + $this->assertContains(1, $includedIds); // male → MEN + $this->assertContains(3, $includedIds); // female → WOMEN + $this->assertNotContains(2, $includedIds); // other → null, excluded + } + + /** @param int[] $menIds @param int[] $womenIds */ + private function createProvider(array $menIds, array $womenIds): IFSCStartListProviderInterface + { + $starters = []; + + foreach ($menIds as $id) { + $starters[] = new IFSCStarter(athleteId: $id, firstName: "M{$id}", lastName: 'Test', country: 'USA', disciplines: []); + } + foreach ($womenIds as $id) { + $starters[] = new IFSCStarter(athleteId: $id, firstName: "W{$id}", lastName: 'Test', country: 'USA', disciplines: []); + } + + return new class ($starters) implements IFSCStartListProviderInterface { + /** @param IFSCStarter[] $starters */ + public function __construct(private array $starters) {} + /** @return IFSCStarter[] */ + #[Override] public function fetchStartListForEvent(int $eventId): array { return $this->starters; } + }; + } + + /** @param int[] $menIds @param int[] $womenIds */ + private function createAthleteProvider(array $menIds, array $womenIds): IFSCAthleteProviderInterface + { + $isMale = array_flip($menIds); + + return new class ($isMale, $menIds, $womenIds) implements IFSCAthleteProviderInterface { + /** + * @param array $isMale + * @param int[] $menIds + * @param int[] $womenIds + */ + public function __construct( + private array $isMale, + private array $menIds, + private array $womenIds, + ) {} + + #[Override] public function fetchAthlete(int $athleteId): IFSCAthlete + { + $athleteIsMale = isset($this->isMale[$athleteId]); + // Rank within gender: 1 = best (first ID in the list) + $genderIds = $athleteIsMale ? $this->menIds : $this->womenIds; + $rank = (int) array_search($athleteId, $genderIds, strict: true) + 1; + + return new IFSCAthlete( + id: $athleteId, + firstName: "Athlete {$athleteId}", + lastName: 'Test', + birthday: null, + gender: $athleteIsMale ? 'male' : 'female', + personalStory: null, + federation: null, + country: 'USA', + flagUrl: 'https://flags/USA.png', + city: null, + age: null, + height: null, + instagram: null, + nickname: null, + spokenLanguages: null, + photoUrl: "https://photos/{$athleteId}", + actionPhotoUrl: null, + disciplinePodiums: [], + worldChampionshipsDisciplinePodiums: [], + continentalChampionshipsDisciplinePodiums: [], + allResults: [ + new IFSCAthleteResult( + season: '2025', + rank: $rank, + discipline: IFSCDiscipline::BOULDER->value, + eventName: "IFSC World Cup {$athleteId}", + eventId: $athleteId, + dCat: 7, + date: '2025-06-01', + categoryName: $athleteIsMale ? 'Men' : 'Women', + resultUrl: "/api/v1/events/{$athleteId}/result/7", + ), + ], + cupRankings: [], + ); + } + }; + } } diff --git a/tests/unit/Infrastructure/Calendar/CalendarSpeedRelayFormattingTest.php b/tests/unit/Infrastructure/Calendar/CalendarSpeedRelayFormattingTest.php index 980a8db..5d85082 100644 --- a/tests/unit/Infrastructure/Calendar/CalendarSpeedRelayFormattingTest.php +++ b/tests/unit/Infrastructure/Calendar/CalendarSpeedRelayFormattingTest.php @@ -15,7 +15,7 @@ use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDisciplines; use SportClimbing\IfscCalendar\Domain\Event\IFSCEvent; use SportClimbing\IfscCalendar\Domain\Round\IFSCRound; -use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundCategory; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteGender; use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundKind; use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundStatus; use SportClimbing\IfscCalendar\Domain\Season\IFSCSeasonYear; @@ -59,7 +59,7 @@ private function createEventWithSpeedRelayRound(): IFSCEvent $roundEnd = new DateTimeImmutable('2026-09-01 11:30:00', $timeZone); $round = new IFSCRound( name: "Men's Speed Relay Qualification", - categories: [IFSCRoundCategory::MEN], + categories: [IFSCAthleteGender::MEN], disciplines: new IFSCDisciplines([IFSCDiscipline::SPEED_RELAY]), kind: IFSCRoundKind::QUALIFICATION, liveStream: new LiveStream(url: 'https://youtube.com/watch?v=relay-test'), diff --git a/tests/unit/Infrastructure/StartList/ApiStartListProviderTest.php b/tests/unit/Infrastructure/StartList/ApiStartListProviderTest.php index 3a704d2..499b6d6 100644 --- a/tests/unit/Infrastructure/StartList/ApiStartListProviderTest.php +++ b/tests/unit/Infrastructure/StartList/ApiStartListProviderTest.php @@ -11,6 +11,7 @@ use Override; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStarter; use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListException; use SportClimbing\IfscCalendar\Infrastructure\HttpClient\HttpClientInterface; @@ -145,6 +146,21 @@ public function __construct( $this->assertArrayHasKey(RequestOptions::COOKIES, $httpClient->requestedOptions); $this->assertSame('https://ifsc.results.info/', $httpClient->requestedOptions[RequestOptions::HEADERS]['referer']); $this->assertSame([1001, 1003, 1004, 1005, 2001, 2002], array_map(static fn (IFSCStarter $starter): int => $starter->athleteId, $startList)); + + // Verify disciplines are parsed from d_cats + $disciplinesByAthleteId = []; + foreach ($startList as $starter) { + $disciplinesByAthleteId[$starter->athleteId] = $starter->disciplines; + } + + // Athlete 1001: "BOULDER Men" (confirmed) → BOULDER + $this->assertSame([IFSCDiscipline::BOULDER], $disciplinesByAthleteId[1001]); + // Athlete 1003: "BOULDER Men" (not attending) + "LEAD Women" (confirmed) → only LEAD + $this->assertSame([IFSCDiscipline::LEAD], $disciplinesByAthleteId[1003]); + // Athlete 1004: "LEAD Women" (no status) → LEAD (attending by default) + $this->assertSame([IFSCDiscipline::LEAD], $disciplinesByAthleteId[1004]); + // Athlete 1005: "BOULDER Women" (null status) → BOULDER (attending by default) + $this->assertSame([IFSCDiscipline::BOULDER], $disciplinesByAthleteId[1005]); } #[Test] public function http_failures_are_mapped_to_domain_exception(): void