From e38aca8134570513435c003c25dc05f1ae51c9c9 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 08 十月 2026 13:56:28 +0800
Subject: [PATCH] 实现 Ticket04:采集器辅助测量与登记入口,拆除开发模拟器

---
 battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java |   87 ++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 80 insertions(+), 7 deletions(-)

diff --git a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
index acee31c..ec6c1be 100644
--- a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
+++ b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
@@ -50,11 +50,19 @@
     private final FakeIngestLanding landing = new FakeIngestLanding();
     private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class);
     private static final Instant NOW = Instant.parse("2026-09-27T09:00:00Z");
+    /**
+     * 环境量的确认结论落在这里:厂商列名只在配置里出现一次,代码里没有任何厂商列名。
+     * 22 所的列语义与量纲仍未现场确认,所以生产配置里默认没有这一项。
+     */
+    private static final Map<String, String> AUXILIARY_METRICS = Map.of("auxiliaryMetrics",
+            "[{\"vendorChannel\":26,\"channelCode\":\"S-01\",\"column\":\"Tempbox\","
+                    + "\"metricCode\":\"TEMPERATURE\",\"unit\":\"℃\"}]");
 
     @Test
     void topologyPushMakesChannelsCandidatesWithTheirOwnLatestDataTime()
     {
-        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26},{\"channel\":18}]}");
+        // 拓扑行里可以带 channelNo:来源自己软件里显示的通道号(现场核对:22 所界面号 = 表名号 + 1)
+        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26,\"channelNo\":27},{\"channel\":18}]}");
         ingest(BATCH);
         PushIngestAdapter adapter = adapter(NOW);
 
@@ -68,9 +76,15 @@
         assertTrue(active.available());
         // 候选的机器地址取实例配置:登记时会被带进检测柜的 IP 字段。
         assertEquals("127.0.0.1", active.serverIp());
+        // 来源自己的通道叫法随候选一起交出(界面与登记用它显示),身份仍是表名里的号。
+        assertEquals("27", active.sourceDisplayNo());
+        assertEquals(26, active.identity().chlid());
         // 登记范围由操作员决定:没有采样行的通道照样出现在候选里,只是没有最近数据时间。
         assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst()
                 .orElseThrow().lastDataAt());
+        // 来源没报叫法的通道保持为空,界面回退到「单元-通道」。
+        assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst()
+                .orElseThrow().sourceDisplayNo());
     }
 
     /** 心跳缺失或过期即来源不可达:发现路径明确失败,业务侧据此把已登记设备置为离线并撤下旧快照。 */
@@ -156,9 +170,9 @@
         assertTrue(adapter.inquire(Set.of(channel())).isEmpty());
     }
 
-    /** 采集器辅助测量按 ADR-0064 逐来源确认;22 所环境量的列语义未确认,因此不交出测量值。 */
+    /** 未声明辅助测量列就不交出测量值:确认之前不按列名推断语义,也不写零占位。 */
     @Test
-    void collectorAndStepCycleObservationsStayEmptyForThisStorageForm()
+    void collectorAndStepCycleObservationsStayEmptyWithoutDeclaredAuxiliaryMetrics()
     {
         ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
         ingest(BATCH);
@@ -171,6 +185,61 @@
         assertEquals(5000L, adapter.persistenceIntervalMillis(channel()));
         AdapterCapabilities capabilities = adapter.capabilities();
         assertTrue(capabilities.discovery() && capabilities.realtimeObservation() && !capabilities.control());
+    }
+
+    /** 环境量与量纲确认后落到配置:列名、指标编码、单位、采集器通道都由声明给出,代码里没有厂商列名。 */
+    @Test
+    void declaredAuxiliaryMetricIsProjectedWithItsOwnSourceIdentityValueUnitAndDeviceTime()
+    {
+        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
+        ingest("""
+                {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
+                  {"record_time":"134349692000000000","Data_Point":420,"Voltage":1.26778,"eCurrent":0.5,"Tempbox":26.5}]}
+                """);
+        PushIngestAdapter adapter = adapter(NOW, AUXILIARY_METRICS);
+
+        var collector = adapter.latestCollector(ADAPTER, "collector-1");
+
+        assertTrue(collector.online());
+        var measurement = collector.measurements().get(0);
+        assertEquals("S-01", measurement.channelCode());
+        assertEquals("TEMPERATURE", measurement.metricCode());
+        assertEquals(0, new java.math.BigDecimal("26.5").compareTo(measurement.value()));
+        assertEquals("℃", measurement.unit());
+        // 设备时间与来源身份来自这一行自己:不与检测柜记录共用时间,也不伪造配对。
+        assertEquals(Instant.parse("2026-09-27T07:53:20Z"), measurement.deviceTime());
+        assertEquals("134349692000000000", measurement.deviceTimeRaw());
+    }
+
+    /** 落地行里没有这一列(设备没给)就不写这一项,也不写零占位。 */
+    @Test
+    void declaredAuxiliaryMetricAbsentFromTheRowIsNotFabricated()
+    {
+        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
+        ingest(BATCH);
+        PushIngestAdapter adapter = adapter(NOW, AUXILIARY_METRICS);
+
+        var collector = adapter.latestCollector(ADAPTER, "collector-1");
+
+        assertTrue(collector.online());
+        assertTrue(collector.measurements().isEmpty());
+    }
+
+    /** 来源离线(心跳超时)时不交出任何测量值:离线不伪造测量值,业务侧据此也不落采集器行。 */
+    @Test
+    void staleHeartbeatReportsCollectorSourceOfflineWithoutMeasurements()
+    {
+        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
+        ingest("""
+                {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
+                  {"record_time":"134349692000000000","Data_Point":420,"Voltage":1.26778,"eCurrent":0.5,"Tempbox":26.5}]}
+                """);
+        PushIngestAdapter adapter = adapter(NOW.plus(Duration.ofMinutes(10)), AUXILIARY_METRICS);
+
+        var collector = adapter.latestCollector(ADAPTER, "collector-1");
+
+        assertFalse(collector.online());
+        assertTrue(collector.measurements().isEmpty());
     }
 
     @Test
@@ -191,14 +260,18 @@
 
     private PushIngestAdapter adapter(Instant now)
     {
-        return new PushIngestAdapter(config(), landing, Clock.fixed(now, ZoneOffset.UTC));
+        return adapter(now, Map.of("tokenEnv", "BATTERY_INGEST_TOKEN"));
     }
 
-    private static AdapterInstanceConfig config()
+    private PushIngestAdapter adapter(Instant now, Map<String, String> sourceOptions)
+    {
+        return new PushIngestAdapter(config(sourceOptions), landing, Clock.fixed(now, ZoneOffset.UTC));
+    }
+
+    private static AdapterInstanceConfig config(Map<String, String> sourceOptions)
     {
         return new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE, "127.0.0.1", 8080, 60000,
-                "Asia/Shanghai", true, AdapterCapabilities.observeOnly(),
-                Map.of("tokenEnv", "BATTERY_INGEST_TOKEN"));
+                "Asia/Shanghai", true, AdapterCapabilities.observeOnly(), sourceOptions);
     }
 
     private void ingest(String body)

--
Gitblit v1.7.1