diff --git a/config/services/domain.yml b/config/services/domain.yml index c1ead56..6e8f35c 100644 --- a/config/services/domain.yml +++ b/config/services/domain.yml @@ -53,6 +53,12 @@ services: arguments: - '@SportClimbing\IfscCalendar\Infrastructure\YouTube\YouTubeVideoProvider' + SportClimbing\IfscCalendar\Domain\YouTube\YouTubeTextNormalizer: ~ + + SportClimbing\IfscCalendar\Domain\YouTube\YouTubeMatchScorer: + class: SportClimbing\IfscCalendar\Domain\YouTube\YouTubeMatchScorer + autowire: true + SportClimbing\IfscCalendar\Domain\YouTube\YouTubeLinkMatcher: class: SportClimbing\IfscCalendar\Domain\YouTube\YouTubeLinkMatcher autowire: true diff --git a/src/Domain/YouTube/YouTubeLinkMatcher.php b/src/Domain/YouTube/YouTubeLinkMatcher.php index 75a657b..232d56d 100644 --- a/src/Domain/YouTube/YouTubeLinkMatcher.php +++ b/src/Domain/YouTube/YouTubeLinkMatcher.php @@ -9,132 +9,79 @@ use DateTimeImmutable; use SportClimbing\IfscCalendar\Domain\Event\Info\IFSCEventInfo; -use SportClimbing\IfscCalendar\Domain\Event\IFSCEventTagsRegex as Tag; use SportClimbing\IfscCalendar\Domain\Stream\LiveStream; use SportClimbing\IfscCalendar\Domain\Tags\IFSCTagsParser; final readonly class YouTubeLinkMatcher { + private const int MIN_CONFIDENCE_SCORE = 14; + private const string YOUTUBE_BASE_URL = 'https://youtu.be/'; + public function __construct( private IFSCTagsParser $tagsParser, + private YouTubeMatchScorer $matchScorer, ) { } - private const string YOUTUBE_BASE_URL = 'https://youtu.be/'; - public function findStreamUrlForRound(IFSCEventInfo $event, string $roundName, YouTubeVideoCollection $videoCollection): LiveStream { + $roundTags = $this->tagsParser->fromString(mb_strtolower($roundName))->allTags(); + $bestVideo = null; + $bestScore = PHP_INT_MIN; + foreach ($videoCollection->getIterator() as $video) { /** @var YouTubeVideo $video */ - if ($this->videoTitleMatchesRoundName($video, $roundName, $event)) { - return new LiveStream( - url: self::YOUTUBE_BASE_URL . $video->videoId, - scheduledStartTime: $video->scheduledStartTime, - duration: $video->duration, - restrictedRegions: $video->restrictedRegions, - ); - } - } + $score = $this->matchScorer->score($video, $roundTags, $event); - return new LiveStream(); - } + if ($score === null) { + continue; + } - private function videoTitleMatchesRoundName(YouTubeVideo $video, string $roundName, IFSCEventInfo $event): bool - { - $videoTitle = mb_strtolower($video->title); - $roundName = mb_strtolower($roundName); - $videoTags = $this->fetchTagsFromTitle($videoTitle); - - if (!$this->videoTitleContainsSameLocationAndSeason($videoTitle, $event) || - $this->videoIsHighlights($videoTags) || - $this->isParaclimbingEvent($event) - ) { - return false; + if ($bestVideo === null || $this->isBetterCandidate($video, $score, $bestVideo, $bestScore)) { + $bestVideo = $video; + $bestScore = $score; + } } - $eventTags = $this->fetchTagsFromTitle($roundName); - - if ($this->videoIsMensAndWomensCombined($videoTags, $eventTags)) { - return true; + if ($bestVideo === null || $bestScore < self::MIN_CONFIDENCE_SCORE) { + return new LiveStream(); } - return $videoTags === $eventTags; - } - - /** @return Tag[] */ - private function fetchTagsFromTitle(string $title): array - { - return $this->tagsParser->fromString($title)->allTags(); + return new LiveStream( + url: self::YOUTUBE_BASE_URL . $bestVideo->videoId, + scheduledStartTime: $bestVideo->scheduledStartTime, + duration: $bestVideo->duration, + restrictedRegions: $bestVideo->restrictedRegions, + ); } - private function videoTitleContainsSameLocationAndSeason(string $videoTitle, IFSCEventInfo $event): bool - { - return - str_contains($this->normalize($videoTitle), $this->normalize($event->location)) && - str_contains($videoTitle, $this->eventSeason($event)); - } - - /** @param Tag[] $videoTags */ - private function videoIsHighlights(array $videoTags): bool - { - return - $this->hasTag($videoTags, Tag::HIGHLIGHTS) || - $this->hasTag($videoTags, Tag::PRESS_CONFERENCE) || - $this->hasTag($videoTags, Tag::REVIEW); - } - - /** - * @param Tag[] $videoTags - * @param Tag[] $eventTags - */ - private function videoIsMensAndWomensCombined(array $videoTags, array $eventTags): bool - { - if (!$this->hasTag($videoTags, Tag::MEN) && - !$this->hasTag($videoTags, Tag::WOMEN) - ) { - $eventTags = $this->removeTags( - $eventTags, - Tag::MEN, - Tag::WOMEN, - ); + private function isBetterCandidate( + YouTubeVideo $candidate, + int $candidateScore, + YouTubeVideo $currentBest, + int $currentBestScore, + ): bool { + if ($candidateScore > $currentBestScore) { + return true; } - return $videoTags === $eventTags; - } - - /** @param Tag[] $tags */ - private function hasTag(array $tags, Tag $tag): bool - { - return in_array($tag, $tags, strict: true); - } - - /** - * @param Tag[] $items - * @return Tag[] - */ - private function removeTags(array $items, Tag ...$tags): array - { - foreach ($tags as $tag) { - unset($items[array_search($tag, $items)]); + if ($candidateScore < $currentBestScore) { + return false; } - return array_values($items); - } - - private function eventSeason(IFSCEventInfo $event): string - { - return new DateTimeImmutable($event->localStartDate)->format('Y'); - } + if ($candidate->scheduledStartTime && !$currentBest->scheduledStartTime) { + return true; + } - private function isParaclimbingEvent(IFSCEventInfo $event): bool - { - $eventTags = $this->fetchTagsFromTitle($event->eventName); + if (!$candidate->scheduledStartTime && $currentBest->scheduledStartTime) { + return false; + } - return $this->hasTag($eventTags, Tag::PARACLIMBING); + return $this->referenceDateTime($candidate) > $this->referenceDateTime($currentBest); } - private function normalize(string $text): string + private function referenceDateTime(YouTubeVideo $video): DateTimeImmutable { - return strtr(mb_strtolower($text), ['ç' => 'c']); + return $video->scheduledStartTime ?? $video->publishedAt; } } diff --git a/src/Domain/YouTube/YouTubeMatchScorer.php b/src/Domain/YouTube/YouTubeMatchScorer.php new file mode 100644 index 0000000..0c1b8f8 --- /dev/null +++ b/src/Domain/YouTube/YouTubeMatchScorer.php @@ -0,0 +1,344 @@ + + */ +namespace SportClimbing\IfscCalendar\Domain\YouTube; + +use DateTimeImmutable; +use SportClimbing\IfscCalendar\Domain\Event\Info\IFSCEventInfo; +use SportClimbing\IfscCalendar\Domain\Event\IFSCEventTagsRegex as Tag; +use SportClimbing\IfscCalendar\Domain\Tags\IFSCTagsParser; + +final readonly class YouTubeMatchScorer +{ + private const array DISCIPLINE_TAGS = [ + Tag::BOULDER, + Tag::LEAD, + Tag::SPEED, + Tag::COMBINED, + ]; + + private const array ROUND_KIND_TAGS = [ + Tag::QUALIFICATION, + Tag::SEMI_FINAL, + Tag::FINAL, + ]; + + private const array LOCATION_ALIASES = [ + 'salt lake city' => ['slc'], + ]; + + public function __construct( + private IFSCTagsParser $tagsParser, + private YouTubeTextNormalizer $textNormalizer, + ) { + } + + /** + * @param Tag[] $roundTags + */ + public function score(YouTubeVideo $video, array $roundTags, IFSCEventInfo $event): ?int + { + $videoTitle = mb_strtolower($video->title); + $videoTags = $this->fetchTagsFromTitle($videoTitle); + $isParaclimbingEvent = $this->isParaclimbingEvent($event); + + if (!$this->videoTitleContainsSameLocationAndSeason($videoTitle, $event) || + $this->videoIsHighlights($videoTags) || + !$this->paraclimbingTagsAreCompatible($videoTags, $isParaclimbingEvent) || + !$this->roundKindMatches($roundTags, $videoTags) || + !$this->disciplinesMatch($roundTags, $videoTags) || + !$this->categoriesAreCompatible($roundTags, $videoTags) + ) { + return null; + } + + return $this->tagsScore($roundTags, $videoTags) + + $this->timingScore($video, $event) + + $this->eventNameTokensScore($videoTitle, $event); + } + + /** @return Tag[] */ + private function fetchTagsFromTitle(string $title): array + { + return $this->tagsParser->fromString($title)->allTags(); + } + + private function videoTitleContainsSameLocationAndSeason(string $videoTitle, IFSCEventInfo $event): bool + { + $normalizedTitle = $this->textNormalizer->normalize($videoTitle); + $year = $this->eventSeason($event); + $containsSeason = (bool) preg_match("~\\b{$year}\\b~", $normalizedTitle); + + if (!$containsSeason) { + return false; + } + + foreach ($this->locationAliases($event->location) as $alias) { + if (str_contains($normalizedTitle, $alias)) { + return true; + } + } + + return + str_contains( + str_replace(' ', '', $normalizedTitle), + str_replace(' ', '', $this->textNormalizer->normalize($event->location)), + ); + } + + /** @param Tag[] $videoTags */ + private function videoIsHighlights(array $videoTags): bool + { + return + $this->hasTag($videoTags, Tag::HIGHLIGHTS) || + $this->hasTag($videoTags, Tag::PRESS_CONFERENCE) || + $this->hasTag($videoTags, Tag::REVIEW); + } + + /** + * @param Tag[] $roundTags + * @param Tag[] $videoTags + */ + private function tagsScore(array $roundTags, array $videoTags): int + { + $comparableRoundTags = $roundTags; + + if (!$this->hasAnyTag($videoTags, Tag::MEN, Tag::WOMEN)) { + $comparableRoundTags = $this->removeTags($comparableRoundTags, Tag::MEN, Tag::WOMEN); + } + + $score = 0; + + foreach ($comparableRoundTags as $roundTag) { + $score += $this->hasTag($videoTags, $roundTag) ? 6 : -7; + } + + foreach ($videoTags as $videoTag) { + if (!$this->hasTag($comparableRoundTags, $videoTag)) { + $score -= 2; + } + } + + if ($comparableRoundTags === $videoTags) { + $score += 8; + } + + return $score; + } + + /** @param Tag[] $tags */ + private function hasTag(array $tags, Tag $tag): bool + { + return in_array($tag, $tags, strict: true); + } + + /** @param Tag[] $tags */ + private function hasAnyTag(array $tags, Tag ...$needle): bool + { + return array_any($needle, fn (Tag $tag): bool => $this->hasTag($tags, $tag)); + + } + + /** + * @param Tag[] $items + * @return Tag[] + */ + private function removeTags(array $items, Tag ...$tags): array + { + foreach ($tags as $tag) { + $index = array_search($tag, $items, strict: true); + + if ($index !== false) { + unset($items[$index]); + } + } + + return array_values($items); + } + + private function eventSeason(IFSCEventInfo $event): string + { + return new DateTimeImmutable($event->localStartDate)->format('Y'); + } + + private function isParaclimbingEvent(IFSCEventInfo $event): bool + { + $eventTags = $this->fetchTagsFromTitle($event->eventName); + + return $this->hasTag($eventTags, Tag::PARACLIMBING); + } + + /** @param Tag[] $videoTags */ + private function paraclimbingTagsAreCompatible(array $videoTags, bool $isParaclimbingEvent): bool + { + $videoIsParaclimbing = $this->hasTag($videoTags, Tag::PARACLIMBING); + + if ($isParaclimbingEvent) { + return $videoIsParaclimbing; + } + + return !$videoIsParaclimbing; + } + + /** + * @param Tag[] $roundTags + * @param Tag[] $videoTags + */ + private function roundKindMatches(array $roundTags, array $videoTags): bool + { + foreach (self::ROUND_KIND_TAGS as $roundKindTag) { + if ($this->hasTag($roundTags, $roundKindTag) && !$this->hasTag($videoTags, $roundKindTag)) { + return false; + } + } + + return true; + } + + /** + * @param Tag[] $roundTags + * @param Tag[] $videoTags + */ + private function disciplinesMatch(array $roundTags, array $videoTags): bool + { + $requiredDisciplines = []; + + foreach (self::DISCIPLINE_TAGS as $disciplineTag) { + if ($this->hasTag($roundTags, $disciplineTag)) { + $requiredDisciplines[] = $disciplineTag; + } + } + + if (!$requiredDisciplines) { + return true; + } + + return array_any($requiredDisciplines, fn (Tag $disciplineTag): bool => $this->hasTag($videoTags, $disciplineTag)); + + } + + /** + * @param Tag[] $roundTags + * @param Tag[] $videoTags + */ + private function categoriesAreCompatible(array $roundTags, array $videoTags): bool + { + $roundHasMen = $this->hasTag($roundTags, Tag::MEN); + $roundHasWomen = $this->hasTag($roundTags, Tag::WOMEN); + $videoHasMen = $this->hasTag($videoTags, Tag::MEN); + $videoHasWomen = $this->hasTag($videoTags, Tag::WOMEN); + + if ($roundHasMen && !$roundHasWomen) { + return !$videoHasWomen || $videoHasMen; + } + + if ($roundHasWomen && !$roundHasMen) { + return !$videoHasMen || $videoHasWomen; + } + + return true; + } + + private function timingScore(YouTubeVideo $video, IFSCEventInfo $event): int + { + $videoDate = $video->scheduledStartTime ?? $video->publishedAt; + $eventStart = $this->createDate($event->localStartDate); + $eventEnd = $this->createDate($event->localEndDate); + + if (!$eventStart || !$eventEnd) { + return 0; + } + + $startBuffer = $eventStart->modify('-2 days'); + $endBuffer = $eventEnd->modify('+2 days'); + + if ($videoDate >= $startBuffer && $videoDate <= $endBuffer) { + return 5; + } + + $broaderStart = $eventStart->modify('-14 days'); + $broaderEnd = $eventEnd->modify('+14 days'); + + if ($videoDate >= $broaderStart && $videoDate <= $broaderEnd) { + return 1; + } + + return -4; + } + + private function eventNameTokensScore(string $videoTitle, IFSCEventInfo $event): int + { + $eventName = $this->textNormalizer->normalize($event->eventName); + $videoTitle = $this->textNormalizer->normalize($videoTitle); + $score = 0; + + foreach ($this->eventNameTokens($eventName) as $token) { + if (str_contains($videoTitle, $token)) { + $score += 1; + } + } + + return min(6, $score); + } + + /** @return string[] */ + private function eventNameTokens(string $eventName): array + { + $tokens = preg_split('~\s+~', $eventName); + + if ($tokens === false) { + return []; + } + + $stopWords = [ + 'ifsc', + 'world', + 'cup', + 'cups', + 'championship', + 'championships', + 'climbing', + 'and', + 'the', + 'series', + ]; + + $normalized = []; + + foreach ($tokens as $token) { + if (mb_strlen($token) <= 2 || in_array($token, $stopWords, true)) { + continue; + } + + $normalized[] = $token; + } + + return array_values(array_unique($normalized)); + } + + /** @return string[] */ + private function locationAliases(string $location): array + { + $normalizedLocation = $this->textNormalizer->normalize($location); + $aliases = [$normalizedLocation, str_replace(' ', '', $normalizedLocation)]; + + foreach (self::LOCATION_ALIASES[$normalizedLocation] ?? [] as $alias) { + $aliases[] = $this->textNormalizer->normalize($alias); + } + + return array_values(array_unique($aliases)); + } + + private function createDate(string $date): ?DateTimeImmutable + { + try { + return new DateTimeImmutable($date); + } catch (\Exception) { + return null; + } + } +} diff --git a/src/Domain/YouTube/YouTubeTextNormalizer.php b/src/Domain/YouTube/YouTubeTextNormalizer.php new file mode 100644 index 0000000..97cf07c --- /dev/null +++ b/src/Domain/YouTube/YouTubeTextNormalizer.php @@ -0,0 +1,36 @@ + + */ +namespace SportClimbing\IfscCalendar\Domain\YouTube; + +final readonly class YouTubeTextNormalizer +{ + private const array ACCENT_TRANSLITERATION = [ + 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'ā' => 'a', 'ă' => 'a', 'ą' => 'a', + 'ç' => 'c', 'ć' => 'c', 'č' => 'c', + 'ď' => 'd', 'đ' => 'd', + 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ē' => 'e', 'ĕ' => 'e', 'ė' => 'e', 'ę' => 'e', 'ě' => 'e', + 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ī' => 'i', 'ĩ' => 'i', 'į' => 'i', + 'ñ' => 'n', 'ń' => 'n', 'ň' => 'n', + 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ø' => 'o', 'ō' => 'o', 'ő' => 'o', + 'ŕ' => 'r', 'ř' => 'r', + 'ś' => 's', 'š' => 's', 'ș' => 's', 'ş' => 's', + 'ť' => 't', 'ț' => 't', 'ţ' => 't', + 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ū' => 'u', 'ů' => 'u', 'ű' => 'u', + 'ý' => 'y', 'ÿ' => 'y', + 'ž' => 'z', 'ź' => 'z', 'ż' => 'z', + ]; + + public function normalize(string $text): string + { + $normalized = mb_strtolower($text); + $normalized = strtr($normalized, self::ACCENT_TRANSLITERATION); + $normalized = preg_replace('~[^a-z0-9]+~', ' ', $normalized) ?? $normalized; + + return trim(preg_replace('~\s+~', ' ', $normalized) ?? $normalized); + } +} diff --git a/tests/unit/Domain/YouTube/YouTubeLinkMatcherTest.php b/tests/unit/Domain/YouTube/YouTubeLinkMatcherTest.php index 3c8d3f4..cd5f447 100644 --- a/tests/unit/Domain/YouTube/YouTubeLinkMatcherTest.php +++ b/tests/unit/Domain/YouTube/YouTubeLinkMatcherTest.php @@ -13,6 +13,8 @@ use SportClimbing\IfscCalendar\Domain\Stream\LiveStream; use SportClimbing\IfscCalendar\Domain\Tags\IFSCTagsParser; use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeLinkMatcher; +use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeMatchScorer; +use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeTextNormalizer; use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeVideo; use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeVideoCollection; use PHPUnit\Framework\Attributes\Test; @@ -132,6 +134,144 @@ final class YouTubeLinkMatcherTest extends TestCase $this->assertSame('https://youtu.be/n6YyV2ddb11', $liveStream->url); } + #[Test] public function best_candidate_is_chosen_when_multiple_titles_match(): void + { + $event = $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2023', + location: 'Salt Lake City', + localStartDate: '2023-05-19T08:00:00Z', + localEndDate: '2023-05-21T23:00:00Z', + ); + $videoCollection = new YouTubeVideoCollection(); + + $videoCollection->add(new YouTubeVideo( + title: 'Women\'s Speed qualification || Salt Lake City 2023', + duration: 0, + videoId: 'older-id', + publishedAt: new DateTimeImmutable('2023-03-10T10:00:00Z'), + scheduledStartTime: null, + restrictedRegions: [], + )); + $videoCollection->add(new YouTubeVideo( + title: 'Women\'s Speed qualification || Salt Lake City 2023', + duration: 54, + videoId: 'better-id', + publishedAt: new DateTimeImmutable('2023-05-21T19:41:25Z'), + scheduledStartTime: null, + restrictedRegions: [], + )); + + $liveStream = $this->linkMatcher->findStreamUrlForRound( + event: $event, + roundName: 'Women\'s Speed Qualification', + videoCollection: $videoCollection, + ); + + $this->assertSame('https://youtu.be/better-id', $liveStream->url); + } + + #[Test] public function slc_alias_matches_salt_lake_city_location(): void + { + $event = $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2023', + location: 'Salt Lake City', + ); + $videoCollection = new YouTubeVideoCollection(); + $videoCollection->add(new YouTubeVideo( + title: 'Women\'s Speed qualification || SLC 2023', + duration: 52, + videoId: 'slc-id', + publishedAt: new DateTimeImmutable('2023-05-20T08:00:00Z'), + scheduledStartTime: null, + restrictedRegions: [], + )); + + $liveStream = $this->linkMatcher->findStreamUrlForRound( + event: $event, + roundName: 'Women\'s Speed Qualification', + videoCollection: $videoCollection, + ); + + $this->assertSame('https://youtu.be/slc-id', $liveStream->url); + } + + #[Test] public function sao_paulo_diacritics_are_normalized_for_location_matching(): void + { + $event = $this->createEvent( + eventName: 'IFSC World Cup Sao Paulo 2023', + location: 'Sao Paulo', + ); + $videoCollection = new YouTubeVideoCollection(); + $videoCollection->add(new YouTubeVideo( + title: 'Speed finals || São Paulo 2023', + duration: 82, + videoId: 'sao-paulo-id', + publishedAt: new DateTimeImmutable('2023-06-03T08:00:00Z'), + scheduledStartTime: null, + restrictedRegions: [], + )); + + $liveStream = $this->linkMatcher->findStreamUrlForRound( + event: $event, + roundName: 'Speed Finals', + videoCollection: $videoCollection, + ); + + $this->assertSame('https://youtu.be/sao-paulo-id', $liveStream->url); + } + + #[Test] public function opposite_gender_candidate_is_rejected(): void + { + $event = $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2023', + location: 'Salt Lake City', + ); + $videoCollection = new YouTubeVideoCollection(); + $videoCollection->add(new YouTubeVideo( + title: 'Men\'s Speed qualification || Salt Lake City 2023', + duration: 73, + videoId: 'men-only', + publishedAt: new DateTimeImmutable('2023-05-21T08:00:00Z'), + scheduledStartTime: null, + restrictedRegions: [], + )); + + $liveStream = $this->linkMatcher->findStreamUrlForRound( + event: $event, + roundName: 'Women\'s Speed Qualification', + videoCollection: $videoCollection, + ); + + $this->assertNull($liveStream->url); + } + + #[Test] public function paraclimbing_qualification_is_found_for_para_event(): void + { + $event = $this->createEvent( + eventName: 'IFSC Para Climbing World Cup Salt Lake City 2026', + location: 'Salt Lake City', + localStartDate: '2026-05-20T08:00:00Z', + localEndDate: '2026-05-22T20:00:00Z', + ); + $videoCollection = new YouTubeVideoCollection(); + $videoCollection->add(new YouTubeVideo( + title: 'Para Climbing qualification | Salt Lake City 2026', + duration: 0, + videoId: 'para-qual-id', + publishedAt: new DateTimeImmutable('2026-05-21T11:00:00Z'), + scheduledStartTime: new DateTimeImmutable('2026-05-21T12:00:00Z'), + restrictedRegions: [], + )); + + $liveStream = $this->linkMatcher->findStreamUrlForRound( + event: $event, + roundName: 'Paraclimbing Qualification', + videoCollection: $videoCollection, + ); + + $this->assertSame('https://youtu.be/para-qual-id', $liveStream->url); + } + private function createVideoCollection(): YouTubeVideoCollection { $titles = [ @@ -180,29 +320,42 @@ private function createVideoCollection(): YouTubeVideoCollection private function createEventWithNameAndDescription(string $roundName, string $eventName, string $location): LiveStream { - $event = new IFSCEventInfo( + $event = $this->createEvent($eventName, $location); + + return $this->linkMatcher->findStreamUrlForRound($event, $roundName, $this->createVideoCollection()); + } + + private function createEvent( + string $eventName, + string $location, + string $localStartDate = '2023-04-10T11:55:00Z', + string $localEndDate = '2023-04-10T12:55:00Z', + ): IFSCEventInfo { + return new IFSCEventInfo( eventId: 1292, eventName: $eventName, slug: 'ifsc-world-cup', leagueId: 37, leagueName: 'World Cups and World Championships', leagueSeasonId: 12, - localStartDate: '2023-04-10T11:55:00Z', - localEndDate: '2023-04-10T12:55:00Z', + localStartDate: $localStartDate, + localEndDate: $localEndDate, timeZone: new DateTimeZone('Europe/Madrid'), location: $location, country: 'JPN', disciplines: [], categories: [], ); - - return $this->linkMatcher->findStreamUrlForRound($event, $roundName, $this->createVideoCollection()); } protected function setUp(): void { + $tagsParser = new IFSCTagsParser(); + $textNormalizer = new YouTubeTextNormalizer(); + $this->linkMatcher = new YouTubeLinkMatcher( - new IFSCTagsParser(), + $tagsParser, + new YouTubeMatchScorer($tagsParser, $textNormalizer), ); } } diff --git a/tests/unit/Domain/YouTube/YouTubeMatchScorerTest.php b/tests/unit/Domain/YouTube/YouTubeMatchScorerTest.php new file mode 100644 index 0000000..5aed98c --- /dev/null +++ b/tests/unit/Domain/YouTube/YouTubeMatchScorerTest.php @@ -0,0 +1,250 @@ + + */ +namespace SportClimbing\IfscCalendar\tests\Domain\YouTube; + +use DateTimeImmutable; +use DateTimeZone; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use SportClimbing\IfscCalendar\Domain\Event\Info\IFSCEventInfo; +use SportClimbing\IfscCalendar\Domain\Event\IFSCEventTagsRegex as Tag; +use SportClimbing\IfscCalendar\Domain\Tags\IFSCTagsParser; +use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeMatchScorer; +use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeTextNormalizer; +use SportClimbing\IfscCalendar\Domain\YouTube\YouTubeVideo; + +final class YouTubeMatchScorerTest extends TestCase +{ + private readonly IFSCTagsParser $tagsParser; + private readonly YouTubeMatchScorer $matchScorer; + + #[Test] public function programmed_video_with_zero_duration_is_scored(): void + { + $video = $this->createVideo( + title: "Women's Speed qualification || Salt Lake City 2026", + duration: 0, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags("Women's Speed Qualification"), + event: $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2026', + location: 'Salt Lake City', + localStartDate: '2026-05-20T08:00:00Z', + localEndDate: '2026-05-22T20:00:00Z', + ), + ); + + $this->assertNotNull($score); + $this->assertGreaterThanOrEqual(14, $score); + } + + #[Test] public function opposite_gender_is_rejected(): void + { + $video = $this->createVideo( + title: "Men's Speed qualification || Salt Lake City 2026", + duration: 90, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags("Women's Speed Qualification"), + event: $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNull($score); + } + + #[Test] public function highlights_video_is_rejected(): void + { + $video = $this->createVideo( + title: "Women's Speed qualification highlights || Salt Lake City 2026", + duration: 4, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: null, + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags("Women's Speed Qualification"), + event: $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNull($score); + } + + #[Test] public function slc_alias_is_accepted_for_salt_lake_city_event(): void + { + $video = $this->createVideo( + title: "Women's Speed qualification || SLC 2026", + duration: 0, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags("Women's Speed Qualification"), + event: $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNotNull($score); + } + + #[Test] public function paraclimbing_video_is_rejected_for_non_para_event(): void + { + $video = $this->createVideo( + title: "Paraclimbing Speed qualification || Salt Lake City 2026", + duration: 90, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags("Women's Speed Qualification"), + event: $this->createEvent( + eventName: 'IFSC World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNull($score); + } + + #[Test] public function paraclimbing_qualification_is_scored_for_para_event(): void + { + $video = $this->createVideo( + title: 'Para Climbing qualification | Salt Lake City 2026', + duration: 0, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags('Paraclimbing Qualification'), + event: $this->createEvent( + eventName: 'IFSC Para Climbing World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNotNull($score); + } + + #[Test] public function non_paraclimbing_video_is_rejected_for_para_event(): void + { + $video = $this->createVideo( + title: "Women's Speed qualification || Salt Lake City 2026", + duration: 85, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags('Paraclimbing Qualification'), + event: $this->createEvent( + eventName: 'IFSC Para Climbing World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNull($score); + } + + #[Test] public function paraclimbing_semi_final_is_scored(): void + { + $video = $this->createVideo( + title: 'Para Climbing semi-final | Salt Lake City 2026', + duration: 120, + publishedAt: '2026-05-21T11:00:00Z', + scheduledStartTime: '2026-05-21T12:00:00Z', + ); + + $score = $this->matchScorer->score( + video: $video, + roundTags: $this->roundTags('Paraclimbing Semi-Final'), + event: $this->createEvent( + eventName: 'IFSC Para Climbing World Cup Salt Lake City 2026', + location: 'Salt Lake City', + ), + ); + + $this->assertNotNull($score); + } + + /** @return Tag[] */ + private function roundTags(string $roundName): array + { + return $this->tagsParser->fromString(mb_strtolower($roundName))->allTags(); + } + + private function createEvent( + string $eventName, + string $location, + string $localStartDate = '2026-05-20T08:00:00Z', + string $localEndDate = '2026-05-22T20:00:00Z', + ): IFSCEventInfo { + return new IFSCEventInfo( + eventId: 9000, + eventName: $eventName, + slug: 'ifsc-world-cup', + leagueId: 37, + leagueName: 'World Cups and World Championships', + leagueSeasonId: 99, + localStartDate: $localStartDate, + localEndDate: $localEndDate, + timeZone: new DateTimeZone('Europe/Madrid'), + location: $location, + country: 'USA', + disciplines: [], + categories: [], + ); + } + + private function createVideo( + string $title, + int $duration, + string $publishedAt, + ?string $scheduledStartTime, + ): YouTubeVideo { + return new YouTubeVideo( + title: $title, + duration: $duration, + videoId: 'video-id', + publishedAt: new DateTimeImmutable($publishedAt), + scheduledStartTime: $scheduledStartTime ? new DateTimeImmutable($scheduledStartTime) : null, + restrictedRegions: [], + ); + } + + protected function setUp(): void + { + $this->tagsParser = new IFSCTagsParser(); + $this->matchScorer = new YouTubeMatchScorer( + tagsParser: $this->tagsParser, + textNormalizer: new YouTubeTextNormalizer(), + ); + } +}