package com.battery.business.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.registry.AdapterInstanceConfig;
import com.battery.business.domain.TDeviceAdapter;
import com.battery.business.domain.TDeviceIngestRecord;
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestBattery;
import com.battery.business.domain.TTestChannelBinding;
import com.battery.business.domain.TTestRealtimeRecord;
import com.battery.business.mapper.TDeviceAdapterMapper;
import com.battery.business.mapper.TTestMapper;
import com.battery.business.service.ITTestService;
/**
* 推式来源接进业务接收路径端到端:取数程序推来的厂商原始行 → 落地表 → 适配器读回统一观测 →
* 接收中测试按水位逐行保存。
*
*
用的是同一个观测契约,所以这里不需要任何推送专属的接收代码:落地行按事件落库、
* 轮询槽位为空(同秒多行因此各自成立),来源行身份承担幂等与冲突判定。
*/
class PushIngestReceptionContractTest
{
private static final String ADAPTER = "j22";
private static final Instant START = Instant.parse("2026-09-27T08:00:00Z");
/** 现场实测的一批:两行落在同一个绝对秒(08:03:20 UTC),协议序号不同。 */
private static final String BATCH = """
{"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
{"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":4,
"Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31},
{"record_time":"134349698000000000","Data_Point":422,"Step_Time":600.0,"Step_Index":4,
"Cycle_Index":1,"Voltage":1.2702,"eCurrent":0.5,"ChargeCapacity":0.122,"ChargeEnergy":0.32}]}
""";
/** 同一批里带上已声明的环境量列:辅助测量的值取自这一行自己,不做任何缩放。 */
private static final String AUXILIARY_BATCH = """
{"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
{"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":4,
"Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31,
"Tempbox":26.5},
{"record_time":"134349704000000000","Data_Point":422,"Step_Time":660.0,"Step_Index":4,
"Cycle_Index":1,"Voltage":1.2702,"eCurrent":0.5,"ChargeCapacity":0.122,"ChargeEnergy":0.32,
"Tempbox":26.5}]}
""";
private final FakeIngestLanding landing = new FakeIngestLanding();
private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class);
private final TestClock clock = new TestClock(START);
@Test
void pushedRawRowsBecomeTestFactsWithoutPollingSlots()
{
PushIngestAdapter adapter = ingestPushSource();
TTestMapper mapper = mock(TTestMapper.class);
ITTestService tests = mock(ITTestService.class);
TTest test = receivingTest();
when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
when(tests.selectTestById(1L)).thenReturn(test);
TestDataReceptionServiceImpl service = new TestDataReceptionServiceImpl(mapper, tests, adapter, clock);
// 第一次接收只取水位:开始接收之前的行即使躺在落地表里也不进本测试。
landing.insertIngestRecord(boundaryRow("DB_20260927_155209/Ws26_IV_0:420", 420L));
service.captureReceivedData();
verify(mapper, never()).insertRealtimeRecord(any());
// 之后推来的行逐行落库:同秒两行各自成立,因为槽位为空(推送记录按事件落库,不占轮询槽位)。
ingest(BATCH);
clock.advance(Duration.ofSeconds(60));
service.captureReceivedData();
ArgumentCaptor rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
verify(mapper, times(2)).insertRealtimeRecord(rows.capture());
assertEquals(List.of("push:DB_20260927_155209/Ws26_IV_0:421",
"push:DB_20260927_155209/Ws26_IV_0:422"),
rows.getAllValues().stream().map(TTestRealtimeRecord::getSourceRecordId).toList());
assertTrue(rows.getAllValues().stream().allMatch(row -> row.getCaptureSlotUtc() == null));
assertEquals("j22:0:0:1:26", rows.getAllValues().get(0).getSourceIdentity());
assertEquals(Instant.parse("2026-09-27T08:03:20Z"),
rows.getAllValues().get(0).getCabinetDeviceTimeUtc().toInstant());
assertEquals("Asia/Shanghai", rows.getAllValues().get(0).getCabinetAdapterTimezoneId());
// 测量值按物理单位直接使用,不做缩放。
assertEquals(0, new java.math.BigDecimal("1.2701")
.compareTo(rows.getAllValues().get(0).getVoltageV()));
assertEquals(0, new java.math.BigDecimal("0.121")
.compareTo(rows.getAllValues().get(0).getCapacityAh()));
// 同一批再推一次不会重复落库:来源行身份让接收幂等。
ingest(BATCH);
clock.advance(Duration.ofSeconds(60));
service.captureReceivedData();
verify(mapper, times(2)).insertRealtimeRecord(any());
}
/**
* 采集器辅助测量按 ADR-0064 由同一来源提供:它作为扩展测量记录进入实时记录,
* **不跟检测柜记录伪造配对**——检测柜走各自的设备数据行(槽位为空),采集器走轮询槽位行(柜端字段为空),
* 两边各自保留自己的设备时间,绑定角色决定它进哪个指标。
*/
@Test
void collectorAuxiliaryMeasurementBecomesItsOwnRealtimeRowWithoutFakingAPairing()
{
PushIngestAdapter adapter = ingestPushSource("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\",\"auxiliaryMetrics\":"
+ "[{\"vendorChannel\":26,\"channelCode\":\"S-01\",\"column\":\"Tempbox\","
+ "\"metricCode\":\"TEMPERATURE\",\"unit\":\"℃\"}]}");
TTestMapper mapper = mock(TTestMapper.class);
ITTestService tests = mock(ITTestService.class);
TTest test = receivingTest();
TTestBattery battery = test.getBatteries().get(0);
battery.setCollectorId(7L);
com.battery.business.domain.TTestCollectorChannel collectorChannel =
new com.battery.business.domain.TTestCollectorChannel();
collectorChannel.setChannelCode("S-01");
collectorChannel.setMeasurementCode("PRIMARY_TEMPERATURE");
battery.setCollectorChannels(List.of(collectorChannel));
when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
when(tests.selectTestById(1L)).thenReturn(test);
when(mapper.selectCollectorCode(7L)).thenReturn("j22-collector");
TestDataReceptionServiceImpl service = new TestDataReceptionServiceImpl(mapper, tests, adapter, clock);
landing.insertIngestRecord(boundaryRow("DB_20260927_155209/Ws26_IV_0:420", 420L));
service.captureReceivedData();
ingest(AUXILIARY_BATCH);
clock.advance(Duration.ofSeconds(60));
service.captureReceivedData();
ArgumentCaptor rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
verify(mapper, times(3)).insertRealtimeRecord(rows.capture());
List cabinetRows = rows.getAllValues().subList(0, 2);
TTestRealtimeRecord collectorRow = rows.getAllValues().get(2);
// 检测柜事实不占轮询槽位,采集器事实占槽位:两条路径互不覆盖。
assertTrue(cabinetRows.stream().allMatch(row -> row.getCaptureSlotUtc() == null));
assertTrue(collectorRow.getCaptureSlotUtc() != null);
// 采集器行不携带检测柜设备时间与检测柜身份,来源行身份里也没有 cabinet 段。
assertEquals(null, collectorRow.getCabinetDeviceTimeUtc());
assertEquals(null, collectorRow.getCabinetChlid());
assertTrue(collectorRow.getSourceRecordId().indexOf("cabinet:") < 0);
// 测量值取自这一行自己的设备时间(该通道落地表里最新一行),指标编码与单位按配置声明,
// 固定指标落到自己的列。
assertEquals(Instant.parse("2026-09-27T08:13:20Z"), collectorRow.getCollectorDeviceTimeUtc().toInstant());
assertEquals("S-01", collectorRow.getCollectorChannelCode());
assertEquals(0, new java.math.BigDecimal("26.5").compareTo(collectorRow.getTemperature()));
assertEquals(Long.valueOf(7L), collectorRow.getCollectorId());
}
private PushIngestAdapter ingestPushSource()
{
return ingestPushSource("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}");
}
private PushIngestAdapter ingestPushSource(String sourceConfig)
{
TDeviceAdapter source = new TDeviceAdapter();
source.setAdapterCode(ADAPTER);
source.setAdapterType(PushIngestAdapter.ADAPTER_TYPE);
source.setStatus("0");
source.setTimezoneId("Asia/Shanghai");
source.setSourceConfig(sourceConfig);
when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(source);
ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
return new PushIngestAdapter(new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE,
"127.0.0.1", 8080, 60000, "Asia/Shanghai", true, AdapterCapabilities.observeOnly(),
com.alibaba.fastjson2.JSON.parseObject(sourceConfig)
.toJavaObject(new com.alibaba.fastjson2.TypeReference>() { })),
landing, clock);
}
private void ingest(String body)
{
new DeviceIngestServiceImpl(adapters, landing, clock, key -> "token").ingest(ADAPTER, "token", body);
}
/** 开始接收之前就躺在来源里的那一行:它只用来把水位定在边界时刻。 */
private TDeviceIngestRecord boundaryRow(String recordKey, long sequence)
{
TDeviceIngestRecord value = new TDeviceIngestRecord();
value.setAdapterCode(ADAPTER);
value.setPayloadKind(TDeviceIngestRecord.KIND_SAMPLE);
value.setDevtype(0); value.setDevid(0); value.setSubdevid(1); value.setChlid(26);
value.setRecordSequence(sequence);
value.setRecordKey(recordKey);
value.setSourceRef("DB_20260927_155209/Ws26_IV_0");
value.setDeviceTimeUtc(Date.from(START.minusSeconds(60)));
value.setDeviceTimeRaw("134349691400000000");
value.setTimezoneId("Asia/Shanghai");
value.setPayloadJson("{}");
value.setContentSha256(new byte[32]);
value.setReceivedAtUtc(Date.from(START.minusSeconds(60)));
return value;
}
private static TTest receivingTest()
{
TTestChannelBinding binding = new TTestChannelBinding();
binding.setBindingId(2L);
binding.setChannelId(3L);
binding.setAdapterCode(ADAPTER);
binding.setAdapterTimezoneId("Asia/Shanghai");
binding.setDevtype(0);
binding.setDevid(0);
binding.setSubdevid(1);
binding.setChlid(26);
TTestBattery battery = new TTestBattery();
battery.setTestBatteryId(4L);
battery.setBinding(binding);
battery.setCollectorChannels(List.of());
TTest test = new TTest();
test.setTestId(1L);
test.setTestStatus("RUNNING");
test.setReceptionStatus("RECEIVING");
test.setStartedAtUtc(Date.from(START));
test.setReceptionBoundaryAtUtc(Date.from(START));
test.setBatteries(List.of(battery));
return test;
}
/** 可推进的测试时钟:接收节奏与推送节奏都按它推进。 */
private static final class TestClock extends Clock
{
private Instant now;
private TestClock(Instant now) { this.now = now; }
private void advance(Duration duration) { now = now.plus(duration); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
}