yeg
6 天以前 e38aca8134570513435c003c25dc05f1ae51c9c9
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)