test #433
@@ -483,74 +483,89 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
|
|
||||||
override fun findAiCharacterSnapshots(
|
override fun findAiCharacterSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
windowEndExclusive: LocalDateTime,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord> {
|
): List<RecommendationSnapshotRecord> {
|
||||||
val sql = """
|
val sql = """
|
||||||
|
select ranked.target_id,
|
||||||
|
ranked.score,
|
||||||
|
ranked.snapshot_rank as random_tie_breaker
|
||||||
|
from (
|
||||||
|
select scored.target_id,
|
||||||
|
scored.score,
|
||||||
|
row_number() over (order by scored.score desc, scored.character_created_at desc, scored.target_id desc) as snapshot_rank
|
||||||
|
from (
|
||||||
select cc.id as target_id,
|
select cc.id as target_id,
|
||||||
(((select count(cm.id)
|
cc.created_at as character_created_at,
|
||||||
from chat_message cm
|
((coalesce(acs.ai_chat_count, 0) * ${RecommendationScoreSpec.AI_CHAT_WEIGHT} +
|
||||||
join chat_participant cp on cp.id = cm.participant_id
|
coalesce(aus.active_user_count, 0) * ${RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT} +
|
||||||
where cp.character_id = cc.id
|
coalesce(fs.follow_increase_count, 0) * ${RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT}) *
|
||||||
and cm.created_at >= :windowStart
|
|
||||||
and cm.created_at <= :snapshotAt
|
|
||||||
and cm.is_active = true
|
|
||||||
and cp.is_active = true
|
|
||||||
and cp.participant_type = 'CHARACTER') * ${RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT} +
|
|
||||||
(select count(distinct up.member_id)
|
|
||||||
from chat_message cm
|
|
||||||
join chat_participant up on up.id = cm.participant_id
|
|
||||||
join chat_participant cp on cp.chat_room_id = cm.chat_room_id
|
|
||||||
join member m on m.id = up.member_id
|
|
||||||
where cp.character_id = cc.id
|
|
||||||
and cm.created_at >= :windowStart
|
|
||||||
and cm.created_at <= :snapshotAt
|
|
||||||
and cm.is_active = true
|
|
||||||
and up.is_active = true
|
|
||||||
and cp.is_active = true
|
|
||||||
and up.participant_type = 'USER'
|
|
||||||
and cp.participant_type = 'CHARACTER'
|
|
||||||
and m.is_active = true) * ${RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT}) *
|
|
||||||
case
|
case
|
||||||
when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS}
|
when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS}
|
||||||
when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS}
|
when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS}
|
||||||
when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS}
|
when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.AI_NEW_BOOST_30_DAYS}
|
||||||
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
|
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
|
||||||
end) as score,
|
end) as score
|
||||||
rand() as random_tie_breaker
|
|
||||||
from chat_character cc
|
from chat_character cc
|
||||||
where cc.is_active = true
|
join member creator_member on creator_member.id = cc.creator_member_id
|
||||||
and (exists (
|
left join (
|
||||||
select 1
|
select cp.character_id as character_id, count(cm.id) as ai_chat_count
|
||||||
from chat_message cm
|
from chat_message cm
|
||||||
join chat_participant cp on cp.id = cm.participant_id
|
join chat_participant cp on cp.id = cm.participant_id
|
||||||
where cp.character_id = cc.id
|
where cm.created_at >= :windowStart
|
||||||
and cm.created_at >= :windowStart
|
and cm.created_at < :windowEndExclusive
|
||||||
and cm.created_at <= :snapshotAt
|
|
||||||
and cm.is_active = true
|
and cm.is_active = true
|
||||||
and cp.is_active = true
|
and cp.is_active = true
|
||||||
and cp.participant_type = 'CHARACTER'
|
and cp.participant_type = 'CHARACTER'
|
||||||
) or exists (
|
group by cp.character_id
|
||||||
select 1
|
) acs on acs.character_id = cc.id
|
||||||
|
left join (
|
||||||
|
select cp.character_id as character_id, count(distinct up.member_id) as active_user_count
|
||||||
from chat_message cm
|
from chat_message cm
|
||||||
join chat_participant up on up.id = cm.participant_id
|
join chat_participant up on up.id = cm.participant_id
|
||||||
join chat_participant cp on cp.chat_room_id = cm.chat_room_id
|
join chat_participant cp on cp.chat_room_id = cm.chat_room_id
|
||||||
join member m on m.id = up.member_id
|
join member m on m.id = up.member_id
|
||||||
where cp.character_id = cc.id
|
where cm.created_at >= :windowStart
|
||||||
and cm.created_at >= :windowStart
|
and cm.created_at < :windowEndExclusive
|
||||||
and cm.created_at <= :snapshotAt
|
|
||||||
and cm.is_active = true
|
and cm.is_active = true
|
||||||
and up.is_active = true
|
and up.is_active = true
|
||||||
and cp.is_active = true
|
and cp.is_active = true
|
||||||
and up.participant_type = 'USER'
|
and up.participant_type = 'USER'
|
||||||
and cp.participant_type = 'CHARACTER'
|
and cp.participant_type = 'CHARACTER'
|
||||||
and m.is_active = true
|
and m.is_active = true
|
||||||
))
|
group by cp.character_id
|
||||||
order by score desc, random_tie_breaker asc
|
) aus on aus.character_id = cc.id
|
||||||
|
left join (
|
||||||
|
select cf.creator_id as creator_id, count(cf.id) as follow_increase_count
|
||||||
|
from creator_following cf
|
||||||
|
join member follower_member on follower_member.id = cf.member_id
|
||||||
|
where cf.created_at >= :windowStart
|
||||||
|
and cf.created_at < :windowEndExclusive
|
||||||
|
and cf.is_active = true
|
||||||
|
and follower_member.is_active = true
|
||||||
|
group by cf.creator_id
|
||||||
|
) fs on fs.creator_id = cc.creator_member_id
|
||||||
|
where cc.is_active = true
|
||||||
|
and cc.created_at is not null
|
||||||
|
and cc.created_at < :windowEndExclusive
|
||||||
|
and creator_member.is_active = true
|
||||||
|
and creator_member.role = 'CREATOR'
|
||||||
|
and creator_member.member_kind = 'AI_CHARACTER'
|
||||||
|
) scored
|
||||||
|
where scored.score > 0
|
||||||
|
) ranked
|
||||||
|
order by ranked.score desc, ranked.snapshot_rank asc
|
||||||
limit :limit
|
limit :limit
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
return executeSnapshotQuery(sql, RecommendedSectionType.AI_CHARACTER, windowStart, snapshotAt, limit)
|
return executeSnapshotQuery(
|
||||||
|
sql,
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
windowStart,
|
||||||
|
windowEndExclusive.minusSeconds(1),
|
||||||
|
limit,
|
||||||
|
windowEndExclusive
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findCheerCreatorSnapshots(
|
override fun findCheerCreatorSnapshots(
|
||||||
@@ -1129,12 +1144,19 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
limit: Int
|
limit: Int,
|
||||||
|
windowEndExclusive: LocalDateTime? = null
|
||||||
): List<RecommendationSnapshotRecord> {
|
): List<RecommendationSnapshotRecord> {
|
||||||
val query = entityManager.createNativeQuery(sql)
|
val query = entityManager.createNativeQuery(sql)
|
||||||
.setParameter("windowStart", windowStart)
|
.setParameter("windowStart", windowStart)
|
||||||
.setParameter("snapshotAt", snapshotAt)
|
|
||||||
.setParameter("limit", limit)
|
.setParameter("limit", limit)
|
||||||
|
if (sql.contains(":snapshotAt")) {
|
||||||
|
query.setParameter("snapshotAt", snapshotAt)
|
||||||
|
}
|
||||||
|
if (windowEndExclusive != null) {
|
||||||
|
query.setParameter("windowEndExclusive", windowEndExclusive)
|
||||||
|
}
|
||||||
|
query
|
||||||
.setParameter(
|
.setParameter(
|
||||||
"boost10Start",
|
"boost10Start",
|
||||||
snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay()
|
snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ interface HomeRecommendationQueryPort {
|
|||||||
|
|
||||||
fun findAiCharacterSnapshots(
|
fun findAiCharacterSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
windowEndExclusive: LocalDateTime,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord>
|
): List<RecommendationSnapshotRecord>
|
||||||
|
|
||||||
|
|||||||
@@ -523,12 +523,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
|
@DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다")
|
||||||
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
|
fun shouldFindAiCharacterSnapshotsWithFollowIncrease() {
|
||||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
val user1 = saveMember("ai-user-1", MemberRole.USER)
|
val user1 = saveMember("ai-user-1", MemberRole.USER)
|
||||||
val user2 = saveMember("ai-user-2", MemberRole.USER)
|
val user2 = saveMember("ai-user-2", MemberRole.USER)
|
||||||
|
val inactiveFollower = saveMember("ai-inactive-follower", MemberRole.USER)
|
||||||
|
val inactiveMemberFollower = saveMember("ai-inactive-member-follower", MemberRole.USER, isActive = false)
|
||||||
val character = saveCharacter("character-1", isActive = true)
|
val character = saveCharacter("character-1", isActive = true)
|
||||||
val inactiveCharacter = saveCharacter("character-2", isActive = false)
|
val inactiveCharacter = saveCharacter("character-2", isActive = false)
|
||||||
val room = saveChatRoom("room-1")
|
val room = saveChatRoom("room-1")
|
||||||
@@ -547,23 +550,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false)
|
val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false)
|
||||||
val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true)
|
val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true)
|
||||||
val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true)
|
val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true)
|
||||||
updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusDays(1))
|
updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusHours(1))
|
||||||
updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusDays(2))
|
updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusHours(2))
|
||||||
updateCreatedAt("ChatMessage", message3.id!!, snapshotAt)
|
updateCreatedAt("ChatMessage", message3.id!!, snapshotAt)
|
||||||
updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusDays(1))
|
updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusHours(1))
|
||||||
updateCreatedAt("ChatMessage", characterMessage2.id!!, snapshotAt)
|
updateCreatedAt("ChatMessage", characterMessage2.id!!, snapshotAt)
|
||||||
updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusDays(3))
|
updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusHours(3))
|
||||||
updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1))
|
updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1))
|
||||||
updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusDays(1))
|
updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusHours(1))
|
||||||
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 5, 20, 12, 0))
|
val follow1 = saveFollowing(user1, character.creatorMember!!, isActive = true)
|
||||||
|
val follow2 = saveFollowing(user2, character.creatorMember!!, isActive = true)
|
||||||
|
val inactiveFollow = saveFollowing(inactiveFollower, character.creatorMember!!, isActive = false)
|
||||||
|
val inactiveMemberFollow = saveFollowing(inactiveMemberFollower, character.creatorMember!!, isActive = true)
|
||||||
|
updateCreatedAt("CreatorFollowing", follow1.id!!, windowStart.plusHours(1))
|
||||||
|
updateCreatedAt("CreatorFollowing", follow2.id!!, snapshotAt)
|
||||||
|
updateCreatedAt("CreatorFollowing", inactiveFollow.id!!, windowStart.plusHours(1))
|
||||||
|
updateCreatedAt("CreatorFollowing", inactiveMemberFollow.id!!, windowStart.plusHours(1))
|
||||||
|
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 7, 1, 12, 0))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 10)
|
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
|
||||||
|
|
||||||
val expectedScore = scorePolicy.calculateAiChatScore(
|
val expectedScore = scorePolicy.calculateAiChatScore(
|
||||||
recentChatCount = 2,
|
recentChatCount = 2,
|
||||||
recentActiveUserCount = 2,
|
recentActiveUserCount = 2,
|
||||||
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 12, 0), snapshotAt)
|
followIncreaseCount = 2,
|
||||||
|
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 7, 1, 12, 0), snapshotAt)
|
||||||
)
|
)
|
||||||
assertEquals(1, snapshots.size)
|
assertEquals(1, snapshots.size)
|
||||||
assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
|
assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
|
||||||
@@ -572,11 +584,75 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(snapshotAt, snapshots.single().snapshotAt)
|
assertEquals(snapshotAt, snapshots.single().snapshotAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 스냅샷은 종료 시각을 exclusive 경계로 집계한다")
|
||||||
|
fun shouldFindAiCharacterSnapshotsWithExclusiveWindowEnd() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
|
||||||
|
val windowEndExclusive = LocalDateTime.of(2026, 7, 9, 15, 0)
|
||||||
|
val snapshotAt = windowEndExclusive.minusSeconds(1)
|
||||||
|
val includedUser = saveMember("ai-window-included-user", MemberRole.USER)
|
||||||
|
val excludedUser = saveMember("ai-window-excluded-user", MemberRole.USER)
|
||||||
|
val character = saveCharacter("ai-window-character", isActive = true)
|
||||||
|
val excludedCharacter = saveCharacter("ai-window-excluded-character", isActive = true)
|
||||||
|
val room = saveChatRoom("ai-window-room")
|
||||||
|
val excludedRoom = saveChatRoom("ai-window-excluded-room")
|
||||||
|
val includedUserParticipant = saveParticipant(room, ParticipantType.USER, member = includedUser)
|
||||||
|
val includedCharacterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character)
|
||||||
|
val excludedUserParticipant = saveParticipant(excludedRoom, ParticipantType.USER, member = excludedUser)
|
||||||
|
val excludedCharacterParticipant = saveParticipant(excludedRoom, ParticipantType.CHARACTER, character = excludedCharacter)
|
||||||
|
updateCreatedAt(
|
||||||
|
"ChatMessage",
|
||||||
|
saveMessage(room, includedUserParticipant, "included-user", true).id!!,
|
||||||
|
windowEndExclusive.minusSeconds(1)
|
||||||
|
)
|
||||||
|
updateCreatedAt(
|
||||||
|
"ChatMessage",
|
||||||
|
saveMessage(room, includedCharacterParticipant, "included-character", true).id!!,
|
||||||
|
windowEndExclusive.minusSeconds(1)
|
||||||
|
)
|
||||||
|
updateCreatedAt(
|
||||||
|
"ChatMessage",
|
||||||
|
saveMessage(excludedRoom, excludedUserParticipant, "excluded-user", true).id!!,
|
||||||
|
windowEndExclusive
|
||||||
|
)
|
||||||
|
updateCreatedAt(
|
||||||
|
"ChatMessage",
|
||||||
|
saveMessage(excludedRoom, excludedCharacterParticipant, "excluded-character", true).id!!,
|
||||||
|
windowEndExclusive
|
||||||
|
)
|
||||||
|
updateCreatedAt(
|
||||||
|
"CreatorFollowing",
|
||||||
|
saveFollowing(includedUser, character.creatorMember!!, isActive = true).id!!,
|
||||||
|
windowEndExclusive.minusSeconds(1)
|
||||||
|
)
|
||||||
|
updateCreatedAt(
|
||||||
|
"CreatorFollowing",
|
||||||
|
saveFollowing(excludedUser, excludedCharacter.creatorMember!!, isActive = true).id!!,
|
||||||
|
windowEndExclusive
|
||||||
|
)
|
||||||
|
updateCreatedAt("ChatCharacter", character.id!!, windowStart)
|
||||||
|
updateCreatedAt("ChatCharacter", excludedCharacter.id!!, windowEndExclusive)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
|
||||||
|
|
||||||
|
val expectedScore = scorePolicy.calculateAiChatScore(
|
||||||
|
recentChatCount = 1,
|
||||||
|
recentActiveUserCount = 1,
|
||||||
|
followIncreaseCount = 1,
|
||||||
|
newBoost = scorePolicy.calculateAiCharacterNewBoost(windowStart, snapshotAt)
|
||||||
|
)
|
||||||
|
assertEquals(listOf(character.id), snapshots.map { it.targetId })
|
||||||
|
assertEquals(expectedScore, snapshots.single().score, 0.0001)
|
||||||
|
assertEquals(snapshotAt, snapshots.single().snapshotAt)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
|
@DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
|
||||||
fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() {
|
fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() {
|
||||||
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
||||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
val user = saveMember("ai-score-user", MemberRole.USER)
|
val user = saveMember("ai-score-user", MemberRole.USER)
|
||||||
val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true)
|
val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true)
|
||||||
val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true)
|
val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true)
|
||||||
@@ -610,20 +686,92 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0))
|
updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 1)
|
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 1)
|
||||||
|
|
||||||
val expectedScore = scorePolicy.calculateAiChatScore(
|
val expectedScore = scorePolicy.calculateAiChatScore(
|
||||||
recentChatCount = 2,
|
recentChatCount = 3,
|
||||||
recentActiveUserCount = 1,
|
recentActiveUserCount = 1,
|
||||||
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt)
|
followIncreaseCount = 0,
|
||||||
|
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 4, 1, 0, 0), snapshotAt)
|
||||||
)
|
)
|
||||||
assertEquals(1, snapshots.size)
|
assertEquals(1, snapshots.size)
|
||||||
assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
|
assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
|
||||||
assertEquals(newLowerActivityCharacter.id, snapshots.single().targetId)
|
assertEquals(oldHighActivityCharacter.id, snapshots.single().targetId)
|
||||||
assertEquals(expectedScore, snapshots.single().score, 0.0001)
|
assertEquals(expectedScore, snapshots.single().score, 0.0001)
|
||||||
assertEquals(snapshotAt, snapshots.single().snapshotAt)
|
assertEquals(snapshotAt, snapshots.single().snapshotAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 스냅샷은 생성일 누락/미래와 유효하지 않은 AI 크리에이터 회원을 제외한다")
|
||||||
|
fun shouldExcludeInvalidAiCharacterSnapshotCandidates() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
|
val user = saveMember("ai-exclude-user", MemberRole.USER)
|
||||||
|
val visibleCharacter = saveCharacter("ai-exclude-visible", isActive = true)
|
||||||
|
val futureCharacter = saveCharacter("ai-exclude-future", isActive = true)
|
||||||
|
val inactiveCreatorCharacter = saveCharacter("ai-exclude-inactive-creator", isActive = true).apply {
|
||||||
|
creatorMember!!.isActive = false
|
||||||
|
}
|
||||||
|
val userCreatorCharacter = saveCharacter("ai-exclude-user-creator", isActive = true).apply {
|
||||||
|
creatorMember!!.role = MemberRole.USER
|
||||||
|
}
|
||||||
|
val humanCreatorCharacter = saveCharacter("ai-exclude-human-creator", isActive = true).apply {
|
||||||
|
creatorMember!!.memberKind = MemberKind.HUMAN
|
||||||
|
}
|
||||||
|
listOf(visibleCharacter, futureCharacter, inactiveCreatorCharacter, userCreatorCharacter, humanCreatorCharacter)
|
||||||
|
.forEachIndexed { index, character ->
|
||||||
|
val room = saveChatRoom("ai-exclude-room-$index")
|
||||||
|
val userParticipant = saveParticipant(room, ParticipantType.USER, member = user)
|
||||||
|
val characterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character)
|
||||||
|
updateCreatedAt(
|
||||||
|
"ChatMessage",
|
||||||
|
saveMessage(room, userParticipant, "user-$index", true).id!!,
|
||||||
|
windowStart.plusHours(1)
|
||||||
|
)
|
||||||
|
updateCreatedAt(
|
||||||
|
"ChatMessage",
|
||||||
|
saveMessage(room, characterParticipant, "character-$index", true).id!!,
|
||||||
|
windowStart.plusHours(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateCreatedAt("ChatCharacter", visibleCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
|
||||||
|
updateCreatedAt("ChatCharacter", futureCharacter.id!!, snapshotAt.plusSeconds(1))
|
||||||
|
updateCreatedAt("ChatCharacter", inactiveCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
|
||||||
|
updateCreatedAt("ChatCharacter", userCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
|
||||||
|
updateCreatedAt("ChatCharacter", humanCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(listOf(visibleCharacter.id), snapshots.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 스냅샷은 점수 동점이면 더 늦게 생성된 캐릭터를 먼저 반환하고 상위 20개만 저장 후보로 반환한다")
|
||||||
|
fun shouldOrderAiCharacterSnapshotsByScoreThenCreatedAtDescWithTopTwentyLimit() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
|
val user = saveMember("ai-top-user", MemberRole.USER)
|
||||||
|
val characters = (0..20).map { index ->
|
||||||
|
val character = saveCharacter("ai-top-$index", isActive = true)
|
||||||
|
val room = saveChatRoom("ai-top-room-$index")
|
||||||
|
val participant = saveParticipant(room, ParticipantType.USER, member = user)
|
||||||
|
saveParticipant(room, ParticipantType.CHARACTER, character = character)
|
||||||
|
updateCreatedAt("ChatMessage", saveMessage(room, participant, "message-$index", true).id!!, windowStart.plusHours(1))
|
||||||
|
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 6, 1, 0, 0).plusMinutes(index.toLong()))
|
||||||
|
character
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 20)
|
||||||
|
|
||||||
|
assertEquals(20, snapshots.size)
|
||||||
|
assertEquals(characters.drop(1).reversed().map { it.id }, snapshots.map { it.targetId })
|
||||||
|
assertEquals(false, snapshots.any { it.targetId == characters.first().id })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 응원 스냅샷은 CHANNEL_DONATION 후원 금액과 후원 수만 집계한다")
|
@DisplayName("최근 응원 스냅샷은 CHANNEL_DONATION 후원 금액과 후원 수만 집계한다")
|
||||||
fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() {
|
fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() {
|
||||||
|
|||||||
Reference in New Issue
Block a user