Init
This commit is contained in:
+18
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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 не зарегистрирован"))
|
||||
}
|
||||
}
|
||||
+147
@@ -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
|
||||
}
|
||||
+196
@@ -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))
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+313
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+268
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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])
|
||||
}
|
||||
}
|
||||
+80
@@ -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
|
||||
)
|
||||
}
|
||||
+71
@@ -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
|
||||
)
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
}
|
||||
+317
@@ -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()
|
||||
}
|
||||
}
|
||||
+122
@@ -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)
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
+72
@@ -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
|
||||
}
|
||||
}
|
||||
+471
@@ -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))"
|
||||
}
|
||||
}
|
||||
+207
@@ -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
|
||||
}
|
||||
}
|
||||
+264
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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
|
||||
}
|
||||
}
|
||||
+303
@@ -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
|
||||
)
|
||||
}
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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
|
||||
Reference in New Issue
Block a user