diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt index 20cdd47d..2c8f73fd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt @@ -1,8 +1,91 @@ package kr.co.vividnext.sodalive.v2.recommend.application import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class HomeRecommendationQueryService( + private val queryPort: HomeRecommendationQueryPort, + private val snapshotPort: RecommendationSnapshotPort +) { + fun findLiveRecommendations(limit: Int = DEFAULT_LIVE_LIMIT): List { + return queryPort.findLiveRecommendations(limit) + } + + fun findHomeBanners(limit: Int = DEFAULT_BANNER_LIMIT): List { + return queryPort.findHomeBanners(limit) + } + + fun findRecentlyActiveCreators(limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT): List { + return queryPort.findRecentlyActiveCreators(limit) + } + + fun findRecentDebutCreators( + now: LocalDateTime, + limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT + ): List { + return queryPort.findRecentDebutCreators(now, limit) + } + + fun findFirstAudioContents( + now: LocalDateTime, + limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT + ): List { + return queryPort.findFirstAudioContents(now, limit) + } + + fun findAiCharacterRecommendations( + limit: Int = DEFAULT_AI_CHARACTER_LIMIT + ): List { + val snapshots = latestSnapshots(RecommendedSectionType.AI_CHARACTER, limit) + val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) + .associateBy { it.characterId } + + return snapshots.mapNotNull { detailsById[it.targetId] } + } + + fun findCheerCreatorRecommendations( + limit: Int = DEFAULT_CHEER_CREATOR_LIMIT + ): List { + val snapshots = latestSnapshots(RecommendedSectionType.CHEER_CREATOR, limit) + val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }) + .associateBy { it.creatorId } + + return snapshots.mapNotNull { detailsById[it.targetId] } + } + + fun findPopularCommunityRecommendations( + limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, + includeAdultCommunities: Boolean = false + ): List { + val snapshots = latestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_CANDIDATE_LIMIT) + val detailsById = queryPort.findPopularCommunityRecommendationDetails( + snapshots.map { it.targetId }, + includeAdultCommunities + ) + .associateBy { it.communityId } + val selectedCreatorIds = mutableSetOf() + + return snapshots.mapNotNull { snapshot -> + detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) } + }.take(limit) + } -class HomeRecommendationQueryService { fun resolveAudioContentActivityType(theme: String): RecommendedActivityType { return if (theme == LIVE_REPLAY_THEME) { RecommendedActivityType.LIVE_REPLAY @@ -11,7 +94,20 @@ class HomeRecommendationQueryService { } } + private fun latestSnapshots(sectionType: RecommendedSectionType, limit: Int): List { + return snapshotPort.findLatestSnapshots(sectionType).take(limit) + } + companion object { + private const val DEFAULT_LIVE_LIMIT = 20 + private const val DEFAULT_BANNER_LIMIT = 20 + private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10 + private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10 + private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10 + private const val DEFAULT_AI_CHARACTER_LIMIT = 10 + private const val DEFAULT_CHEER_CREATOR_LIMIT = 8 + private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10 + private const val POPULAR_COMMUNITY_CANDIDATE_LIMIT = 20 private const val LIVE_REPLAY_THEME = "다시듣기" } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt index 053998b6..64820de5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt @@ -1,8 +1,19 @@ package kr.co.vividnext.sodalive.v2.recommend.port.out +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType import java.time.LocalDateTime interface HomeRecommendationQueryPort { + fun findLiveRecommendations(limit: Int): List + + fun findHomeBanners(limit: Int): List + + fun findRecentlyActiveCreators(limit: Int): List + + fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List + + fun findFirstAudioContents(now: LocalDateTime, limit: Int): List + fun findAiCharacterSnapshots( windowStart: LocalDateTime, snapshotAt: LocalDateTime, @@ -20,4 +31,91 @@ interface HomeRecommendationQueryPort { snapshotAt: LocalDateTime, limit: Int ): List + + fun findAiCharacterRecommendationDetails(characterIds: List): List + + fun findCheerCreatorRecommendationDetails(creatorIds: List): List + + fun findPopularCommunityRecommendationDetails( + communityIds: List, + includeAdultCommunities: Boolean + ): List } + +data class HomeLiveRecommendationRecord( + val liveRoomId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val title: String, + val coverImage: String?, + val beginDateTime: LocalDateTime, + val channelName: String +) + +data class HomeBannerRecommendationRecord( + val bannerId: Long, + val type: String, + val thumbnailImage: String, + val eventId: Long?, + val creatorId: Long?, + val seriesId: Long?, + val link: String?, + val orders: Int, + val randomTieBreaker: Double +) + +data class RecentlyActiveCreatorRecord( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val activityType: RecommendedActivityType, + val activityAt: LocalDateTime, + val targetId: Long? +) + +data class RecentDebutCreatorRecord( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val debutAt: LocalDateTime, + val score: Double, + val randomTieBreaker: Double +) + +data class HomeFirstAudioContentRecord( + val contentId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val title: String, + val coverImage: String?, + val releaseDate: LocalDateTime, + val recencyScore: Int, + val randomTieBreaker: Double +) + +data class HomeAiCharacterRecommendationRecord( + val characterId: Long, + val name: String, + val description: String, + val totalChatCount: Long, + val originalWorkTitle: String? +) + +data class HomeCheerCreatorRecommendationRecord( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String? +) + +data class HomePopularCommunityRecommendationRecord( + val communityId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val content: String, + val createdAt: LocalDateTime, + val likeCount: Long, + val commentCount: Long +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt index a8ebd10b..471dbdf1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt @@ -2,12 +2,26 @@ package kr.co.vividnext.sodalive.v2.recommend.application import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import java.time.LocalDateTime class HomeRecommendationQueryServiceTest { - private val service = HomeRecommendationQueryService() + private val port = FakeHomeRecommendationQueryPort() + private val snapshotPort = FakeHomeRecommendationSnapshotPort() + private val service = HomeRecommendationQueryService(port, snapshotPort) @Test @DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다") @@ -47,4 +61,398 @@ class HomeRecommendationQueryServiceTest { assertEquals("CHEER_CREATOR", RecommendedSectionType.CHEER_CREATOR.code) assertEquals("POPULAR_COMMUNITY", RecommendedSectionType.POPULAR_COMMUNITY.code) } + + @Test + @DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다") + fun shouldFindLatestLiveRecommendationsWithDefaultLimit() { + val recommendations = service.findLiveRecommendations() + + assertEquals(20, port.liveLimit) + assertEquals(port.liveRecommendations, recommendations) + } + + @Test + @DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다") + fun shouldFindHomeBannersWithDefaultLimit() { + val banners = service.findHomeBanners() + + assertEquals(20, port.bannerLimit) + assertEquals(port.banners, banners) + } + + @Test + @DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다") + fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() { + val creators = service.findRecentlyActiveCreators() + + assertEquals(10, port.activeCreatorLimit) + assertEquals(port.activeCreators, creators) + } + + @Test + @DisplayName("최근 데뷔 크리에이터는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다") + fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() { + val now = LocalDateTime.of(2026, 5, 31, 10, 0) + + val creators = service.findRecentDebutCreators(now) + + assertEquals(now, port.recentDebutNow) + assertEquals(10, port.recentDebutLimit) + assertEquals(port.recentDebutCreators, creators) + } + + @Test + @DisplayName("첫 오디오 콘텐츠는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다") + fun shouldFindFirstAudioContentsWithDefaultLimitAndNow() { + val now = LocalDateTime.of(2026, 5, 31, 10, 0) + + val contents = service.findFirstAudioContents(now) + + assertEquals(now, port.firstAudioNow) + assertEquals(10, port.firstAudioLimit) + assertEquals(port.firstAudioContents, contents) + } + + @Test + @DisplayName("AI 캐릭터 추천은 최신 스냅샷 10개를 기준으로 순서를 유지해 상세를 조립한다") + fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() { + val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) + val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + oldSnapshotAt, + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 999.0, oldSnapshotAt)) + ) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + latestSnapshotAt, + (1L..12L).map { targetId -> + snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt) + } + ) + port.aiCharacterDetails = listOf( + HomeAiCharacterRecommendationRecord( + characterId = 1L, + name = "character-1", + description = "description-1", + totalChatCount = 3L, + originalWorkTitle = "original-work" + ), + HomeAiCharacterRecommendationRecord( + characterId = 2L, + name = "character-2", + description = "description-2", + totalChatCount = 0L, + originalWorkTitle = null + ) + ) + + val characters = service.findAiCharacterRecommendations() + + assertEquals((1L..10L).toList(), port.aiCharacterDetailIds) + assertEquals(listOf(1L, 2L), characters.map { it.characterId }) + assertEquals("original-work", characters.first().originalWorkTitle) + assertEquals(null, characters.last().originalWorkTitle) + } + + @Test + @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다") + fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + snapshotAt, + (1L..9L).map { targetId -> + snapshot(RecommendedSectionType.CHEER_CREATOR, targetId, 100.0 - targetId, snapshotAt) + } + ) + port.cheerCreatorDetails = listOf( + HomeCheerCreatorRecommendationRecord( + creatorId = 1L, + creatorNickname = "creator-1", + creatorProfileImage = "profile-1.png" + ), + HomeCheerCreatorRecommendationRecord( + creatorId = 2L, + creatorNickname = "creator-2", + creatorProfileImage = null + ) + ) + + val creators = service.findCheerCreatorRecommendations() + + assertEquals((1L..8L).toList(), port.cheerCreatorDetailIds) + assertEquals(listOf(1L, 2L), creators.map { it.creatorId }) + assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname }) + } + + @Test + @DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") + fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.POPULAR_COMMUNITY, + snapshotAt, + (1L..11L).map { targetId -> + snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt) + } + ) + port.popularCommunityDetails = listOf( + HomePopularCommunityRecommendationRecord( + communityId = 1L, + creatorId = 10L, + creatorNickname = "creator-10", + creatorProfileImage = "profile-10.png", + content = "content-1", + createdAt = LocalDateTime.of(2026, 5, 29, 1, 0), + likeCount = 3L, + commentCount = 2L + ), + HomePopularCommunityRecommendationRecord( + communityId = 2L, + creatorId = 10L, + creatorNickname = "creator-10", + creatorProfileImage = "profile-10.png", + content = "content-2", + createdAt = LocalDateTime.of(2026, 5, 29, 2, 0), + likeCount = 1L, + commentCount = 1L + ), + HomePopularCommunityRecommendationRecord( + communityId = 3L, + creatorId = 11L, + creatorNickname = "creator-11", + creatorProfileImage = null, + content = "content-3", + createdAt = LocalDateTime.of(2026, 5, 29, 3, 0), + likeCount = 0L, + commentCount = 0L + ) + ) + + val communities = service.findPopularCommunityRecommendations(includeAdultCommunities = true) + + assertEquals((1L..11L).toList(), port.popularCommunityDetailIds) + assertEquals(true, port.popularCommunityIncludeAdultCommunities) + assertEquals(listOf(1L, 3L), communities.map { it.communityId }) + assertEquals(listOf(10L, 11L), communities.map { it.creatorId }) + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), communities.first().createdAt) + } + + @Test + @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") + fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.POPULAR_COMMUNITY, + snapshotAt, + (1L..20L).map { targetId -> + snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt) + } + ) + port.popularCommunityDetails = (1L..20L).map { communityId -> + HomePopularCommunityRecommendationRecord( + communityId = communityId, + creatorId = if (communityId <= 10L) 1L else communityId, + creatorNickname = "creator-$communityId", + creatorProfileImage = null, + content = "content-$communityId", + createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId), + likeCount = 0L, + commentCount = 0L + ) + } + + val communities = service.findPopularCommunityRecommendations() + + assertEquals(20, port.popularCommunityDetailIds.size) + assertEquals(10, communities.size) + assertEquals(listOf(1L) + (11L..19L).toList(), communities.map { it.communityId }) + assertEquals(communities.size, communities.map { it.creatorId }.toSet().size) + } + + @Test + @DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다") + fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() { + assertEquals(emptyList(), service.findAiCharacterRecommendations()) + assertEquals(emptyList(), service.findCheerCreatorRecommendations()) + assertEquals(emptyList(), service.findPopularCommunityRecommendations()) + } + + private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { + var liveLimit: Int? = null + var bannerLimit: Int? = null + var activeCreatorLimit: Int? = null + var recentDebutNow: LocalDateTime? = null + var recentDebutLimit: Int? = null + var firstAudioNow: LocalDateTime? = null + var firstAudioLimit: Int? = null + var aiCharacterDetailIds: List = emptyList() + var cheerCreatorDetailIds: List = emptyList() + var popularCommunityDetailIds: List = emptyList() + var popularCommunityIncludeAdultCommunities: Boolean? = null + val liveRecommendations = listOf( + HomeLiveRecommendationRecord( + liveRoomId = 1L, + creatorId = 10L, + creatorNickname = "creator", + creatorProfileImage = "profile.png", + title = "live", + coverImage = "cover.png", + beginDateTime = LocalDateTime.of(2026, 5, 31, 10, 0), + channelName = "channel" + ) + ) + val banners = listOf( + HomeBannerRecommendationRecord( + bannerId = 2L, + type = "LINK", + thumbnailImage = "banner.png", + eventId = null, + creatorId = null, + seriesId = null, + link = "https://example.com", + orders = 1, + randomTieBreaker = 0.1 + ) + ) + val activeCreators = listOf( + RecentlyActiveCreatorRecord( + creatorId = 10L, + creatorNickname = "creator", + creatorProfileImage = "profile.png", + activityType = RecommendedActivityType.LIVE, + activityAt = LocalDateTime.of(2026, 5, 31, 10, 0), + targetId = null + ) + ) + val recentDebutCreators = listOf( + RecentDebutCreatorRecord( + creatorId = 11L, + creatorNickname = "debut-creator", + creatorProfileImage = "debut-profile.png", + debutAt = LocalDateTime.of(2026, 5, 20, 10, 0), + score = 1.2, + randomTieBreaker = 0.2 + ) + ) + val firstAudioContents = listOf( + HomeFirstAudioContentRecord( + contentId = 21L, + creatorId = 11L, + creatorNickname = "debut-creator", + creatorProfileImage = "debut-profile.png", + title = "first-audio", + coverImage = "first-audio.png", + releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0), + recencyScore = 100, + randomTieBreaker = 0.3 + ) + ) + var aiCharacterDetails: List = emptyList() + var cheerCreatorDetails: List = emptyList() + var popularCommunityDetails: List = emptyList() + + override fun findLiveRecommendations(limit: Int): List { + liveLimit = limit + return liveRecommendations + } + + override fun findHomeBanners(limit: Int): List { + bannerLimit = limit + return banners + } + + override fun findRecentlyActiveCreators(limit: Int): List { + activeCreatorLimit = limit + return activeCreators + } + + override fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List { + recentDebutNow = now + recentDebutLimit = limit + return recentDebutCreators + } + + override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List { + firstAudioNow = now + firstAudioLimit = limit + return firstAudioContents + } + + override fun findAiCharacterSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List = emptyList() + + override fun findCheerCreatorSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List = emptyList() + + override fun findPopularCommunitySnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List = emptyList() + + override fun findAiCharacterRecommendationDetails(characterIds: List): List { + aiCharacterDetailIds = characterIds + return aiCharacterDetails + } + + override fun findCheerCreatorRecommendationDetails(creatorIds: List): List { + cheerCreatorDetailIds = creatorIds + return cheerCreatorDetails + } + + override fun findPopularCommunityRecommendationDetails( + communityIds: List, + includeAdultCommunities: Boolean + ): List { + popularCommunityDetailIds = communityIds + popularCommunityIncludeAdultCommunities = includeAdultCommunities + return popularCommunityDetails + } + } +} + +private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { + private val snapshots = mutableListOf() + + override fun findLatestSnapshots(sectionType: RecommendedSectionType): List { + val latestSnapshotAt = snapshots + .filter { it.sectionType == sectionType } + .maxOfOrNull { it.snapshotAt } + + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } + .sortedWith(compareByDescending { it.score }.thenBy { it.randomTieBreaker }) + } + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) { + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + snapshots.addAll(newSnapshots) + } +} + +private fun snapshot( + sectionType: RecommendedSectionType, + targetId: Long, + score: Double, + snapshotAt: LocalDateTime +): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = score, + snapshotAt = snapshotAt, + randomTieBreaker = targetId.toDouble() / 100 + ) }