|
|
@@ -0,0 +1,258 @@
|
|
|
+package com.zksy.web.controller.cockpit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.zksy.base.domain.EquipmentBase;
|
|
|
+import com.zksy.base.domain.EquipmentType;
|
|
|
+import com.zksy.base.environment.domain.ERealTimeData;
|
|
|
+import com.zksy.base.environment.service.ERealTimeDataService;
|
|
|
+import com.zksy.base.perception.mapper.PerceptionMapper;
|
|
|
+import com.zksy.base.gas.service.GasMonitorDataService;
|
|
|
+import com.zksy.base.service.EquipmentBaseService;
|
|
|
+import com.zksy.base.service.EquipmentTypeService;
|
|
|
+import com.zksy.base.water.service.WaterMonitorDataService;
|
|
|
+import com.zksy.cockpit.type.*;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.params.ParameterizedTest;
|
|
|
+import org.junit.jupiter.params.provider.CsvSource;
|
|
|
+import org.junit.jupiter.params.provider.NullAndEmptySource;
|
|
|
+import org.junit.jupiter.params.provider.ValueSource;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/** Public current-metrics seam; only database-facing dependencies are mocked. */
|
|
|
+class BusinessCockpitCollectedMetricsTest {
|
|
|
+ private final EquipmentBaseService devices = mock(EquipmentBaseService.class);
|
|
|
+ private final EquipmentTypeService types = mock(EquipmentTypeService.class);
|
|
|
+ private final GasMonitorDataService gas = mock(GasMonitorDataService.class);
|
|
|
+ private final WaterMonitorDataService water = mock(WaterMonitorDataService.class);
|
|
|
+ private final ERealTimeDataService environment = mock(ERealTimeDataService.class);
|
|
|
+ private final PerceptionMapper perception = mock(PerceptionMapper.class);
|
|
|
+ private final BusinessCockpitTypeMetricsAdapter adapter = new BusinessCockpitTypeMetricsAdapter(
|
|
|
+ devices, types, gas, water, new CollectedMetricsReader(environment, perception));
|
|
|
+ private final CockpitTypeMetricsService service = new CockpitTypeMetricsService(adapter,
|
|
|
+ Clock.fixed(Instant.parse("2026-10-30T04:00:00Z"), ZoneOffset.UTC));
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void environmentUsesLatestRecordForEachNodeAndKeepsOldNullAndZero() {
|
|
|
+ device("21119740", "e_real_time_data");
|
|
|
+ when(environment.getLatestByDeviceNode(21119740, 1)).thenReturn(node(1, "0", 999f, 21f, "2026-10-30T11:59:50"));
|
|
|
+ // node 2 has not reported recently; a recent global 50-row window would lose this factor.
|
|
|
+ when(environment.getLatestByDeviceNode(21119740, 2)).thenReturn(node(2, "0.00", 17.3f, 22.2f, "2026-10-23T17:04:29"));
|
|
|
+ when(environment.getLatestByDeviceNode(21119740, 3)).thenReturn(node(3, "999", null, null, "2026-10-30T11:59:55"));
|
|
|
+ when(environment.getLatestByDeviceNode(21119740, 4)).thenReturn(node(4, "999", 420f, 23f, "2026-10-30T11:59:56"));
|
|
|
+ when(environment.getLatestByDeviceNode(21119740, 5)).thenReturn(node(5, "999", 7.2f, 24f, "2026-10-30T11:59:57"));
|
|
|
+
|
|
|
+ CockpitTypeMetricsResponse response = service.loadCurrent("test", " 21119740 ");
|
|
|
+ assertEquals("21119740", response.getDeviceCode());
|
|
|
+ assertEquals("STALE", response.getState());
|
|
|
+ assertEquals(10, response.getMetrics().size());
|
|
|
+ assertMetric(response, "ENV_SUSPENDED_SOLIDS", "0", "mg/L", "CURRENT");
|
|
|
+ assertMetric(response, "ENV_COD", "17.3", "mg/L", "STALE");
|
|
|
+ assertMetric(response, "ENV_AMMONIA_NITROGEN", null, "mg/L", "CURRENT");
|
|
|
+ assertMetric(response, "ENV_CONDUCTIVITY", "420.0", "μS/cm", "CURRENT");
|
|
|
+ assertMetric(response, "ENV_PH", "7.2", "", "CURRENT");
|
|
|
+ assertMetric(response, "ENV_NODE_2_TEMPERATURE", "22.2", "℃", "STALE");
|
|
|
+ assertEquals("2026-10-23T17:04:29+08:00", metric(response, "ENV_COD").getSampledAt());
|
|
|
+ assertTrue(response.getMetrics().stream().allMatch(m -> "CREATED_AT".equals(m.getTimeSource())));
|
|
|
+ assertTrue(response.getMetrics().stream().allMatch(m -> "UNKNOWN".equals(m.getStatus())));
|
|
|
+ for (int node = 1; node <= 5; node++) verify(environment).getLatestByDeviceNode(21119740, node);
|
|
|
+ verifyNoMoreInteractions(environment);
|
|
|
+ verifyNoInteractions(gas, water, perception);
|
|
|
+ }
|
|
|
+
|
|
|
+ @ParameterizedTest
|
|
|
+ @NullAndEmptySource
|
|
|
+ @ValueSource(strings = {"untrusted_table", "gas_monitor_data; DROP TABLE equipment_base", "app_user.gas_monitor_data"})
|
|
|
+ void unknownCollectionSourceNeverFallsBackToIndustryTable(String source) {
|
|
|
+ device("21119740", source);
|
|
|
+ assertEquals("EMPTY", service.loadCurrent("test", "21119740").getState());
|
|
|
+ verifyNoInteractions(gas, water, environment, perception);
|
|
|
+ }
|
|
|
+
|
|
|
+ @ParameterizedTest
|
|
|
+ @CsvSource({
|
|
|
+ "firefighting_pressure,telemetering_station,pressure_value,FIREFIGHTING_PRESSURE,MPa",
|
|
|
+ "jg_device_data,imei_card_number,tilt_angle,MANHOLE_TILT_ANGLE,°",
|
|
|
+ "radar_data,device_code,meter1_instant_flow,RADAR_INSTANT_FLOW,m³/h",
|
|
|
+ "telemetry_data,device_code,pressure,TELEMETRY_PRESSURE,MPa",
|
|
|
+ "noise_info,encode,rms,NOISE_RMS,''"
|
|
|
+ })
|
|
|
+ void readsOnlyWhitelistedActualSourceAndDevice(String table, String idColumn, String field, String metricCode, String unit) {
|
|
|
+ device("REAL-CODE-7", table);
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put(idColumn, "REAL-CODE-7");
|
|
|
+ row.put(field, "0.00");
|
|
|
+ row.put("create_time", java.sql.Timestamp.valueOf("2026-10-30 11:59:55"));
|
|
|
+ when(perception.selectLatestRecords(table, idColumn, "REAL-CODE-7", 1)).thenReturn(Collections.singletonList(row));
|
|
|
+
|
|
|
+ CockpitTypeMetricsResponse response = service.loadCurrent("test", "REAL-CODE-7");
|
|
|
+ assertEquals("CURRENT", response.getState());
|
|
|
+ assertMetric(response, metricCode, "0.00", unit, "CURRENT");
|
|
|
+ assertEquals("UNKNOWN", metric(response, metricCode).getStatus());
|
|
|
+ assertEquals("CREATED_AT", metric(response, metricCode).getTimeSource());
|
|
|
+ verify(perception).selectLatestRecords(table, idColumn, "REAL-CODE-7", 1);
|
|
|
+ verifyNoMoreInteractions(perception);
|
|
|
+ verifyNoInteractions(gas, water, environment);
|
|
|
+ }
|
|
|
+
|
|
|
+ @ParameterizedTest
|
|
|
+ @CsvSource({
|
|
|
+ "firefighting_pressure,telemetering_station,observed_time,FIREFIGHTING_PRESSURE",
|
|
|
+ "radar_data,device_code,timestamp_since,RADAR_INSTANT_FLOW",
|
|
|
+ "telemetry_data,device_code,timestamp_since,TELEMETRY_PRESSURE",
|
|
|
+ "noise_info,encode,ts,NOISE_RMS"
|
|
|
+ })
|
|
|
+ void retainsOldDeviceReportedTimeEvenWhenReceiptIsRecent(String table, String idColumn, String timeColumn, String metricCode) {
|
|
|
+ device("REAL-CODE-7", table);
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put(timeColumn, "2026-10-23 17:04:29");
|
|
|
+ row.put("create_time", LocalDateTime.parse("2026-10-30T11:59:55"));
|
|
|
+ when(perception.selectLatestRecords(table, idColumn, "REAL-CODE-7", 1)).thenReturn(Collections.singletonList(row));
|
|
|
+ CockpitTypeMetricsResponse response = service.loadCurrent("test", "REAL-CODE-7");
|
|
|
+ assertEquals("STALE", response.getState());
|
|
|
+ CockpitMetric metric = metric(response, metricCode);
|
|
|
+ assertEquals("2026-10-23T17:04:29+08:00", metric.getSampledAt());
|
|
|
+ assertEquals("REPORTED_AT", metric.getTimeSource());
|
|
|
+ assertNull(metric.getValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void latestWideRecordKeepsNullAndUnparseableFactorsInsteadOfLookingBack() {
|
|
|
+ device("REAL-CODE-7", "telemetry_data");
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("pressure", null);
|
|
|
+ row.put("meter1_instant_flow", "not-a-number");
|
|
|
+ row.put("flow_speed", "0");
|
|
|
+ row.put("temperature", "21.5");
|
|
|
+ row.put("create_time", LocalDateTime.parse("2026-10-30T11:59:55"));
|
|
|
+ when(perception.selectLatestRecords("telemetry_data", "device_code", "REAL-CODE-7", 1))
|
|
|
+ .thenReturn(Collections.singletonList(row));
|
|
|
+ CockpitTypeMetricsResponse response = service.loadCurrent("test", "REAL-CODE-7");
|
|
|
+ assertMetric(response, "TELEMETRY_PRESSURE", null, "MPa", "CURRENT");
|
|
|
+ assertMetric(response, "TELEMETRY_INSTANT_FLOW", null, "m³/h", "CURRENT");
|
|
|
+ assertMetric(response, "TELEMETRY_FLOW_SPEED", "0", "m/s", "CURRENT");
|
|
|
+ assertMetric(response, "TELEMETRY_TEMPERATURE", "21.5", "℃", "CURRENT");
|
|
|
+ verify(perception).selectLatestRecords("telemetry_data", "device_code", "REAL-CODE-7", 1);
|
|
|
+ verifyNoMoreInteractions(perception);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void onlyExplicitManholeAlarmBitsProduceAlarmOrNormalStatus() {
|
|
|
+ device("REAL-CODE-7", "jg_device_data");
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("water_infiltration_alarm_status", "1");
|
|
|
+ row.put("water_level_alarm_status", "0");
|
|
|
+ row.put("tilt_angle", "0");
|
|
|
+ row.put("battery_level", "85");
|
|
|
+ row.put("temperature_value", "23");
|
|
|
+ row.put("create_time", LocalDateTime.parse("2026-10-30T11:59:55"));
|
|
|
+ when(perception.selectLatestRecords("jg_device_data", "imei_card_number", "REAL-CODE-7", 1))
|
|
|
+ .thenReturn(Collections.singletonList(row));
|
|
|
+ CockpitTypeMetricsResponse response = service.loadCurrent("test", "REAL-CODE-7");
|
|
|
+ assertEquals("ALARM", metric(response, "MANHOLE_WATER_INFILTRATION_ALARM").getStatus());
|
|
|
+ assertEquals("NORMAL", metric(response, "MANHOLE_WATER_LEVEL_ALARM").getStatus());
|
|
|
+ assertEquals("UNKNOWN", metric(response, "MANHOLE_TILT_ANGLE").getStatus());
|
|
|
+ assertMetric(response, "MANHOLE_BATTERY", "85", "%", "CURRENT");
|
|
|
+ assertMetric(response, "MANHOLE_TEMPERATURE", "23", "℃", "CURRENT");
|
|
|
+ }
|
|
|
+
|
|
|
+ @ParameterizedTest
|
|
|
+ @ValueSource(strings = {"CN-ENV-21119740", "021119740", "21119740x", "9999999999999"})
|
|
|
+ void doesNotCoerceAnotherDeviceCodeIntoAnEnvironmentId(String code) {
|
|
|
+ device(code, "e_real_time_data");
|
|
|
+ assertEquals("EMPTY", service.loadCurrent("test", code).getState());
|
|
|
+ verifyNoInteractions(environment, perception, gas, water);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void noCollectedRowsRemainEmptyWithoutSynthesizingMetrics() {
|
|
|
+ device("21119740", "e_real_time_data");
|
|
|
+ assertEquals("EMPTY", service.loadCurrent("test", "21119740").getState());
|
|
|
+ for (int node = 1; node <= 5; node++) verify(environment).getLatestByDeviceNode(21119740, node);
|
|
|
+ device("REAL-CODE-7", "radar_data");
|
|
|
+ assertEquals("EMPTY", service.loadCurrent("test", "REAL-CODE-7").getState());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void timestampWithExplicitOffsetKeepsItsInstantInBusinessZone() {
|
|
|
+ device("REAL-CODE-7", "radar_data");
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("timestamp_since", "2026-10-30T03:59:55Z");
|
|
|
+ when(perception.selectLatestRecords("radar_data", "device_code", "REAL-CODE-7", 1))
|
|
|
+ .thenReturn(Collections.singletonList(row));
|
|
|
+ CockpitMetric metric = metric(service.loadCurrent("test", "REAL-CODE-7"), "RADAR_INSTANT_FLOW");
|
|
|
+ assertEquals("2026-10-30T11:59:55+08:00", metric.getSampledAt());
|
|
|
+ assertEquals("CURRENT", metric.getFreshness());
|
|
|
+ row.put("timestamp_since", java.time.OffsetDateTime.parse("2026-10-30T03:59:55Z"));
|
|
|
+ assertEquals("2026-10-30T11:59:55+08:00",
|
|
|
+ metric(service.loadCurrent("test", "REAL-CODE-7"), "RADAR_INSTANT_FLOW").getSampledAt());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingOrUnspecifiedEpochTimeFailsClosedInsteadOfClaimingCurrent() {
|
|
|
+ device("REAL-CODE-7", "radar_data");
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("meter1_instant_flow", "3.5");
|
|
|
+ when(perception.selectLatestRecords("radar_data", "device_code", "REAL-CODE-7", 1))
|
|
|
+ .thenReturn(Collections.singletonList(row));
|
|
|
+ assertThrows(com.zksy.cockpit.foundation.CockpitReadException.class,
|
|
|
+ () -> service.loadCurrent("test", "REAL-CODE-7"));
|
|
|
+ row.put("timestamp_since", 1793332795000L);
|
|
|
+ assertThrows(com.zksy.cockpit.foundation.CockpitReadException.class,
|
|
|
+ () -> service.loadCurrent("test", "REAL-CODE-7"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void device(String code, String source) {
|
|
|
+ EquipmentBase device = new EquipmentBase();
|
|
|
+ device.setEquipmentId("database-primary-key-must-not-be-used");
|
|
|
+ device.setEquipmentCode(code);
|
|
|
+ device.setEquipmentTypeId(15);
|
|
|
+ EquipmentType root = new EquipmentType();
|
|
|
+ root.setId(3);
|
|
|
+ root.setTypeName("燃气");
|
|
|
+ root.setParentId(0);
|
|
|
+ EquipmentType type = new EquipmentType();
|
|
|
+ type.setId(15);
|
|
|
+ type.setParentId(3);
|
|
|
+ type.setTypeName("实际采集类型");
|
|
|
+ type.setOrigCollTable(source);
|
|
|
+ when(devices.list(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(device));
|
|
|
+ when(types.list()).thenReturn(Arrays.asList(root, type));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static ERealTimeData node(int node, String solids, Float hum, Float tem, String at) {
|
|
|
+ ERealTimeData row = new ERealTimeData();
|
|
|
+ row.setDeviceId(21119740);
|
|
|
+ row.setNodeId(node);
|
|
|
+ row.setFloatValue(solids);
|
|
|
+ row.setHum(hum);
|
|
|
+ row.setTem(tem);
|
|
|
+ row.setCreateTime(LocalDateTime.parse(at));
|
|
|
+ return row;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static CockpitMetric metric(CockpitTypeMetricsResponse response, String code) {
|
|
|
+ return response.getMetrics().stream().filter(m -> code.equals(m.getMetricCode())).findFirst()
|
|
|
+ .orElseThrow(() -> new AssertionError("missing metric: " + code));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void assertMetric(CockpitTypeMetricsResponse response, String code, String value, String unit, String freshness) {
|
|
|
+ CockpitMetric metric = metric(response, code);
|
|
|
+ if (value == null) assertNull(metric.getValue());
|
|
|
+ else assertEquals(0, new BigDecimal(value).compareTo(metric.getValue()), code);
|
|
|
+ assertEquals(unit, metric.getUnit());
|
|
|
+ assertEquals(freshness, metric.getFreshness());
|
|
|
+ }
|
|
|
+}
|