pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ
Бэкенд (pcp-tgu-service): - REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ взводится, цепочка встаёт на паузу (новый флаг SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу. - Пауза централизована в PlanProjectionService.project(): для отклонённой цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался). - Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT. - Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT, повторное создание окна -> 409. - ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с), устойчиво к усечению ISO на фронте до целых секунд. Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана, панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков. Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
+9
@@ -41,6 +41,15 @@ class PlanController(
|
||||
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List<PlanResponse> =
|
||||
planQueryService.getAllPlans(historyDays)
|
||||
|
||||
/**
|
||||
* Черновики (DRAFT) для режима «Черновики» во вкладке «Планы»: созданные, но ещё не
|
||||
* отправленные планы-кандидаты. Отдельный эндпоинт, т.к. остальные чтения DRAFT отсекают.
|
||||
* Литеральный путь `/drafts` приоритетнее шаблона `/{spacecraftId}`.
|
||||
*/
|
||||
@GetMapping("/drafts")
|
||||
fun getDraftPlans(@RequestParam(required = false) spacecraftId: String?): List<PlanResponse> =
|
||||
planQueryService.getDraftPlans(spacecraftId)
|
||||
|
||||
/** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
|
||||
@GetMapping("/{spacecraftId}")
|
||||
fun getPlansBySpacecraftId(
|
||||
|
||||
+7
@@ -35,6 +35,13 @@ class SpacecraftPlanningStateEntity(
|
||||
@Column(name = "urgent_reissue_from")
|
||||
var urgentReissueFrom: LocalDateTime? = null,
|
||||
|
||||
/**
|
||||
* Цепочка стоит на паузе после REJECTED: проекция пуста, воркер не выдаёт. Снимается при ручной
|
||||
* выдаче / промоуте черновика / приёмке — то есть когда оператор осознанно вмешался.
|
||||
*/
|
||||
@Column(name = "paused_after_reject", nullable = false)
|
||||
var pausedAfterReject: Boolean = false,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
) {
|
||||
|
||||
+11
@@ -16,6 +16,17 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
|
||||
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
|
||||
|
||||
/**
|
||||
* Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков:
|
||||
* сочетание (КА + начало + конец) уникально среди DRAFT.
|
||||
*/
|
||||
fun findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||
spacecraftId: String,
|
||||
startTime: LocalDateTime,
|
||||
endTime: LocalDateTime,
|
||||
status: PlannedPlanStatus
|
||||
): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает все планы в порядке старта (для read-проекции и истории). */
|
||||
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
||||
|
||||
|
||||
+15
@@ -75,6 +75,21 @@ class CreateChainPlanUseCase(
|
||||
plan
|
||||
}
|
||||
|
||||
// Гард на дубли: сочетание (КА + начало + конец) уникально среди черновиков. Повторное создание
|
||||
// того же окна не плодит второй черновик — оператор не теряется в одинаковых кандидатах.
|
||||
val duplicate = plannedPlanRepository
|
||||
.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||
req.spacecraftId, req.windowStart, req.windowStop, PlannedPlanStatus.DRAFT
|
||||
)
|
||||
.firstOrNull()
|
||||
if (duplicate != null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Черновик на окно [${req.windowStart}–${req.windowStop}] для КА ${req.spacecraftId} уже есть " +
|
||||
"(${duplicate.planId}) — откройте его или удалите"
|
||||
)
|
||||
}
|
||||
|
||||
// Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой).
|
||||
val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId)
|
||||
if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) {
|
||||
|
||||
+17
-4
@@ -25,8 +25,11 @@ import java.time.ZoneOffset
|
||||
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
|
||||
*
|
||||
* - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
|
||||
* - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ
|
||||
* запускается срочная перевыдача ([reissueOnNearestWindow]).
|
||||
* - REJECTED — план отклонён как неправильный: помечается терминально, авто-перевыдача НЕ
|
||||
* запускается. Цепочка встаёт на паузу — оператор должен вручную создать исправленный план
|
||||
* («Создать план»). Бессмысленно автоматически перевыдавать тот же план на то же окно.
|
||||
* - «не заложен» / «не принят» — технический сбой доставки (содержимое не отклонялось): план
|
||||
* помечается терминально, и на ближайшую ЗРВ запускается срочная перевыдача ([reissueOnNearestWindow]).
|
||||
*/
|
||||
@Service
|
||||
class HandlePlanDecisionUseCase(
|
||||
@@ -156,6 +159,7 @@ class HandlePlanDecisionUseCase(
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = null
|
||||
state.pausedAfterReject = false
|
||||
state.chainVersion += 1
|
||||
state.updatedAt = now
|
||||
|
||||
@@ -177,10 +181,19 @@ class HandlePlanDecisionUseCase(
|
||||
plan.status = PlannedPlanStatus.REJECTED
|
||||
plan.updatedAt = now
|
||||
|
||||
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный», и повторно выдавать тот же
|
||||
// план бессмысленно. Снимаем активную выдачу, гасим срочную перевыдачу и ставим цепочку на
|
||||
// паузу (pausedAfterReject) — проекция станет пустой, воркер ничего не выдаст, пока оператор
|
||||
// вручную не создаст исправленный план (его promote/ручная выдача снимут паузу). Базу не трогаем.
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = null
|
||||
state.pausedAfterReject = true
|
||||
state.updatedAt = now
|
||||
|
||||
planIssueAttemptRepository.save(attempt)
|
||||
plannedPlanRepository.save(plan)
|
||||
|
||||
reissueOnNearestWindow(state, plan, now)
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
@@ -106,6 +106,9 @@ class IssueDuePlanWorker(
|
||||
return null
|
||||
}
|
||||
|
||||
// Пауза после REJECTED централизована в PlanProjectionService.project(): для отклонённой
|
||||
// цепочки проекция приходит пустой (выше уже сработал early-return), поэтому отдельного
|
||||
// гарда «не выдавать отклонённое окно» здесь не нужно.
|
||||
val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes)
|
||||
val due = urgentFrom != null || !now.isBefore(issueNotBefore)
|
||||
if (!due) {
|
||||
@@ -173,6 +176,8 @@ class IssueDuePlanWorker(
|
||||
state.activePlanId = plan.planId
|
||||
state.activeAttemptId = attempt.attemptId
|
||||
state.urgentReissueFrom = null
|
||||
// Оператор вручную выдал план — снимаем паузу после отказа (если была).
|
||||
state.pausedAfterReject = false
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
@@ -202,6 +207,8 @@ class IssueDuePlanWorker(
|
||||
state.activePlanId = plan.planId
|
||||
state.activeAttemptId = attempt.attemptId
|
||||
state.urgentReissueFrom = null
|
||||
// Оператор отправил исправленный черновик — снимаем паузу после отказа (если была).
|
||||
state.pausedAfterReject = false
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
|
||||
+8
-1
@@ -33,8 +33,15 @@ class PlanProjectionService(
|
||||
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
||||
|
||||
val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
||||
val anchorPlan = resolveAnchorPlan(state)
|
||||
|
||||
// Пауза после REJECTED: цепочка стоит, пока оператор вручную не создаст исправленный план
|
||||
// (ручная выдача/промоут снимут флаг). Будущего автоматически быть не может — отдаём пусто.
|
||||
// Это единый источник паузы: воркер выдачи получает пустую проекцию и ничего не материализует.
|
||||
if (state?.pausedAfterReject == true) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val anchorPlan = resolveAnchorPlan(state)
|
||||
return if (anchorPlan == null) {
|
||||
planCalculationService.calculatePlans(points)
|
||||
} else {
|
||||
|
||||
+17
@@ -53,6 +53,23 @@ class PlanQueryService(
|
||||
return combine(persisted, projected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Черновики (DRAFT) — отдельным чтением, т.к. основной read-path их намеренно отсекает.
|
||||
* Для режима «Черновики» во вкладке «Планы»: список созданных, но ещё не отправленных
|
||||
* планов-кандидатов (из диалога новой цепочки или из редактора). Опционально по одному КА.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getDraftPlans(spacecraftId: String?): List<PlanResponse> {
|
||||
val plans = if (spacecraftId != null) {
|
||||
plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
|
||||
} else {
|
||||
plannedPlanRepository.findAllByOrderByStartTime()
|
||||
}
|
||||
return plans
|
||||
.filter { plan -> plan.status == PlannedPlanStatus.DRAFT }
|
||||
.map(PlanResponse::from)
|
||||
}
|
||||
|
||||
/** Возвращает persisted plan по ID для test-only/debug API. */
|
||||
@Transactional(readOnly = true)
|
||||
fun getByPlanId(planId: UUID): PlannedPlanEntity =
|
||||
|
||||
+12
-6
@@ -18,8 +18,14 @@ import java.time.LocalDateTime
|
||||
*/
|
||||
object ZrvWindowMatcher {
|
||||
|
||||
/** Допуск сравнения времён — компенсирует погрешность сериализации ISO-строк на фронте. */
|
||||
private const val TOLERANCE_SECONDS = 1L
|
||||
/**
|
||||
* Допуск сравнения времён — компенсирует усечение ISO-строк на фронте до целых секунд
|
||||
* (`.slice(0,19)`): реальная ЗРВ имеет доли секунды, набранное окно — нет, расхождение строго
|
||||
* меньше секунды. Сравнивается истинная абсолютная длительность (не усечённые секунды): у
|
||||
* отрицательного `Duration` доли нормализуются в +nanos, поэтому `.toSeconds()` округлял бы 0.18с
|
||||
* вниз до −1 и ломал совпадение.
|
||||
*/
|
||||
private val TOLERANCE = Duration.ofSeconds(1)
|
||||
|
||||
fun matches(
|
||||
windows: List<CalculatedPlan>,
|
||||
@@ -31,14 +37,14 @@ object ZrvWindowMatcher {
|
||||
return false
|
||||
}
|
||||
val startMatches = windows.any { zrv ->
|
||||
zrv.kppId == windowKppId && diffSeconds(zrv.startTime, windowStart) < TOLERANCE_SECONDS
|
||||
zrv.kppId == windowKppId && within(zrv.startTime, windowStart)
|
||||
}
|
||||
val stopMatches = windows.any { zrv ->
|
||||
diffSeconds(zrv.endTime, windowStop) < TOLERANCE_SECONDS
|
||||
within(zrv.endTime, windowStop)
|
||||
}
|
||||
return startMatches && stopMatches
|
||||
}
|
||||
|
||||
private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long =
|
||||
Math.abs(Duration.between(a, b).toSeconds())
|
||||
private fun within(a: LocalDateTime, b: LocalDateTime): Boolean =
|
||||
Duration.between(a, b).abs() <= TOLERANCE
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- Цепочка КА стоит на паузе после REJECTED: пока флаг TRUE, проекция пуста и воркер ничего не
|
||||
-- выдаёт — оператор должен вручную создать исправленный план («Создать план»). Флаг снимается при
|
||||
-- ручной выдаче / промоуте черновика / приёмке. В отличие от urgent_reissue_from (тех. негативы
|
||||
-- LAYIN_FAILED/NOT_ACCEPTED авто-перевыдаются), отказ решением требует осознанного вмешательства.
|
||||
ALTER TABLE spacecraft_planning_state
|
||||
ADD COLUMN IF NOT EXISTS paused_after_reject BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
+18
@@ -137,6 +137,18 @@ class CreateChainPlanUseCaseTest {
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate window for the same spacecraft yields 409 and no mission is created`() {
|
||||
val existing = plan(time(5), time(6), PlannedPlanStatus.DRAFT)
|
||||
val fixture = fixture(base = null, existingDuplicate = existing)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() {
|
||||
val newMissionId = UUID.randomUUID()
|
||||
@@ -165,10 +177,16 @@ class CreateChainPlanUseCaseTest {
|
||||
projectedTail: List<CalculatedPlan> = emptyList(),
|
||||
failSave: Boolean = false,
|
||||
spacecraftIdOverride: String = spacecraftId,
|
||||
existingDuplicate: PlannedPlanEntity? = null,
|
||||
window: Pair<LocalDateTime, LocalDateTime> = time(5) to time(6)
|
||||
): Fixture {
|
||||
val repo = mock(PlannedPlanRepository::class.java)
|
||||
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
||||
`when`(
|
||||
repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||
anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull()
|
||||
)
|
||||
).thenReturn(listOfNotNull(existingDuplicate))
|
||||
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
||||
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
|
||||
if (failSave) throw RuntimeException("DB down")
|
||||
|
||||
+16
@@ -186,6 +186,22 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
assertNotNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow clears the paused-after-reject flag (operator intervened)`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
// Цепочка стояла на паузе после отказа; ручная выдача = осознанное вмешательство → пауза снимается.
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
worker.issuePlanNow(target.planId)
|
||||
|
||||
assertEquals(false, state.pausedAfterReject)
|
||||
assertEquals(target.planId, state.activePlanId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow rejects a plan inside the freeze window`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
|
||||
+5
-2
@@ -83,7 +83,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected decision marks plan REJECTED and arms urgent reissue`() {
|
||||
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
@@ -95,8 +95,11 @@ class PlanIssueAndDecisionFlowTest {
|
||||
|
||||
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
|
||||
assertEquals(PlannedPlanStatus.REJECTED, plan.status)
|
||||
// Активная выдача снята, перевыдача НЕ взведена, цепочка на паузе — ждёт ручного исправленного плана.
|
||||
assertNull(state.activePlanId)
|
||||
assertNull(state.activeAttemptId)
|
||||
assertNotNull(state.urgentReissueFrom)
|
||||
assertNull(state.urgentReissueFrom)
|
||||
assertTrue(state.pausedAfterReject)
|
||||
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -132,6 +132,21 @@ class PlanProjectionServiceTest {
|
||||
assertEquals(base.kppId, anchor?.kppId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty projection when the chain is paused after reject`() {
|
||||
val calculation = mock(PlanCalculationService::class.java)
|
||||
val service = service(
|
||||
state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true),
|
||||
calculation = calculation
|
||||
)
|
||||
|
||||
val result = service.project(spacecraftId)
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
verify(calculation, never()).calculatePlans(anyNonNull())
|
||||
verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull())
|
||||
}
|
||||
|
||||
private fun service(
|
||||
snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(),
|
||||
state: SpacecraftPlanningStateEntity? = null,
|
||||
|
||||
+32
@@ -79,6 +79,38 @@ class PlanQueryServiceTest {
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDraftPlans returns only DRAFT plans`() {
|
||||
val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT)
|
||||
val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val plannedRepository = mock(PlannedPlanRepository::class.java)
|
||||
`when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active))
|
||||
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
||||
val projection = mock(PlanProjectionService::class.java)
|
||||
|
||||
val service = PlanQueryService(plannedRepository, snapshotRepository, projection)
|
||||
val result = service.getDraftPlans(spacecraftId = null)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, result.single().status)
|
||||
assertEquals(draft.planId, result.single().planId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDraftPlans filters by spacecraftId when provided`() {
|
||||
val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT)
|
||||
val plannedRepository = mock(PlannedPlanRepository::class.java)
|
||||
`when`(plannedRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)).thenReturn(listOf(draft))
|
||||
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
||||
val projection = mock(PlanProjectionService::class.java)
|
||||
|
||||
val service = PlanQueryService(plannedRepository, snapshotRepository, projection)
|
||||
val result = service.getDraftPlans(spacecraftId = spacecraftId)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(draft.planId, result.single().planId)
|
||||
}
|
||||
|
||||
private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
|
||||
+13
@@ -51,4 +51,17 @@ class ZrvWindowMatcherTest {
|
||||
fun `empty windows never match`() {
|
||||
assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sub-second ZRV matches a window truncated to whole seconds`() {
|
||||
// Фронт усекает ISO до целых секунд (.slice(0,19)); реальная ЗРВ имеет доли секунды.
|
||||
// Расхождение строго меньше секунды должно совпадать (регрессия на Duration.toSeconds()).
|
||||
val fractional = CalculatedPlan(
|
||||
sc,
|
||||
at(0).withNano(180_078_000),
|
||||
at(10).withNano(19_687_000),
|
||||
"KPP-1"
|
||||
)
|
||||
assertTrue(ZrvWindowMatcher.matches(listOf(fractional), at(0), at(10), "KPP-1"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user