|
@@ -0,0 +1,614 @@
|
|
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
|
|
+
|
|
|
|
|
+import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewAlarmDisposalResponse;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewAlarmDisposalSnapshot;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewModulesAdapter;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewModulesService;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionResponse;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionSnapshot;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewPipeNetworkResponse;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewPipeNetworkSnapshot;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewWarningStudyResponse;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewWarningStudySnapshot;
|
|
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewWindows;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
+
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.time.Clock;
|
|
|
|
|
+import java.time.Instant;
|
|
|
|
|
+import java.time.LocalDate;
|
|
|
|
|
+import java.time.LocalDateTime;
|
|
|
|
|
+import java.time.ZoneOffset;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 规格 §8 边界:分母 0、无状态设备、未分区/未分类、今日/昨日边界、未解除状态集合、
|
|
|
|
|
+ * Top3 并列、无日志样本、7 日含 0 点。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>业务时区 {@code Asia/Shanghai},时钟固定在 2026-10-22T08:00Z = 2026-10-22 16:00 北京时间。
|
|
|
|
|
+ */
|
|
|
|
|
+class CockpitOverviewModulesServiceTest {
|
|
|
|
|
+
|
|
|
|
|
+ private CockpitOverviewModulesAdapter adapter;
|
|
|
|
|
+ private CockpitOverviewModulesService service;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ adapter = mock(CockpitOverviewModulesAdapter.class);
|
|
|
|
|
+ Clock clock = Clock.fixed(Instant.parse("2026-10-22T08:00:00Z"), ZoneOffset.UTC);
|
|
|
|
|
+ service = new CockpitOverviewModulesService(adapter, clock);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------ 感知设备
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void perceptionCountsAllBucketsAndSplitsFixedCards() {
|
|
|
|
|
+ when(adapter.loadPerception()).thenReturn(perception(Arrays.asList(
|
|
|
|
|
+ bucket(1, 0, true, false, 2),
|
|
|
|
|
+ bucket(1, 1, true, false, 1),
|
|
|
|
|
+ bucket(2, 0, true, true, 2),
|
|
|
|
|
+ bucket(2, 0, true, false, 2),
|
|
|
|
|
+ bucket(2, 1, true, true, 1),
|
|
|
|
|
+ bucket(4, 1, true, false, 1),
|
|
|
|
|
+ bucket(null, 2, true, false, 1))));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPerceptionResponse response = service.loadPerception("perception-001");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("perception-001", response.getRequestId());
|
|
|
|
|
+ assertEquals("v1", response.getApiVersion());
|
|
|
|
|
+ assertEquals("Asia/Shanghai", response.getTimezone());
|
|
|
|
|
+ assertEquals(10, response.getTotals().getDeviceCount());
|
|
|
|
|
+ assertEquals(3, response.getTotals().getOnlineCount());
|
|
|
|
|
+ assertEquals(7, response.getTotals().getOfflineCount());
|
|
|
|
|
+ assertEquals(0, response.getTotals().getNoStatusCount());
|
|
|
|
|
+ assertEquals("0.3000", response.getTotals().getOnlineRate().getValue().toPlainString());
|
|
|
|
|
+ assertEquals("AVAILABLE", response.getTotals().getOnlineRate().getState());
|
|
|
|
|
+ assertNull(response.getTotals().getOnlineRate().getReason());
|
|
|
|
|
+ assertEquals(1, response.getExcluded().getUnknownTypeCount());
|
|
|
|
|
+ assertEquals(0, response.getExcluded().getUnassignedDistrictCount());
|
|
|
|
|
+
|
|
|
|
|
+ List<CockpitOverviewPerceptionResponse.TypeItem> byType = response.getByType();
|
|
|
|
|
+ assertEquals(4, byType.size());
|
|
|
|
|
+ assertEquals(Arrays.asList("1", "2", "3", "4"), Arrays.asList(
|
|
|
|
|
+ byType.get(0).getCode(), byType.get(1).getCode(), byType.get(2).getCode(), byType.get(3).getCode()));
|
|
|
|
|
+ assertEquals("供水设备", byType.get(0).getName());
|
|
|
|
|
+ assertEquals(3, byType.get(0).getDeviceCount());
|
|
|
|
|
+ assertEquals(0, byType.get(0).getOnlineCount());
|
|
|
|
|
+ assertEquals(3, byType.get(0).getOfflineCount());
|
|
|
|
|
+ assertEquals(5, byType.get(1).getDeviceCount());
|
|
|
|
|
+ assertEquals(3, byType.get(1).getOnlineCount());
|
|
|
|
|
+ assertEquals(2, byType.get(1).getOfflineCount());
|
|
|
|
|
+ assertEquals(0, byType.get(2).getDeviceCount());
|
|
|
|
|
+ assertEquals(1, byType.get(3).getDeviceCount());
|
|
|
|
|
+
|
|
|
|
|
+ List<CockpitOverviewPerceptionResponse.DistrictItem> byDistrict = response.getByDistrict();
|
|
|
|
|
+ assertEquals(3, byDistrict.size());
|
|
|
|
|
+ assertEquals(Arrays.asList(0, 1, 2), Arrays.asList(
|
|
|
|
|
+ byDistrict.get(0).getDistrict(), byDistrict.get(1).getDistrict(), byDistrict.get(2).getDistrict()));
|
|
|
|
|
+ assertEquals(Arrays.asList("城南", "城北", "太常"), Arrays.asList(
|
|
|
|
|
+ byDistrict.get(0).getName(), byDistrict.get(1).getName(), byDistrict.get(2).getName()));
|
|
|
|
|
+ assertEquals(6, byDistrict.get(0).getDeviceCount());
|
|
|
|
|
+ assertEquals(3, byDistrict.get(1).getDeviceCount());
|
|
|
|
|
+ assertEquals(1, byDistrict.get(2).getDeviceCount());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void perceptionWithoutDevicesReturnsEmptyRateInsteadOfZero() {
|
|
|
|
|
+ when(adapter.loadPerception()).thenReturn(perception(Collections.emptyList(), Collections.emptyList()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPerceptionResponse response = service.loadPerception("perception-002");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0, response.getTotals().getDeviceCount());
|
|
|
|
|
+ assertEquals("0.0000", response.getTotals().getOnlineRate().getValue().toPlainString());
|
|
|
|
|
+ assertEquals("EMPTY", response.getTotals().getOnlineRate().getState());
|
|
|
|
|
+ assertEquals("NO_DEVICES", response.getTotals().getOnlineRate().getReason());
|
|
|
|
|
+ assertEquals(0, response.getTotals().getOnlineRate().getDenominator());
|
|
|
|
|
+ assertEquals("EMPTY", response.getFreshness().getStatus());
|
|
|
|
|
+ assertEquals("NO_DEVICES", response.getFreshness().getReason());
|
|
|
|
|
+ assertEquals(4, response.getByType().size());
|
|
|
|
|
+ assertEquals(3, response.getByDistrict().size());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void perceptionCountsMissingStatusAsOfflineAndExplainsReason() {
|
|
|
|
|
+ when(adapter.loadPerception()).thenReturn(perception(
|
|
|
|
|
+ Collections.singletonList(bucket(1, 0, false, false, 2))));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPerceptionResponse response = service.loadPerception("perception-003");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(2, response.getTotals().getOfflineCount());
|
|
|
|
|
+ assertEquals(2, response.getTotals().getNoStatusCount());
|
|
|
|
|
+ assertEquals("0.0000", response.getTotals().getOnlineRate().getValue().toPlainString());
|
|
|
|
|
+ assertEquals("AVAILABLE", response.getTotals().getOnlineRate().getState());
|
|
|
|
|
+ assertEquals("NO_STATUS_COUNTED_OFFLINE", response.getTotals().getOnlineRate().getReason());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void perceptionDerivesMonitoringContentAndFallsBackToConstant() {
|
|
|
|
|
+ when(adapter.loadPerception()).thenReturn(perception(
|
|
|
|
|
+ Arrays.asList(bucket(1, 0, true, true, 1), bucket(3, 0, true, true, 1)),
|
|
|
|
|
+ Arrays.asList(
|
|
|
|
|
+ content(1, "WARN-PRESSURE", "供水压力监测"),
|
|
|
|
|
+ content(1, "WARN-LEAK", "漏失监测"),
|
|
|
|
|
+ content(1, "WARN-LEAK", "漏失监测"),
|
|
|
|
|
+ content(2, "WARN-COD", "COD监测"))));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPerceptionResponse response = service.loadPerception("perception-004");
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPerceptionResponse.TypeItem water = response.getByType().get(0);
|
|
|
|
|
+ assertEquals("供水压力监测 / 漏失监测", water.getMonitoringContent());
|
|
|
|
|
+ assertEquals("DERIVED", water.getMonitoringContentSource());
|
|
|
|
|
+ CockpitOverviewPerceptionResponse.TypeItem gas = response.getByType().get(2);
|
|
|
|
|
+ assertEquals("可燃气体 / 压力", gas.getMonitoringContent());
|
|
|
|
|
+ assertEquals("FALLBACK", gas.getMonitoringContentSource());
|
|
|
|
|
+ assertEquals("井盖状态 / 液位", response.getByType().get(3).getMonitoringContent());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void perceptionTreatsUnknownTypeAndOutOfDomainDistrictAsExcluded() {
|
|
|
|
|
+ when(adapter.loadPerception()).thenReturn(perception(
|
|
|
|
|
+ Collections.singletonList(bucket(null, 7, true, true, 3))));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPerceptionResponse response = service.loadPerception("perception-005");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(3, response.getTotals().getDeviceCount());
|
|
|
|
|
+ assertEquals(3, response.getTotals().getOnlineCount());
|
|
|
|
|
+ assertEquals(3, response.getExcluded().getUnknownTypeCount());
|
|
|
|
|
+ assertEquals(3, response.getExcluded().getUnassignedDistrictCount());
|
|
|
|
|
+ assertEquals(0, response.getByDistrict().get(0).getDeviceCount());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------ 基础设施
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pipeNetworkAggregatesFixedThreeTypesInOrder() {
|
|
|
|
|
+ when(adapter.loadPipeNetwork()).thenReturn(pipes(
|
|
|
|
|
+ pipe("WATER_PIPE", 2, "19104.68"),
|
|
|
|
|
+ pipe("DRAINAGE_PIPE", 1, "52260.98"),
|
|
|
|
|
+ pipe("GAS_PIPE", 1, "7180.38"),
|
|
|
|
|
+ pipe("PIPE_UNCLASSIFIED", 1, "1000")));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-001");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(5, response.getTotals().getPipeCount());
|
|
|
|
|
+ assertEquals("79.55", response.getTotals().getLengthKm().toPlainString());
|
|
|
|
|
+ List<CockpitOverviewPipeNetworkResponse.PipeTypeItem> byPipeType = response.getByPipeType();
|
|
|
|
|
+ assertEquals(3, byPipeType.size());
|
|
|
|
|
+ assertEquals("WATER_PIPE", byPipeType.get(0).getCode());
|
|
|
|
|
+ assertEquals("供水管道", byPipeType.get(0).getName());
|
|
|
|
|
+ assertEquals("DRAINAGE_PIPE", byPipeType.get(1).getCode());
|
|
|
|
|
+ assertEquals("排水管道", byPipeType.get(1).getName());
|
|
|
|
|
+ assertEquals("GAS_PIPE", byPipeType.get(2).getCode());
|
|
|
|
|
+ assertEquals("燃气管道", byPipeType.get(2).getName());
|
|
|
|
|
+ assertEquals(2, byPipeType.get(0).getCount());
|
|
|
|
|
+ assertEquals("19.10", byPipeType.get(0).getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals("0.5000", byPipeType.get(0).getRatio().toPlainString());
|
|
|
|
|
+ assertEquals("0.2500", byPipeType.get(1).getRatio().toPlainString());
|
|
|
|
|
+ assertEquals("0.2500", byPipeType.get(2).getRatio().toPlainString());
|
|
|
|
|
+ assertEquals(1, response.getExcluded().getUnclassifiedCount());
|
|
|
|
|
+ assertEquals("AVAILABLE", response.getState());
|
|
|
|
|
+ assertNull(response.getReason());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pipeNetworkRoundsLikeTheRealSample() {
|
|
|
|
|
+ when(adapter.loadPipeNetwork()).thenReturn(pipes(
|
|
|
|
|
+ pipe("WATER_PIPE", 2489, "19104.68"),
|
|
|
|
|
+ pipe("DRAINAGE_PIPE", 3574, "52260.98"),
|
|
|
|
|
+ pipe("GAS_PIPE", 699, "7180.38")));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-002");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(6762, response.getTotals().getPipeCount());
|
|
|
|
|
+ assertEquals("78.55", response.getTotals().getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals("19.10", response.getByPipeType().get(0).getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals("52.26", response.getByPipeType().get(1).getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals("7.18", response.getByPipeType().get(2).getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals("0.3681", response.getByPipeType().get(0).getRatio().toPlainString());
|
|
|
|
|
+ // 实测口径:3574 / 6762 = 0.52853 → 0.5285(规格样例的 0.5286 是手写近似)。
|
|
|
|
|
+ assertEquals("0.5285", response.getByPipeType().get(1).getRatio().toPlainString());
|
|
|
|
|
+ assertEquals("0.1034", response.getByPipeType().get(2).getRatio().toPlainString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pipeNetworkWithoutPipesReturnsEmptyState() {
|
|
|
|
|
+ when(adapter.loadPipeNetwork()).thenReturn(pipes());
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-003");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0, response.getTotals().getPipeCount());
|
|
|
|
|
+ assertEquals("0.00", response.getTotals().getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals("EMPTY", response.getState());
|
|
|
|
|
+ assertEquals("NO_PIPES", response.getReason());
|
|
|
|
|
+ assertEquals("EMPTY", response.getFreshness().getStatus());
|
|
|
|
|
+ assertEquals("0", response.getByPipeType().get(0).getRatio().toPlainString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void pipeNetworkWithOnlyUnclassifiedRowsKeepsTotalsButEmptyRatio() {
|
|
|
|
|
+ when(adapter.loadPipeNetwork()).thenReturn(pipes(pipe("PIPE_UNCLASSIFIED", 1, "500")));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewPipeNetworkResponse response = service.loadPipeNetwork("pipe-004");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(1, response.getTotals().getPipeCount());
|
|
|
|
|
+ assertEquals("0.50", response.getTotals().getLengthKm().toPlainString());
|
|
|
|
|
+ assertEquals(1, response.getExcluded().getUnclassifiedCount());
|
|
|
|
|
+ assertEquals(0, response.getByPipeType().get(0).getCount());
|
|
|
|
|
+ assertEquals("0", response.getByPipeType().get(0).getRatio().toPlainString());
|
|
|
|
|
+ assertEquals("EMPTY", response.getState());
|
|
|
|
|
+ assertEquals("NO_CLASSIFIED_PIPES", response.getReason());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------ 预警研判
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void warningStudyPassesBusinessWindowsToTheAdapter() {
|
|
|
|
|
+ when(adapter.loadWarningStudy(any())).thenReturn(warnings());
|
|
|
|
|
+
|
|
|
|
|
+ service.loadWarningStudy("warning-001");
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<CockpitOverviewWindows> captor = ArgumentCaptor.forClass(CockpitOverviewWindows.class);
|
|
|
|
|
+ verify(adapter).loadWarningStudy(captor.capture());
|
|
|
|
|
+ CockpitOverviewWindows windows = captor.getValue();
|
|
|
|
|
+ assertEquals(LocalDateTime.parse("2026-10-21T00:00:00"), windows.yesterdayStart());
|
|
|
|
|
+ assertEquals(LocalDateTime.parse("2026-10-22T00:00:00"), windows.todayStart());
|
|
|
|
|
+ assertEquals(LocalDateTime.parse("2026-10-23T00:00:00"), windows.tomorrowStart());
|
|
|
|
|
+ assertEquals(LocalDateTime.parse("2026-09-23T00:00:00"), windows.rankingStart());
|
|
|
|
|
+ assertEquals(LocalDateTime.parse("2026-10-16T00:00:00"), windows.trendStart());
|
|
|
|
|
+ assertEquals(LocalDate.parse("2026-10-22"), windows.today());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void warningUnresolvedOnlyCountsFourStatusesAndLevelsAreCrossed() {
|
|
|
|
|
+ when(adapter.loadWarningStudy(any())).thenReturn(warnings(1, 0, 42,
|
|
|
|
|
+ Arrays.asList(
|
|
|
|
|
+ statusLevel("PENDING", 1, 5),
|
|
|
|
|
+ statusLevel("PENDING", 2, 4),
|
|
|
|
|
+ statusLevel("PROCESSING", 1, 3),
|
|
|
|
|
+ statusLevel("HANDLED", 1, 1),
|
|
|
|
|
+ statusLevel("PROCESSING", null, 1),
|
|
|
|
|
+ statusLevel("PROCESSING", 4, 1),
|
|
|
|
|
+ // 下面两行是 SQL 之外的脏数据:DRAFT/CLOSED 不得进未解除与分级。
|
|
|
|
|
+ statusLevel("DRAFT", 1, 9),
|
|
|
|
|
+ statusLevel("CLOSED", 4, 22)),
|
|
|
|
|
+ Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-002");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(1, response.getToday().getNewCount());
|
|
|
|
|
+ assertEquals(0, response.getToday().getYesterdayCount());
|
|
|
|
|
+ assertEquals(1, response.getToday().getDelta());
|
|
|
|
|
+ assertEquals(15, response.getUnresolved().getCount());
|
|
|
|
|
+ Map<String, Integer> statuses = response.getUnresolved().getStatuses();
|
|
|
|
|
+ assertEquals(Arrays.asList("PENDING", "RECEIVED", "PROCESSING", "HANDLED"),
|
|
|
|
|
+ Arrays.asList(statuses.keySet().toArray(new String[0])));
|
|
|
|
|
+ assertEquals(9, statuses.get("PENDING"));
|
|
|
|
|
+ assertEquals(0, statuses.get("RECEIVED"));
|
|
|
|
|
+ assertEquals(5, statuses.get("PROCESSING"));
|
|
|
|
|
+ assertEquals(1, statuses.get("HANDLED"));
|
|
|
|
|
+ List<CockpitOverviewWarningStudyResponse.LevelItem> byLevel = response.getByLevel();
|
|
|
|
|
+ assertEquals(4, byLevel.size());
|
|
|
|
|
+ assertEquals(9, byLevel.get(0).getCount());
|
|
|
|
|
+ assertEquals(4, byLevel.get(1).getCount());
|
|
|
|
|
+ assertEquals(0, byLevel.get(2).getCount());
|
|
|
|
|
+ assertEquals(1, byLevel.get(3).getCount());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void warningByLevelPrefersDictionaryLabelAndFallsBackToFixedFormat() {
|
|
|
|
|
+ Map<Integer, String> levelNames = new LinkedHashMap<>();
|
|
|
|
|
+ levelNames.put(1, "一级预警");
|
|
|
|
|
+ when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 0,
|
|
|
|
|
+ Collections.singletonList(statusLevel("PENDING", 2, 1)),
|
|
|
|
|
+ Collections.emptyList(), Collections.emptyMap(), levelNames));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-003");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("一级预警", response.getByLevel().get(0).getName());
|
|
|
|
|
+ assertEquals("二级预警", response.getByLevel().get(1).getName());
|
|
|
|
|
+ assertEquals("三级预警", response.getByLevel().get(2).getName());
|
|
|
|
|
+ assertEquals("四级预警", response.getByLevel().get(3).getName());
|
|
|
|
|
+ assertEquals(1, response.getByLevel().get(1).getCount());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void warningTypeRankingTieBreaksByCodeAndDegradesUnknownName() {
|
|
|
|
|
+ when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 10,
|
|
|
|
|
+ Collections.emptyList(),
|
|
|
|
|
+ Arrays.asList(typeCount("WARN-A", 3), typeCount("WARN-B", 2), typeCount("WARN-C", 2),
|
|
|
|
|
+ typeCount("WARN-D", 2), typeCount("", 1)),
|
|
|
|
|
+ names("WARN-A", "压力预警"), Collections.emptyMap()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-004");
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewWarningStudyResponse.TypeRanking ranking = response.getTypeRanking();
|
|
|
|
|
+ assertEquals(10, ranking.getTotal());
|
|
|
|
|
+ // 维护者 2026-10-26:不再只给前三 —— 页面「共27起」而三行只凑出 23 起,读者对不上数。
|
|
|
|
|
+ // 四个有 code 的类型全部给出;空 code 的那条仍不参与排行(但已计入 total)。
|
|
|
|
|
+ assertEquals(4, ranking.getTop().size());
|
|
|
|
|
+ assertEquals("WARN-A", ranking.getTop().get(0).getCode());
|
|
|
|
|
+ assertEquals("压力预警", ranking.getTop().get(0).getName());
|
|
|
|
|
+ assertEquals(3, ranking.getTop().get(0).getCount());
|
|
|
|
|
+ // 并列按 code 升序:WARN-B / WARN-C / WARN-D 都是 2 起。
|
|
|
|
|
+ assertEquals("WARN-B", ranking.getTop().get(1).getCode());
|
|
|
|
|
+ assertEquals("WARN-C", ranking.getTop().get(2).getCode());
|
|
|
|
|
+ assertEquals("WARN-D", ranking.getTop().get(3).getCode());
|
|
|
|
|
+ // 关联不到 warning_type 时如实降级为 code。
|
|
|
|
|
+ assertEquals("WARN-B", ranking.getTop().get(1).getName());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void warningTypeNameMatchesAcrossDashAndUnderscore() {
|
|
|
|
|
+ when(adapter.loadWarningStudy(any())).thenReturn(warnings(0, 0, 3,
|
|
|
|
|
+ Collections.emptyList(),
|
|
|
|
|
+ Collections.singletonList(typeCount("WARN_INSTANT_FLOW", 3)),
|
|
|
|
|
+ names("WARN-INSTANT-FLOW", "排水瞬时流量监测"), Collections.emptyMap()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-005");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("WARN_INSTANT_FLOW", response.getTypeRanking().getTop().get(0).getCode());
|
|
|
|
|
+ assertEquals("排水瞬时流量监测", response.getTypeRanking().getTop().get(0).getName());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void warningWithoutRowsIsEmptyNotFailed() {
|
|
|
|
|
+ when(adapter.loadWarningStudy(any())).thenReturn(warnings());
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewWarningStudyResponse response = service.loadWarningStudy("warning-006");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0, response.getToday().getNewCount());
|
|
|
|
|
+ assertEquals(0, response.getUnresolved().getCount());
|
|
|
|
|
+ assertEquals(0, response.getTypeRanking().getTotal());
|
|
|
|
|
+ assertEquals(0, response.getTypeRanking().getTop().size());
|
|
|
|
|
+ assertEquals(4, response.getByLevel().size());
|
|
|
|
|
+ assertEquals(30, response.getTypeRanking().getRangeDays());
|
|
|
|
|
+ assertEquals("EMPTY", response.getFreshness().getStatus());
|
|
|
|
|
+ assertEquals("NO_WARNINGS", response.getFreshness().getReason());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------ 报警处置
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void alarmDisposalComputesRateFromTheAuditStatusOneBaseSet() {
|
|
|
|
|
+ when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(3, 1, 1, 1, 1,
|
|
|
|
|
+ Arrays.asList(levelCount(1, 1), levelCount(4, 2)),
|
|
|
|
|
+ Collections.emptyList(), Collections.emptyMap(),
|
|
|
|
|
+ levels(1, "一级报警", 4, "四级报警")));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-001");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(1, response.getToday().getNewCount());
|
|
|
|
|
+ assertEquals(1, response.getToday().getYesterdayCount());
|
|
|
|
|
+ assertEquals(0, response.getToday().getDelta());
|
|
|
|
|
+ assertEquals(2, response.getWorkOrderConversion().getConvertedCount());
|
|
|
|
|
+ assertEquals(1, response.getWorkOrderConversion().getUnconvertedCount());
|
|
|
|
|
+ assertEquals(3, response.getWorkOrderConversion().getTotalCount());
|
|
|
|
|
+ assertEquals(1, response.getDisposal().getRate().getNumerator());
|
|
|
|
|
+ assertEquals(3, response.getDisposal().getRate().getDenominator());
|
|
|
|
|
+ assertEquals("0.3333", response.getDisposal().getRate().getValue().toPlainString());
|
|
|
|
|
+ assertEquals("AVAILABLE", response.getDisposal().getRate().getState());
|
|
|
|
|
+ assertEquals("CLOSED_WORK_ORDERS_OVER_ALL_ALARMS", response.getDisposal().getRate().getBasis());
|
|
|
|
|
+ assertEquals("一级报警", response.getByLevel().get(0).getName());
|
|
|
|
|
+ assertEquals(1, response.getByLevel().get(0).getCount());
|
|
|
|
|
+ assertEquals("四级报警", response.getByLevel().get(3).getName());
|
|
|
|
|
+ assertEquals(2, response.getByLevel().get(3).getCount());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void alarmDisposalWithoutBaseSetReturnsEmptyRateAndEmptyDurations() {
|
|
|
|
|
+ when(adapter.loadAlarmDisposal(any())).thenReturn(alarms());
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-002");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0, response.getWorkOrderConversion().getTotalCount());
|
|
|
|
|
+ assertEquals("EMPTY", response.getDisposal().getRate().getState());
|
|
|
|
|
+ assertEquals("NO_ALARMS", response.getDisposal().getRate().getReason());
|
|
|
|
|
+ assertEquals("0", response.getDisposal().getRate().getValue().toPlainString());
|
|
|
|
|
+ assertEquals("EMPTY", response.getDisposal().getAvgResponseDuration().getState());
|
|
|
|
|
+ assertEquals("NO_RESPONSE_LOGS", response.getDisposal().getAvgResponseDuration().getReason());
|
|
|
|
|
+ assertEquals(0, response.getDisposal().getAvgResponseDuration().getSampleCount());
|
|
|
|
|
+ assertEquals("NO_DISPOSAL_LOGS", response.getDisposal().getAvgDisposalDuration().getReason());
|
|
|
|
|
+ assertEquals("EMPTY", response.getFreshness().getStatus());
|
|
|
|
|
+ assertEquals(7, response.getSevenDayTrend().size());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void alarmDisposalSevenDayTrendKeepsZeroPoints() {
|
|
|
|
|
+ when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(4, 1, 1, 4, 0,
|
|
|
|
|
+ Collections.emptyList(),
|
|
|
|
|
+ Arrays.asList(dayCount("2026-10-16", 1, 5), dayCount("2026-10-21", 1, 3),
|
|
|
|
|
+ dayCount("2026-10-22", 1, 3), dayCount("2026-10-15", 9, 9)),
|
|
|
|
|
+ Collections.emptyMap(), Collections.emptyMap()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-003");
|
|
|
|
|
+
|
|
|
|
|
+ List<CockpitOverviewAlarmDisposalResponse.TrendPoint> trend = response.getSevenDayTrend();
|
|
|
|
|
+ assertEquals(7, trend.size());
|
|
|
|
|
+ assertEquals("2026-10-16", trend.get(0).getDate());
|
|
|
|
|
+ assertEquals(1, trend.get(0).getCount());
|
|
|
|
|
+ assertEquals("2026-10-18", trend.get(2).getDate());
|
|
|
|
|
+ assertEquals(0, trend.get(2).getCount());
|
|
|
|
|
+ assertEquals("2026-10-21", trend.get(5).getDate());
|
|
|
|
|
+ assertEquals(1, trend.get(5).getCount());
|
|
|
|
|
+ assertEquals("2026-10-22", trend.get(6).getDate());
|
|
|
|
|
+ assertEquals(1, trend.get(6).getCount());
|
|
|
|
|
+ // 同一天还带"截至当天未处理累计"(维护者 2026-10-23:折线改为两条线对照)。
|
|
|
|
|
+ // 缺失的日期补 0 —— 那是"当天没有数据",不是"积压为零"(真积压由有数据的那几天给出)。
|
|
|
|
|
+ assertEquals(5, trend.get(0).getUnhandledTotal());
|
|
|
|
|
+ assertEquals(0, trend.get(2).getUnhandledTotal());
|
|
|
|
|
+ assertEquals(3, trend.get(5).getUnhandledTotal());
|
|
|
|
|
+ assertEquals(3, trend.get(6).getUnhandledTotal());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void alarmDisposalAveragesDurationsAndKeepsInvalidSampleCount() {
|
|
|
|
|
+ Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> samples = new LinkedHashMap<>();
|
|
|
|
|
+ samples.put(2, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(109924L, 1, 2));
|
|
|
|
|
+ samples.put(5, new CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample(789067L, 1, 0));
|
|
|
|
|
+ when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(2, 0, 0, 1, 1,
|
|
|
|
|
+ Collections.emptyList(), Collections.emptyList(), samples, Collections.emptyMap()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-004");
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse.AverageDuration responseDuration =
|
|
|
|
|
+ response.getDisposal().getAvgResponseDuration();
|
|
|
|
|
+ assertEquals("30.5", responseDuration.getValue().toPlainString());
|
|
|
|
|
+ assertEquals("HOURS", responseDuration.getUnit());
|
|
|
|
|
+ assertEquals(1, responseDuration.getSampleCount());
|
|
|
|
|
+ assertEquals(2, responseDuration.getInvalidSampleCount());
|
|
|
|
|
+ assertEquals("AVAILABLE", responseDuration.getState());
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse.AverageDuration disposal =
|
|
|
|
|
+ response.getDisposal().getAvgDisposalDuration();
|
|
|
|
|
+ assertEquals("219.2", disposal.getValue().toPlainString());
|
|
|
|
|
+ assertEquals(1, disposal.getSampleCount());
|
|
|
|
|
+ assertEquals(0, disposal.getInvalidSampleCount());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void alarmLevelCardsFallBackToFixedAlarmLabels() {
|
|
|
|
|
+ when(adapter.loadAlarmDisposal(any())).thenReturn(alarms(1, 0, 0, 1, 0,
|
|
|
|
|
+ Collections.singletonList(levelCount(1, 1)),
|
|
|
|
|
+ Collections.emptyList(), Collections.emptyMap(), Collections.emptyMap()));
|
|
|
|
|
+
|
|
|
|
|
+ CockpitOverviewAlarmDisposalResponse response = service.loadAlarmDisposal("alarm-005");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("一级报警", response.getByLevel().get(0).getName());
|
|
|
|
|
+ assertEquals("二级报警", response.getByLevel().get(1).getName());
|
|
|
|
|
+ assertEquals("三级报警", response.getByLevel().get(2).getName());
|
|
|
|
|
+ assertEquals("四级报警", response.getByLevel().get(3).getName());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void adapterFailureIsReadAsTypedReadException() {
|
|
|
|
|
+ when(adapter.loadPerception()).thenThrow(new IllegalStateException("upstream down"));
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(CockpitReadException.class, () -> service.loadPerception("perception-500"));
|
|
|
|
|
+ assertTrue(true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------ 夹具
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewPerceptionSnapshot perception(
|
|
|
|
|
+ List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets) {
|
|
|
|
|
+ return perception(buckets, Collections.emptyList());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewPerceptionSnapshot perception(
|
|
|
|
|
+ List<CockpitOverviewPerceptionSnapshot.SourceDeviceBucket> buckets,
|
|
|
|
|
+ List<CockpitOverviewPerceptionSnapshot.SourceMonitoringContent> contents) {
|
|
|
|
|
+ return new CockpitOverviewPerceptionSnapshot(
|
|
|
|
|
+ LocalDateTime.parse("2026-10-22T15:00:00"), buckets, contents);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewPerceptionSnapshot.SourceDeviceBucket bucket(
|
|
|
|
|
+ Integer rootTypeId, Integer district, boolean hasStatus, boolean online, int deviceCount) {
|
|
|
|
|
+ return new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(
|
|
|
|
|
+ rootTypeId, district, hasStatus, online, deviceCount);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewPerceptionSnapshot.SourceMonitoringContent content(
|
|
|
|
|
+ Integer rootTypeId, String code, String name) {
|
|
|
|
|
+ return new CockpitOverviewPerceptionSnapshot.SourceMonitoringContent(rootTypeId, code, name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewPipeNetworkSnapshot pipes(
|
|
|
|
|
+ CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket... buckets) {
|
|
|
|
|
+ return new CockpitOverviewPipeNetworkSnapshot(
|
|
|
|
|
+ LocalDateTime.parse("2026-10-22T15:00:00"), Arrays.asList(buckets));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket pipe(
|
|
|
|
|
+ String typeCode, int pipeCount, String lengthMeters) {
|
|
|
|
|
+ return new CockpitOverviewPipeNetworkSnapshot.SourcePipeBucket(
|
|
|
|
|
+ typeCode, pipeCount, new BigDecimal(lengthMeters));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewWarningStudySnapshot warnings() {
|
|
|
|
|
+ return warnings(0, 0, 0, Collections.emptyList(), Collections.emptyList(),
|
|
|
|
|
+ Collections.emptyMap(), Collections.emptyMap());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewWarningStudySnapshot warnings(
|
|
|
|
|
+ int todayNew,
|
|
|
|
|
+ int yesterdayNew,
|
|
|
|
|
+ int rankingTotal,
|
|
|
|
|
+ List<CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount> statusLevelCounts,
|
|
|
|
|
+ List<CockpitOverviewWarningStudySnapshot.SourceTypeCount> typeCounts,
|
|
|
|
|
+ Map<String, String> typeNames,
|
|
|
|
|
+ Map<Integer, String> levelNames) {
|
|
|
|
|
+ return new CockpitOverviewWarningStudySnapshot(
|
|
|
|
|
+ LocalDateTime.parse("2026-10-22T15:00:00"),
|
|
|
|
|
+ todayNew, yesterdayNew, statusLevelCounts, rankingTotal, 30,
|
|
|
|
|
+ typeCounts, typeNames, levelNames);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount statusLevel(
|
|
|
|
|
+ String status, Integer level, int count) {
|
|
|
|
|
+ return new CockpitOverviewWarningStudySnapshot.SourceStatusLevelCount(status, level, count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewWarningStudySnapshot.SourceTypeCount typeCount(String code, int count) {
|
|
|
|
|
+ return new CockpitOverviewWarningStudySnapshot.SourceTypeCount(code, count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Map<String, String> names(String code, String name) {
|
|
|
|
|
+ Map<String, String> names = new LinkedHashMap<>();
|
|
|
|
|
+ names.put(code, name);
|
|
|
|
|
+ return names;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewAlarmDisposalSnapshot alarms() {
|
|
|
|
|
+ return alarms(0, 0, 0, 0, 0, Collections.emptyList(), Collections.emptyList(),
|
|
|
|
|
+ Collections.emptyMap(), Collections.emptyMap());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewAlarmDisposalSnapshot alarms(
|
|
|
|
|
+ int totalCount,
|
|
|
|
|
+ int todayNew,
|
|
|
|
|
+ int yesterdayNew,
|
|
|
|
|
+ int unconvertedCount,
|
|
|
|
|
+ int closedCount,
|
|
|
|
|
+ List<CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount> levelCounts,
|
|
|
|
|
+ List<CockpitOverviewAlarmDisposalSnapshot.SourceDayCount> dailyCounts,
|
|
|
|
|
+ Map<Integer, CockpitOverviewAlarmDisposalSnapshot.SourceDurationSample> durationSamples,
|
|
|
|
|
+ Map<Integer, String> levelNames) {
|
|
|
|
|
+ return new CockpitOverviewAlarmDisposalSnapshot(
|
|
|
|
|
+ LocalDateTime.parse("2026-10-22T15:00:00"),
|
|
|
|
|
+ totalCount, todayNew, yesterdayNew, unconvertedCount, closedCount,
|
|
|
|
|
+ levelCounts, dailyCounts, durationSamples, levelNames);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount levelCount(
|
|
|
|
|
+ Integer level, int count) {
|
|
|
|
|
+ return new CockpitOverviewAlarmDisposalSnapshot.SourceLevelCount(level, count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按日两个读数:当天新增 + 截至当天未处理的累计(后端新契约)。 */
|
|
|
|
|
+ private static CockpitOverviewAlarmDisposalSnapshot.SourceDayCount dayCount(
|
|
|
|
|
+ String day, int count, int unhandledTotal) {
|
|
|
|
|
+ return new CockpitOverviewAlarmDisposalSnapshot.SourceDayCount(day, count, unhandledTotal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Map<Integer, String> levels(int firstLevel, String firstLabel, int secondLevel, String secondLabel) {
|
|
|
|
|
+ Map<Integer, String> levels = new LinkedHashMap<>();
|
|
|
|
|
+ levels.put(firstLevel, firstLabel);
|
|
|
|
|
+ levels.put(secondLevel, secondLabel);
|
|
|
|
|
+ return levels;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|