From ccd7e5a4038be111956135420dac6e93587987ce Mon Sep 17 00:00:00 2001 From: Nico Oelgart Date: Tue, 26 May 2026 16:37:10 +0200 Subject: [PATCH] Fix speed relay events --- src/Domain/Discipline/IFSCDiscipline.php | 13 ++ src/Domain/Event/IFSCEventTagsRegex.php | 3 +- src/Domain/Event/IFSCEventsFetcher.php | 28 ++- src/Domain/Round/IFSCRoundNameNormalizer.php | 6 +- src/Domain/Tags/IFSCParsedTags.php | 3 +- src/Domain/YouTube/YouTubeMatchScorer.php | 1 + src/Infrastructure/Calendar/JsonCalendar.php | 20 +- .../Domain/Event/IFSCEventsFetcherTest.php | 183 ++++++++++++++++++ .../Domain/Round/IFSCRoundNormalizerTest.php | 2 + 9 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 tests/unit/Domain/Event/IFSCEventsFetcherTest.php diff --git a/src/Domain/Discipline/IFSCDiscipline.php b/src/Domain/Discipline/IFSCDiscipline.php index 41c705c..028920b 100644 --- a/src/Domain/Discipline/IFSCDiscipline.php +++ b/src/Domain/Discipline/IFSCDiscipline.php @@ -12,5 +12,18 @@ enum IFSCDiscipline: string case BOULDER = 'boulder'; case LEAD = 'lead'; case SPEED = 'speed'; + case SPEED_RELAY = 'speed_relay'; case COMBINED = 'combined'; + + public function displayName(): string + { + return str_replace('_', ' ', $this->value); + } + + public function calendarDiscipline(): string + { + return $this === self::SPEED_RELAY + ? self::SPEED->value + : $this->value; + } } diff --git a/src/Domain/Event/IFSCEventTagsRegex.php b/src/Domain/Event/IFSCEventTagsRegex.php index 2751515..c21555a 100644 --- a/src/Domain/Event/IFSCEventTagsRegex.php +++ b/src/Domain/Event/IFSCEventTagsRegex.php @@ -13,7 +13,8 @@ enum IFSCEventTagsRegex: string case MEN = '(men|male)'; case LEAD = 'lead'; case BOULDER = 'boulder(ing)?'; - case SPEED = 'speed'; + case SPEED_RELAY = 'speed[\s_-]*relay'; + case SPEED = 'speed(?![\s_-]*relay)'; case COMBINED = 'combined'; case PARACLIMBING = 'para[\s-]?climbing'; case QUALIFICATION = 'qualifications?'; diff --git a/src/Domain/Event/IFSCEventsFetcher.php b/src/Domain/Event/IFSCEventsFetcher.php index 75942bc..cfed633 100644 --- a/src/Domain/Event/IFSCEventsFetcher.php +++ b/src/Domain/Event/IFSCEventsFetcher.php @@ -259,7 +259,12 @@ private function parseDisciplines(array $eventData): array throw new RuntimeException(sprintf("Invalid discipline value for %s", $this->eventReference($eventData))); } - $parsed[$discipline] = IFSCDiscipline::from($discipline); + $parsedDiscipline = $this->parseDiscipline($discipline); + if (!$parsedDiscipline) { + continue; + } + + $parsed[$parsedDiscipline->value] = $parsedDiscipline; } return array_values($parsed); @@ -354,7 +359,9 @@ private function parseEventRound(array $roundPayload, array $eventData): IFSCEve $disciplineKey = isset($roundPayload['discipline']) ? 'discipline' : 'kind'; $kindKey = isset($roundPayload['discipline']) ? 'kind' : 'name'; - $discipline = $this->requiredString($roundPayload, $disciplineKey); + $discipline = $this->parseRoundDiscipline( + $this->requiredString($roundPayload, $disciplineKey), + ); $kind = $this->parseRoundKind( roundKind: $this->requiredString($roundPayload, $kindKey), eventData: $eventData, @@ -367,6 +374,21 @@ private function parseEventRound(array $roundPayload, array $eventData): IFSCEve ); } + private function parseRoundDiscipline(string $discipline): string + { + return $this->parseDiscipline($discipline)?->value ?? $this->normalizeDisciplineValue($discipline); + } + + private function parseDiscipline(string $discipline): ?IFSCDiscipline + { + return IFSCDiscipline::tryFrom($this->normalizeDisciplineValue($discipline)); + } + + private function normalizeDisciplineValue(string $discipline): string + { + return strtolower(str_replace([' ', '-'], '_', trim($discipline))); + } + /** @param array $eventData */ private function parseRoundKind(string $roundKind, array $eventData): IFSCRoundKind { @@ -416,6 +438,8 @@ private function normalizeRoundName(IFSCEventRound $round): string subject: $round->discipline, ); + $discipline = str_replace('_', ' ', $discipline); + return sprintf("%s's %s %s", $round->category, $discipline, $round->kind->value) |> ucwords(...); } diff --git a/src/Domain/Round/IFSCRoundNameNormalizer.php b/src/Domain/Round/IFSCRoundNameNormalizer.php index 67a2e46..9a9330e 100644 --- a/src/Domain/Round/IFSCRoundNameNormalizer.php +++ b/src/Domain/Round/IFSCRoundNameNormalizer.php @@ -45,7 +45,7 @@ private function upperCaseWords(string $name): string */ private function disciplineNames(array $disciplines): array { - return array_map(static fn (IFSCDiscipline $discipline): string => $discipline->value, $disciplines); + return array_map(static fn (IFSCDiscipline $discipline): string => $discipline->displayName(), $disciplines); } private function buildCategories(IFSCParsedTags $tags): string @@ -66,9 +66,9 @@ private function buildDisciplines(array $disciplines): string $lastDiscipline = array_pop($disciplines); $disciplines = $this->disciplineNames($disciplines); - return implode(', ', $disciplines) . ' & ' . $lastDiscipline->value; + return implode(', ', $disciplines) . ' & ' . $lastDiscipline->displayName(); } else { - return array_first($disciplines)->value; + return array_first($disciplines)->displayName(); } } } diff --git a/src/Domain/Tags/IFSCParsedTags.php b/src/Domain/Tags/IFSCParsedTags.php index 8ef9d9e..9da9cca 100644 --- a/src/Domain/Tags/IFSCParsedTags.php +++ b/src/Domain/Tags/IFSCParsedTags.php @@ -17,6 +17,7 @@ private const array DISCIPLINES = [ IFSCDiscipline::BOULDER->value => Tag::BOULDER, IFSCDiscipline::LEAD->value => Tag::LEAD, + IFSCDiscipline::SPEED_RELAY->value => Tag::SPEED_RELAY, IFSCDiscipline::SPEED->value => Tag::SPEED, IFSCDiscipline::COMBINED->value => Tag::COMBINED, ]; @@ -51,7 +52,7 @@ public function getDisciplines(): array foreach (self::DISCIPLINES as $name => $tag) { if ($this->hasTag($tag)) { - if ($tag === IFSCDiscipline::COMBINED) { + if ($tag === Tag::COMBINED) { $disciplines[] = IFSCDiscipline::BOULDER; $disciplines[] = IFSCDiscipline::LEAD; } else { diff --git a/src/Domain/YouTube/YouTubeMatchScorer.php b/src/Domain/YouTube/YouTubeMatchScorer.php index aa3fade..5865b06 100644 --- a/src/Domain/YouTube/YouTubeMatchScorer.php +++ b/src/Domain/YouTube/YouTubeMatchScorer.php @@ -17,6 +17,7 @@ private const array DISCIPLINE_TAGS = [ Tag::BOULDER, Tag::LEAD, + Tag::SPEED_RELAY, Tag::SPEED, Tag::COMBINED, ]; diff --git a/src/Infrastructure/Calendar/JsonCalendar.php b/src/Infrastructure/Calendar/JsonCalendar.php index 3d185db..192650e 100644 --- a/src/Infrastructure/Calendar/JsonCalendar.php +++ b/src/Infrastructure/Calendar/JsonCalendar.php @@ -55,7 +55,7 @@ 'summary' => $event->ticketsSummary ?? '', 'purchase_url' => $event->ticketsPurchaseUrl ?? '' ], - 'disciplines' => $event->disciplines, + 'disciplines' => $this->buildEventDisciplines($event), 'starts_at' => $this->formatDate($event->startsAt), 'ends_at' => $this->formatDate($event->endsAt), 'timezone' => $event->timeZone->getName(), @@ -112,10 +112,26 @@ private function buildUrl(IFSCEvent $event): string return sprintf(self::WORLD_CLIMBING_INFO_URL, $event->slug); } + /** @return string[] */ + private function buildEventDisciplines(IFSCEvent $event): array + { + $disciplines = array_map( + static fn (IFSCDiscipline $discipline): string => $discipline->calendarDiscipline(), + $event->disciplines, + ); + + return array_values(array_unique($disciplines)); + } + /** @return string[] */ private function buildDisciplines(IFSCRound $round): array { - return array_map(static fn (IFSCDiscipline $discipline): string => $discipline->value, $round->disciplines->all()); + $disciplines = array_map( + static fn (IFSCDiscipline $discipline): string => $discipline->calendarDiscipline(), + $round->disciplines->all(), + ); + + return array_values(array_unique($disciplines)); } /** @return string[] */ diff --git a/tests/unit/Domain/Event/IFSCEventsFetcherTest.php b/tests/unit/Domain/Event/IFSCEventsFetcherTest.php new file mode 100644 index 0000000..112aa3b --- /dev/null +++ b/tests/unit/Domain/Event/IFSCEventsFetcherTest.php @@ -0,0 +1,183 @@ + + */ +namespace SportClimbing\IfscCalendar\tests\Domain\Event; + +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthlete; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteProviderInterface; +use SportClimbing\IfscCalendar\Domain\Athlete\IFSCAthleteService; +use SportClimbing\IfscCalendar\Domain\Calendar\SiteURLBuilder; +use SportClimbing\IfscCalendar\Domain\Discipline\IFSCDiscipline; +use SportClimbing\IfscCalendar\Domain\DomainEvent\Event; +use SportClimbing\IfscCalendar\Domain\DomainEvent\EventDispatcherInterface; +use SportClimbing\IfscCalendar\Domain\Event\IFSCEventFactory; +use SportClimbing\IfscCalendar\Domain\Event\IFSCEventSlug; +use SportClimbing\IfscCalendar\Domain\Event\IFSCEventsFetcher; +use SportClimbing\IfscCalendar\Domain\Event\Info\IFSCEventInfo; +use SportClimbing\IfscCalendar\Domain\Ranking\IFSCAthleteRankingCalculator; +use SportClimbing\IfscCalendar\Domain\Round\IFSCAverageRoundDuration; +use SportClimbing\IfscCalendar\Domain\Round\IFSCAverageRoundDurationLookupKey; +use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundFactory; +use SportClimbing\IfscCalendar\Domain\Round\IFSCRoundNameNormalizer; +use SportClimbing\IfscCalendar\Domain\Round\IFSCSameStreamRoundsMerger; +use SportClimbing\IfscCalendar\Domain\Schedule\IFSCScheduleFactory; +use SportClimbing\IfscCalendar\Domain\Season\IFSCSeasonYear; +use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListGenerator; +use SportClimbing\IfscCalendar\Domain\StartList\IFSCStartListProviderInterface; +use SportClimbing\IfscCalendar\Domain\Stream\LiveStream; +use SportClimbing\IfscCalendar\Domain\Tags\IFSCTagsParser; +use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeLiveStreamFinderInterface; +use Override; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class IFSCEventsFetcherTest extends TestCase +{ + #[Test] public function speed_relay_is_parsed_as_a_dedicated_discipline(): void + { + $schedulePath = $this->createSchedulePath([ + [ + 'event_id' => 9001, + 'event_name' => 'IFSC Relay Test Event 2026', + 'league_name' => 'World Cups and World Championships', + 'local_start_date' => '2026-05-26', + 'local_end_date' => '2026-05-27', + 'timezone' => 'Europe/Madrid', + 'location' => 'Madrid', + 'country' => 'ES', + 'disciplines' => ['speed_relay'], + 'categories' => ['men'], + ], + ]); + + try { + $events = $this->createFetcher()->fetchEventsForSeason( + season: IFSCSeasonYear::SEASON_2026, + selectedLeagues: ['World Cups and World Championships'], + schedulePath: $schedulePath, + ); + } finally { + @unlink($schedulePath); + } + + $this->assertCount(1, $events); + $this->assertSame([IFSCDiscipline::SPEED_RELAY], $events[0]->disciplines); + } + + #[Test] public function speed_relay_round_payloads_produce_speed_relay_round_names(): void + { + $schedulePath = $this->createSchedulePath([ + [ + 'event_id' => 9002, + 'event_name' => 'IFSC Relay Round Payload Test 2026', + 'league_name' => 'World Cups and World Championships', + 'local_start_date' => '2026-05-26', + 'local_end_date' => '2026-05-27', + 'timezone' => 'Europe/Madrid', + 'location' => 'Madrid', + 'country' => 'ES', + 'disciplines' => ['speed_relay'], + 'categories' => [[ + 'rounds' => [[ + 'discipline' => 'speed_relay', + 'kind' => 'qualification', + 'category' => 'men', + ]], + ]], + ], + ]); + + try { + $events = $this->createFetcher()->fetchEventsForSeason( + season: IFSCSeasonYear::SEASON_2026, + selectedLeagues: ['World Cups and World Championships'], + schedulePath: $schedulePath, + ); + } finally { + @unlink($schedulePath); + } + + $this->assertCount(1, $events); + $this->assertCount(1, $events[0]->rounds); + $this->assertSame("Men's Speed Relay Qualification", $events[0]->rounds[0]->name); + } + + private function createFetcher(): IFSCEventsFetcher + { + $tagsParser = new IFSCTagsParser(); + $roundNameNormalizer = new IFSCRoundNameNormalizer(); + + $roundFactory = new IFSCRoundFactory( + tagsParser: $tagsParser, + liveStreamFinder: new class () implements YouTubeLiveStreamFinderInterface { + #[Override] public function findLiveStream( + IFSCEventInfo $event, + string $roundName, + ): LiveStream { + return new LiveStream(); + } + }, + averageRoundDuration: new IFSCAverageRoundDuration( + new IFSCAverageRoundDurationLookupKey(), + ), + ); + + $eventFactory = new IFSCEventFactory( + siteURLBuilder: new SiteURLBuilder('https://ifsc.stream/{season}/{event_id}/{slug}'), + startListGenerator: new IFSCStartListGenerator( + startListProvider: new class () implements IFSCStartListProviderInterface { + #[Override] public function fetchStartListForEvent(int $eventId): array + { + return []; + } + }, + athleteService: new IFSCAthleteService( + athleteProvider: new class () implements IFSCAthleteProviderInterface { + #[Override] public function fetchAthlete(int $athleteId): IFSCAthlete + { + throw new RuntimeException('Athletes are not expected in this test'); + } + }, + ), + rankingCalculator: new IFSCAthleteRankingCalculator(), + ), + sameStreamRoundsMerger: new IFSCSameStreamRoundsMerger( + tagsParser: $tagsParser, + nameNormalizer: $roundNameNormalizer, + ), + ); + + return new IFSCEventsFetcher( + eventFactory: $eventFactory, + roundFactory: $roundFactory, + scheduleFactory: new IFSCScheduleFactory($tagsParser, $roundNameNormalizer), + eventDispatcher: new class () implements EventDispatcherInterface { + #[Override] public function dispatch(Event $event): void + { + } + }, + eventSlug: new IFSCEventSlug(), + ); + } + + /** @param array> $events */ + private function createSchedulePath(array $events): string + { + $schedulePath = tempnam(sys_get_temp_dir(), 'ifsc-events-test-'); + if ($schedulePath === false) { + throw new RuntimeException('Unable to create temporary schedule file'); + } + + $json = json_encode(['events' => $events], JSON_THROW_ON_ERROR); + if (file_put_contents($schedulePath, $json) === false) { + throw new RuntimeException('Unable to write temporary schedule file'); + } + + return $schedulePath; + } +} diff --git a/tests/unit/Domain/Round/IFSCRoundNormalizerTest.php b/tests/unit/Domain/Round/IFSCRoundNormalizerTest.php index 85165cf..88b13b2 100644 --- a/tests/unit/Domain/Round/IFSCRoundNormalizerTest.php +++ b/tests/unit/Domain/Round/IFSCRoundNormalizerTest.php @@ -48,6 +48,7 @@ public static function event_names(): array ["Speed Warm Up Zone Open", "Speed Warm Up Zone Open"], ["Speed Practice", "Speed Practice"], ["Speed Qualification", "Men's & Women's Speed Qualification"], + ["Speed Relay Qualification", "Men's & Women's Speed Relay Qualification"], ["Isolation Opens", "Isolation Opens"], ["Isolation Closes", "Isolation Closes"], ["Men’s & Women’s Lead Semi Finals", "Men's & Women's Lead Semi-Final"], @@ -68,6 +69,7 @@ public static function event_names(): array ["Men’s Speed warm-up", "Men’s Speed Warm-Up"], ["Men’s Speed practice", "Men’s Speed Practice"], ["Men’s Speed Qualification", "Men's Speed Qualification"], + ["Men’s Speed Relay Final", "Men's Speed Relay Final"], ["Women’s Boulder final", "Women's Boulder Final"], ["Men’s Speed Final", "Men's Speed Final"], ["Women’s Boulder Semi-Final", "Women's Boulder Semi-Final"],