test #426
@@ -0,0 +1,131 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.domain
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class AudioRankingScorePolicy {
|
||||||
|
fun calculateWeeklyPopularScore(
|
||||||
|
revenue: Long,
|
||||||
|
salesCount: Long,
|
||||||
|
viewCount: Long,
|
||||||
|
likeCount: Long,
|
||||||
|
commentCount: Long,
|
||||||
|
isPaid: Boolean
|
||||||
|
): Double {
|
||||||
|
return if (isPaid) {
|
||||||
|
revenue * WEEKLY_PAID_REVENUE_WEIGHT +
|
||||||
|
salesCount * WEEKLY_PAID_SALES_COUNT_WEIGHT +
|
||||||
|
likeCount * WEEKLY_PAID_LIKE_COUNT_WEIGHT +
|
||||||
|
commentCount * WEEKLY_PAID_COMMENT_COUNT_WEIGHT
|
||||||
|
} else {
|
||||||
|
viewCount * WEEKLY_FREE_VIEW_COUNT_WEIGHT +
|
||||||
|
likeCount * WEEKLY_FREE_LIKE_COUNT_WEIGHT +
|
||||||
|
commentCount * WEEKLY_FREE_COMMENT_COUNT_WEIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeScore(currentScore: Double, maxScore: Double): Double {
|
||||||
|
if (maxScore <= 0.0) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentScore / maxScore * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateRisingScore(
|
||||||
|
recentSalesCount: Long,
|
||||||
|
previousSalesCount: Long,
|
||||||
|
recentViewCount: Long,
|
||||||
|
previousViewCount: Long,
|
||||||
|
recentLikeCount: Long,
|
||||||
|
previousLikeCount: Long,
|
||||||
|
recentCommentCount: Long,
|
||||||
|
previousCommentCount: Long,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
aggregationEndAt: LocalDateTime,
|
||||||
|
isPaid: Boolean
|
||||||
|
): Double {
|
||||||
|
val salesGrowth = applyMinimumThreshold(
|
||||||
|
growthRate(recentSalesCount, previousSalesCount),
|
||||||
|
recentSalesCount,
|
||||||
|
RISING_SALES_COUNT_THRESHOLD
|
||||||
|
)
|
||||||
|
val viewGrowth = applyMinimumThreshold(
|
||||||
|
growthRate(recentViewCount, previousViewCount),
|
||||||
|
recentViewCount,
|
||||||
|
RISING_VIEW_COUNT_THRESHOLD
|
||||||
|
)
|
||||||
|
val likeGrowth = applyMinimumThreshold(
|
||||||
|
growthRate(recentLikeCount, previousLikeCount),
|
||||||
|
recentLikeCount,
|
||||||
|
RISING_LIKE_COUNT_THRESHOLD
|
||||||
|
)
|
||||||
|
val commentGrowth = applyMinimumThreshold(
|
||||||
|
growthRate(recentCommentCount, previousCommentCount),
|
||||||
|
recentCommentCount,
|
||||||
|
RISING_COMMENT_COUNT_THRESHOLD
|
||||||
|
)
|
||||||
|
val contentGrowthScore = if (isPaid) {
|
||||||
|
salesGrowth * RISING_PAID_SALES_GROWTH_WEIGHT +
|
||||||
|
viewGrowth * RISING_PAID_VIEW_GROWTH_WEIGHT
|
||||||
|
} else {
|
||||||
|
viewGrowth * RISING_FREE_VIEW_GROWTH_WEIGHT +
|
||||||
|
likeGrowth * RISING_FREE_LIKE_GROWTH_WEIGHT +
|
||||||
|
commentGrowth * RISING_FREE_COMMENT_GROWTH_WEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
contentGrowthScore * RISING_CONTENT_GROWTH_SCORE_WEIGHT +
|
||||||
|
likeGrowth * RISING_LIKE_GROWTH_WEIGHT +
|
||||||
|
commentGrowth * RISING_COMMENT_GROWTH_WEIGHT
|
||||||
|
) * releaseBoost(releaseDate, aggregationEndAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyMinimumThreshold(growthRate: Double, recentCount: Long, minimumThreshold: Long): Double {
|
||||||
|
return if (recentCount < minimumThreshold) 0.0 else growthRate
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseBoost(releaseDate: LocalDateTime, aggregationEndAt: LocalDateTime): Double {
|
||||||
|
val days = ChronoUnit.DAYS.between(releaseDate, aggregationEndAt).coerceAtLeast(0)
|
||||||
|
return when {
|
||||||
|
days <= 3 -> RELEASE_BOOST_WITHIN_THREE_DAYS
|
||||||
|
days <= 7 -> RELEASE_BOOST_WITHIN_SEVEN_DAYS
|
||||||
|
days <= 14 -> RELEASE_BOOST_WITHIN_FOURTEEN_DAYS
|
||||||
|
else -> RELEASE_BOOST_DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun growthRate(recentCount: Long, previousCount: Long): Double {
|
||||||
|
return (recentCount - previousCount).toDouble() / max(previousCount, 1).toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WEEKLY_PAID_REVENUE_WEIGHT = 0.45
|
||||||
|
const val WEEKLY_PAID_SALES_COUNT_WEIGHT = 0.35
|
||||||
|
const val WEEKLY_PAID_LIKE_COUNT_WEIGHT = 0.1
|
||||||
|
const val WEEKLY_PAID_COMMENT_COUNT_WEIGHT = 0.1
|
||||||
|
const val WEEKLY_FREE_VIEW_COUNT_WEIGHT = 0.5
|
||||||
|
const val WEEKLY_FREE_LIKE_COUNT_WEIGHT = 0.25
|
||||||
|
const val WEEKLY_FREE_COMMENT_COUNT_WEIGHT = 0.25
|
||||||
|
|
||||||
|
const val RISING_CONTENT_GROWTH_SCORE_WEIGHT = 0.5
|
||||||
|
const val RISING_LIKE_GROWTH_WEIGHT = 0.25
|
||||||
|
const val RISING_COMMENT_GROWTH_WEIGHT = 0.25
|
||||||
|
const val RISING_PAID_SALES_GROWTH_WEIGHT = 0.6
|
||||||
|
const val RISING_PAID_VIEW_GROWTH_WEIGHT = 0.4
|
||||||
|
const val RISING_FREE_VIEW_GROWTH_WEIGHT = 0.5
|
||||||
|
const val RISING_FREE_LIKE_GROWTH_WEIGHT = 0.25
|
||||||
|
const val RISING_FREE_COMMENT_GROWTH_WEIGHT = 0.25
|
||||||
|
|
||||||
|
const val RISING_VIEW_COUNT_THRESHOLD = 10L
|
||||||
|
const val RISING_LIKE_COUNT_THRESHOLD = 3L
|
||||||
|
const val RISING_COMMENT_COUNT_THRESHOLD = 3L
|
||||||
|
const val RISING_SALES_COUNT_THRESHOLD = 3L
|
||||||
|
|
||||||
|
const val RELEASE_BOOST_WITHIN_THREE_DAYS = 1.5
|
||||||
|
const val RELEASE_BOOST_WITHIN_SEVEN_DAYS = 1.3
|
||||||
|
const val RELEASE_BOOST_WITHIN_FOURTEEN_DAYS = 1.15
|
||||||
|
const val RELEASE_BOOST_DEFAULT = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.domain
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class AudioRankingScorePolicyTest {
|
||||||
|
private val policy = AudioRankingScorePolicy()
|
||||||
|
private val aggregationEndAt = LocalDateTime.of(2026, 6, 22, 0, 0)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("유료 주간 인기 원점수는 매출 45%, 판매량 35%, 좋아요 10%, 댓글 10%로 계산한다")
|
||||||
|
fun shouldCalculatePaidWeeklyPopularRawScore() {
|
||||||
|
assertEquals(0.45, AudioRankingScorePolicy.WEEKLY_PAID_REVENUE_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.35, AudioRankingScorePolicy.WEEKLY_PAID_SALES_COUNT_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.1, AudioRankingScorePolicy.WEEKLY_PAID_LIKE_COUNT_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.1, AudioRankingScorePolicy.WEEKLY_PAID_COMMENT_COUNT_WEIGHT, 0.0001)
|
||||||
|
|
||||||
|
val score = policy.calculateWeeklyPopularScore(
|
||||||
|
revenue = 1000,
|
||||||
|
salesCount = 100,
|
||||||
|
viewCount = 0,
|
||||||
|
likeCount = 30,
|
||||||
|
commentCount = 20,
|
||||||
|
isPaid = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(490.0, score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("무료 주간 인기 원점수는 조회수 50%, 좋아요 25%, 댓글 25%로 계산한다")
|
||||||
|
fun shouldCalculateFreeWeeklyPopularRawScore() {
|
||||||
|
assertEquals(0.5, AudioRankingScorePolicy.WEEKLY_FREE_VIEW_COUNT_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.25, AudioRankingScorePolicy.WEEKLY_FREE_LIKE_COUNT_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.25, AudioRankingScorePolicy.WEEKLY_FREE_COMMENT_COUNT_WEIGHT, 0.0001)
|
||||||
|
|
||||||
|
val score = policy.calculateWeeklyPopularScore(
|
||||||
|
revenue = 0,
|
||||||
|
salesCount = 0,
|
||||||
|
viewCount = 200,
|
||||||
|
likeCount = 20,
|
||||||
|
commentCount = 8,
|
||||||
|
isPaid = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(107.0, score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("정규화 점수는 그룹 최고 점수 기준 0~100으로 계산하고 최고 점수가 0 이하면 0으로 처리한다")
|
||||||
|
fun shouldNormalizeScoreToZeroToOneHundred() {
|
||||||
|
assertEquals(100.0, policy.normalizeScore(currentScore = 80.0, maxScore = 80.0), 0.0001)
|
||||||
|
assertEquals(25.0, policy.normalizeScore(currentScore = 20.0, maxScore = 80.0), 0.0001)
|
||||||
|
assertEquals(0.0, policy.normalizeScore(currentScore = 20.0, maxScore = 0.0), 0.0001)
|
||||||
|
assertEquals(0.0, policy.normalizeScore(currentScore = -20.0, maxScore = -10.0), 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("유료 지금 뜨는 중 점수는 판매/조회 콘텐츠 성장 점수와 좋아요/댓글 증가율 및 신규 부스트로 계산한다")
|
||||||
|
fun shouldCalculatePaidRisingScore() {
|
||||||
|
assertEquals(0.5, AudioRankingScorePolicy.RISING_CONTENT_GROWTH_SCORE_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.25, AudioRankingScorePolicy.RISING_LIKE_GROWTH_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.25, AudioRankingScorePolicy.RISING_COMMENT_GROWTH_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.6, AudioRankingScorePolicy.RISING_PAID_SALES_GROWTH_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.4, AudioRankingScorePolicy.RISING_PAID_VIEW_GROWTH_WEIGHT, 0.0001)
|
||||||
|
|
||||||
|
val score = policy.calculateRisingScore(
|
||||||
|
recentSalesCount = 9,
|
||||||
|
previousSalesCount = 3,
|
||||||
|
recentViewCount = 30,
|
||||||
|
previousViewCount = 10,
|
||||||
|
recentLikeCount = 8,
|
||||||
|
previousLikeCount = 4,
|
||||||
|
recentCommentCount = 6,
|
||||||
|
previousCommentCount = 3,
|
||||||
|
releaseDate = aggregationEndAt.minusDays(2),
|
||||||
|
aggregationEndAt = aggregationEndAt,
|
||||||
|
isPaid = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2.25, score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("무료 지금 뜨는 중 점수는 조회/좋아요/댓글 콘텐츠 성장 점수와 좋아요/댓글 증가율 및 신규 부스트로 계산한다")
|
||||||
|
fun shouldCalculateFreeRisingScore() {
|
||||||
|
assertEquals(0.5, AudioRankingScorePolicy.RISING_FREE_VIEW_GROWTH_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.25, AudioRankingScorePolicy.RISING_FREE_LIKE_GROWTH_WEIGHT, 0.0001)
|
||||||
|
assertEquals(0.25, AudioRankingScorePolicy.RISING_FREE_COMMENT_GROWTH_WEIGHT, 0.0001)
|
||||||
|
|
||||||
|
val score = policy.calculateRisingScore(
|
||||||
|
recentSalesCount = 0,
|
||||||
|
previousSalesCount = 0,
|
||||||
|
recentViewCount = 30,
|
||||||
|
previousViewCount = 10,
|
||||||
|
recentLikeCount = 8,
|
||||||
|
previousLikeCount = 4,
|
||||||
|
recentCommentCount = 6,
|
||||||
|
previousCommentCount = 3,
|
||||||
|
releaseDate = aggregationEndAt.minusDays(8),
|
||||||
|
aggregationEndAt = aggregationEndAt,
|
||||||
|
isPaid = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1.4375, score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최소 기준 미만 지표만 증가율 반영값을 0으로 처리한다")
|
||||||
|
fun shouldApplyMinimumThresholdPerMetric() {
|
||||||
|
assertEquals(10, AudioRankingScorePolicy.RISING_VIEW_COUNT_THRESHOLD)
|
||||||
|
assertEquals(3, AudioRankingScorePolicy.RISING_LIKE_COUNT_THRESHOLD)
|
||||||
|
assertEquals(3, AudioRankingScorePolicy.RISING_COMMENT_COUNT_THRESHOLD)
|
||||||
|
assertEquals(3, AudioRankingScorePolicy.RISING_SALES_COUNT_THRESHOLD)
|
||||||
|
|
||||||
|
assertEquals(0.0, policy.applyMinimumThreshold(growthRate = 5.0, recentCount = 9, minimumThreshold = 10), 0.0001)
|
||||||
|
assertEquals(5.0, policy.applyMinimumThreshold(growthRate = 5.0, recentCount = 10, minimumThreshold = 10), 0.0001)
|
||||||
|
|
||||||
|
val score = policy.calculateRisingScore(
|
||||||
|
recentSalesCount = 2,
|
||||||
|
previousSalesCount = 1,
|
||||||
|
recentViewCount = 9,
|
||||||
|
previousViewCount = 1,
|
||||||
|
recentLikeCount = 2,
|
||||||
|
previousLikeCount = 1,
|
||||||
|
recentCommentCount = 3,
|
||||||
|
previousCommentCount = 1,
|
||||||
|
releaseDate = aggregationEndAt.minusDays(20),
|
||||||
|
aggregationEndAt = aggregationEndAt,
|
||||||
|
isPaid = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0.5, score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최소 기준을 통과한 지표의 음수 증가율은 지금 뜨는 중 점수에 그대로 반영한다")
|
||||||
|
fun shouldPreserveNegativeGrowthWhenThresholdPasses() {
|
||||||
|
val score = policy.calculateRisingScore(
|
||||||
|
recentSalesCount = 3,
|
||||||
|
previousSalesCount = 6,
|
||||||
|
recentViewCount = 10,
|
||||||
|
previousViewCount = 20,
|
||||||
|
recentLikeCount = 3,
|
||||||
|
previousLikeCount = 6,
|
||||||
|
recentCommentCount = 3,
|
||||||
|
previousCommentCount = 6,
|
||||||
|
releaseDate = aggregationEndAt.minusDays(20),
|
||||||
|
aggregationEndAt = aggregationEndAt,
|
||||||
|
isPaid = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(-0.5, score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("신규 콘텐츠 부스트는 집계 종료일 기준 3일/7일/14일 경계를 포함해 적용한다")
|
||||||
|
fun shouldReturnReleaseBoostByBoundaries() {
|
||||||
|
assertEquals(1.5, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_THREE_DAYS, 0.0001)
|
||||||
|
assertEquals(1.3, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_SEVEN_DAYS, 0.0001)
|
||||||
|
assertEquals(1.15, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_FOURTEEN_DAYS, 0.0001)
|
||||||
|
assertEquals(1.0, AudioRankingScorePolicy.RELEASE_BOOST_DEFAULT, 0.0001)
|
||||||
|
|
||||||
|
assertEquals(1.5, policy.releaseBoost(aggregationEndAt.minusDays(3), aggregationEndAt), 0.0001)
|
||||||
|
assertEquals(1.3, policy.releaseBoost(aggregationEndAt.minusDays(7), aggregationEndAt), 0.0001)
|
||||||
|
assertEquals(1.15, policy.releaseBoost(aggregationEndAt.minusDays(14), aggregationEndAt), 0.0001)
|
||||||
|
assertEquals(1.0, policy.releaseBoost(aggregationEndAt.minusDays(15), aggregationEndAt), 0.0001)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user