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 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());
}
private PushIngestAdapter ingestPushSource()
{
TDeviceAdapter source = new TDeviceAdapter();
source.setAdapterCode(ADAPTER);
source.setAdapterType(PushIngestAdapter.ADAPTER_TYPE);
source.setStatus("0");
source.setTimezoneId("Asia/Shanghai");
source.setSourceConfig("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}");
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(),
java.util.Map.of("tokenEnv", "BATTERY_INGEST_TOKEN")), 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; }
}
}