mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 08:43:02 +08:00
test(ActionScheduler): add unit test for ActionScheduler
This commit is contained in:
@@ -0,0 +1,264 @@
|
|||||||
|
package work.slhaf.partner.module.modules.action.dispatcher.executor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.mockito.ArgumentMatchers.any
|
||||||
|
import org.mockito.InjectMocks
|
||||||
|
import org.mockito.Mock
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.mockito.Mockito.times
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension
|
||||||
|
import work.slhaf.partner.core.action.ActionCapability
|
||||||
|
import work.slhaf.partner.core.action.entity.ActionData
|
||||||
|
import work.slhaf.partner.core.action.entity.ScheduledActionData
|
||||||
|
import work.slhaf.partner.module.modules.action.dispatcher.scheduler.ActionScheduler
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ActionScheduler.execute(...) 测试矩阵(控制流入口:execute)。
|
||||||
|
*
|
||||||
|
* 场景编号与矩阵对应:
|
||||||
|
* 1) null 入参早退(B1)
|
||||||
|
* 2) PREPARE + ONCE 合法时间入轮(B2 → B2.3)
|
||||||
|
* 3) 非 PREPARE 状态忽略(B2 → B2.1)
|
||||||
|
* 4) ONCE 过期/跨日解析失败(B2 → B2.2)
|
||||||
|
* 5) CYCLE cron 非法解析失败(B2 → B2.2)
|
||||||
|
* 6) putAction 异常传播(B2 异常中断)
|
||||||
|
* 7) 同小时调度触发 ACTIVE(B2.3 + 状态变更)
|
||||||
|
* 15) 混合输入(成功/失败/忽略路径混合)
|
||||||
|
*
|
||||||
|
* 以下矩阵场景因并发/时间依赖难以稳定复现,仅在文档中标注,不在本类实现:
|
||||||
|
* 8) withTimeout 超时导致协程取消
|
||||||
|
* 9) tick 触发 onTrigger 并调用 ActionExecutor
|
||||||
|
* 10) tick step<=0 空转延迟
|
||||||
|
* 11) loadActions 跨小时修复
|
||||||
|
* 13) actionExecutor 阻塞导致调度延迟
|
||||||
|
* 14) schedule 与 tick 并发访问竞态
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension::class)
|
||||||
|
class ActionSchedulerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private lateinit var actionExecutor: ActionExecutor
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private lateinit var actionCapability: ActionCapability
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private lateinit var actionScheduler: ActionScheduler
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute with null input should return null and no side effects`() {
|
||||||
|
// 场景编号:1;路径:B1;目的:验证正常早退
|
||||||
|
val result = actionScheduler.execute(null)
|
||||||
|
|
||||||
|
assertEquals(null, result)
|
||||||
|
verify(actionCapability, Mockito.never()).putAction(any(ActionData::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should put action and schedule valid ONCE prepare action`() {
|
||||||
|
// 场景编号:2;路径:B2 → B2.3;目的:验证正常入轮与副作用
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val action = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE,
|
||||||
|
ZonedDateTime.now().plusHours(1).toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
actionScheduler.execute(setOf(action))
|
||||||
|
|
||||||
|
verify(actionCapability, times(1)).putAction(action)
|
||||||
|
val timeWheel = timeWheel()
|
||||||
|
val bucket = actionsGroupByHour(timeWheel)[action.scheduleContentHour()]
|
||||||
|
assertTrue(bucket.contains(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should ignore non-prepare action for scheduling`() {
|
||||||
|
// 场景编号:3;路径:B2 → B2.1;目的:验证忽略非 PREPARE 状态
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val action = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE
|
||||||
|
)
|
||||||
|
|
||||||
|
actionScheduler.execute(setOf(action))
|
||||||
|
|
||||||
|
verify(actionCapability, times(1)).putAction(action)
|
||||||
|
val allScheduled = allScheduledActions(timeWheel())
|
||||||
|
assertFalse(allScheduled.contains(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should skip expired ONCE action`() {
|
||||||
|
// 场景编号:4;路径:B2 → B2.2;目的:验证解析失败被跳过
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val action = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE
|
||||||
|
)
|
||||||
|
|
||||||
|
actionScheduler.execute(setOf(action))
|
||||||
|
|
||||||
|
val allScheduled = allScheduledActions(timeWheel())
|
||||||
|
assertFalse(allScheduled.contains(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should skip invalid CYCLE cron`() {
|
||||||
|
// 场景编号:5;路径:B2 → B2.2;目的:验证 cron 解析失败被跳过
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val action = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.CYCLE,
|
||||||
|
scheduleContentOverride = "invalid-cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
actionScheduler.execute(setOf(action))
|
||||||
|
|
||||||
|
val allScheduled = allScheduledActions(timeWheel())
|
||||||
|
assertFalse(allScheduled.contains(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should propagate exception from putAction`() {
|
||||||
|
// 场景编号:6;路径:B2 异常中断;目的:验证异常传播
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val action = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE
|
||||||
|
)
|
||||||
|
Mockito.doThrow(RuntimeException("boom"))
|
||||||
|
.`when`(actionCapability)
|
||||||
|
.putAction(action)
|
||||||
|
|
||||||
|
assertThrows(RuntimeException::class.java) {
|
||||||
|
actionScheduler.execute(setOf(action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should activate wheel when scheduling current hour`() {
|
||||||
|
// 场景编号:7;路径:B2.3;目的:验证同小时调度触发 ACTIVE
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val action = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE,
|
||||||
|
scheduleContentOverride = ZonedDateTime.now().plusMinutes(2).toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
val timeWheel = timeWheel()
|
||||||
|
val actionHour = action.scheduleContentHour()
|
||||||
|
setCurrentHour(timeWheel, actionHour)
|
||||||
|
setWheelState(timeWheel, "SLEEPING")
|
||||||
|
|
||||||
|
actionScheduler.execute(setOf(action))
|
||||||
|
|
||||||
|
assertEquals("ACTIVE", wheelStateName(timeWheel))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute should handle mixed actions consistently`() {
|
||||||
|
// 场景编号:15;路径:B2 + B2.1/B2.2/B2.3;目的:验证混合输入行为
|
||||||
|
initTimeWheelWithPrimaryActions(emptySet())
|
||||||
|
val ok = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE,
|
||||||
|
scheduleContentOverride = ZonedDateTime.now().plusMinutes(2).toString()
|
||||||
|
)
|
||||||
|
val nonPrepare = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.ONCE,
|
||||||
|
scheduleContentOverride = ZonedDateTime.now().plusMinutes(2).toString()
|
||||||
|
)
|
||||||
|
nonPrepare.status = ActionData.ActionStatus.FAILED
|
||||||
|
val invalid = buildScheduledAction(
|
||||||
|
type = ScheduledActionData.ScheduleType.CYCLE,
|
||||||
|
scheduleContentOverride = "invalid-cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
actionScheduler.execute(setOf(ok, nonPrepare, invalid))
|
||||||
|
|
||||||
|
verify(actionCapability, times(1)).putAction(ok)
|
||||||
|
verify(actionCapability, times(1)).putAction(nonPrepare)
|
||||||
|
verify(actionCapability, times(1)).putAction(invalid)
|
||||||
|
val allScheduled = allScheduledActions(timeWheel())
|
||||||
|
assertTrue(allScheduled.contains(ok))
|
||||||
|
assertFalse(allScheduled.contains(nonPrepare))
|
||||||
|
assertFalse(allScheduled.contains(invalid))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initTimeWheelWithPrimaryActions(actions: Set<ScheduledActionData>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
Mockito.`when`(actionCapability.listActions(null, null))
|
||||||
|
.thenReturn(actions as Set<ActionData>)
|
||||||
|
actionScheduler.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildScheduledAction(
|
||||||
|
type: ScheduledActionData.ScheduleType,
|
||||||
|
scheduleContentOverride: String? = null
|
||||||
|
): ScheduledActionData {
|
||||||
|
val action = ScheduledActionData(
|
||||||
|
"test",
|
||||||
|
mutableMapOf(),
|
||||||
|
"reason",
|
||||||
|
"description",
|
||||||
|
"test",
|
||||||
|
type,
|
||||||
|
scheduleContentOverride ?: scheduleContentOverride.toString()
|
||||||
|
)
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ScheduledActionData.scheduleContentHour(): Int {
|
||||||
|
return ZonedDateTime.parse(this.scheduleContent).hour
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun timeWheel(): Any {
|
||||||
|
val field = actionScheduler.javaClass.getDeclaredField("timeWheel")
|
||||||
|
field.isAccessible = true
|
||||||
|
return field.get(actionScheduler)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun actionsGroupByHour(timeWheel: Any): Array<MutableSet<ScheduledActionData>> {
|
||||||
|
val field = timeWheel.javaClass.getDeclaredField("actionsGroupByHour")
|
||||||
|
field.isAccessible = true
|
||||||
|
return field.get(timeWheel) as Array<MutableSet<ScheduledActionData>>
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allScheduledActions(timeWheel: Any): Set<ScheduledActionData> {
|
||||||
|
val result = linkedSetOf<ScheduledActionData>()
|
||||||
|
for (bucket in actionsGroupByHour(timeWheel)) {
|
||||||
|
result.addAll(bucket)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCurrentHour(timeWheel: Any, hour: Int) {
|
||||||
|
val field = timeWheel.javaClass.getDeclaredField("currentHour")
|
||||||
|
field.isAccessible = true
|
||||||
|
field.setInt(timeWheel, hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setWheelState(timeWheel: Any, name: String) {
|
||||||
|
val field = timeWheel.javaClass.getDeclaredField("state")
|
||||||
|
field.isAccessible = true
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val state = field.get(timeWheel) as MutableStateFlow<Any>
|
||||||
|
state.value = wheelStateEnum(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wheelStateName(timeWheel: Any): String {
|
||||||
|
val field = timeWheel.javaClass.getDeclaredField("state")
|
||||||
|
field.isAccessible = true
|
||||||
|
val state = field.get(timeWheel) as MutableStateFlow<*>
|
||||||
|
val value = state.value as Enum<*>
|
||||||
|
return value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wheelStateEnum(name: String): Any {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val clazz = Class.forName(
|
||||||
|
$$"work.slhaf.partner.module.modules.action.dispatcher.scheduler.ActionScheduler$TimeWheel$WheelState"
|
||||||
|
) as Class<out Enum<*>>
|
||||||
|
return java.lang.Enum.valueOf(clazz, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user