Merge branch 'dev' into route-processing

Conflict resolution:
- build.gradle.kts: dev version (keep nstart.cloud repo + jacoco/asm mavenCentral)
- settings.gradle.kts: project name from route-processing (observatio-terrae), rest from dev
- config addresses (application-local, pcp-mission-planing-service, pcp-dynamic-plan application.yaml): dev versions
- menu.html: merged both branches (TGU planning + bookings items)
- CatalogControllerTest.kt: merged both mocks (TguPlanningService + BallisticsService)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий Соловьев
2026-06-01 09:19:29 +03:00
120 changed files with 5180 additions and 345 deletions
@@ -1,10 +1,12 @@
package space.nstart.pcp.slots_service.controller
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
@@ -14,6 +16,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
@@ -34,6 +37,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupCreateDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteGroupDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSlotProfileDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteVisualizationDTO
import space.nstart.pcp.slots_service.service.BallisticsService
import space.nstart.pcp.slots_service.service.GroupStateService
import space.nstart.pcp.slots_service.service.SatelliteCatalogService
import space.nstart.pcp.slots_service.service.SatellitePdcmService
@@ -77,6 +81,9 @@ class CatalogControllerTest {
@MockitoBean
private lateinit var tguPlanningService: TguPlanningService
@MockitoBean
private lateinit var ballisticsService: BallisticsService
@Test
fun `groups page is rendered`() {
val body = WebClient.create("http://localhost:$port")
@@ -139,9 +146,31 @@ class CatalogControllerTest {
assertTrue(body.contains("Asc-node"))
assertTrue(body.contains("Спутники"))
assertTrue(body.contains("Скачать CSV"))
assertTrue(body.contains("satellite-pdcm-send-ic-time"))
assertTrue(body.contains("Задать НУ"))
assertTrue(body.contains("satellite-pdcm-set-ic-modal"))
assertTrue(body.contains("value=\"FOTO\" selected"))
assertTrue(body.contains("dd.mm.yyyy hh:MM:ss.zzz"))
assertTrue(body.contains("/webjars/bootstrap/5.3.0/css/bootstrap.min.css"))
}
@Test
fun `tle analysis page is rendered with page script`() {
val body = WebClient.create("http://localhost:$port")
.get()
.uri("/tle-analysis")
.retrieve()
.bodyToMono(String::class.java)
.block()!!
assertTrue(body.contains("Анализ TLE"))
assertTrue(body.contains("tle-analysis-satellites-body"))
assertTrue(body.contains("tle-analysis-count"))
assertTrue(body.contains("Разница Времени, ч"))
assertTrue(body.contains("value=\"5\""))
assertTrue(body.contains("/tle_analysis_scripts.js"))
}
@Test
fun `stations page is rendered`() {
val body = WebClient.create("http://localhost:$port")
@@ -334,6 +363,114 @@ class CatalogControllerTest {
verify(satellitePdcmService).satelliteSummaries()
}
@Test
fun `satellite pdcm send initial conditions endpoint parses ui time and proxies request`() {
val requestedTime = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000)
val status = WebClient.create("http://localhost:$port")
.post()
.uri { builder ->
builder
.path("/api/catalog/satellites/pdcm/501/send-ic")
.queryParam("time", "24.04.2026 10:15:30.123")
.build()
}
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
assertEquals(HttpStatus.ACCEPTED, status)
verify(slotService).sendSatelliteInitialConditions(501L, requestedTime)
}
@Test
fun `satellite pdcm send initial conditions endpoint rejects invalid ui time`() {
val status = WebClient.create("http://localhost:$port")
.post()
.uri { builder ->
builder
.path("/api/catalog/satellites/pdcm/501/send-ic")
.queryParam("time", "2026-04-24T10:15:30")
.build()
}
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
assertEquals(HttpStatus.BAD_REQUEST, status)
verify(slotService, never()).sendSatelliteInitialConditions(eq(501L), anyLocalDateTime())
}
@Test
fun `satellite pdcm set initial conditions endpoint proxies request to ballistics`() {
val request = SatelliteICDTO(
satelliteId = 999L,
movementModel = MovementModel.KONDOR,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000),
revolution = 42L,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.07,
f81 = 145.2
)
)
val status = WebClient.create("http://localhost:$port")
.post()
.uri("/api/catalog/satellites/pdcm/501/initial-conditions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchangeToMono { response -> Mono.just(response.statusCode()) }
.block()!!
val requestCaptor = ArgumentCaptor.forClass(SatelliteICDTO::class.java)
assertEquals(HttpStatus.ACCEPTED, status)
verify(ballisticsService).receiveRv(captureSatelliteIc(requestCaptor))
assertEquals(501L, requestCaptor.value.satelliteId)
assertEquals(MovementModel.KONDOR, requestCaptor.value.movementModel)
assertEquals(42L, requestCaptor.value.ic.orbPoint.revolution)
}
@Test
fun `satellite pdcm current initial conditions endpoint proxies request to ballistics`() {
val response = SatelliteICDTO(
satelliteId = 501L,
movementModel = MovementModel.KONDOR,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = LocalDateTime.of(2026, 4, 24, 10, 15, 30, 123_000_000),
revolution = 42L,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
),
sBall = 0.07,
f81 = 145.2
)
)
doReturn(response).`when`(ballisticsService).currentInitialConditions(501L)
val actual = WebClient.create("http://localhost:$port")
.get()
.uri("/api/catalog/satellites/pdcm/501/initial-conditions")
.retrieve()
.bodyToMono(SatelliteICDTO::class.java)
.block()!!
assertEquals(501L, actual.satelliteId)
assertEquals(MovementModel.KONDOR, actual.movementModel)
assertEquals(42L, actual.ic.orbPoint.revolution)
verify(ballisticsService).currentInitialConditions(501L)
}
@Test
fun `satellite create endpoint proxies request`() {
val request = SatelliteCreateDTO(
@@ -568,4 +705,7 @@ class CatalogControllerTest {
private fun anyStation(): StationDTO = any(StationDTO::class.java) ?: StationDTO()
private fun anyInitialConditions(): InitialConditionsDTO = any(InitialConditionsDTO::class.java) ?: InitialConditionsDTO()
private fun anySlotProfile(): SatelliteSlotProfileDTO = any(SatelliteSlotProfileDTO::class.java) ?: SatelliteSlotProfileDTO()
private fun anyLocalDateTime(): LocalDateTime = any(LocalDateTime::class.java) ?: LocalDateTime.MIN
private fun captureSatelliteIc(captor: ArgumentCaptor<SatelliteICDTO>): SatelliteICDTO =
captor.capture() ?: SatelliteICDTO()
}
@@ -10,6 +10,8 @@ import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO
@@ -61,6 +63,18 @@ class MapControllerComplexPlanTest {
private val objectMapper = ObjectMapper()
private fun stationEllipseRadius(stationId: String, orbitHeightKm: Double): Double {
val actual = WebClient.create("http://localhost:$port")
.post()
.uri("/requests/station/$stationId?view=true&orbitHeightKm=$orbitHeightKm")
.retrieve()
.bodyToMono(String::class.java)
.map(objectMapper::readTree)
.block()!!
return actual[1]["ellipse"]["semiMajorAxis"].asDouble()
}
@Test
fun `local post api requests delegates to earth service facade`() {
val responseId = UUID.fromString("11111111-1111-4111-8111-111111111111")
@@ -256,11 +270,34 @@ class MapControllerComplexPlanTest {
.block()!!
assertTrue(body.contains("cesiumContainer"))
assertTrue(body.contains("stationOrbitHeightKm"))
assertTrue(body.contains("Высота орбиты, км"))
assertTrue(body.contains("dynamic_plan_map_layers.js"))
assertTrue(body.contains("PcpDynamicPlanMapLayers"))
verify(earthService).reqs()
}
@Test
fun `station czml endpoint uses orbit height for visibility zone`() {
val stationId = "11111111-1111-4111-8111-111111111111"
doReturn(
Mono.just(
StationDTO(
id = UUID.fromString(stationId),
number = 1,
name = "Station 1",
position = PositionDTO(lat = 55.0, long = 37.0, height = 200.0),
elevationMin = 5.0
)
)
).`when`(stationService).station(stationId)
val baseRadius = stationEllipseRadius(stationId, 500.0)
val higherOrbitRadius = stationEllipseRadius(stationId, 700.0)
assertTrue(higherOrbitRadius > baseRadius)
}
@Test
fun `dynamic plan map layer keeps only one visible run`() {
val body = WebClient.create("http://localhost:$port")
@@ -7,7 +7,10 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.web.reactive.function.client.WebClient
import space.nstart.pcp.pcp_types_lib.dto.ballistics.InitialConditionsDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO
import java.net.InetSocketAddress
import java.net.URI
import java.time.LocalDateTime
@@ -27,7 +30,8 @@ class BallisticsServiceTest {
@Test
fun `sat data methods forward time interval to ballistics service`() {
val requests = CopyOnWriteArrayList<URI>()
val serverUrl = startServer(requests)
val requestBodies = CopyOnWriteArrayList<String>()
val serverUrl = startServer(requests, requestBodies)
val service = createService(serverUrl)
val timeStart = LocalDateTime.of(2026, 4, 21, 10, 0)
val timeStop = LocalDateTime.of(2026, 4, 21, 12, 0)
@@ -37,6 +41,25 @@ class BallisticsServiceTest {
service.points(101L, timeStart, timeStop).collectList().block()
val availability = service.orbitAvailability()
val exactPoint = service.exactTimePoint(101L, timeStart)
service.receiveRv(
SatelliteICDTO(
satelliteId = 101L,
movementModel = MovementModel.KONDOR,
ic = InitialConditionsDTO(
orbPoint = OrbPointDTO(
time = timeStart,
revolution = 7L,
vx = 1.0,
vy = 2.0,
vz = 3.0,
x = 4.0,
y = 5.0,
z = 6.0
)
)
)
)
val currentInitialConditions = service.currentInitialConditions(101L)
assertEquals(
listOf(
@@ -44,7 +67,9 @@ class BallisticsServiceTest {
"/api/satellites/101/flight-line",
"/api/satellites/101/orbit",
"/api/satellites/orbit/availability",
"/api/satellites/101/extract-time"
"/api/satellites/101/extract-time",
"/api/satellites/receive-rv",
"/api/satellites/101/initial-conditions/current"
),
requests.map { it.path }
)
@@ -54,6 +79,8 @@ class BallisticsServiceTest {
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
"time_start=2026-04-21T10:00&time_stop=2026-04-21T12:00",
null,
null,
null,
null
),
requests.map { it.query }
@@ -62,6 +89,9 @@ class BallisticsServiceTest {
assertEquals(101L, availability.single().satelliteId)
assertNotNull(exactPoint)
assertEquals(timeStart, exactPoint.time)
assertEquals(true, requestBodies.single().contains(""""movementModel":"KONDOR""""))
assertNotNull(currentInitialConditions)
assertEquals(101L, currentInitialConditions.satelliteId)
}
private fun createService(serverUrl: String): BallisticsService {
@@ -77,7 +107,7 @@ class BallisticsServiceTest {
return service
}
private fun startServer(requests: MutableList<URI>): String {
private fun startServer(requests: MutableList<URI>, requestBodies: MutableList<String>): String {
val startedServer = HttpServer.create(InetSocketAddress(0), 0)
listOf(
"/api/satellites/101/asc-node",
@@ -103,6 +133,37 @@ class BallisticsServiceTest {
"""[{"time":"2026-04-21T10:00:00","revolution":1,"vx":1.0,"vy":2.0,"vz":3.0,"x":4.0,"y":5.0,"z":6.0}]"""
)
}
startedServer.createContext("/api/satellites/receive-rv") { exchange ->
requests.add(exchange.requestURI)
requestBodies.add(exchange.requestBody.bufferedReader().use { it.readText() })
respond(exchange, "")
}
startedServer.createContext("/api/satellites/101/initial-conditions/current") { exchange ->
requests.add(exchange.requestURI)
respond(
exchange,
"""
{
"satelliteId": 101,
"movementModel": "KONDOR",
"ic": {
"orbPoint": {
"time": "2026-04-21T10:00:00",
"revolution": 7,
"vx": 1.0,
"vy": 2.0,
"vz": 3.0,
"x": 4.0,
"y": 5.0,
"z": 6.0
},
"sBall": 0.0,
"f81": 147.8
}
}
""".trimIndent()
)
}
startedServer.start()
server = startedServer
return "http://localhost:${startedServer.address.port}"
@@ -0,0 +1,121 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEExtensionDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteSummaryDTO
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class TleAnalysisServiceTest {
private val tleMonitoringService: TleMonitoringService = mock()
private val satelliteCatalogService: SatelliteCatalogService = mock()
private val service = TleAnalysisService(tleMonitoringService, satelliteCatalogService)
@Test
fun `satellites uses TLE satellite id and enriches rows from catalog by norad id`() {
doReturn(
listOf(
SatelliteSummaryDTO(
id = 101,
noradId = 62138,
code = "SAT-62138",
name = "Test satellite",
typeCode = "EO",
scanTle = true
)
)
).`when`(satelliteCatalogService).allSatellites()
doReturn(
listOf(
TLEExtensionDTO(satelliteId = 62138, revolution = 10, tle = TLEDTO()),
TLEExtensionDTO(satelliteId = 62138, revolution = 11, tle = TLEDTO())
)
).`when`(tleMonitoringService).allTles()
val satellites = service.satellites()
assertEquals(1, satellites.size)
assertEquals(62138, satellites.single().id)
assertEquals(101, satellites.single().catalogId)
assertEquals(62138, satellites.single().noradId)
assertEquals("SAT-62138", satellites.single().code)
assertEquals("Test satellite", satellites.single().name)
assertEquals("EO", satellites.single().typeCode)
assertEquals(true, satellites.single().scanTle)
assertEquals(2, satellites.single().tleRecordsCount)
}
@Test
fun `satellites falls back to catalog when TLE records are empty`() {
doReturn(
listOf(
SatelliteSummaryDTO(
id = 101,
noradId = 62138,
code = "SAT-62138",
name = "Test satellite"
)
)
).`when`(satelliteCatalogService).allSatellites()
doReturn(emptyList<TLEExtensionDTO>()).`when`(tleMonitoringService).allTles()
val satellites = service.satellites()
assertEquals(1, satellites.size)
assertEquals(62138, satellites.single().id)
assertEquals(101, satellites.single().catalogId)
assertEquals(62138, satellites.single().noradId)
assertEquals(0, satellites.single().tleRecordsCount)
}
@Test
fun `analyze satellite uses only requested number of latest TLE records`() {
doReturn(
listOf(
tleRecord(revolution = 1, epoch = "21274.51041667"),
tleRecord(revolution = 2, epoch = "21275.51041667"),
tleRecord(revolution = 3, epoch = "21276.51041667")
)
).`when`(tleMonitoringService).satelliteTles(25544)
val response = service.analyzeSatellite(25544, tleCount = 2)
assertEquals(2, response.rows.size)
assertEquals(listOf(2L, 3L), response.rows.map { it.revolution })
}
@Test
fun `analyze satellite reports epoch difference between compared TLE records`() {
doReturn(
listOf(
tleRecord(revolution = 1, epoch = "21275.51041667"),
tleRecord(revolution = 2, epoch = "21276.01041667")
)
).`when`(tleMonitoringService).satelliteTles(25544)
val response = service.analyzeSatellite(25544, tleCount = 2)
assertEquals(2, response.rows.size)
val currentRow = response.rows[1]
val discrepancy = assertNotNull(currentRow.discrepancy)
assertEquals(response.rows[0].epoch, discrepancy.previousEpoch)
assertTrue(discrepancy.previousEpoch.isBefore(currentRow.epoch))
assertEquals(12.0, discrepancy.epochDifferenceHours, 0.001)
}
private fun tleRecord(revolution: Long, epoch: String = "21275.51041667") =
TLEExtensionDTO(
satelliteId = 25544,
revolution = revolution,
tle = TLEDTO(
header = "ISS (ZARYA)",
first = "1 25544U 98067A $epoch .00000282 00000-0 12558-4 0 9993",
second = "2 25544 51.6435 177.7258 0003783 65.7820 55.4027 15.48915330299929"
)
)
}