|
|
@@ -1,602 +0,0 @@
|
|
|
-package com.zksy.web.controller.cockpit;
|
|
|
-
|
|
|
-import com.zksy.cockpit.foundation.CockpitReadException;
|
|
|
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
|
|
|
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionAdapter;
|
|
|
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionDeviceStatusDelta;
|
|
|
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
|
|
|
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionSnapshot;
|
|
|
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionResponse;
|
|
|
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
|
|
|
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
|
|
|
-import org.junit.jupiter.api.BeforeEach;
|
|
|
-import org.junit.jupiter.api.Test;
|
|
|
-import org.springframework.test.web.servlet.MockMvc;
|
|
|
-import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
-import org.springframework.web.bind.annotation.DeleteMapping;
|
|
|
-import org.springframework.web.bind.annotation.GetMapping;
|
|
|
-import org.springframework.web.bind.annotation.PatchMapping;
|
|
|
-import org.springframework.web.bind.annotation.PostMapping;
|
|
|
-import org.springframework.web.bind.annotation.PutMapping;
|
|
|
-
|
|
|
-import java.lang.reflect.Method;
|
|
|
-import java.math.BigDecimal;
|
|
|
-import java.time.Clock;
|
|
|
-import java.time.Instant;
|
|
|
-import java.time.ZoneOffset;
|
|
|
-import java.util.Arrays;
|
|
|
-import java.util.Collections;
|
|
|
-import java.util.List;
|
|
|
-
|
|
|
-import static org.hamcrest.Matchers.hasSize;
|
|
|
-import static org.hamcrest.Matchers.nullValue;
|
|
|
-import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
-import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
-import static org.mockito.Mockito.mock;
|
|
|
-import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
-import static org.mockito.Mockito.when;
|
|
|
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
-
|
|
|
-class CockpitMonitoringDistributionControllerTest {
|
|
|
-
|
|
|
- private static final String ENDPOINT = "/api/cockpit/v1/monitoring/distribution";
|
|
|
- private static final String SNAPSHOT_AT = "2026-09-02T10:00:00+08:00";
|
|
|
-
|
|
|
- private CockpitMonitoringDistributionAdapter adapter;
|
|
|
- private MockMvc mockMvc;
|
|
|
- private CockpitMonitoringDistributionService service;
|
|
|
-
|
|
|
- @BeforeEach
|
|
|
- void setUp() {
|
|
|
- adapter = mock(CockpitMonitoringDistributionAdapter.class);
|
|
|
- service = new CockpitMonitoringDistributionService(
|
|
|
- adapter,
|
|
|
- Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
|
|
|
- mockMvc = MockMvcBuilders
|
|
|
- .standaloneSetup(new CockpitMonitoringDistributionController(service))
|
|
|
- .build();
|
|
|
- when(adapter.load(null)).thenReturn(fixture());
|
|
|
- when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT)
|
|
|
- .param("snapshotAt", SNAPSHOT_AT)
|
|
|
- .header("X-Request-Id", "monitoring-distribution-001"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.code").value(200))
|
|
|
- .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-001"))
|
|
|
- .andExpect(jsonPath("$.data.apiVersion").value("v1"))
|
|
|
- .andExpect(jsonPath("$.data.readOnly").value(true))
|
|
|
- .andExpect(jsonPath("$.data.displayName").value("监测分布"))
|
|
|
- .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
|
|
|
- .andExpect(jsonPath("$.data.pagination.page").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(9))
|
|
|
- .andExpect(jsonPath("$.data.pagination.totalPages").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("P-DR-1"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("WARNING"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[1].pointCode").value("P-GAS-1"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[1].pointStatus").value("ALARM"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[1].statusReasons[0]").value("GAS-002 浓度超限"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[2].pointCode").value("P-GAS-2"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[2].pointStatus").value("OFFLINE"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[3].pointCode").value("P-WS-1"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[3].pointStatus").value("WORK_ORDER"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[4].pointCode").value("P-WS-2"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[4].pointStatus").value("EMPTY"))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment[0].pointStatus").value("NORMAL"))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-002"))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment[1].pointStatus").value("WARNING"))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(8)))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[0].status").value("ALARM"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(5))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[1].pointCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[1].deviceCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[2].district").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[2].districtName").value("未分区"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[2].pointCount").value(1))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[2].deviceCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(5))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[1].deviceCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[2].deviceCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(3)))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(5))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution[1].deviceType.code").value("SEWAGE_ENV"))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution[2].deviceType.code").value("WATER_SUPPLY_SENSOR"))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(5)))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(9)))
|
|
|
- .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
|
|
|
- .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
|
|
|
- .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
|
|
|
- .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
|
|
|
- .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
|
|
|
- .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void associatedDevicesRenderAsOnePointAndPaginationMatchesStatusTotals() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(9))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void industryFilterNarrowsDistributionAndMapFilter() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("industry", "GAS"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(4))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
|
|
|
- .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution", hasSize(1)))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void deviceTypeFilterNarrowsDistribution() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("deviceType", "GAS_SENSOR"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(4))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.deviceType").value("GAS_SENSOR"))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(1)))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void pointStatusFilterNarrowsMarkersAndResetRestoresScope() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("pointStatus", "WARNING"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(2))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING"))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
|
|
|
-
|
|
|
- mockMvc.perform(get(ENDPOINT))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(9))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void districtFilterNarrowsDistributionAndMapFilter() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("district", "1"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(3))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.district").value(1))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.districtName").value("城北"))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].district").value(1))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城北"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution", hasSize(2)));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void unassignedDistrictFilterSelectsOnlyUnassignedAndEchoesFilterFlag() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(2))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("UNASSIGNED"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].district").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(1))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("district", "城区B"))
|
|
|
- .andExpect(status().isBadRequest())
|
|
|
- .andExpect(jsonPath("$.code").value(400))
|
|
|
- .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
- .andExpect(jsonPath("$.data.message").value(
|
|
|
- "district must be 0, 1, 2 or UNASSIGNED"));
|
|
|
-
|
|
|
- verifyNoInteractions(adapter);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void unknownDeviceTypeReturnsExplicitEmptyDistribution() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("deviceType", "NOT_A_TYPE"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(0))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE"))
|
|
|
- .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
|
|
|
- .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
|
|
|
- .andExpect(jsonPath("$.data.freshness.reason").value("NO_RESOLVED_DEVICES"));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("industry", "WHATEVER"))
|
|
|
- .andExpect(status().isBadRequest())
|
|
|
- .andExpect(jsonPath("$.code").value(400))
|
|
|
- .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
- .andExpect(jsonPath("$.data.message").value(
|
|
|
- "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
|
|
|
-
|
|
|
- verifyNoInteractions(adapter);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void invalidPointStatusReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("pointStatus", "UNKNOWN_STATE"))
|
|
|
- .andExpect(status().isBadRequest())
|
|
|
- .andExpect(jsonPath("$.code").value(400))
|
|
|
- .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
- .andExpect(jsonPath("$.data.message").value(
|
|
|
- "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED"));
|
|
|
-
|
|
|
- verifyNoInteractions(adapter);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void invalidPaginationReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT)
|
|
|
- .param("page", "0")
|
|
|
- .param("pageSize", "1001"))
|
|
|
- .andExpect(status().isBadRequest())
|
|
|
- .andExpect(jsonPath("$.code").value(400))
|
|
|
- .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
- .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
|
|
|
-
|
|
|
- verifyNoInteractions(adapter);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void invalidSnapshotTimestampReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
|
|
|
- mockMvc.perform(get(ENDPOINT).param("snapshotAt", "not-a-timestamp"))
|
|
|
- .andExpect(status().isBadRequest())
|
|
|
- .andExpect(jsonPath("$.code").value(400))
|
|
|
- .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
|
|
|
- .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
|
|
|
-
|
|
|
- verifyNoInteractions(adapter);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void expiredSnapshotReturnsTypedBadRequestAndDoesNotPretendToBeEmpty() throws Exception {
|
|
|
- when(adapter.load("2026-09-02T09:00:00+08:00"))
|
|
|
- .thenThrow(new CockpitSnapshotExpiredException("snapshot expired"));
|
|
|
-
|
|
|
- mockMvc.perform(get(ENDPOINT).param("snapshotAt", "2026-09-02T09:00:00+08:00"))
|
|
|
- .andExpect(status().isBadRequest())
|
|
|
- .andExpect(jsonPath("$.code").value(400))
|
|
|
- .andExpect(jsonPath("$.data.category").value("SNAPSHOT_EXPIRED"))
|
|
|
- .andExpect(jsonPath("$.data.message").value("地图快照已失效,请重新加载"));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void serverFailureReturnsTypedServerError() throws Exception {
|
|
|
- when(adapter.load(null)).thenThrow(new CockpitReadException("upstream unavailable"));
|
|
|
-
|
|
|
- mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-distribution-500"))
|
|
|
- .andExpect(status().isInternalServerError())
|
|
|
- .andExpect(jsonPath("$.code").value(500))
|
|
|
- .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
|
|
|
- .andExpect(jsonPath("$.data.message").value("驾驶舱监测分布数据暂不可用"))
|
|
|
- .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-500"));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
|
|
|
- mockMvc.perform(post(ENDPOINT))
|
|
|
- .andExpect(status().isMethodNotAllowed());
|
|
|
-
|
|
|
- verifyNoInteractions(adapter);
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
|
|
|
- assertTrue(Arrays.stream(CockpitMonitoringDistributionController.class.getDeclaredMethods())
|
|
|
- .filter(this::hasRequestMappingAnnotation)
|
|
|
- .allMatch(CockpitMonitoringDistributionControllerTest::isReadOnlyMappedMethod));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
|
|
|
- CockpitMonitoringDistributionResponse initial = service.load(
|
|
|
- "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
|
|
|
- assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
|
|
|
- assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
|
|
|
-
|
|
|
- CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
|
|
|
- "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
|
|
|
- new CockpitMonitoringDistributionDeviceStatusDelta(
|
|
|
- "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
|
|
|
- "2026-09-02T10:05:00+08:00"));
|
|
|
-
|
|
|
- assertEquals(0, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
|
|
|
- assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
|
|
|
-
|
|
|
- CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
|
|
|
- "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
|
|
|
- new CockpitMonitoringDistributionDeviceStatusDelta(
|
|
|
- "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
|
|
|
- "2026-09-02T10:04:00+08:00"));
|
|
|
-
|
|
|
- assertEquals(0, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
|
|
|
- assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
|
|
|
-
|
|
|
- CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
|
|
|
- "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
|
|
|
- new CockpitMonitoringDistributionDeviceStatusDelta(
|
|
|
- "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
|
|
|
- "2026-09-02T10:06:00+08:00"));
|
|
|
-
|
|
|
- assertEquals(0, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
|
|
|
- assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
|
|
|
- List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
|
|
|
- for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
|
|
|
- points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
|
|
|
- code, code, null, null, new BigDecimal("110.399876"), new BigDecimal("28.458287"),
|
|
|
- false, Collections.emptyList()));
|
|
|
- }
|
|
|
- CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
|
|
|
- SNAPSHOT_AT, SNAPSHOT_AT, Collections.emptyList(), points, Collections.emptyList());
|
|
|
- when(adapter.load(null)).thenReturn(snapshot);
|
|
|
- when(adapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
|
|
|
-
|
|
|
- mockMvc.perform(get(ENDPOINT).param("pointStatus", "EMPTY").param("pageSize", "2"))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(6))
|
|
|
- .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
|
|
|
- .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("GS001"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
|
|
|
- .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
|
|
|
- .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[0].industry").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(6))
|
|
|
- .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value(nullValue()))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
|
|
|
- .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
|
|
|
- .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0))
|
|
|
- .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
|
|
|
- .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
|
|
|
- .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
|
|
|
- mockMvc.perform(get(ENDPOINT).param("page", "3").param("pageSize", "2").param("snapshotAt", SNAPSHOT_AT))
|
|
|
- .andExpect(status().isOk())
|
|
|
- .andExpect(jsonPath("$.data.pagination.total").value(6))
|
|
|
- .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
|
|
|
- .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("RQ001"));
|
|
|
- mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
|
|
|
- .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(6));
|
|
|
- for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"},
|
|
|
- new String[]{"deviceType", "GAS_SENSOR"}, new String[]{"district", "0"},
|
|
|
- new String[]{"pointStatus", "NORMAL"})) {
|
|
|
- mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
|
|
|
- .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(0));
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void coordinatesDoNotAffectStatisticsAndAssociatedDevicesNeverBecomeStandaloneMarkers() {
|
|
|
- CockpitMonitoringPointDeviceSnapshot bound = device(
|
|
|
- "BOUND", "关联设备", true, true, false, false, Collections.emptyList());
|
|
|
- CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
|
|
|
- SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(),
|
|
|
- Collections.singletonList(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
|
|
|
- "P", "点位", 0, "GAS_SENSOR", null, null, false, Collections.singletonList(bound))),
|
|
|
- Arrays.asList(smartDevice("BOUND", "关联设备", 1, "GAS_SENSOR", "110.4", "28.4", false,
|
|
|
- true, true, false, false, Collections.emptyList()),
|
|
|
- smartDevice("FREE", "独立设备", 1, "GAS_SENSOR", "181", "28.4", true,
|
|
|
- true, false, false, false, Collections.emptyList())));
|
|
|
- when(adapter.load(null)).thenReturn(snapshot);
|
|
|
- CockpitMonitoringDistributionResponse all = service.load("all", null, null, null, null, 1, 100, null);
|
|
|
- assertEquals(2, all.getPagination().getTotal());
|
|
|
- assertEquals(1, all.getUnassociatedEquipment().size());
|
|
|
- assertEquals("FREE", all.getUnassociatedEquipment().get(0).getDeviceCode());
|
|
|
- assertTrue(!all.getMonitoringPoints().get(0).getCoordinateValidation().isRenderable());
|
|
|
- assertTrue(!all.getUnassociatedEquipment().get(0).getCoordinateValidation().isRenderable());
|
|
|
- assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
|
|
|
- assertEquals("READY", all.getFreshness().getReason());
|
|
|
- assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
|
|
|
- assertEquals(0, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
|
|
|
-
|
|
|
- CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
|
|
|
- assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
|
|
|
- assertTrue(normal.getMapFilter().getPointCodes().isEmpty());
|
|
|
- CockpitMonitoringDistributionResponse district = service.load("district", null, null, null, "1", 1, 100, null);
|
|
|
- assertEquals(1, district.getPagination().getTotal(), "被筛掉的点位不能让关联设备变成独立设备");
|
|
|
- assertEquals(Collections.singletonList("FREE"), district.getMapFilter().getDeviceCodes());
|
|
|
- }
|
|
|
-
|
|
|
- @Test
|
|
|
- void exclusionsCountEachRejectedObjectOnceRegardlessOfAssociatedDeviceCount() {
|
|
|
- CockpitMonitoringPointDeviceSnapshot d = device("D", "设备", true, false, false, false, Collections.emptyList());
|
|
|
- CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
|
|
|
- SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(), Arrays.asList(
|
|
|
- point("EMPTY", "合法空点", null, null, "110.4", "28.4"),
|
|
|
- point("UNKNOWN", "未知类型", 0, "LEGACY", "110.4", "28.4", d, d, d),
|
|
|
- point("MISMATCH", "错种类", 0, "WATER_PLANT", "110.4", "28.4", d, d),
|
|
|
- point("NONFORMAL", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", d)), Arrays.asList(
|
|
|
- smartDevice("U", "未知", 0, "LEGACY", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
|
|
|
- smartDevice("M", "错种类", 0, "WATER_PLANT", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
|
|
|
- smartDevice("N", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", true, true, false, false, false, Collections.emptyList())));
|
|
|
- when(adapter.load(null)).thenReturn(snapshot);
|
|
|
- CockpitMonitoringDistributionResponse response = service.load("reconcile", null, null, null, null, 1, 100, null);
|
|
|
- assertEquals(1, response.getPagination().getTotal());
|
|
|
- assertEquals(1, statusCount(response, CockpitMonitoringPointStatus.EMPTY));
|
|
|
- assertEquals(2, response.getExcluded().getUnknownTypeCount());
|
|
|
- assertEquals(2, response.getExcluded().getTypeKindMismatchCount());
|
|
|
- assertEquals(2, response.getExcluded().getNonFormalIndustryCount());
|
|
|
- int excluded = response.getExcluded().getUnknownTypeCount() + response.getExcluded().getTypeKindMismatchCount()
|
|
|
- + response.getExcluded().getNonFormalIndustryCount() + response.getExcluded().getInvalidCoordinateCount();
|
|
|
- assertEquals(7, response.getPagination().getTotal() + excluded);
|
|
|
- }
|
|
|
-
|
|
|
- private int statusCount(CockpitMonitoringDistributionResponse response,
|
|
|
- CockpitMonitoringPointStatus status) {
|
|
|
- return response.getPointStatusDistribution()
|
|
|
- .get(status.ordinal())
|
|
|
- .getCount();
|
|
|
- }
|
|
|
-
|
|
|
- private boolean hasRequestMappingAnnotation(Method method) {
|
|
|
- return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
|
|
|
- || method.isAnnotationPresent(GetMapping.class)
|
|
|
- || method.isAnnotationPresent(PostMapping.class)
|
|
|
- || method.isAnnotationPresent(PutMapping.class)
|
|
|
- || method.isAnnotationPresent(PatchMapping.class)
|
|
|
- || method.isAnnotationPresent(DeleteMapping.class);
|
|
|
- }
|
|
|
-
|
|
|
- private static boolean isReadOnlyMappedMethod(Method method) {
|
|
|
- return method.isAnnotationPresent(GetMapping.class)
|
|
|
- && !method.isAnnotationPresent(PostMapping.class)
|
|
|
- && !method.isAnnotationPresent(PutMapping.class)
|
|
|
- && !method.isAnnotationPresent(PatchMapping.class)
|
|
|
- && !method.isAnnotationPresent(DeleteMapping.class);
|
|
|
- }
|
|
|
-
|
|
|
- private static CockpitMonitoringDistributionSnapshot fixture() {
|
|
|
- return new CockpitMonitoringDistributionSnapshot(
|
|
|
- SNAPSHOT_AT,
|
|
|
- "2026-09-02T09:59:50+08:00",
|
|
|
- Arrays.asList(
|
|
|
- new CockpitMonitoringDistributionSnapshot.SourceType(
|
|
|
- "GAS_SENSOR", "燃气浓度计", "GAS", "SMART_DEVICE"),
|
|
|
- new CockpitMonitoringDistributionSnapshot.SourceType(
|
|
|
- "WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", "SMART_DEVICE"),
|
|
|
- new CockpitMonitoringDistributionSnapshot.SourceType(
|
|
|
- "SEWAGE_ENV", "污水环境监测", "DRAINAGE", "SMART_DEVICE"),
|
|
|
- new CockpitMonitoringDistributionSnapshot.SourceType(
|
|
|
- "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
|
|
|
- new CockpitMonitoringDistributionSnapshot.SourceType(
|
|
|
- "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
|
|
|
- Arrays.asList(
|
|
|
- point("P-WS-1", "供水监测点-1", 1, "WATER_SUPPLY_SENSOR",
|
|
|
- "110.150", "28.460", device("WS-001", "供水压力计-001",
|
|
|
- true, false, false, true, Collections.emptyList())),
|
|
|
- point("P-WS-2", "供水监测点-2", 1, "WATER_SUPPLY_SENSOR",
|
|
|
- "110.151", "28.461"),
|
|
|
- point("P-GAS-1", "燃气监测点-1", 0, "GAS_SENSOR",
|
|
|
- "110.130", "28.440",
|
|
|
- device("GAS-001", "燃气浓度计-001",
|
|
|
- true, false, false, false, Collections.emptyList()),
|
|
|
- device("GAS-002", "燃气浓度计-002",
|
|
|
- true, true, false, false, Collections.singletonList("浓度超限"))),
|
|
|
- point("P-GAS-2", "燃气监测点-2", 0, "GAS_SENSOR",
|
|
|
- "110.131", "28.441", device("GAS-003", "燃气浓度计-003",
|
|
|
- false, false, false, false, Collections.emptyList())),
|
|
|
- // 快照里点位片区为 null(如实未分区)→ 与脏值设备一起落入未分区桶
|
|
|
- point("P-DR-1", "污水监测点-1", null, "SEWAGE_ENV",
|
|
|
- "110.170", "28.480", device("DRAIN-001", "污水环境监测仪-001",
|
|
|
- true, false, true, false, Collections.singletonList("浓度预警"))),
|
|
|
- point("P-MISMATCH", "水厂设施点", 0, "WATER_PLANT",
|
|
|
- "110.120", "28.430", device("MISMATCH-001", "水厂设备",
|
|
|
- true, false, false, false, Collections.emptyList())),
|
|
|
- point("P-UNKNOWN", "未知类型点", 0, "LEGACY",
|
|
|
- "110.121", "28.431", device("UNKNOWN-001", "旧设备",
|
|
|
- true, false, false, false, Collections.emptyList()))),
|
|
|
- Arrays.asList(
|
|
|
- // 脏值片区(7)不得冒充合法片区,归一后并入未分区
|
|
|
- smartDevice("EQ-FREE-003", "压力井盖外设备", 7, "WATER_SUPPLY_SENSOR",
|
|
|
- "110.171", "28.481", true,
|
|
|
- false, false, false, false, Arrays.asList("离线")),
|
|
|
- smartDevice("EQ-FREE-001", "井盖外燃气计", 0, "GAS_SENSOR",
|
|
|
- "110.132", "28.442", true,
|
|
|
- true, false, false, false, Collections.emptyList()),
|
|
|
- smartDevice("EQ-FREE-002", "污水环境监测-独立", 1, "SEWAGE_ENV",
|
|
|
- "110.152", "28.462", true,
|
|
|
- true, false, true, false, Collections.singletonList("设备预警")),
|
|
|
- smartDevice("EQ-FREE-004", "智能井盖独立", 0, "SMART_MANHOLE",
|
|
|
- "110.133", "28.443", true,
|
|
|
- true, false, false, false, Collections.emptyList()),
|
|
|
- smartDevice("EQ-FREE-005", "越界坐标设备", 0, "GAS_SENSOR",
|
|
|
- "181", "28.444", true,
|
|
|
- true, false, false, false, Collections.emptyList())));
|
|
|
- }
|
|
|
-
|
|
|
- private static CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point(
|
|
|
- String code, String name, Integer district, String typeCode,
|
|
|
- String longitude, String latitude, CockpitMonitoringPointDeviceSnapshot... devices) {
|
|
|
- return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
|
|
|
- code, name, district, typeCode,
|
|
|
- new BigDecimal(longitude), new BigDecimal(latitude), true,
|
|
|
- Arrays.asList(devices));
|
|
|
- }
|
|
|
-
|
|
|
- private static CockpitMonitoringPointDeviceSnapshot device(
|
|
|
- String code, String name, boolean online, boolean alarm, boolean warning,
|
|
|
- boolean workOrderInProgress, List<String> statusReasons) {
|
|
|
- return new CockpitMonitoringPointDeviceSnapshot(
|
|
|
- code, name, name, online, alarm, warning, workOrderInProgress,
|
|
|
- statusReasons, SNAPSHOT_AT);
|
|
|
- }
|
|
|
-
|
|
|
- private static CockpitMonitoringDistributionSnapshot.SourceSmartDevice smartDevice(
|
|
|
- String code, String name, Integer district, String typeCode,
|
|
|
- String longitude, String latitude, boolean verified,
|
|
|
- boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
|
|
|
- List<String> statusReasons) {
|
|
|
- return new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
|
|
|
- code, name, district, typeCode,
|
|
|
- new BigDecimal(longitude), new BigDecimal(latitude), verified,
|
|
|
- online, alarm, warning, workOrderInProgress, statusReasons, SNAPSHOT_AT);
|
|
|
- }
|
|
|
-}
|