|
|
@@ -0,0 +1,331 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.zksy.cockpit.foundation.CockpitDistrict;
|
|
|
+import com.zksy.cockpit.foundation.CockpitDistrictPrefix;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportAdapter;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportService;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportSnapshot;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationAdapter;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationService;
|
|
|
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationSnapshot;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewAdapter;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewSnapshot;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationAdapter;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationService;
|
|
|
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationSnapshot;
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewModulesAdapter;
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewModulesService;
|
|
|
+import com.zksy.cockpit.overview.CockpitOverviewPerceptionSnapshot;
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderListSnapshot;
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadAdapter;
|
|
|
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.hamcrest.Matchers.nullValue;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 票 81 —— 全局筛选(片区/行业)的**契约**测试:每个端点四条判据
|
|
|
+ * (合法值真的生效 / 非法值 400 / 不传=全域 / {@code filters} 双字段回显)。
|
|
|
+ *
|
|
|
+ * <p>与既有 Controller 测试的分工:那些测试锁「既有读数口径」,本类只锁票 81 新加的参数面,
|
|
|
+ * 所以用 mock 适配器 + 真实 service/controller,断言的是「参数怎么进、结果怎么出、回显什么」。
|
|
|
+ */
|
|
|
+class CockpitDistrictFilterContractTest {
|
|
|
+
|
|
|
+ private static final Clock CLOCK =
|
|
|
+ Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
|
|
|
+ private static final String INVALID_DISTRICT_MESSAGE = "district must be 0, 1, 2 or UNASSIGNED";
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 监测概览
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void monitoringOverviewFiltersDevicesByDistrictAndEchoesFilters() throws Exception {
|
|
|
+ CockpitMonitoringOverviewAdapter adapter = mock(CockpitMonitoringOverviewAdapter.class);
|
|
|
+ when(adapter.load()).thenReturn(new CockpitMonitoringOverviewSnapshot(
|
|
|
+ "2026-09-02T10:00:00+08:00",
|
|
|
+ Arrays.asList(
|
|
|
+ new CockpitMonitoringOverviewSnapshot.SourceType("14", "消火栓", "WATER_SUPPLY", "SMART_DEVICE")),
|
|
|
+ Arrays.asList(
|
|
|
+ device("DEV-S", 0),
|
|
|
+ device("DEV-N", 1),
|
|
|
+ device("DEV-U", null)),
|
|
|
+ Collections.emptyList()));
|
|
|
+ MockMvc mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitMonitoringOverviewController(
|
|
|
+ new CockpitMonitoringOverviewService(adapter, CLOCK)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ // 不传=全域:三台都算
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/overview"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(3))
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(nullValue()));
|
|
|
+
|
|
|
+ // district=0:只剩城南那一台,且回显片区编码与展示名双字段
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/overview").param("district", "0"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.devices", org.hamcrest.Matchers.hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceCode").value("DEV-S"))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].district").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].districtName").value("城南"))
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.filters.districtName").value("城南"));
|
|
|
+
|
|
|
+ // UNASSIGNED:只看未分区
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/overview").param("district", "UNASSIGNED"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.counts.equipmentCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.devices[0].deviceCode").value("DEV-U"))
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
|
|
|
+ .andExpect(jsonPath("$.data.filters.districtName").value("未分区"));
|
|
|
+
|
|
|
+ // 非法值 400(静默忽略会造假绿)
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/overview").param("district", "99"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 运行概况
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void monitoringSituationFiltersDevicesAndTrendsByDistrict() throws Exception {
|
|
|
+ CockpitMonitoringSituationAdapter adapter = mock(CockpitMonitoringSituationAdapter.class);
|
|
|
+ when(adapter.load()).thenReturn(situationSnapshot());
|
|
|
+ MockMvc mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitMonitoringSituationController(
|
|
|
+ new CockpitMonitoringSituationService(adapter, CLOCK)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/situation"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(nullValue()));
|
|
|
+
|
|
|
+ // KPI(指标态分布)与行业覆盖率都随片区收敛,不是只筛异常清单
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/situation").param("district", "1"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.industryCoverage", org.hamcrest.Matchers.hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.industryCoverage[0].deviceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.abnormalEquipment", org.hamcrest.Matchers.hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.abnormalEquipment[0].district").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.filters.districtName").value("城北"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/monitoring/situation").param("district", "99"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 运行信息(设施编码前缀)
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void operatingInformationFiltersByFacilityCodePrefix() throws Exception {
|
|
|
+ CockpitInfrastructureOperatingInformationAdapter adapter =
|
|
|
+ mock(CockpitInfrastructureOperatingInformationAdapter.class);
|
|
|
+ when(adapter.load()).thenReturn(new CockpitInfrastructureOperatingInformationSnapshot(
|
|
|
+ "2026-09-02T10:00:00+08:00",
|
|
|
+ Arrays.asList(
|
|
|
+ operatingMetric("water_plant_current_supply", "TCRQ0001"),
|
|
|
+ operatingMetric("water_plant_current_supply", "CNRQ0002"))));
|
|
|
+ MockMvc mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitInfrastructureOperatingInformationController(
|
|
|
+ new CockpitInfrastructureOperatingInformationService(adapter, CLOCK, 60)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ // 太常(2):只剩 TC 前缀那条,逐条带片区可本地复核
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/infrastructure/operating-information").param("district", "2"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.industries[1].metrics", org.hamcrest.Matchers.hasSize(1)))
|
|
|
+ .andExpect(jsonPath("$.data.industries[1].metrics[0].pointCodes[0]").value("TCRQ0001"))
|
|
|
+ .andExpect(jsonPath("$.data.industries[1].metrics[0].district").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.industries[1].metrics[0].districtName").value("太常"))
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.filters.districtName").value("太常"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/infrastructure/operating-information").param("district", "99"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 应急保障(对象→设备→片区)
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void emergencySupportFiltersByObjectDistrictAndKeepsAreaSeparate() throws Exception {
|
|
|
+ CockpitInfrastructureEmergencySupportAdapter adapter =
|
|
|
+ mock(CockpitInfrastructureEmergencySupportAdapter.class);
|
|
|
+ when(adapter.load()).thenReturn(new CockpitInfrastructureEmergencySupportSnapshot(
|
|
|
+ "2026-09-02T10:00:00+08:00",
|
|
|
+ Collections.singletonList(
|
|
|
+ new CockpitInfrastructureEmergencySupportSnapshot.SourceSection(
|
|
|
+ "EMERGENCY_RESOURCE", "READY", "READY",
|
|
|
+ Arrays.asList(
|
|
|
+ emergencyObject("RES-S", 0),
|
|
|
+ emergencyObject("RES-N", 1))))));
|
|
|
+ MockMvc mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitInfrastructureEmergencySupportController(
|
|
|
+ new CockpitInfrastructureEmergencySupportService(adapter, CLOCK)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/infrastructure/emergency-support").param("district", "0"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.totals.emergencyResourceCount").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].objectCode").value("RES-S"))
|
|
|
+ .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].district").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].districtName").value("城南"))
|
|
|
+ // area 仍是地址字段,不被片区码空间冒充
|
|
|
+ .andExpect(jsonPath("$.data.sections.emergencyResources.items[0].area").value("沅陵镇"))
|
|
|
+ .andExpect(jsonPath("$.data.selectedFilters.district").value(0))
|
|
|
+ .andExpect(jsonPath("$.data.selectedFilters.districtName").value("城南"))
|
|
|
+ // 三个正式片区 + 未分区,四行全铺
|
|
|
+ .andExpect(jsonPath("$.data.districtDistribution", org.hamcrest.Matchers.hasSize(4)))
|
|
|
+ .andExpect(jsonPath("$.data.districtDistribution[3].districtName").value("未分区"));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/infrastructure/emergency-support").param("district", "99"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 全量工单
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void workOrdersPassParsedDistrictToAdapterAndEchoFilters() throws Exception {
|
|
|
+ CockpitWorkOrderReadAdapter adapter = mock(CockpitWorkOrderReadAdapter.class);
|
|
|
+ when(adapter.loadAll(any(), any(), any(), any(), any(), anyInt(), anyInt()))
|
|
|
+ .thenReturn(new CockpitWorkOrderListSnapshot(
|
|
|
+ Collections.emptyList(), 0, 1, 10, null));
|
|
|
+ MockMvc mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitWorkOrderReadController(
|
|
|
+ new CockpitWorkOrderReadService(adapter, CLOCK)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/work-orders").param("district", "2").param("industry", "GAS"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(2))
|
|
|
+ .andExpect(jsonPath("$.data.filters.districtName").value("太常"))
|
|
|
+ .andExpect(jsonPath("$.data.filters.industry").value("GAS"));
|
|
|
+
|
|
|
+ ArgumentCaptor<CockpitDistrict.Query> captor = ArgumentCaptor.forClass(CockpitDistrict.Query.class);
|
|
|
+ verify(adapter).loadAll(any(), any(), any(), any(), captor.capture(), anyInt(), anyInt());
|
|
|
+ assertEquals(Integer.valueOf(2), captor.getValue().code());
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/work-orders").param("district", "99"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 概览四模块(选项 a:下推 SQL)
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void overviewModulesParseDistrictAndIndustryAndRejectIllegalValues() throws Exception {
|
|
|
+ CockpitOverviewModulesAdapter adapter = mock(CockpitOverviewModulesAdapter.class);
|
|
|
+ when(adapter.loadPerception(any(), any())).thenReturn(new CockpitOverviewPerceptionSnapshot(
|
|
|
+ null,
|
|
|
+ Collections.singletonList(new CockpitOverviewPerceptionSnapshot.SourceDeviceBucket(
|
|
|
+ 1, 2, true, true, 1)),
|
|
|
+ Collections.emptyList()));
|
|
|
+ MockMvc mockMvc = MockMvcBuilders
|
|
|
+ .standaloneSetup(new CockpitOverviewModulesController(
|
|
|
+ new CockpitOverviewModulesService(adapter, CLOCK)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/overview/perception")
|
|
|
+ .param("district", "UNASSIGNED")
|
|
|
+ .param("industry", "GAS"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.filters.district").value(nullValue()))
|
|
|
+ .andExpect(jsonPath("$.data.filters.districtName").value("未分区"))
|
|
|
+ .andExpect(jsonPath("$.data.filters.industry").value("GAS"));
|
|
|
+
|
|
|
+ // 片区/行业都落成「下推 SQL 用的形态」:未分区=code null,行业=根类型 3(燃气)
|
|
|
+ ArgumentCaptor<CockpitDistrict.Query> districtCaptor =
|
|
|
+ ArgumentCaptor.forClass(CockpitDistrict.Query.class);
|
|
|
+ ArgumentCaptor<Integer> rootTypeCaptor = ArgumentCaptor.forClass(Integer.class);
|
|
|
+ verify(adapter).loadPerception(districtCaptor.capture(), rootTypeCaptor.capture());
|
|
|
+ assertNull(districtCaptor.getValue().code());
|
|
|
+ assertEquals(Boolean.TRUE, districtCaptor.getValue().unassignedOnly());
|
|
|
+ assertEquals(Integer.valueOf(3), rootTypeCaptor.getValue());
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/overview/perception").param("district", "99"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.msg").value(INVALID_DISTRICT_MESSAGE));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/api/cockpit/v1/overview/perception").param("industry", "WHATEVER"))
|
|
|
+ .andExpect(status().isBadRequest())
|
|
|
+ .andExpect(jsonPath("$.msg").value("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 编码前缀规则单点
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void facilityCodePrefixRuleIsSingleSourced() {
|
|
|
+ assertEquals(Integer.valueOf(0), CockpitDistrictPrefix.codeOf("CNRQ202609080001"));
|
|
|
+ assertEquals(Integer.valueOf(1), CockpitDistrictPrefix.codeOf("cbws202610150001"));
|
|
|
+ assertEquals(Integer.valueOf(2), CockpitDistrictPrefix.codeOf(" TCRQ202609080001 "));
|
|
|
+ // 推不出前缀一律未分区,不硬塞
|
|
|
+ assertNull(CockpitDistrictPrefix.codeOf("GS001"));
|
|
|
+ assertNull(CockpitDistrictPrefix.codeOf("C"));
|
|
|
+ assertNull(CockpitDistrictPrefix.codeOf(null));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 夹具
|
|
|
+
|
|
|
+ private static CockpitMonitoringOverviewSnapshot.SourceDevice device(String code, Integer district) {
|
|
|
+ return new CockpitMonitoringOverviewSnapshot.SourceDevice(
|
|
|
+ code, code, "14", "ONLINE", null, null, null, district);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMonitoringSituationSnapshot situationSnapshot() {
|
|
|
+ List<String> reasons = Collections.emptyList();
|
|
|
+ return new CockpitMonitoringSituationSnapshot(
|
|
|
+ "2026-09-02T10:00:00+08:00",
|
|
|
+ Collections.singletonList(new CockpitMonitoringSituationSnapshot.SourceType(
|
|
|
+ "14", "消火栓", "WATER_SUPPLY", "SMART_DEVICE")),
|
|
|
+ Arrays.asList(
|
|
|
+ new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
|
|
|
+ "DEV-S", "城南设备", 0, "14", "OFFLINE", reasons, null, null, null, false, null),
|
|
|
+ new CockpitMonitoringSituationSnapshot.SourceMetricDevice(
|
|
|
+ "DEV-N", "城北设备", 1, "14", "ALARM", reasons, null, null, null, false, null)),
|
|
|
+ Collections.emptyList(),
|
|
|
+ Collections.emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitInfrastructureOperatingInformationSnapshot.SourceMetric operatingMetric(
|
|
|
+ String metricCode, String facilityCode) {
|
|
|
+ return new CockpitInfrastructureOperatingInformationSnapshot.SourceMetric(
|
|
|
+ metricCode, "水厂实际供水能力", "SUPPLY", "万m³/d", "WATER_SUPPLY",
|
|
|
+ java.math.BigDecimal.ONE, null, null, false,
|
|
|
+ Collections.singletonList(facilityCode), Collections.emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitInfrastructureEmergencySupportSnapshot.SourceObject emergencyObject(
|
|
|
+ String code, Integer district) {
|
|
|
+ return new CockpitInfrastructureEmergencySupportSnapshot.SourceObject(
|
|
|
+ code, code, "EMERGENCY_RESOURCE", "WATER_SUPPLY", "沅陵镇",
|
|
|
+ Collections.emptyList(),
|
|
|
+ new CockpitInfrastructureEmergencySupportSnapshot.SourceEventAssociation(0, 0, 0),
|
|
|
+ district);
|
|
|
+ }
|
|
|
+}
|