This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,18 @@
package space.nstart.pcp.slots_service
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest(
properties = [
"spring.main.lazy-initialization=true",
"spring.autoconfigure.exclude="
]
)
class SlotsServiceApplicationTests {
@Test
fun contextLoads() {
}
}
@@ -0,0 +1,37 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verifyNoInteractions
import org.springframework.http.MediaType
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import space.nstart.pcp.slots_service.service.SlotService
import kotlin.test.assertTrue
class BookingControllerTest {
private val slotService = mock(SlotService::class.java)
private val controller = BookingController().also {
ReflectionTestUtils.setField(it, "slotService", slotService)
}
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller).build()
@Test
fun `legacy process endpoint returns gone and does not update booked slots`() {
val result = mockMvc.perform(
post("/api/slots/booking/process")
.contentType(MediaType.APPLICATION_JSON)
.content("[10,20]")
)
.andExpect(status().isGone)
.andReturn()
assertTrue(result.resolvedException?.message?.contains("Legacy booked slots HTTP processing is retired") == true)
verifyNoInteractions(slotService)
}
}
@@ -0,0 +1,56 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.springframework.http.MediaType
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler
import space.nstart.pcp.slots_service.service.SlotService
import java.time.LocalDateTime
class SatelliteControllerTest {
private val slotService = mock(SlotService::class.java)
private val controller = SatelliteController().also {
ReflectionTestUtils.setField(it, "slotService", slotService)
}
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(GlobalExceptionHandler())
.build()
@Test
fun `publish initial conditions endpoint delegates to slot service`() {
val time = LocalDateTime.of(2026, 3, 30, 10, 0)
mockMvc.perform(
post("/api/satellite/56756/send-ic")
.param("time", "2026-03-30T10:00:00")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isAccepted)
verify(slotService).publishInitialConditions(56756L, time, true)
}
@Test
fun `publish initial conditions endpoint returns bad request when satellite is missing`() {
val time = LocalDateTime.of(2026, 3, 30, 10, 0)
doThrow(CustomValidationException("КА 999 не зарегистрирован"))
.`when`(slotService).publishInitialConditions(999L, time, true)
mockMvc.perform(
post("/api/satellite/999/send-ic")
.param("time", "2026-03-30T10:00:00")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.error").value("КА 999 не зарегистрирован"))
}
}
@@ -0,0 +1,147 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler
import space.nstart.pcp.slots_service.service.SatelliteIcService
import java.time.LocalDateTime
class SatelliteIcControllerTest {
private val satelliteIcService = mock(SatelliteIcService::class.java)
private val controller = SatelliteIcController(satelliteIcService)
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(GlobalExceptionHandler())
.build()
@Test
fun `get satellite ic delegates to service`() {
`when`(satelliteIcService.bySatelliteId(22L)).thenReturn(sampleIc(22L))
mockMvc.perform(get("/api/satellite-ic/22"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.satelliteId").value(22L))
.andExpect(jsonPath("$.ic.orbPoint.revolution").value(42))
verify(satelliteIcService).bySatelliteId(22L)
}
@Test
fun `create satellite ic delegates to service`() {
`when`(satelliteIcService.create(anySatelliteIc()))
.thenReturn(sampleIc(22L))
mockMvc.perform(
post("/api/satellite-ic")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"satelliteId": 22,
"ic": {
"orbPoint": {
"time": "2026-03-20T07:20:00",
"revolution": 42,
"vx": 1.0,
"vy": 2.0,
"vz": 3.0,
"x": 4.0,
"y": 5.0,
"z": 6.0
},
"sBall": 0.1,
"f81": 125.0
}
}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.satelliteId").value(22L))
verify(satelliteIcService).create(anySatelliteIc())
}
@Test
fun `update satellite ic delegates to service`() {
`when`(satelliteIcService.update(eqValue(22L), anyInitialConditions()))
.thenReturn(sampleIc(22L))
mockMvc.perform(
put("/api/satellite-ic/22")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"orbPoint": {
"time": "2026-03-20T07:20:00",
"revolution": 42,
"vx": 1.0,
"vy": 2.0,
"vz": 3.0,
"x": 4.0,
"y": 5.0,
"z": 6.0
},
"sBall": 0.1,
"f81": 125.0
}
""".trimIndent()
)
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.satelliteId").value(22L))
verify(satelliteIcService).update(eqValue(22L), anyInitialConditions())
}
@Test
fun `delete satellite ic delegates to service`() {
mockMvc.perform(delete("/api/satellite-ic/22"))
.andExpect(status().isNoContent)
verify(satelliteIcService).delete(22L)
}
private fun sampleIc(satelliteId: Long) = SatelliteICDTO(
satelliteId = satelliteId,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 3, 20, 7, 20),
revolution = 42,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.1,
f81 = 125.0
)
)
private fun anySatelliteIc(): SatelliteICDTO =
any(SatelliteICDTO::class.java) ?: SatelliteICDTO()
private fun anyInitialConditions(): InitialConditionsDTO =
any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO()
private fun eqValue(value: Long): Long = eq(value) ?: value
}
@@ -0,0 +1,196 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.configuration.GlobalExceptionHandler
import space.nstart.pcp.slots_service.service.SlotService
import java.time.LocalDateTime
class SlotControllerTest {
private val slotService = mock(SlotService::class.java)
private val controller = SlotController().also {
ReflectionTestUtils.setField(it, "slotService", slotService)
}
private val mockMvc: MockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(GlobalExceptionHandler())
.build()
@Test
fun `interval endpoint delegates to slot service`() {
val satelliteId = 56756L
val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0)
val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0)
doReturn(
listOf(
SlotDTO(
cycle = 2,
satelliteId = 56756L,
tn = LocalDateTime.of(2026, 4, 20, 1, 0),
tk = LocalDateTime.of(2026, 4, 20, 1, 15),
roll = 12.5,
contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))",
revolution = 44L,
revolutionSign = RevolutionSign.ASC,
slotNumber = 101L
)
)
).`when`(slotService).allByInterval(satelliteId, timeStart, timeStop)
mockMvc.perform(
get("/api/slots/interval")
.param("satelliteId", satelliteId.toString())
.param("timeStart", timeStart.toString())
.param("timeStop", timeStop.toString())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].satelliteId").value(56756L))
.andExpect(jsonPath("$[0].slotNumber").value(101L))
verify(slotService).allByInterval(satelliteId, timeStart, timeStop)
}
@Test
fun `poly cover endpoint delegates coverage strategy to slot service`() {
val wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0)
val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0)
doReturn(emptyList<SlotDTO>()).`when`(slotService).polySlots(
wkt,
timeStart,
timeStop,
RevolutionSign.ASC,
true,
null,
listOf(22L),
12.5,
SlotCoverageStrategy.CONTINUOUS
)
mockMvc.perform(
get("/api/slots/poly-cover")
.param("wkt", wkt)
.param("timeStart", timeStart.toString())
.param("timeStop", timeStop.toString())
.param("revSign", "ASC")
.param("cov", "true")
.param("satellites", "22")
.param("sun", "12.5")
.param("coverageStrategy", "CONTINUOUS")
)
.andExpect(status().isOk)
verify(slotService).polySlots(
wkt,
timeStart,
timeStop,
RevolutionSign.ASC,
true,
null,
listOf(22L),
12.5,
SlotCoverageStrategy.CONTINUOUS
)
}
@Test
fun `request cover endpoint delegates coverage strategy to slot service`() {
val timeStart = LocalDateTime.of(2026, 4, 20, 0, 0)
val timeStop = LocalDateTime.of(2026, 4, 21, 0, 0)
doReturn(emptyList<SlotDTO>()).`when`(slotService).reqCover(
"req-1",
timeStart,
timeStop,
RevolutionSign.ASC,
true,
listOf(22L),
12.5,
SlotCoverageStrategy.CONTINUOUS
)
mockMvc.perform(
get("/api/slots/request-cover")
.param("requestId", "req-1")
.param("timeStart", timeStart.toString())
.param("timeStop", timeStop.toString())
.param("revSign", "ASC")
.param("cov", "true")
.param("satellites", "22")
.param("sun", "12.5")
.param("coverageStrategy", "CONTINUOUS")
)
.andExpect(status().isOk)
verify(slotService).reqCover(
"req-1",
timeStart,
timeStop,
RevolutionSign.ASC,
true,
listOf(22L),
12.5,
SlotCoverageStrategy.CONTINUOUS
)
}
@Test
fun `interval endpoint returns bad request on invalid interval`() {
val satelliteId = 56756L
val timeStart = LocalDateTime.of(2026, 4, 21, 0, 0)
val timeStop = LocalDateTime.of(2026, 4, 20, 0, 0)
doThrow(CustomValidationException("Параметр timeStop должен быть больше или равен timeStart"))
.`when`(slotService).allByInterval(satelliteId, timeStart, timeStop)
mockMvc.perform(
get("/api/slots/interval")
.param("satelliteId", satelliteId.toString())
.param("timeStart", timeStart.toString())
.param("timeStop", timeStop.toString())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.error").value("Параметр timeStop должен быть больше или равен timeStart"))
}
@Test
fun `calculation summary endpoint delegates to slot service`() {
doReturn(
listOf(
SlotCalculationSummaryDTO.calculated(
satelliteId = 22L,
slotCount = 12L,
minTime = LocalDateTime.of(2026, 4, 20, 0, 0),
maxTime = LocalDateTime.of(2026, 4, 21, 0, 0),
minRevolution = 100L,
maxRevolution = 112L
)
)
).`when`(slotService).slotCalculationSummaries(listOf(22L, 44L))
mockMvc.perform(
get("/api/slots/calculation-summary")
.param("satelliteIds", "22")
.param("satelliteIds", "44")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].satelliteId").value(22L))
.andExpect(jsonPath("$[0].calculated").value(true))
.andExpect(jsonPath("$[0].slotCount").value(12L))
.andExpect(jsonPath("$[0].revolutionInterval").value(12L))
verify(slotService).slotCalculationSummaries(listOf(22L, 44L))
}
}
@@ -0,0 +1,79 @@
package space.nstart.pcp.slots_service.dto
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import java.time.LocalDateTime
class CoverageStrategyDTOTest {
private val mapper = jacksonObjectMapper().registerModule(JavaTimeModule())
private val intervalStart = LocalDateTime.of(2026, 3, 26, 10, 0)
private val intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0)
@Test
fun `slot coverage batch request defaults strategy to greedy`() {
val request = SlotCoverageBatchRequestDTO(
timeStart = intervalStart,
timeStop = intervalEnd
)
assertEquals(SlotCoverageStrategy.GREEDY, request.coverageStrategy)
}
@Test
fun `slot coverage batch request copy keeps coverage strategy`() {
val request = SlotCoverageBatchRequestDTO(
timeStart = intervalStart,
timeStop = intervalEnd,
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
)
assertEquals(SlotCoverageStrategy.CONTINUOUS, request.copy(cov = true).coverageStrategy)
}
@Test
fun `slot coverage batch request serializes coverage strategy`() {
val json = mapper.writeValueAsString(
SlotCoverageBatchRequestDTO(
timeStart = intervalStart,
timeStop = intervalEnd,
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
)
)
assertTrue(json.contains("\"coverageStrategy\":\"CONTINUOUS\""))
}
@Test
fun `slot coverage batch request deserializes missing strategy as greedy`() {
val request = mapper.readValue<SlotCoverageBatchRequestDTO>(
"""
{
"items": [],
"timeStart": "2026-03-26T10:00:00",
"timeStop": "2026-03-26T12:00:00"
}
""".trimIndent()
)
assertEquals(SlotCoverageStrategy.GREEDY, request.coverageStrategy)
}
@Test
fun `complex plan process request copy keeps coverage strategy`() {
val request = ComplexPlanProcessRequestDTO(
intervalStart = intervalStart,
intervalEnd = intervalEnd,
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
)
assertEquals(SlotCoverageStrategy.CONTINUOUS, request.copy(satelliteId = 22L).coverageStrategy)
}
}
@@ -0,0 +1,88 @@
package space.nstart.pcp.slots_service.model
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import java.time.LocalDateTime
import kotlin.system.measureNanoTime
class ContinuousReqCoverSolverBenchmarkTest {
private val satellitesById: Map<Long, AbstractSatellite> = mapOf(
22L to TestSatelliteImpl(id = 22L, maxSurveyDuration = 300, mmi = 10),
23L to TestSatelliteImpl(id = 23L, maxSurveyDuration = 300, mmi = 10),
24L to TestSatelliteImpl(id = 24L, maxSurveyDuration = 300, mmi = 10)
)
@Test
fun `benchmark continuous solver on dense strip scenario`() {
val solver = ContinuousReqCoverSolver(
logger = LoggerFactory.getLogger("ContinuousReqCoverSolverBenchmark"),
satellitesById = satellitesById
)
val targetWkt = "POLYGON ((0 0, 40 0, 40 2, 0 2, 0 0))"
val slots = buildScenario()
repeat(3) { solver.select(targetWkt, slots) }
val elapsedNanos = measureNanoTime {
repeat(20) {
solver.select(targetWkt, slots)
}
}
val elapsedMillis = elapsedNanos / 1_000_000.0
LoggerFactory.getLogger("ContinuousReqCoverSolverBenchmark")
.info("continuous benchmark: iterations=20, slots={}, elapsedMs={}", slots.size, "%.3f".format(elapsedMillis))
}
private fun buildScenario(): List<SlotDTO> {
val result = mutableListOf<SlotDTO>()
val baseTime = LocalDateTime.of(2026, 4, 1, 10, 0)
var slotNumber = 1L
for (satelliteId in listOf(22L, 23L, 24L)) {
for (index in 0 until 60) {
val left = index * 0.6
val right = left + 1.4
val timeStart = index * 18L + satelliteId
val timeStop = timeStart + 10L
result += SlotDTO(
cycle = 1L,
satelliteId = satelliteId,
tn = baseTime.plusSeconds(timeStart),
tk = baseTime.plusSeconds(timeStop),
roll = if (satelliteId == 23L) 5.02 else 5.0,
contour = "POLYGON (($left 0, $right 0, $right 2, $left 2, $left 0))",
revolution = 100 + index.toLong(),
revolutionSign = if (satelliteId == 24L && index % 6 < 2) RevolutionSign.DESC else RevolutionSign.ASC,
slotNumber = slotNumber++,
latitude = 0.0,
longitude = left
)
if (index % 5 == 0) {
val sliverLeft = left + 1.38
val sliverRight = sliverLeft + 0.04
result += SlotDTO(
cycle = 1L,
satelliteId = satelliteId,
tn = baseTime.plusSeconds(timeStart + 8L),
tk = baseTime.plusSeconds(timeStop + 8L),
roll = if (satelliteId == 23L) 5.02 else 5.0,
contour = "POLYGON (($sliverLeft 0, $sliverRight 0, $sliverRight 2, $sliverLeft 2, $sliverLeft 0))",
revolution = 500 + index.toLong(),
revolutionSign = RevolutionSign.ASC,
slotNumber = slotNumber++,
latitude = 0.0,
longitude = sliverLeft
)
}
}
}
return result
}
}
@@ -0,0 +1,313 @@
package space.nstart.pcp.slots_service.model
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.io.WKTReader
import org.slf4j.LoggerFactory
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import space.nstart.pcp.slots_service.util.LongitudeWrapGeometry
import java.time.LocalDateTime
class ContinuousReqCoverSolverTest {
private val satellitesById: Map<Long, AbstractSatellite> = mapOf(
22L to TestSatelliteImpl(id = 22L, maxSurveyDuration = 300, mmi = 10),
23L to TestSatelliteImpl(id = 23L, maxSurveyDuration = 300, mmi = 10)
)
private val solver = ContinuousReqCoverSolver(
logger = LoggerFactory.getLogger("ContinuousReqCoverSolverTest"),
satellitesById = satellitesById
)
private val greedySolver = OptimalReqCoverSolver(
logger = LoggerFactory.getLogger("ContinuousReqCoverSolverTest.greedy"),
satellitesById = satellitesById
)
@Test
fun `continuous solver aggregates overlapping slots into chain-aware candidates`() {
val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java)
mergeMethod.isAccessible = true
@Suppress("UNCHECKED_CAST")
val chains = mergeMethod.invoke(
solver,
listOf(
slot(
slotNumber = 1,
startOffsetSeconds = 0,
endOffsetSeconds = 30,
contour = "POLYGON ((0 0, 2 0, 2 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 60,
contour = "POLYGON ((1 0, 3 0, 3 1, 1 1, 1 0))"
),
slot(
slotNumber = 3,
startOffsetSeconds = 120,
endOffsetSeconds = 150,
contour = "POLYGON ((4 0, 5 0, 5 1, 4 1, 4 0))"
)
)
) as List<Any>
assertEquals(2, chains.size)
val slotsField = chains.first().javaClass.getDeclaredField("slots").apply { isAccessible = true }
val representativeField = chains.first().javaClass.getDeclaredField("representative").apply { isAccessible = true }
val firstChainSlots = slotsField.get(chains.first()) as List<*>
val firstRepresentative = representativeField.get(chains.first()) as SlotDTO
assertEquals(2, firstChainSlots.size)
assertEquals(1L, firstRepresentative.slotNumber)
}
@Test
fun `start selection uses continuation potential instead of leftmost fragment`() {
val result = solver.select(
targetWkt = "POLYGON ((0 0, 5 0, 5 1, 0 1, 0 0))",
slots = listOf(
slot(
slotNumber = 1,
startOffsetSeconds = 0,
endOffsetSeconds = 10,
contour = "POLYGON ((0 0, 0.4 0, 0.4 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 30,
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
),
slot(
slotNumber = 3,
startOffsetSeconds = 40,
endOffsetSeconds = 50,
contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"
),
slot(
slotNumber = 4,
startOffsetSeconds = 60,
endOffsetSeconds = 70,
contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))"
)
)
)
assertFalse(result.isEmpty())
assertEquals(2L, result.first().slotNumber)
}
@Test
fun `next selection prefers meaningful gain over tiny touching continuation`() {
val result = solver.select(
targetWkt = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))",
slots = listOf(
slot(
slotNumber = 1,
startOffsetSeconds = 0,
endOffsetSeconds = 10,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 30,
contour = "POLYGON ((0.999 0, 1.001 0, 1.001 1, 0.999 1, 0.999 0))"
),
slot(
slotNumber = 3,
startOffsetSeconds = 40,
endOffsetSeconds = 50,
contour = "POLYGON ((1.25 0, 2.25 0, 2.25 1, 1.25 1, 1.25 0))"
)
)
)
assertEquals(listOf(1L, 3L), result.map { it.slotNumber })
}
@Test
fun `continuity is evaluated by target-covered geometry rather than full contour`() {
val result = solver.select(
targetWkt = "POLYGON ((0 0, 3 0, 3 1, 0 1, 0 0))",
slots = listOf(
slot(
slotNumber = 1,
startOffsetSeconds = 0,
endOffsetSeconds = 10,
contour = "POLYGON ((0 0, 1 0, 1 2, 0 2, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 30,
contour = "MULTIPOLYGON (((1 1.2, 1.5 1.2, 1.5 2, 1 2, 1 1.2)), ((2 0, 3 0, 3 1, 2 1, 2 0)))"
),
slot(
slotNumber = 3,
startOffsetSeconds = 40,
endOffsetSeconds = 50,
contour = "POLYGON ((0.9 0, 2 0, 2 1, 0.9 1, 0.9 0))"
)
)
)
assertEquals(listOf(1L, 3L), result.map { it.slotNumber })
}
@Test
fun `same longitude candidate without meaningful gain is rejected`() {
val result = solver.select(
targetWkt = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))",
slots = listOf(
slot(
slotNumber = 1,
startOffsetSeconds = 0,
endOffsetSeconds = 10,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 30,
contour = "POLYGON ((0.8 0, 1 0, 1 1, 0.8 1, 0.8 0))"
),
slot(
slotNumber = 3,
startOffsetSeconds = 40,
endOffsetSeconds = 50,
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
)
)
)
assertEquals(listOf(1L, 3L), result.map { it.slotNumber })
}
@Test
fun `same longitude candidate is allowed only when it adds meaningful new coverage`() {
val result = solver.select(
targetWkt = "POLYGON ((0 0, 1 0, 1 2, 0 2, 0 0))",
slots = listOf(
slot(
slotNumber = 1,
startOffsetSeconds = 0,
endOffsetSeconds = 10,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 30,
contour = "POLYGON ((0 1, 1 1, 1 2, 0 2, 0 1))"
)
)
)
assertEquals(listOf(1L, 2L), result.map { it.slotNumber })
}
@Test
fun `continuous coverage remains competitive with greedy on long strip`() {
val target = "POLYGON ((0 0, 6 0, 6 1, 0 1, 0 0))"
val slots = listOf(
slot(1, 0, 10, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
slot(2, 20, 30, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"),
slot(3, 40, 50, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"),
slot(4, 60, 70, contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))"),
slot(5, 80, 90, contour = "POLYGON ((4 0, 5 0, 5 1, 4 1, 4 0))"),
slot(11, 100, 110, contour = "POLYGON ((0.999 0, 1.001 0, 1.001 1, 0.999 1, 0.999 0))"),
slot(12, 120, 130, contour = "POLYGON ((1.999 0, 2.001 0, 2.001 1, 1.999 1, 1.999 0))")
)
val greedy = greedySolver.select(target, slots)
val continuous = solver.select(target, slots)
val greedyMetrics = coverageMetrics(target, greedy)
val continuousMetrics = coverageMetrics(target, continuous)
assertTrue(continuousMetrics.coveragePercent >= greedyMetrics.coveragePercent * 0.95)
assertTrue(continuousMetrics.redundancyArea <= greedyMetrics.redundancyArea + 0.1)
assertTrue(continuous.size <= greedy.size + 1)
}
@Test
fun `continuous does not collapse on competing branches fixture`() {
val target = "POLYGON ((0 0, 5 0, 5 1, 0 1, 0 0))"
val slots = listOf(
slot(1, 0, 10, contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
slot(2, 20, 30, contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"),
slot(3, 40, 50, contour = "POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))"),
slot(10, 15, 25, revolutionSign = RevolutionSign.DESC, contour = "POLYGON ((0.8 0, 2.4 0, 2.4 1, 0.8 1, 0.8 0))"),
slot(11, 35, 45, revolutionSign = RevolutionSign.DESC, contour = "POLYGON ((2.4 0, 4.0 0, 4.0 1, 2.4 1, 2.4 0))"),
slot(12, 55, 65, contour = "POLYGON ((3 0, 4 0, 4 1, 3 1, 3 0))")
)
val greedy = greedySolver.select(target, slots)
val continuous = solver.select(target, slots)
val greedyMetrics = coverageMetrics(target, greedy)
val continuousMetrics = coverageMetrics(target, continuous)
assertTrue(continuousMetrics.coveragePercent >= greedyMetrics.coveragePercent * 0.85)
assertTrue(continuousMetrics.coveragePercent >= 75.0)
}
private fun coverageMetrics(targetWkt: String, slots: List<SlotDTO>): CoverageMetrics {
val reader = WKTReader()
val target = LongitudeWrapGeometry.normalizeToContinuous360(reader.read(targetWkt))
val coveredParts = slots.map { slot ->
val geometry = LongitudeWrapGeometry.alignToReference(reader.read(slot.contour), target)
geometry.intersection(target)
}.filter { !it.isEmpty }
val union = coveredParts.reduceOrNull(Geometry::union)
val coveredArea = union?.area ?: 0.0
val totalArea = target.area
val redundancyArea = coveredParts.sumOf { it.area } - coveredArea
return CoverageMetrics(
coveragePercent = if (totalArea == 0.0) 0.0 else coveredArea * 100.0 / totalArea,
redundancyArea = redundancyArea
)
}
private data class CoverageMetrics(
val coveragePercent: Double,
val redundancyArea: Double
)
private fun slot(
slotNumber: Long,
startOffsetSeconds: Long,
endOffsetSeconds: Long,
satelliteId: Long = 22L,
cycle: Long = 1L,
roll: Double = 5.0,
revolutionSign: RevolutionSign = RevolutionSign.ASC,
contour: String
): SlotDTO {
val start = LocalDateTime.of(2026, 3, 30, 10, 0)
return SlotDTO(
cycle = cycle,
satelliteId = satelliteId,
tn = start.plusSeconds(startOffsetSeconds),
tk = start.plusSeconds(endOffsetSeconds),
roll = roll,
contour = contour,
revolution = 101,
revolutionSign = revolutionSign,
slotNumber = slotNumber,
latitude = 0.0,
longitude = 0.0
)
}
}
@@ -0,0 +1,268 @@
package space.nstart.pcp.slots_service.model
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import java.time.LocalDateTime
class OptimalReqCoverSolverTest {
@Test
fun `mergeSlotsToChains preserves representative coordinates`() {
val solver = OptimalReqCoverSolver(
logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.coordinates"),
satellitesById = emptyMap()
)
val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java)
mergeMethod.isAccessible = true
val start = LocalDateTime.of(2026, 3, 30, 10, 0)
val slots = listOf(
SlotDTO(
cycle = 1,
satelliteId = 22,
tn = start,
tk = start.plusSeconds(30),
roll = 5.0,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
revolution = 101,
revolutionSign = RevolutionSign.ASC,
slotNumber = 1,
latitude = 55.75,
longitude = 37.62
),
SlotDTO(
cycle = 1,
satelliteId = 22,
tn = start.plusSeconds(20),
tk = start.plusSeconds(60),
roll = 5.0,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
revolution = 101,
revolutionSign = RevolutionSign.ASC,
slotNumber = 2,
latitude = 56.0,
longitude = 38.0
)
)
@Suppress("UNCHECKED_CAST")
val chains = mergeMethod.invoke(solver, slots) as List<Any>
val representativeField = chains.single().javaClass.getDeclaredField("representative")
representativeField.isAccessible = true
val representative = representativeField.get(chains.single()) as SlotDTO
assertEquals(55.75, representative.latitude)
assertEquals(37.62, representative.longitude)
}
@Test
fun `mergeSlotsToChains merges when time overlaps and geometry intersects`() {
val solver = OptimalReqCoverSolver(
logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.mergeAllowed"),
satellitesById = emptyMap()
)
val chains = mergeChains(
solver,
listOf(
slot(
startOffsetSeconds = 0,
endOffsetSeconds = 30,
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 60,
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
)
)
)
assertEquals(1, chains.size)
val representative = representative(chains.single())
assertTrue(representative.contour.startsWith("POLYGON"))
assertFalse(representative.contour.startsWith("MULTIPOLYGON"))
}
@Test
fun `mergeSlotsToChains keeps chains separate when time overlaps but geometry does not intersect`() {
val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.noGeometry") as Logger
val logs = captureLogs(logger) {
val solver = OptimalReqCoverSolver(
logger = logger,
satellitesById = emptyMap()
)
val chains = mergeChains(
solver,
listOf(
slot(
startOffsetSeconds = 0,
endOffsetSeconds = 30,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 60,
contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))"
)
)
)
assertEquals(2, chains.size)
}
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
}
@Test
fun `mergeSlotsToChains keeps chains separate when contours only touch by boundary`() {
val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.touchingBoundary") as Logger
val logs = captureLogs(logger) {
val solver = OptimalReqCoverSolver(
logger = logger,
satellitesById = emptyMap()
)
val chains = mergeChains(
solver,
listOf(
slot(
startOffsetSeconds = 0,
endOffsetSeconds = 30,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 60,
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
)
)
)
assertEquals(2, chains.size)
}
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
}
@Test
fun `mergeSlotsToChains does not merge when intervals do not overlap`() {
val solver = OptimalReqCoverSolver(
logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.noTimeOverlap"),
satellitesById = emptyMap()
)
val chains = mergeChains(
solver,
listOf(
slot(
startOffsetSeconds = 0,
endOffsetSeconds = 30,
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 31,
endOffsetSeconds = 60,
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
)
)
)
assertEquals(2, chains.size)
}
@Test
fun `mergeSlotsToChains skips merge when union returns multipolygon`() {
val logger = LoggerFactory.getLogger("OptimalReqCoverSolverTest.multiPolygon") as Logger
val logs = captureLogs(logger) {
val solver = OptimalReqCoverSolver(
logger = logger,
satellitesById = emptyMap()
)
val method = solver.javaClass.getDeclaredMethod(
"unionContoursIfPolygon",
String::class.java,
String::class.java,
SlotDTO::class.java,
SlotDTO::class.java
)
method.isAccessible = true
val result = method.invoke(
solver,
"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
"POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
slot(
startOffsetSeconds = 0,
endOffsetSeconds = 30,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
slot(
slotNumber = 2,
startOffsetSeconds = 20,
endOffsetSeconds = 60,
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
)
)
assertEquals(null, result)
}
assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") })
}
private fun mergeChains(solver: OptimalReqCoverSolver, slots: List<SlotDTO>): List<Any> {
val mergeMethod = solver.javaClass.getDeclaredMethod("mergeSlotsToChains", List::class.java)
mergeMethod.isAccessible = true
@Suppress("UNCHECKED_CAST")
return mergeMethod.invoke(solver, slots) as List<Any>
}
private fun representative(chain: Any): SlotDTO {
val representativeField = chain.javaClass.getDeclaredField("representative")
representativeField.isAccessible = true
return representativeField.get(chain) as SlotDTO
}
private fun slot(
slotNumber: Long = 1,
startOffsetSeconds: Long,
endOffsetSeconds: Long,
contour: String
): SlotDTO {
val start = LocalDateTime.of(2026, 3, 30, 10, 0)
return SlotDTO(
cycle = 1,
satelliteId = 22,
tn = start.plusSeconds(startOffsetSeconds),
tk = start.plusSeconds(endOffsetSeconds),
roll = 5.0,
contour = contour,
revolution = 101,
revolutionSign = RevolutionSign.ASC,
slotNumber = slotNumber,
latitude = 55.75,
longitude = 37.62
)
}
private fun captureLogs(logger: Logger, block: () -> Unit): List<ILoggingEvent> {
val appender = ListAppender<ILoggingEvent>()
appender.start()
logger.addAppender(appender)
try {
block()
return appender.list.toList()
} finally {
logger.detachAppender(appender)
}
}
}
@@ -0,0 +1,68 @@
package space.nstart.pcp.slots_service.repository
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verifyNoInteractions
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.util.ReflectionTestUtils
import java.sql.Timestamp
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SlotRepositoryImplTest {
@Test
fun `findTargetSlotRows returns empty result without hitting jdbc for empty request batch`() {
val jdbcTemplate = mock(JdbcTemplate::class.java)
val repository = SlotRepositoryImpl(jdbcTemplate)
val result = repository.findTargetSlotRows(emptyList(), chunkSize = 256)
assertTrue(result.isEmpty())
verifyNoInteractions(jdbcTemplate)
}
@Test
fun `batch spatial fetch sql filters by satellite time bbox and exact geometry without giant in`() {
val repository = SlotRepositoryImpl(mock(JdbcTemplate::class.java))
val sql = ReflectionTestUtils.invokeMethod<String>(repository, "buildBatchSpatialFetchSql", 2)
val params = ReflectionTestUtils.invokeMethod<Array<Any>>(
repository,
"buildBatchSpatialFetchParams",
listOf(
BatchCoverageSearchRequest(
targetId = 1L,
satelliteId = 22L,
windowStart = LocalDateTime.of(2026, 4, 1, 10, 0),
windowStop = LocalDateTime.of(2026, 4, 1, 12, 0),
polygonWkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
BatchCoverageSearchRequest(
targetId = 2L,
satelliteId = 33L,
windowStart = LocalDateTime.of(2026, 4, 2, 10, 0),
windowStop = LocalDateTime.of(2026, 4, 2, 12, 0),
polygonWkt = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
)
)
)
requireNotNull(sql)
requireNotNull(params)
assertTrue(sql.contains("slot.satellite_id = search.satellite_id"))
assertTrue(sql.contains("slot.tn >= search.window_start"))
assertTrue(sql.contains("slot.tk <= search.window_stop"))
assertTrue(sql.contains("slot.contour_geom && search.search_geom"))
assertTrue(sql.contains("st_intersects(search.search_geom, slot.contour_geom)"))
assertFalse(sql.contains("slot.satellite_id in"))
assertFalse(sql.contains("slot_id in"))
assertEquals(10, params.size)
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 1, 10, 0)), params[2])
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 1, 12, 0)), params[3])
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 2, 10, 0)), params[7])
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 4, 2, 12, 0)), params[8])
}
}
@@ -0,0 +1,80 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import java.time.LocalDateTime
class BookedSlotStatusServiceTest {
private val repository = mock(BookedSlotsRepository::class.java)
private val service = BookedSlotStatusService(repository)
@Test
fun `updates booked slots from booked to processed`() {
val entity = bookedSlotEntity(id = 10L, status = BookedSlotStatus.BOOKED)
`when`(repository.findAllByBookedSlotIdIn(listOf(10L))).thenReturn(listOf(entity))
val updated = service.updateBookedSlotsStatus(listOf(10L), BookedSlotStatus.PROCESSED, "test")
assertEquals(1, updated)
assertEquals(BookedSlotStatus.PROCESSED.name, entity.status)
verify(repository).saveAll(listOf(entity))
}
@Test
fun `updates processed slots to terminal state`() {
val entity = bookedSlotEntity(id = 15L, status = BookedSlotStatus.PROCESSED)
`when`(repository.findAllByBookedSlotIdIn(listOf(15L))).thenReturn(listOf(entity))
val updated = service.updateBookedSlotsStatus(listOf(15L), BookedSlotStatus.FINISHED, "test")
assertEquals(1, updated)
assertEquals(BookedSlotStatus.FINISHED.name, entity.status)
verify(repository).saveAll(listOf(entity))
}
@Test
fun `same status delivery is idempotent`() {
val entity = bookedSlotEntity(id = 20L, status = BookedSlotStatus.PROCESSED)
`when`(repository.findAllByBookedSlotIdIn(listOf(20L))).thenReturn(listOf(entity))
val updated = service.updateBookedSlotsStatus(listOf(20L), BookedSlotStatus.PROCESSED, "duplicate")
assertEquals(0, updated)
verify(repository, never()).saveAll(org.mockito.ArgumentMatchers.anyList())
}
@Test
fun `invalid transition is ignored`() {
val entity = bookedSlotEntity(id = 25L, status = BookedSlotStatus.FAILED_TIMEOUT)
`when`(repository.findAllByBookedSlotIdIn(listOf(25L))).thenReturn(listOf(entity))
val updated = service.updateBookedSlotsStatus(listOf(25L), BookedSlotStatus.PROCESSED, "invalid")
assertEquals(0, updated)
assertEquals(BookedSlotStatus.FAILED_TIMEOUT.name, entity.status)
verify(repository, never()).saveAll(org.mockito.ArgumentMatchers.anyList())
}
private fun bookedSlotEntity(id: Long, status: BookedSlotStatus): BookedSlotEntity =
BookedSlotEntity(
bookedSlotId = id,
slot = SlotEntity(
slotId = 100L + id,
slotNum = id,
satelliteId = 77L,
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
tk = LocalDateTime.of(2026, 3, 31, 10, 10)
),
cycle = 0,
status = status.name
)
}
@@ -0,0 +1,71 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.slots_service.configuration.BookedSlotsTimeoutProperties
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
class BookedSlotTimeoutSchedulerTest {
private val repository = mock(BookedSlotsRepository::class.java)
private val statusService = mock(BookedSlotStatusUpdater::class.java)
private val timeResolver = mock(BookedSlotTimeResolver::class.java)
private val properties = BookedSlotsTimeoutProperties(
timeoutCheckInterval = Duration.ofHours(1),
timeoutThreshold = Duration.ofHours(1)
)
private val clock = Clock.fixed(Instant.parse("2026-03-31T12:00:00Z"), ZoneOffset.UTC)
private val scheduler = BookedSlotTimeoutScheduler(repository, statusService, timeResolver, properties, clock)
@Test
fun `scheduler marks stale booked slots as failed timeout`() {
val booked = bookedSlotEntity(10L)
`when`(repository.findAllByStatus(BookedSlotStatus.BOOKED.name)).thenReturn(listOf(booked))
`when`(timeResolver.resolveBookedSlotStartTime(booked)).thenReturn(LocalDateTime.of(2026, 3, 31, 9, 30))
scheduler.markTimedOutBookedSlots()
verify(statusService).updateBookedSlotsStatus(
listOf(10L),
BookedSlotStatus.FAILED_TIMEOUT,
"timeout-scheduler"
)
}
@Test
fun `scheduler ignores not yet timed out slots`() {
val booked = bookedSlotEntity(20L)
`when`(repository.findAllByStatus(BookedSlotStatus.BOOKED.name)).thenReturn(listOf(booked))
`when`(timeResolver.resolveBookedSlotStartTime(booked)).thenReturn(LocalDateTime.of(2026, 3, 31, 11, 30))
scheduler.markTimedOutBookedSlots()
verifyNoInteractions(statusService)
}
private fun bookedSlotEntity(id: Long): BookedSlotEntity =
BookedSlotEntity(
bookedSlotId = id,
slot = SlotEntity(
slotId = id + 100,
slotNum = id,
satelliteId = 77L,
tn = LocalDateTime.of(2026, 3, 31, 8, 0),
tk = LocalDateTime.of(2026, 3, 31, 8, 10)
),
cycle = 0,
status = BookedSlotStatus.BOOKED.name
)
}
@@ -0,0 +1,68 @@
package space.nstart.pcp.slots_service.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.time.LocalDateTime
import java.util.UUID
class BookedSlotsKafkaListenerTest {
private val objectMapper = jacksonObjectMapper().findAndRegisterModules()
private val bookedSlotStatusService = RecordingBookedSlotStatusUpdater()
private val listener = BookedSlotsKafkaListener(SingleObjectProvider(objectMapper), bookedSlotStatusService)
@Test
fun `consumer forwards booked slot status event to status service`() {
val payload = objectMapper.writeValueAsString(
KafkaMessage(
type = PcpKafkaEvent.BookedSlotsStatusChangedEvent,
data = BookedSlotsStatusChangedEvent(
bookedSlotIds = listOf(10L, 20L),
status = BookedSlotStatus.PROCESSED,
occurredAt = LocalDateTime.of(2026, 3, 31, 12, 0),
sourceService = "pcp-mission-planing-service",
missionId = UUID.randomUUID(),
modeId = 100L,
satelliteId = 77L
)
)
)
listener.consume(payload)
assertEquals(listOf(10L, 20L), bookedSlotStatusService.lastBookedSlotIds)
assertEquals(BookedSlotStatus.PROCESSED, bookedSlotStatusService.lastStatus)
assertTrue(bookedSlotStatusService.lastContext.startsWith("kafka:pcp-mission-planing-service:"))
}
private class RecordingBookedSlotStatusUpdater : BookedSlotStatusUpdater {
var lastBookedSlotIds: List<Long> = emptyList()
var lastStatus: BookedSlotStatus? = null
var lastContext: String = ""
override fun updateBookedSlotsStatus(
bookedSlotIds: List<Long>,
targetStatus: BookedSlotStatus,
context: String
): Int {
lastBookedSlotIds = bookedSlotIds
lastStatus = targetStatus
lastContext = context
return bookedSlotIds.size
}
}
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
override fun getObject(vararg args: Any?): T = value
override fun getIfAvailable(): T = value
override fun getIfUnique(): T = value
override fun getObject(): T = value
}
}
@@ -0,0 +1,317 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import java.net.URI
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class EarthServiceTest {
@Test
fun `reqs calls v1 requests list endpoint`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000001")
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
requestsPageResponse(requestId),
requestDetailsResponse(requestId),
)
)
service.reqs().collectList().block()
assertEquals("/v1/requests", requestedUris.first().path)
assertEquals("page=0&size=500", requestedUris.first().query)
assertFalse(requestedUris.any { uri -> uri.path == "/api/requests" })
}
@Test
fun `reqs unwraps list items and maps request details to current request dto`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002")
val service = earthService(
responses = mutableListOf(
requestsPageResponse(requestId, importance = 3.0),
requestDetailsResponse(
id = requestId,
importance = 7.5,
geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
),
)
)
val request = service.reqs().collectList().block()!!.single()
assertEquals(requestId, request.requestId)
assertEquals("Full request", request.name)
assertEquals(7.5, request.importance)
assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", request.contour)
}
@Test
fun `reqs reads all pages from v1 requests`() {
val firstRequestId = UUID.fromString("00000000-0000-0000-0000-000000000003")
val secondRequestId = UUID.fromString("00000000-0000-0000-0000-000000000004")
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
requestsPageResponse(firstRequestId, page = 0, totalPages = 2),
requestsPageResponse(secondRequestId, page = 1, totalPages = 2),
requestDetailsResponse(firstRequestId, geometry = "POLYGON EMPTY"),
requestDetailsResponse(secondRequestId, geometry = "POLYGON EMPTY"),
)
)
val requests = service.reqs().collectList().block()!!
assertEquals(listOf(firstRequestId, secondRequestId), requests.map { request -> request.requestId })
assertEquals(
listOf("page=0&size=500", "page=1&size=500"),
requestedUris.filter { uri -> uri.path == "/v1/requests" }.map { uri -> uri.query },
)
}
@Test
fun `reqs loads full request details for contour`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000005")
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
requestsPageResponse(requestId, name = "Summary request"),
requestDetailsResponse(
id = requestId,
name = "Detailed request",
geometry = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))",
),
)
)
val request = service.reqs().collectList().block()!!.single()
assertEquals("/v1/requests/$requestId", requestedUris[1].path)
assertEquals("Detailed request", request.name)
assertEquals("POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))", request.contour)
}
@Test
fun `reqcells calls v1 request with cells and maps request and cell contours`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000006")
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
requestWithCellsResponse(requestId),
)
)
val response = service.reqcells(requestId.toString())!!
assertEquals("/v1/requests/$requestId/with-cells", requestedUris.single().path)
assertFalse(requestedUris.any { uri -> uri.path.contains("/api/requests/by-number") })
assertEquals(requestId, response.request.requestId)
assertEquals("Request with cells", response.request.name)
assertEquals("POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))", response.request.contour)
assertEquals(123, response.cells.single().id)
assertEquals(123, response.cells.single().num)
assertEquals(10.0, response.cells.single().importance)
assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", response.cells.single().contour)
}
@Test
fun `cells calls v1 cells with min importance and maps wrapper items`() {
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
cellsPageResponse(),
)
)
val cells = service.cells(4.5).toList()
assertEquals("/v1/cells", requestedUris.single().path)
assertEquals("minImportance=4.5&page=0&size=500", requestedUris.single().query)
assertFalse(requestedUris.any { uri -> uri.path == "/api/earth-grid/by-importance" })
assertFalse(requestedUris.any { uri -> uri.query?.contains("countLat") == true })
assertFalse(requestedUris.any { uri -> uri.query?.contains("countLong") == true })
assertEquals(1, cells.single().id)
assertEquals(1, cells.single().num)
assertEquals(55.5, cells.single().latitude)
assertEquals(37.5, cells.single().longitude)
assertEquals(4.5, cells.single().importance)
assertEquals("POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))", cells.single().contour)
}
@Test
fun `cells reads all pages from v1 cells`() {
val requestedUris = mutableListOf<URI>()
val service = earthService(
requestedUris = requestedUris,
responses = mutableListOf(
cellsPageResponse(cellNum = 1, page = 0, totalPages = 2),
cellsPageResponse(cellNum = 2, page = 1, totalPages = 2),
)
)
val cells = service.cells(9.0).toList()
assertEquals(listOf(1L, 2L), cells.map { cell -> cell.num })
assertEquals(
listOf("minImportance=9.0&page=0&size=500", "minImportance=9.0&page=1&size=500"),
requestedUris.filter { uri -> uri.path == "/v1/cells" }.map { uri -> uri.query },
)
}
private fun earthService(
requestedUris: MutableList<URI> = mutableListOf(),
responses: MutableList<ClientResponse>,
): EarthService {
val builder = WebClient.builder()
.exchangeFunction { request ->
requestedUris += request.url()
Mono.just(responses.removeAt(0))
}
return EarthService(provider(builder), url = "http://pcp-request-service")
}
private fun requestsPageResponse(
id: UUID,
page: Int = 0,
totalPages: Int = 1,
importance: Double = 1.0,
name: String = "Summary request",
): ClientResponse =
jsonResponse(
"""
{
"items": [
{
"id": "$id",
"name": "$name",
"status": "ACTIVE",
"surveyType": "OPTICS",
"importance": $importance,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T00:00:00Z",
"kpp": [1],
"highPriorityTransmit": false,
"coveragePercent": 0.0,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}
],
"page": $page,
"size": 500,
"totalItems": $totalPages,
"totalPages": $totalPages
}
""".trimIndent()
)
private fun requestDetailsResponse(
id: UUID,
name: String = "Full request",
importance: Double = 1.0,
geometry: String = "POLYGON EMPTY",
): ClientResponse =
jsonResponse(
"""
{
"id": "$id",
"name": "$name",
"status": "ACTIVE",
"surveyType": "OPTICS",
"geometry": "$geometry",
"importance": $importance,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T00:00:00Z",
"kpp": [1],
"highPriorityTransmit": false,
"coverage": {"currentPercent": 0.0},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
}
""".trimIndent()
)
private fun requestWithCellsResponse(id: UUID): ClientResponse =
jsonResponse(
"""
{
"request": {
"id": "$id",
"name": "Request with cells",
"status": "ACTIVE",
"surveyType": "OPTICS",
"geometry": "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))",
"importance": 7.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T00:00:00Z",
"kpp": [1],
"highPriorityTransmit": false,
"coverage": {"currentPercent": 0.0},
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z"
},
"cells": [
{
"cellNum": 123,
"coveragePercent": 42.5,
"importance": 10.0,
"contour": "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
}
]
}
""".trimIndent()
)
private fun cellsPageResponse(
cellNum: Long = 1,
page: Int = 0,
totalPages: Int = 1,
importance: Double = 4.5,
): ClientResponse =
jsonResponse(
"""
{
"items": [
{
"cellNum": $cellNum,
"latitude": 55.5,
"longitude": 37.5,
"importance": $importance,
"contour": "POLYGON ((30 50, 31 50, 31 51, 30 51, 30 50))"
}
],
"page": $page,
"size": 500,
"totalItems": $totalPages,
"totalPages": $totalPages
}
""".trimIndent()
)
private fun jsonResponse(body: String): ClientResponse =
ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.body(body)
.build()
private fun provider(builder: WebClient.Builder): ObjectProvider<WebClient.Builder> =
object : ObjectProvider<WebClient.Builder> {
override fun getObject(vararg args: Any?): WebClient.Builder = builder
override fun getObject(): WebClient.Builder = builder
override fun getIfAvailable(): WebClient.Builder = builder
override fun getIfUnique(): WebClient.Builder = builder
override fun iterator(): MutableIterator<WebClient.Builder> = mutableListOf(builder).iterator()
}
}
@@ -0,0 +1,122 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OccupiedIntervalPrefilterTest {
@Test
fun `different roll blocks candidate inside occupied mmi window`() {
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
val candidate = candidateSlot(roll = 12.0, startOffsetSeconds = 44, endOffsetSeconds = 70)
val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40))
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
assertTrue(filtered.isEmpty())
}
@Test
fun `same roll continuation is allowed when merged duration fits duration max`() {
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 44, endOffsetSeconds = 70)
val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40))
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
assertEquals(listOf(candidate), filtered)
}
@Test
fun `same roll continuation is rejected when merged duration exceeds duration max`() {
val satellite = testSatellite(maxSurveyDuration = 60, mmi = 5)
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 44, endOffsetSeconds = 70)
val occupied = listOf(occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40))
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
assertTrue(filtered.isEmpty())
}
@Test
fun `occupied intervals with same roll normalize into one chain`() {
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
val normalized = OccupiedIntervalPrefilter.normalize(
occupiedIntervals = listOf(
occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 40),
occupiedInterval(roll = 10.0, startOffsetSeconds = 43, endOffsetSeconds = 60)
),
satellite = satellite
)
assertEquals(1, normalized.size)
assertEquals(baseTime().plusSeconds(0), normalized.single().startTime)
assertEquals(baseTime().plusSeconds(60), normalized.single().endTime)
}
@Test
fun `candidate can bridge two same-roll occupied chains within duration max`() {
val satellite = testSatellite(maxSurveyDuration = 90, mmi = 5)
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 24, endOffsetSeconds = 46)
val occupied = OccupiedIntervalPrefilter.normalize(
occupiedIntervals = listOf(
occupiedInterval(roll = 10.0, startOffsetSeconds = 0, endOffsetSeconds = 20),
occupiedInterval(roll = 10.0, startOffsetSeconds = 50, endOffsetSeconds = 70)
),
satellite = satellite
)
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
assertEquals(listOf(candidate), filtered)
}
private fun testSatellite(maxSurveyDuration: Int, mmi: Long): AbstractSatellite =
object : AbstractSatellite(id = 22, maxSurveyDuration = maxSurveyDuration, mmi = mmi) {
override fun prepareCoverageSlots(
slots: List<SlotEntity>,
bookedSlots: List<BookedSlotEntity>,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
sign: RevolutionSign?,
states: List<SlotStatus>?
): List<PreparedCoverageSlot> = emptyList()
}
private fun occupiedInterval(
roll: Double,
startOffsetSeconds: Long,
endOffsetSeconds: Long
) = OccupiedIntervalDTO(
satelliteId = 22,
startTime = baseTime().plusSeconds(startOffsetSeconds),
endTime = baseTime().plusSeconds(endOffsetSeconds),
roll = roll,
source = "TEST"
)
private fun candidateSlot(
roll: Double,
startOffsetSeconds: Long,
endOffsetSeconds: Long
) = SlotDTO(
cycle = 0,
satelliteId = 22,
tn = baseTime().plusSeconds(startOffsetSeconds),
tk = baseTime().plusSeconds(endOffsetSeconds),
roll = roll,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
)
private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0)
}
@@ -0,0 +1,35 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import space.nstart.pcp.slots_service.repository.SatelliteIcRepository
import space.nstart.pcp.slots_service.repository.SlotRepository
class SatelliteDeletedServiceTest {
@Test
fun `deleteSatelliteData removes slots and initial conditions`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val satelliteIcRepository = mock(SatelliteIcRepository::class.java)
val service = SatelliteDeletedService(slotRepository, bookedSlotsRepository, satelliteIcRepository)
`when`(bookedSlotsRepository.countBySlot_SatelliteId(56756L)).thenReturn(3L)
`when`(slotRepository.countBySatelliteId(56756L)).thenReturn(10L)
`when`(slotRepository.deleteBySatId(56756L)).thenReturn(10)
`when`(satelliteIcRepository.deleteBySatelliteId(56756L)).thenReturn(1)
val summary = service.deleteSatelliteData(56756L)
assertEquals(56756L, summary.satelliteId)
assertEquals(10, summary.slotsDeleted)
assertEquals(3L, summary.bookedSlotsDeletedByCascade)
assertEquals(1, summary.initialConditionsDeleted)
verify(slotRepository).deleteBySatId(56756L)
verify(satelliteIcRepository).deleteBySatelliteId(56756L)
}
}
@@ -0,0 +1,72 @@
package space.nstart.pcp.slots_service.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.apache.kafka.clients.producer.ProducerRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.ObjectProvider
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.concurrent.CompletableFuture
class SatelliteIcKafkaPublisherTest {
private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, Any>
private val objectMapper = jacksonObjectMapper().findAndRegisterModules()
@Test
fun `publisher sends satellite initial conditions to kafka with ICRVPlacedEvent header`() {
val publisher = SatelliteIcKafkaPublisher(
kafkaTemplateProvider = SingleObjectProvider(kafkaTemplate),
objectMapperProvider = SingleObjectProvider(objectMapper),
topic = "pcp.tle",
applicationName = "slots-service"
)
val recordCaptor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, Any>>
`when`(kafkaTemplate.send(recordCaptor.capture())).thenReturn(CompletableFuture.completedFuture(mock(SendResult::class.java) as SendResult<String, Any>))
publisher.publish(
SatelliteICDTO(
satelliteId = 56756L,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 16, 12, 0),
revolution = 42L,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.7,
f81 = 140.0
)
)
)
verify(kafkaTemplate).send(recordCaptor.value)
assertEquals("pcp.tle", recordCaptor.value.topic())
assertEquals("56756", recordCaptor.value.key())
val typeHeader = recordCaptor.value.headers().lastHeader("type")
assertNotNull(typeHeader)
assertEquals("ICRVPlacedEvent", String(typeHeader.value(), StandardCharsets.UTF_8))
}
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
override fun getObject(vararg args: Any?): T = value
override fun getIfAvailable(): T = value
override fun getIfUnique(): T = value
override fun getObject(): T = value
}
}
@@ -0,0 +1,471 @@
package space.nstart.pcp.slots_service.service
import ballistics.types.InitialConditions
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.springframework.test.util.ReflectionTestUtils
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.PreparedCoverageSlot
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import space.nstart.pcp.slots_service.repository.BatchCoverageSearchRequest
import space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import space.nstart.pcp.slots_service.repository.SlotRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import javax.sql.DataSource
import kotlin.test.assertEquals
class SlotServiceBatchTest {
private fun <T> anyListValue(): List<T> = anyList<T>() ?: emptyList()
private fun anyLongValue(): Long = anyLong()
private fun anyIntValue(): Int = anyInt()
@Test
fun `polySlotsBatch uses single batch spatial fetch and keeps input target order`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(defaultSatellite(22L))
)
`when`(
slotRepository.findTargetSlotRows(
anyListValue(),
anyIntValue()
)
).thenReturn(
listOf(
row(targetId = 2L, slotId = 1002L, satelliteId = 22L),
row(targetId = 1L, slotId = 1001L, satelliteId = 22L)
)
)
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())).thenReturn(emptyList())
val response = service.polySlotsBatch(
SlotCoverageBatchRequestDTO(
items = listOf(
SlotCoverageTargetDTO(1, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
SlotCoverageTargetDTO(2, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
),
timeStart = java.time.LocalDateTime.of(2026, 3, 26, 10, 0),
timeStop = java.time.LocalDateTime.of(2026, 3, 26, 12, 0),
cov = false,
satellites = listOf(22L)
)
)
verify(bookedSlotsRepository, times(1))
.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue())
verify(slotRepository, times(1))
.findTargetSlotRows(anyListValue(), anyIntValue())
kotlin.test.assertEquals(2, response.size)
kotlin.test.assertEquals(listOf(1L, 2L), response.map { it.targetId })
}
@Test
fun `polySlotsBatch handles large spatial batch without secondary slot id fetch`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(defaultSatellite(22L))
)
val rows = (1L..70_000L).map { slotId ->
row(targetId = 1L, slotId = slotId, satelliteId = 22L)
}
`when`(
slotRepository.findTargetSlotRows(
anyListValue(),
anyIntValue()
)
).thenReturn(rows)
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = service.polySlotsBatch(
SlotCoverageBatchRequestDTO(
items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
cov = false,
satellites = listOf(22L)
)
)
kotlin.test.assertEquals(1, response.size)
verify(slotRepository, times(1))
.findTargetSlotRows(anyListValue(), anyIntValue())
}
@Test
fun `polySlotsBatch precomputes prepared slots once per satellite for whole batch`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val satellite = CountingSatellite(id = 22L)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(satellite)
)
`when`(
slotRepository.findTargetSlotRows(
anyListValue(),
anyIntValue()
)
).thenReturn(
listOf(
row(targetId = 1L, slotId = 1001L, satelliteId = 22L),
row(targetId = 2L, slotId = 1001L, satelliteId = 22L)
)
)
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = service.polySlotsBatch(
SlotCoverageBatchRequestDTO(
items = listOf(
SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
SlotCoverageTargetDTO(2L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")
),
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
cov = false,
satellites = listOf(22L)
)
)
assertEquals(1, satellite.prepareCoverageSlotsCalls)
assertEquals(2, response.size)
assertEquals(listOf(1, 1), response.map { it.slots.size })
}
@Test
fun `polySlotsBatch pushes satellite and base time windows into spatial fetch request`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val satellite = defaultSatellite(
id = 62138L,
tnCalc = LocalDateTime.of(LocalDate.of(2025, 10, 27), LocalTime.of(0, 29, 23, 162630000)),
cycleRevs = 243,
durationCalc = 16,
maxChainLength = 3
)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(satellite)
)
val baseStart = satellite.tnCalc.plusHours(2)
val requestTimeStart = baseStart.minusMinutes(1)
val requestTimeStop = baseStart.plusMinutes(11)
val capturedRequests = mutableListOf<BatchCoverageSearchRequest>()
`when`(
slotRepository.findTargetSlotRows(
anyListValue(),
anyIntValue()
)
).thenAnswer { invocation ->
capturedRequests += invocation.getArgument<List<BatchCoverageSearchRequest>>(0)
listOf(row(targetId = 1L, slotId = 1001L, satelliteId = satellite.id))
}
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
service.polySlotsBatch(
SlotCoverageBatchRequestDTO(
items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
timeStart = requestTimeStart,
timeStop = requestTimeStop,
cov = false,
satellites = listOf(satellite.id)
)
)
assertEquals(3, capturedRequests.size)
assertEquals(setOf(satellite.id), capturedRequests.map { it.satelliteId }.toSet())
assertEquals(setOf(1L), capturedRequests.map { it.targetId }.toSet())
assertEquals(
setOf(
requestTimeStart to requestTimeStop
),
capturedRequests.map { it.windowStart to it.windowStop }.toSet()
)
}
@Test
fun `polySlotsBatch applies continuous coverage strategy`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(CountingSatellite(22L))
)
stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository)
val response = service.polySlotsBatch(
SlotCoverageBatchRequestDTO(
items = listOf(SlotCoverageTargetDTO(1L, TARGET_WKT)),
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
cov = true,
satellites = listOf(22L),
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
)
)
assertEquals(listOf(1L, 3L), response.single().slots.map { it.slotNumber })
}
@Test
fun `polySlots applies continuous coverage strategy in single target path`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(CountingSatellite(22L))
)
stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository)
val response = service.polySlots(
wkt = TARGET_WKT,
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
sign = null,
cov = true,
sats = listOf(22L),
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
).toList()
assertEquals(listOf(1L, 3L), response.map { it.slotNumber })
}
@Test
fun `reqCover applies continuous coverage strategy`() {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val earthService = mock(EarthService::class.java)
val service = createService(
slotRepository = slotRepository,
bookedSlotsRepository = bookedSlotsRepository,
catalogSatellites = listOf(CountingSatellite(22L)),
earthService = earthService
)
stubRowsForContinuousCoverage(slotRepository, bookedSlotsRepository)
doReturn(RequestWithCellsDTO(request = RequestDTO(contour = TARGET_WKT)))
.`when`(earthService).reqcells("req-1")
val response = service.reqCover(
id = "req-1",
timeStart = LocalDateTime.of(2026, 3, 26, 10, 0),
timeStop = LocalDateTime.of(2026, 3, 26, 12, 0),
sign = null,
cov = true,
sats = listOf(22L),
coverageStrategy = SlotCoverageStrategy.CONTINUOUS
).toList()
assertEquals(listOf(1L, 3L), response.map { it.slotNumber })
}
private fun createService(
slotRepository: SlotRepository,
bookedSlotsRepository: BookedSlotsRepository,
catalogSatellites: List<AbstractSatellite>,
earthService: EarthService = mock(EarthService::class.java)
): SlotService {
val service = SlotService()
ReflectionTestUtils.setField(service, "slotRepository", slotRepository)
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java))
ReflectionTestUtils.setField(service, "earthService", earthService)
ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java))
ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(catalogSatellites))
return service
}
private fun testSatelliteCatalogClient(satellites: List<AbstractSatellite>) = object : SatelliteCatalogClient {
override fun allSatellites(): List<AbstractSatellite> = satellites
override fun satellites(ids: List<Long>): List<AbstractSatellite> =
ids.distinct().map { satelliteId ->
satellites.find { it.id == satelliteId }
?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
}
override fun satellite(id: Long): AbstractSatellite =
satellites.find { it.id == id }
?: throw CustomValidationException("КА $id не зарегистрирован")
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request
}
private fun defaultSatellite(
id: Long,
tnCalc: LocalDateTime = LocalDateTime.of(2026, 3, 20, 7, 20),
cycleRevs: Long = 275,
durationCalc: Long = 18,
maxChainLength: Int = 3
) = TestSatelliteImpl(
id = id,
cycleRevs = cycleRevs,
tnCalc = tnCalc,
durationCalc = durationCalc,
maxChainLength = maxChainLength
)
private fun stubRowsForContinuousCoverage(
slotRepository: SlotRepository,
bookedSlotsRepository: BookedSlotsRepository
) {
`when`(
slotRepository.findTargetSlotRows(
anyListValue(),
anyIntValue()
)
).thenReturn(
listOf(
row(
targetId = 1L,
slotId = 1L,
satelliteId = 22L,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
longitude = 0.0,
tn = LocalDateTime.of(2026, 3, 26, 10, 0, 0),
tk = LocalDateTime.of(2026, 3, 26, 10, 0, 10)
),
row(
targetId = 1L,
slotId = 2L,
satelliteId = 22L,
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
longitude = 0.0,
tn = LocalDateTime.of(2026, 3, 26, 10, 0, 20),
tk = LocalDateTime.of(2026, 3, 26, 10, 0, 30)
),
row(
targetId = 1L,
slotId = 3L,
satelliteId = 22L,
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))",
longitude = 1.0,
tn = LocalDateTime.of(2026, 3, 26, 10, 0, 40),
tk = LocalDateTime.of(2026, 3, 26, 10, 0, 50)
)
)
)
`when`(bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(anyListValue(), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
}
private fun row(
targetId: Long,
slotId: Long,
satelliteId: Long,
contour: String = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
longitude: Double = 37.0,
roll: Double = 10.0,
revolutionSign: String = "ASC",
tn: LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0),
tk: LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 10)
) = BatchCoverageSlotRow(
targetId = targetId,
slotId = slotId,
slotNum = slotId,
cycle = 0L,
satelliteId = satelliteId,
coveringType = 0,
tn = tn,
tk = tk,
roll = roll,
contour = contour,
revolution = 1L,
revolutionSign = revolutionSign,
latitude = 55.0,
longitude = longitude
)
private class CountingSatellite(id: Long) : AbstractSatellite(
id = id,
cycleRevs = 243,
tnCalc = LocalDateTime.of(LocalDate.of(2025, 10, 27), LocalTime.of(0, 29, 23, 162630000)),
durationCalc = 16,
maxChainLength = 3,
angles = emptyList(),
ic = InitialConditions()
) {
var prepareCoverageSlotsCalls: Int = 0
override fun prepareCoverageSlots(
slots: List<SlotEntity>,
bookedSlots: List<BookedSlotEntity>,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
sign: RevolutionSign?,
states: List<SlotStatus>?
): List<PreparedCoverageSlot> {
prepareCoverageSlotsCalls++
return slots.map { slot ->
PreparedCoverageSlot(
sourceSlotId = slot.slotId ?: 0L,
slot = SlotDTO(
cycle = slot.cycle,
satelliteId = slot.satelliteId,
tn = slot.tn,
tk = slot.tk,
roll = slot.roll,
contour = slot.contour,
revolution = slot.revolution,
revolutionSign = RevolutionSign.valueOf(slot.revolutionSign),
slotNumber = slot.slotNum,
state = SlotStatus.AVAILABLE,
latitude = slot.latitude,
longitude = slot.longitude
)
)
}
}
}
private companion object {
private const val TARGET_WKT = "POLYGON ((0 0, 4 0, 4 1, 0 1, 0 0))"
}
}
@@ -0,0 +1,207 @@
package space.nstart.pcp.slots_service.service
import ballistics.orbitalPoints.timeStepper.AbstractStepper
import ballistics.types.OrbitalPoint
import ballistics.utils.math.Vector3D
import org.junit.jupiter.api.Test
import org.springframework.test.util.ReflectionTestUtils
import space.nstart.pcp.slots_service.model.Mar
import java.time.LocalDateTime
import kotlin.math.cos
import kotlin.math.sin
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SlotServiceCalcSlotsTest {
@Test
fun `buildSlotCalculationChunks creates aligned half-open windows without gaps`() {
val service = SlotService()
ReflectionTestUtils.setField(service, "slotCalcChunkDurationSeconds", 20L)
ReflectionTestUtils.setField(service, "slotCalcSourceMarginSeconds", 120L)
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
val endExclusive = start.plusSeconds(60)
val chunks = ReflectionTestUtils.invokeMethod<List<Any>>(
service,
"buildSlotCalculationChunks",
start,
endExclusive,
10L,
60L
).orEmpty()
assertEquals(3, chunks.size)
assertEquals(start, ReflectionTestUtils.getField(chunks[0], "generationStart"))
assertEquals(start.plusSeconds(20), ReflectionTestUtils.getField(chunks[0], "generationEndExclusive"))
assertEquals(start.plusSeconds(20), ReflectionTestUtils.getField(chunks[1], "generationStart"))
assertEquals(start.plusSeconds(40), ReflectionTestUtils.getField(chunks[1], "generationEndExclusive"))
assertEquals(start.plusSeconds(40), ReflectionTestUtils.getField(chunks[2], "generationStart"))
assertEquals(endExclusive, ReflectionTestUtils.getField(chunks[2], "generationEndExclusive"))
assertEquals(5L, ReflectionTestUtils.getField(chunks[0], "generationStepSeconds"))
val sourceWindowStartSeconds = ReflectionTestUtils.getField(chunks[0], "sourceWindowStartSeconds") as Double
val sourceWindowEndSeconds = ReflectionTestUtils.getField(chunks[0], "sourceWindowEndSeconds") as Double
assertTrue(sourceWindowStartSeconds < ballistics.utils.fromDateTime(start))
assertTrue(sourceWindowEndSeconds > ballistics.utils.fromDateTime(start.plusSeconds(30)))
}
@Test
fun `drainReadyChunkResults preserves deterministic chunk order`() {
val service = SlotService()
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
val pending = linkedMapOf(
2 to pendingChunk(2, start.plusSeconds(40), start.plusSeconds(60)),
0 to pendingChunk(0, start, start.plusSeconds(20)),
1 to pendingChunk(1, start.plusSeconds(20), start.plusSeconds(40))
)
val drained = ReflectionTestUtils.invokeMethod<Pair<List<Any>, Int>>(
service,
"drainReadyChunkResults",
pending,
0
)!!
assertEquals(
listOf(0, 1, 2),
drained.first.map { ReflectionTestUtils.getField(ReflectionTestUtils.getField(it, "completion")!!, "index") }
)
assertEquals(3, drained.second)
assertTrue(pending.isEmpty())
}
@Test
fun `prepared orbital window is reused across multiple contours`() {
val service = SlotService()
val stepper = CountingStepper()
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
val endInclusive = start.plusSeconds(10)
val window = prepareOrbitalWindow(service, stepper, start, endInclusive)
val sampledPoints = ReflectionTestUtils.getField(window, "points") as List<*>
assertEquals(3, sampledPoints.size)
assertEquals(3, stepper.requestedTimes.size)
val firstContour = contourFromPreparedWindow(service, window, 20.0, 2.0)
val secondContour = contourFromPreparedWindow(service, window, 24.0, 2.0)
assertTrue(firstContour.isNotBlank())
assertTrue(secondContour.isNotBlank())
assertEquals(3, stepper.requestedTimes.size)
assertEquals(
listOf(5.0, 5.0),
stepper.requestedTimes.zipWithNext { left, right -> right - left }
)
}
@Test
fun `prepareOrbitalWindow reuses initial tn point when provided`() {
val service = SlotService()
val stepper = CountingStepper()
val start = LocalDateTime.of(2026, 4, 9, 10, 0, 0)
val endInclusive = start.plusSeconds(10)
val initialPoint = stepper.calculate(ballistics.utils.fromDateTime(start))
val callsAfterInitialPoint = stepper.requestedTimes.size
val window = prepareOrbitalWindow(service, stepper, start, endInclusive, initialPoint)
val sampledPoints = ReflectionTestUtils.getField(window, "points") as List<*>
assertEquals(3, sampledPoints.size)
assertEquals(callsAfterInitialPoint + 2, stepper.requestedTimes.size)
}
private fun pendingChunk(
index: Int,
start: LocalDateTime,
endExclusive: LocalDateTime
): Any {
val completionClass = Class.forName(
"space.nstart.pcp.slots_service.service.SlotService\$SlotCalculationChunkComplete"
)
val completionConstructor = completionClass.declaredConstructors.single { it.parameterCount == 11 }
completionConstructor.isAccessible = true
val completion = completionConstructor.newInstance(
index,
10L,
start,
endExclusive,
1,
1,
5L,
2L,
3L,
1,
2
)
val pendingChunkClass = Class.forName(
"space.nstart.pcp.slots_service.service.SlotService\$SlotCalculationPendingChunk"
)
val pendingChunkConstructor = pendingChunkClass.declaredConstructors.single { it.parameterCount == 2 }
pendingChunkConstructor.isAccessible = true
return pendingChunkConstructor.newInstance(
mutableListOf(listOf(Mar(sat = 1L, tn = start, tk = endExclusive))),
completion
)
}
private fun prepareOrbitalWindow(
service: SlotService,
stepper: CountingStepper,
start: LocalDateTime,
endInclusive: LocalDateTime,
initialPoint: OrbitalPoint? = null
): Any {
val method = SlotService::class.java.getDeclaredMethod(
"prepareOrbitalWindow",
AbstractStepper::class.java,
LocalDateTime::class.java,
LocalDateTime::class.java,
OrbitalPoint::class.java
)
method.isAccessible = true
return method.invoke(service, stepper, start, endInclusive, initialPoint)!!
}
private fun contourFromPreparedWindow(
service: SlotService,
window: Any,
roll: Double,
capture: Double
): String {
val preparedWindowClass = Class.forName(
"space.nstart.pcp.slots_service.service.SlotService\$PreparedOrbitalWindow"
)
val method = SlotService::class.java.getDeclaredMethod(
"contour",
preparedWindowClass,
java.lang.Double.TYPE,
java.lang.Double.TYPE
)
method.isAccessible = true
return method.invoke(service, window, roll, capture) as String
}
private class CountingStepper : AbstractStepper {
val requestedTimes = mutableListOf<Double>()
override fun calculate(t: Double): OrbitalPoint {
requestedTimes += t
val phase = t / 600.0
return OrbitalPoint(
t,
42,
Vector3D(7_000_000.0 * cos(phase), 7_000_000.0 * sin(phase), 1_000_000.0),
Vector3D(-7_500.0 * sin(phase), 7_500.0 * cos(phase), 10.0)
)
}
override fun calculate(t: Double, prev: OrbitalPoint): OrbitalPoint = calculate(t)
override fun clear() = Unit
}
}
@@ -0,0 +1,264 @@
package space.nstart.pcp.slots_service.service
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
class SlotServiceChainAggregationTest {
private val service = SlotService()
private val method = service.javaClass.getDeclaredMethod(
"buildSurveyChains",
Class.forName("space.nstart.pcp.slots_service.model.satellite.AbstractSatellite"),
List::class.java,
LocalDateTime::class.java,
LocalDateTime::class.java
).apply { isAccessible = true }
@Test
fun `buildSurveyChains merges when time overlaps and geometry intersects`() {
val satellite = satellite()
val chains = buildSurveyChains(
satellite,
listOf(
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 100,
slotNumber = 1,
start = baseTime(),
end = baseTime().plusSeconds(30),
contour = "POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))"
),
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 101,
slotNumber = 2,
start = baseTime().plusSeconds(20),
end = baseTime().plusSeconds(60),
contour = "POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))"
)
)
)
assertEquals(1, chains.size)
assertTrue(chains.single().contour.startsWith("POLYGON"))
assertFalse(chains.single().contour.startsWith("MULTIPOLYGON"))
}
@Test
fun `buildSurveyChains keeps routes separate when time overlaps but geometry does not intersect`() {
val satellite = satellite()
val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger
val logs = captureLogs(logger) {
val chains = buildSurveyChains(
satellite,
listOf(
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 100,
slotNumber = 1,
start = baseTime(),
end = baseTime().plusSeconds(30),
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 101,
slotNumber = 2,
start = baseTime().plusSeconds(20),
end = baseTime().plusSeconds(60),
contour = "POLYGON ((10 10, 11 10, 11 11, 10 11, 10 10))"
)
)
)
assertEquals(2, chains.size)
}
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
}
@Test
fun `buildSurveyChains keeps routes separate when contours only touch by boundary`() {
val satellite = satellite()
val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger
val logs = captureLogs(logger) {
val chains = buildSurveyChains(
satellite,
listOf(
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 100,
slotNumber = 1,
start = baseTime(),
end = baseTime().plusSeconds(30),
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 101,
slotNumber = 2,
start = baseTime().plusSeconds(20),
end = baseTime().plusSeconds(60),
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
)
)
)
assertEquals(2, chains.size)
}
assertTrue(logs.any { it.formattedMessage.contains("contours lack sufficient areal intersection despite temporal overlap") })
}
@Test
fun `buildSurveyChains merges adjacent routes when contours are continuous`() {
val satellite = satellite()
val chains = buildSurveyChains(
satellite,
listOf(
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 100,
slotNumber = 1,
start = baseTime(),
end = baseTime().plusSeconds(30),
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
),
bookedSlot(
satelliteId = satellite.id,
bookedSlotId = 101,
slotNumber = 2,
start = baseTime().plusSeconds(30),
end = baseTime().plusSeconds(60),
contour = "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))"
)
)
)
assertEquals(1, chains.size)
assertEquals(baseTime(), chains.single().tn)
assertEquals(baseTime().plusSeconds(60), chains.single().tk)
assertEquals(listOf(100L, 101L), chains.single().slotIds)
}
@Test
fun `buildSurveyChains keeps routes separate when union returns multipolygon`() {
val logger = LoggerFactory.getLogger(SlotService::class.java) as Logger
val logs = captureLogs(logger) {
val method = service.javaClass.getDeclaredMethod(
"unionContoursIfPolygon",
String::class.java,
String::class.java,
Long::class.javaPrimitiveType,
LocalDateTime::class.java,
LocalDateTime::class.java,
Double::class.javaPrimitiveType,
List::class.java,
Class.forName("space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO"),
Long::class.javaPrimitiveType
)
method.isAccessible = true
val result = method.invoke(
service,
"POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
"POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
22L,
baseTime(),
baseTime().plusSeconds(30),
5.0,
listOf(100L),
bookedSlot(
satelliteId = 22,
bookedSlotId = 101,
slotNumber = 2,
start = baseTime().plusSeconds(20),
end = baseTime().plusSeconds(60),
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))"
).slot.toDTO(),
101L
)
assertEquals(null, result)
}
assertTrue(logs.any { it.formattedMessage.contains("union produced non-polygon geometry") })
}
private fun buildSurveyChains(
satellite: TestSatelliteImpl,
bookedSlots: List<BookedSlotEntity>
): List<SurveySlotDTO> {
@Suppress("UNCHECKED_CAST")
return method.invoke(
service,
satellite,
bookedSlots,
baseTime().minusMinutes(5),
baseTime().plusMinutes(10)
) as List<SurveySlotDTO>
}
private fun satellite() = TestSatelliteImpl(
id = 22,
tnCalc = LocalDateTime.of(LocalDate.of(2026, 3, 31), LocalTime.of(0, 0)),
durationCalc = 16
)
private fun bookedSlot(
satelliteId: Long,
bookedSlotId: Long,
slotNumber: Long,
start: LocalDateTime,
end: LocalDateTime,
contour: String
) = BookedSlotEntity(
bookedSlotId = bookedSlotId,
slot = SlotEntity(
slotId = bookedSlotId,
slotNum = slotNumber,
cycle = 0,
satelliteId = satelliteId,
coveringType = 0,
tn = start,
tk = end,
roll = 5.0,
contour = contour,
revolution = 101,
revolutionSign = RevolutionSign.ASC.toString(),
latitude = 55.75,
longitude = 37.62
),
cycle = 0,
status = BookedSlotStatus.BOOKED.name
)
private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 31, 10, 0)
private fun captureLogs(logger: Logger, block: () -> Unit): List<ILoggingEvent> {
val appender = ListAppender<ILoggingEvent>()
appender.start()
logger.addAppender(appender)
try {
block()
return appender.list.toList()
} finally {
logger.detachAppender(appender)
}
}
}
@@ -0,0 +1,175 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.springframework.test.util.ReflectionTestUtils
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import space.nstart.pcp.slots_service.repository.SlotRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import javax.sql.DataSource
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class SlotServiceIntervalTest {
private fun anyDateTimeValue(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN
private fun eqLongValue(value: Long): Long = eq(value) ?: value
private fun eqDateTimeValue(value: LocalDateTime): LocalDateTime = eq(value) ?: value
private data class TestSlotCalculationSummaryProjection(
override val satelliteId: Long,
override val slotCount: Long,
override val minTime: LocalDateTime?,
override val maxTime: LocalDateTime?,
override val minRevolution: Long?,
override val maxRevolution: Long?,
) : SlotRepository.SlotCalculationSummaryProjection
@Test
fun `allByInterval shifts base slots by closure cycle`() {
val slotRepository = mock(SlotRepository::class.java)
val satellite = TestSatelliteImpl(
id = 22L,
cycleRevs = 243L,
tnCalc = LocalDateTime.of(LocalDate.of(2026, 3, 31), LocalTime.of(0, 0)),
durationCalc = 16
)
val service = slotServiceFixture(slotRepository, satellite)
val intervalStart = LocalDateTime.of(2026, 4, 16, 9, 59)
val intervalStop = LocalDateTime.of(2026, 4, 16, 10, 11)
val baseWindowStart = intervalStart.minusDays(satellite.durationCalc)
val baseWindowStop = intervalStop.minusDays(satellite.durationCalc)
val baseSlot = SlotEntity(
slotId = 100L,
slotNum = 7L,
cycle = 0L,
satelliteId = satellite.id,
coveringType = 0,
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
tk = LocalDateTime.of(2026, 3, 31, 10, 10),
roll = 5.0,
contour = "POLYGON ((0 0, 0 1, 1 1, 0 0))",
revolution = 11L,
revolutionSign = RevolutionSign.ASC.toString(),
latitude = 55.75,
longitude = 37.62
)
`when`(
slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
eqLongValue(satellite.id),
eqDateTimeValue(baseWindowStart),
eqDateTimeValue(baseWindowStop)
)
).thenReturn(listOf(baseSlot))
`when`(
slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
eqLongValue(satellite.id),
anyDateTimeValue(),
anyDateTimeValue()
)
).thenReturn(emptyList())
`when`(
slotRepository.findBySatelliteIdAndTkAfterAndTnBeforeOrderByTnAscTkAscSatelliteIdAscSlotNumAsc(
eqLongValue(satellite.id),
eqDateTimeValue(baseWindowStart),
eqDateTimeValue(baseWindowStop)
)
).thenReturn(listOf(baseSlot))
val actual = service.allByInterval(satellite.id, intervalStart, intervalStop)
assertEquals(1, actual.size)
assertEquals(1L, actual.first().cycle)
assertEquals(LocalDateTime.of(2026, 4, 16, 10, 0), actual.first().tn)
assertEquals(LocalDateTime.of(2026, 4, 16, 10, 10), actual.first().tk)
assertEquals(11L + satellite.cycleRevs, actual.first().revolution)
}
@Test
fun `allByInterval rejects reversed interval`() {
val service = slotServiceFixture(mock(SlotRepository::class.java), TestSatelliteImpl(id = 22L))
assertFailsWith<CustomValidationException> {
service.allByInterval(
satelliteId = 22L,
timeStart = LocalDateTime.of(2026, 4, 17, 0, 0),
timeStop = LocalDateTime.of(2026, 4, 16, 0, 0)
)
}
}
@Test
fun `slotCalculationSummaries returns empty summary for satellites without slots`() {
val slotRepository = mock(SlotRepository::class.java)
val service = slotServiceFixture(slotRepository, TestSatelliteImpl(id = 22L))
val summary = TestSlotCalculationSummaryProjection(
satelliteId = 22L,
slotCount = 3L,
minTime = LocalDateTime.of(2026, 4, 20, 0, 0),
maxTime = LocalDateTime.of(2026, 4, 21, 0, 0),
minRevolution = 10L,
maxRevolution = 15L
)
`when`(slotRepository.slotCalculationSummary(22L)).thenReturn(summary)
`when`(slotRepository.slotCalculationSummary(44L)).thenReturn(
TestSlotCalculationSummaryProjection(
satelliteId = 44L,
slotCount = 0L,
minTime = null,
maxTime = null,
minRevolution = null,
maxRevolution = null
)
)
val actual = service.slotCalculationSummaries(listOf(22L, 44L, 22L))
assertEquals(2, actual.size)
assertEquals(true, actual[0].calculated)
assertEquals(5L, actual[0].revolutionInterval)
assertEquals(44L, actual[1].satelliteId)
assertEquals(false, actual[1].calculated)
assertEquals(0L, actual[1].slotCount)
}
private fun slotServiceFixture(slotRepository: SlotRepository, satellite: AbstractSatellite): SlotService {
val service = SlotService()
ReflectionTestUtils.setField(service, "slotRepository", slotRepository)
ReflectionTestUtils.setField(service, "bookedSlotsRepository", mock(BookedSlotsRepository::class.java))
ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java))
ReflectionTestUtils.setField(service, "earthService", mock(EarthService::class.java))
ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java))
ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(listOf(satellite)))
return service
}
private fun testSatelliteCatalogClient(satellites: List<AbstractSatellite>) = object : SatelliteCatalogClient {
override fun allSatellites(): List<AbstractSatellite> = satellites
override fun satellites(ids: List<Long>): List<AbstractSatellite> =
ids.distinct().map { satelliteId ->
satellites.find { it.id == satelliteId }
?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
}
override fun satellite(id: Long): AbstractSatellite =
satellites.find { it.id == id }
?: throw CustomValidationException("КА $id не зарегистрирован")
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request
}
}
@@ -0,0 +1,303 @@
package space.nstart.pcp.slots_service.service
import ballistics.types.EarthType
import ballistics.utils.astro.AstronomerJ2000
import ballistics.utils.fromDateTime
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.springframework.test.util.ReflectionTestUtils
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import space.nstart.pcp.slots_service.repository.BatchCoverageSlotRow
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import space.nstart.pcp.slots_service.repository.SlotRepository
import java.time.LocalDateTime
import javax.sql.DataSource
import kotlin.math.PI
import kotlin.math.abs
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SlotServiceSunFilterTest {
private fun anyStringValue(): String = anyString()
private fun <T> anyListValue(): List<T> = anyList<T>() ?: emptyList()
private fun anyLongValue(): Long = anyLong()
private fun anyIntValue(): Int = anyInt()
private fun <T> eqValue(value: T): T = eq(value) ?: value
@Test
fun `polySlotsBatch does not apply sun filter when sun is null`() {
val fixture = slotServiceFixture()
val satellite = fixture.satellite
val templateStart = satellite.tnCalc.plusHours(2)
val slot = templateSlot(satellite, templateStart)
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = fixture.service.polySlotsBatch(
request(
satellite = satellite,
timeStart = templateStart.minusMinutes(1),
timeStop = templateStart.plusMinutes(11),
sun = null
)
)
assertEquals(1, response.size)
assertEquals(1, response.single().slots.size)
}
@Test
fun `polySlotsBatch drops slot when sun angle is less than or equal to requested threshold`() {
val fixture = slotServiceFixture()
val satellite = fixture.satellite
val templateStart = satellite.tnCalc.plusHours(2)
val slot = templateSlot(satellite, templateStart)
val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude)
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = fixture.service.polySlotsBatch(
request(
satellite = satellite,
timeStart = templateStart.minusMinutes(1),
timeStop = templateStart.plusMinutes(11),
sun = actualAngle
)
)
assertTrue(response.single().slots.isEmpty())
}
@Test
fun `polySlotsBatch keeps slot when sun angle is above requested threshold`() {
val fixture = slotServiceFixture()
val satellite = fixture.satellite
val templateStart = satellite.tnCalc.plusHours(2)
val slot = templateSlot(satellite, templateStart)
val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude)
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = fixture.service.polySlotsBatch(
request(
satellite = satellite,
timeStart = templateStart.minusMinutes(1),
timeStop = templateStart.plusMinutes(11),
sun = actualAngle - 0.1
)
)
assertEquals(1, response.single().slots.size)
}
@Test
fun `polySlotsBatch uses actual slot time when filtering by sun`() {
val fixture = slotServiceFixture()
val satellite = fixture.satellite
val templateStart = satellite.tnCalc.plusHours(2)
val slot = templateSlot(satellite, templateStart)
val (cycle, templateAngle, actualAngle) = findCycleWithDifferentSunAngle(satellite, slot)
val actualStart = templateStart.plusDays(cycle * satellite.durationCalc)
val actualStop = actualStart.plusMinutes(10)
val requestedSun = (templateAngle + actualAngle) / 2.0
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = fixture.service.polySlotsBatch(
request(
satellite = satellite,
timeStart = actualStart.minusMinutes(1),
timeStop = actualStop.plusMinutes(1),
sun = requestedSun
)
)
val slots = response.single().slots
if (actualAngle > templateAngle) {
assertEquals(1, slots.size)
assertEquals(actualStart, slots.single().tn)
} else {
assertTrue(slots.isEmpty())
}
}
@Test
fun `polySlotsBatch keeps target list when sun filter removes all slots`() {
val fixture = slotServiceFixture()
val satellite = fixture.satellite
val templateStart = satellite.tnCalc.plusHours(2)
val slot = templateSlot(satellite, templateStart)
val actualAngle = sunAngleDegrees(templateStart, slot.latitude, slot.longitude)
stubSingleSlotSpatialFetch(fixture, satellite.id, slot)
`when`(fixture.bookedSlotsRepository.findBySlot_SatelliteIdInAndCycleBetween(eqValue(listOf(satellite.id)), anyLongValue(), anyLongValue()))
.thenReturn(emptyList())
val response = fixture.service.polySlotsBatch(
SlotCoverageBatchRequestDTO(
items = listOf(
SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"),
SlotCoverageTargetDTO(2L, "POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))")
),
timeStart = templateStart.minusMinutes(1),
timeStop = templateStart.plusMinutes(11),
cov = false,
satellites = listOf(satellite.id),
sun = actualAngle
)
)
assertEquals(2, response.size)
assertTrue(response.all { it.slots.isEmpty() })
}
private fun slotServiceFixture(): SlotServiceFixture {
val slotRepository = mock(SlotRepository::class.java)
val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
val satellite = TestSatelliteImpl(id = 22L)
val service = SlotService()
ReflectionTestUtils.setField(service, "slotRepository", slotRepository)
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
ReflectionTestUtils.setField(service, "requestRepository", mock(BookedRequestRepository::class.java))
ReflectionTestUtils.setField(service, "earthService", mock(EarthService::class.java))
ReflectionTestUtils.setField(service, "dataSource", mock(DataSource::class.java))
ReflectionTestUtils.setField(service, "satelliteCatalogClient", testSatelliteCatalogClient(listOf(satellite)))
return SlotServiceFixture(service, slotRepository, bookedSlotsRepository, satellite)
}
private fun testSatelliteCatalogClient(satellites: List<AbstractSatellite>) = object : SatelliteCatalogClient {
override fun allSatellites(): List<AbstractSatellite> = satellites
override fun satellites(ids: List<Long>): List<AbstractSatellite> =
ids.distinct().map { satelliteId ->
satellites.find { it.id == satelliteId }
?: throw CustomValidationException("КА $satelliteId не зарегистрирован")
}
override fun satellite(id: Long): AbstractSatellite =
satellites.find { it.id == id }
?: throw CustomValidationException("КА $id не зарегистрирован")
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO = request
}
private fun request(
satellite: AbstractSatellite,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
sun: Double?
) = SlotCoverageBatchRequestDTO(
items = listOf(SlotCoverageTargetDTO(1L, "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")),
timeStart = timeStart,
timeStop = timeStop,
cov = false,
satellites = listOf(satellite.id),
sun = sun
)
private fun templateSlot(
satellite: AbstractSatellite,
templateStart: LocalDateTime
) = SlotEntity(
slotId = 101L,
slotNum = 1L,
cycle = 0L,
satelliteId = satellite.id,
coveringType = 0,
tn = templateStart,
tk = templateStart.plusMinutes(10),
roll = 10.0,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
revolution = 500L,
revolutionSign = "ASC",
latitude = 55.75,
longitude = 37.62
)
private fun stubSingleSlotSpatialFetch(
fixture: SlotServiceFixture,
satelliteId: Long,
slot: SlotEntity
) {
`when`(
fixture.slotRepository.findTargetSlotRows(
anyListValue(),
anyIntValue()
)
).thenReturn(
listOf(
BatchCoverageSlotRow(
targetId = 1L,
slotId = slot.slotId ?: -1L,
slotNum = slot.slotNum,
cycle = slot.cycle,
satelliteId = slot.satelliteId,
coveringType = slot.coveringType,
tn = slot.tn,
tk = slot.tk,
roll = slot.roll,
contour = slot.contour,
revolution = slot.revolution,
revolutionSign = slot.revolutionSign,
latitude = slot.latitude,
longitude = slot.longitude
)
)
)
}
private fun findCycleWithDifferentSunAngle(
satellite: AbstractSatellite,
slot: SlotEntity
): Triple<Long, Double, Double> {
val templateAngle = sunAngleDegrees(slot.tn, slot.latitude, slot.longitude)
for (cycle in 1L..40L) {
val actualTime = slot.tn.plusDays(cycle * satellite.durationCalc)
val actualAngle = sunAngleDegrees(actualTime, slot.latitude, slot.longitude)
if (abs(actualAngle - templateAngle) > 0.5) {
return Triple(cycle, templateAngle, actualAngle)
}
}
error("Unable to find cycle with distinct sun angle for actual slot time test")
}
private fun sunAngleDegrees(time: LocalDateTime, latitudeDegrees: Double, longitudeDegrees: Double): Double {
val astronomer = AstronomerJ2000(EarthType.PZ90d02)
val position = astronomer.earth.blh2xyz(
latitudeDegrees / 180.0 * PI,
longitudeDegrees / 180.0 * PI,
0.0
)
return astronomer.sunAngle(fromDateTime(time), position) * 180.0 / PI
}
private data class SlotServiceFixture(
val service: SlotService,
val slotRepository: SlotRepository,
val bookedSlotsRepository: BookedSlotsRepository,
val satellite: AbstractSatellite
)
}
@@ -0,0 +1,95 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.springframework.test.util.ReflectionTestUtils
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotAngleDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.slots_service.configuration.CustomValidationException
import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import java.lang.reflect.InvocationTargetException
import java.time.LocalDateTime
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class SlotServiceUpdateSlotsProfileTest {
@Test
fun `updateSlotsProfile sends current satellite slot profile to catalog`() {
val service = SlotService()
val catalogClient = RecordingSatelliteCatalogClient()
ReflectionTestUtils.setField(service, "satelliteCatalogClient", catalogClient)
val satellite = TestSatelliteImpl(
id = 22L,
cycleRevs = 275L,
tnCalc = LocalDateTime.of(2026, 4, 20, 11, 35),
durationCalc = 18L,
slotDuration = 12L,
maxChainLength = 7,
angles = listOf(18.5, 21.5, 23.0, 26.0)
)
invokeUpdateSlotsProfile(service, satellite)
assertEquals(22L, catalogClient.updatedSatelliteId)
assertEquals(
SatelliteSlotProfileDTO(
cycleRevs = 275L,
tnCalc = LocalDateTime.of(2026, 4, 20, 11, 35),
slotDuration = 12L,
durationCalcDays = 18L,
maxChainLength = 7,
defaultAngles = listOf(
SatelliteSlotAngleDTO(angleBegin = 18.5, angleEnd = 21.5),
SatelliteSlotAngleDTO(angleBegin = 23.0, angleEnd = 26.0)
)
),
catalogClient.updatedProfile
)
}
@Test
fun `updateSlotsProfile rejects odd number of slot angle boundaries`() {
val service = SlotService()
ReflectionTestUtils.setField(service, "satelliteCatalogClient", RecordingSatelliteCatalogClient())
val satellite = TestSatelliteImpl(
id = 22L,
angles = listOf(18.5, 21.5, 23.0)
)
assertFailsWith<CustomValidationException> {
invokeUpdateSlotsProfile(service, satellite)
}
}
private fun invokeUpdateSlotsProfile(service: SlotService, satellite: AbstractSatellite) {
val method = SlotService::class.java.getDeclaredMethod(
"updateSlotsProfile",
AbstractSatellite::class.java
)
method.isAccessible = true
try {
method.invoke(service, satellite)
} catch (ex: InvocationTargetException) {
throw ex.targetException
}
}
private class RecordingSatelliteCatalogClient : SatelliteCatalogClient {
var updatedSatelliteId: Long? = null
var updatedProfile: SatelliteSlotProfileDTO? = null
override fun allSatellites(): List<AbstractSatellite> = emptyList()
override fun satellites(ids: List<Long>): List<AbstractSatellite> = emptyList()
override fun satellite(id: Long): AbstractSatellite =
throw UnsupportedOperationException("Not used in this test")
override fun updateSlotProfile(id: Long, request: SatelliteSlotProfileDTO): SatelliteSlotProfileDTO {
updatedSatelliteId = id
updatedProfile = request
return request
}
}
}
@@ -0,0 +1,80 @@
package space.nstart.pcp.slots_service.util
import org.junit.jupiter.api.Test
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertTrue
class LongitudeWrapGeometryTest {
private val reader = WKTReader()
@Test
fun `normalizes prime meridian polygon into continuous longitude range`() {
val geometry = LongitudeWrapGeometry.normalizeToContinuous360(
reader.read("POLYGON ((-1 1, 1 1, 1 -1, -1 -1, -1 1))")
) as Polygon
val xs = geometry.coordinates.map { it.x }
assertEquals(359.0, xs.min())
assertEquals(361.0, xs.max())
}
@Test
fun `equivalent polygons around zero meridian intersect after normalization`() {
val slotGeometry = LongitudeWrapGeometry.normalizeToContinuous360(
reader.read("POLYGON ((359 1, 1 1, 1 -1, 359 -1, 359 1))")
)
val queryGeometry = LongitudeWrapGeometry.normalizeToContinuous360(
reader.read("POLYGON ((-0.5 0.5, 0.5 0.5, 0.5 -0.5, -0.5 -0.5, -0.5 0.5))")
)
assertTrue(slotGeometry.intersects(queryGeometry))
}
@Test
fun `builds search variants for polygons crossing zero meridian`() {
val variants = LongitudeWrapGeometry.buildSearchVariantsWkt(
"POLYGON ((355 1, 361 1, 361 -1, 355 -1, 355 1))"
).map { reader.read(it) as Polygon }
val bounds = variants.map { listOf(it.envelopeInternal.minX, it.envelopeInternal.maxX) }
assertContentEquals(
listOf(
listOf(-5.0, 1.0),
listOf(355.0, 361.0),
listOf(715.0, 721.0)
),
bounds
)
}
@Test
fun `aligns eastern slot copy to western continuous target band`() {
val target = reader.read("POLYGON ((355 1, 361 1, 361 -1, 355 -1, 355 1))")
val easternSlot = reader.read("POLYGON ((0.2 0.5, 0.8 0.5, 0.8 -0.5, 0.2 -0.5, 0.2 0.5))")
val aligned = LongitudeWrapGeometry.alignToReference(easternSlot, target) as Polygon
assertEquals(360.2, aligned.envelopeInternal.minX)
assertEquals(360.8, aligned.envelopeInternal.maxX)
assertTrue(aligned.intersects(LongitudeWrapGeometry.normalizeToContinuous360(target)))
}
@Test
fun `repairs wrap induced self intersection into valid multipolygon`() {
val geometry = LongitudeWrapGeometry.normalizeToContinuous360(
reader.read("POLYGON ((179 2, -179 2, 179 0, -179 0, 179 -2, -179 -2, 179 2))")
)
assertTrue(geometry is MultiPolygon)
assertTrue(geometry.isValid)
assertEquals(179.0, geometry.envelopeInternal.minX)
assertEquals(181.0, geometry.envelopeInternal.maxX)
}
}
@@ -0,0 +1,65 @@
spring:
application:
name: slots-service
cloud:
config:
enabled: false
lifecycle.timeout-per-shutdown-phase: 40s
jackson:
default-property-inclusion: non_null
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.100.160:35400/pcp_slots
username: postgres
password: password
hikari:
maximum-pool-size: 2
minimum-idle: 0
connection-timeout: 5000
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
lob:
non_contextual_creation: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
codec:
max-in-memory-size: 20MB
springdoc:
swagger-ui:
enabled: true
layout: BaseLayout
path: /swagger/ui
api-docs:
enabled: true
path: /api-docs
logging:
level:
.: ERROR
file:
name: ./logs/application.log
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
info:
enabled: true
settings:
earth-grid-service: http://192.168.60.68:7005
server:
port: 7006