diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt index 9e0573c..9c942aa 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt @@ -3,8 +3,14 @@ package space.nstart.pcp.pcp_mission_planing_service.controller import java.time.LocalDateTime import java.util.UUID +/** Допустимые значения [MissionCloneRequest.mode]. */ +object CloneMode { + const val REPLACE = "replace" + const val FORK_FORWARD = "fork-forward" +} + data class MissionCloneRequest( - /** "replace" — полный клон; "fork-forward" — только включения, перекрывающиеся с окном. */ + /** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */ val mode: String, /** Начало окна нового плана (UTC naive). Обязательно для fork-forward. */ val windowStart: LocalDateTime? = null, diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 616a024..1076648 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -29,6 +29,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDT import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity @@ -135,12 +136,22 @@ class MissionPlaningService( * пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна * из его связанных съёмок тоже попала в клон (иначе сброс становится пустым). * + * Намеренные особенности fork-forward: + * - Времена включений переносятся как есть. Включение, начавшееся до `windowStart` и закончившееся + * внутри окна, клонируется со своим исходным `timeStart` — он может быть раньше `missionStart` + * клона. Границы миссии описывают слот целевой ЗРВ, а не оболочку времён её включений. + * - Съёмка переносится, даже если её сброс не попал в окно: сброс будет перевыдан в новом плане. + * Обратное неверно — сброс без единой перенесённой съёмки отбрасывается. + * * Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service * (инвариант planId == missionId сохраняется). */ @Transactional fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse { - val isForkForward = req.mode == "fork-forward" + if (req.mode != CloneMode.REPLACE && req.mode != CloneMode.FORK_FORWARD) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown clone mode: ${req.mode}") + } + val isForkForward = req.mode == CloneMode.FORK_FORWARD if (isForkForward && (req.windowStart == null || req.windowStop == null)) { throw ResponseStatusException( HttpStatus.BAD_REQUEST, diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt index ef6f9ea..e0fcb57 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -217,6 +217,28 @@ class MissionCloneTest { assertTrue(clonedDrops.isEmpty()) } + @Test + fun `fork-forward keeps a survey even when its drop falls outside the window`() { + // Съёмка внутри окна [t(1), t(2)), но её сброс — до окна. Сброс не клонируется, + // съёмка остаётся (будет пересброшена в новом плане) — намеренная семантика fork-forward. + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + val inSurvey = survey(plan, start = t(1).plusMinutes(10), duration = 600.0) + val outDrop = drop(plan, start = t(0)) + addDropLink(outDrop, inSurvey) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2)) + ) + val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(1, clonedSurveys.size) + assertTrue(clonedDrops.isEmpty()) + } + @Test fun `fork-forward requires windowStart and windowStop`() { val (missionId) = buildSourceMission(t(0), t(1)) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt index 0b02827..daaacc2 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt @@ -3,8 +3,14 @@ package space.nstart.pcp_tgu_service.dto import java.time.LocalDateTime import java.util.UUID +/** Допустимые значения [CreateDraftRequest.mode]; та же строка уходит на mission-planing. */ +object DraftMode { + const val REPLACE = "replace" + const val FORK_FORWARD = "fork-forward" +} + data class CreateDraftRequest( - /** "replace" — черновик в том же слоте; "fork-forward" — черновик на следующей ЗРВ. */ + /** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */ val mode: String, /** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ val windowStart: LocalDateTime? = null, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt index 4e3f963..dd2f115 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt @@ -1,8 +1,11 @@ package space.nstart.pcp_tgu_service.integration.api +import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.MissionPlaningProperties +import java.time.Duration import java.time.LocalDateTime import java.util.UUID @@ -21,21 +24,35 @@ class MissionPlaningClient(properties: MissionPlaningProperties) { .baseUrl(properties.baseUrl) .build() + private val timeout: Duration = Duration.ofSeconds(30) + fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse = webClient.post() .uri("/api/missions/{id}/clone", missionId) .bodyValue(req) .retrieve() + .onStatus({ it.isError }) { resp -> + resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body -> + // 4xx апстрима пробрасываем как есть (404 — нет базовой миссии и т.п.), + // 5xx сворачиваем в 502: проблема не в запросе оператора, а в mission-planing. + val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY + else HttpStatus.valueOf(resp.statusCode().value()) + ResponseStatusException(status, "Клон миссии $missionId не удался (${resp.statusCode()}): $body") + } + } .bodyToMono(MissionCloneResponse::class.java) - .block() - ?: throw IllegalStateException("Empty response from mission-planing clone for missionId=$missionId") + .block(timeout) + ?: throw ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "Пустой ответ mission-planing на клон миссии $missionId" + ) - /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. */ + /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */ fun deleteMission(missionId: UUID) { webClient.delete() .uri("/api/missions/{id}", missionId) .retrieve() .toBodilessEntity() - .block() + .block(timeout) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt index 62b594a..4a8abff 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.time.LocalDateTime import java.util.UUID @@ -12,6 +13,9 @@ interface PlannedPlanRepository : JpaRepository { /** Возвращает планы КА в порядке старта (для read-проекции и истории). */ fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List + /** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */ + fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List + /** Возвращает все планы в порядке старта (для read-проекции и истории). */ fun findAllByOrderByStartTime(): List diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt index 44c3673..506d027 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -7,6 +7,7 @@ import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.dto.CreateDraftRequest import space.nstart.pcp_tgu_service.dto.CreateDraftResponse +import space.nstart.pcp_tgu_service.dto.DraftMode import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest @@ -45,9 +46,21 @@ class CreateDraftUseCase( val now = LocalDateTime.now(ZoneOffset.UTC) + // Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик» + // не плодит вторую миссию-клон и вторую DRAFT-строку. + val existingDraft = plannedPlanRepository + .findAllByBasePlanIdAndStatus(base.planId, PlannedPlanStatus.DRAFT) + .firstOrNull() + if (existingDraft != null) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "У плана ${base.planId} уже есть черновик ${existingDraft.planId} — завершите или отмените его" + ) + } + return when (req.mode) { - "replace" -> createReplaceDraft(base, now) - "fork-forward" -> createForkForwardDraft(base, req, now) + DraftMode.REPLACE -> createReplaceDraft(base, now) + DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, now) else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown draft mode: ${req.mode}") } } @@ -73,7 +86,7 @@ class CreateDraftUseCase( val cloneResponse = missionPlaningClient.cloneMission( missionId = base.planId, - req = MissionCloneRequest(mode = "replace") + req = MissionCloneRequest(mode = DraftMode.REPLACE) ) return saveDraftWithCompensation( @@ -116,7 +129,7 @@ class CreateDraftUseCase( val cloneResponse = missionPlaningClient.cloneMission( missionId = base.planId, - req = MissionCloneRequest(mode = "fork-forward", windowStart = windowStart, windowStop = windowStop) + req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop) ) return saveDraftWithCompensation( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt new file mode 100644 index 0000000..6b167ff --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt @@ -0,0 +1,257 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.DraftMode +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class CreateDraftUseCaseTest { + + private val spacecraftId = "25544" + private val kppId = "KPP-1" + + // --- replace --- + + @Test + fun `replace creates DRAFT with cloned missionId as planId, base window and basePlanId`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val newMissionId = UUID.randomUUID() + val fixture = fixture(base, cloneResult = newMissionId) + + val response = fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(newMissionId, response.draftPlanId) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertEquals(base.planId, saved.basePlanId) + assertEquals(base.startTime, saved.startTime) + assertEquals(base.endTime, saved.endTime) + assertEquals(base.kppId, saved.kppId) + } + + @Test + fun `replace rejects a DRAFT base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `replace rejects a LAID_IN base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `replace rejects a base inside the freeze window`() { + // startTime через 5 минут, заморозка 10 минут → точка невозврата пройдена. + val base = plan(time(0).plusMinutes(5), time(3), PlannedPlanStatus.ACCEPTED) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + // --- fork-forward --- + + @Test + fun `fork-forward creates DRAFT on a window matching the projection`() { + val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN) + val windowStart = time(5) + val windowStop = time(6) + val newMissionId = UUID.randomUUID() + val fixture = fixture( + base, + cloneResult = newMissionId, + projection = listOf(CalculatedPlan(spacecraftId, windowStart, windowStop, kppId)) + ) + + val response = fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop, windowKppId = kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + val saved = fixture.lastSaved!! + assertEquals(windowStart, saved.startTime) + assertEquals(windowStop, saved.endTime) + assertEquals(kppId, saved.kppId) + assertEquals(base.planId, saved.basePlanId) + } + + @Test + fun `fork-forward requires windowStart, windowStop and windowKppId`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.FORK_FORWARD)) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + @Test + fun `fork-forward rejects a windowStart not strictly in the future`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(-1), windowStop = time(1), windowKppId = kppId) + ) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `fork-forward rejects a window that matches no future ZRV in the projection`() { + val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN) + val fixture = fixture( + base, + projection = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId)) + ) + val ex = assertThrows { + fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(5), windowStop = time(6), windowKppId = kppId) + ) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + // --- общие --- + + @Test + fun `unknown mode is rejected`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = "weird")) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + @Test + fun `unknown base plan yields 404`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createDraft(UUID.randomUUID(), CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `an existing open DRAFT on the same base yields 409`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base, existingDrafts = listOf(existing)) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + @Test + fun `a failed DRAFT save compensates by deleting the cloned mission and rethrows`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val newMissionId = UUID.randomUUID() + val fixture = fixture(base, cloneResult = newMissionId, failSave = true) + + assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + verify(fixture.client, times(1)).deleteMission(newMissionId) + } + + // --- helpers --- + + private class Fixture( + val useCase: CreateDraftUseCase, + val client: MissionPlaningClient, + val savedHolder: Array + ) { + val lastSaved: PlannedPlanEntity? get() = savedHolder[0] + } + + private fun fixture( + base: PlannedPlanEntity?, + cloneResult: UUID = UUID.randomUUID(), + projection: List = emptyList(), + existingDrafts: List = emptyList(), + failSave: Boolean = false + ): Fixture { + val repo = mock(PlannedPlanRepository::class.java) + val basePlanId = base?.planId ?: UUID.randomUUID() + `when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base)) + `when`(repo.findAllByBasePlanIdAndStatus(basePlanId, PlannedPlanStatus.DRAFT)).thenReturn(existingDrafts) + + val saved = arrayOfNulls(1) + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + if (failSave) throw RuntimeException("DB down") + val e = inv.arguments[0] as PlannedPlanEntity + saved[0] = e + e + } + + val projectionService = mock(PlanProjectionService::class.java) + `when`(projectionService.project(spacecraftId)).thenReturn(projection) + + val client = mock(MissionPlaningClient::class.java) + `when`(client.cloneMission(anyUuid(), anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) + + val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10)) + return Fixture(useCase, client, saved) + } + + private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status + ) + + private fun props(freeze: Long): PlanningProperties = + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, freeze) + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) + + // Kotlin non-null матчеры: ArgumentMatchers.any() возвращает null и роняет NPE на non-null параметрах. + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +}