test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
2 changed files with 302 additions and 0 deletions
Showing only changes of commit e4706d6699 - Show all commits

View File

@@ -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
}
}

View File

@@ -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)
}
}