1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
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.ArrayList;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
import com.battery.device.api.TestDataObservationAdapter.CollectorRealtime;
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DetectedChannel;
import com.battery.device.api.model.DeviceObservation;
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.TTestMapper;
import com.battery.business.service.ITTestService;
 
/**
 * 逐行接收的归属边界:水位由"开始接收"与"继续接收"这一刻来源里的最新一行决定,
 * 只取严格晚于水位的数据行;同秒多行都保留;重复同内容幂等;同身份不同内容不覆盖。
 */
class VendorSampleReceptionServiceTest
{
    private static final Instant START = Instant.parse("2026-09-28T02:00:00Z");
    private static final ChannelIdentity CHANNEL = new ChannelIdentity("neware76", 21, 36, 1, 2);
    private static final String IDENTITY = "neware76:21:36:1:2";
 
    @Test
    void firstPollReadsTheWatermarkWithoutWritingDeviceHistory()
    {
        Fixture fixture = fixture();
        fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
 
        fixture.service.captureReceivedData();
 
        assertEquals(1, fixture.adapter.watermarkReads);
        assertTrue(fixture.adapter.sampleReads.isEmpty());
        verify(fixture.mapper, never()).insertRealtimeRecord(any());
    }
 
    @Test
    void everyRowStrictlyAfterTheWatermarkBecomesATestFactIncludingSameSecondRows()
    {
        Fixture fixture = fixture();
        fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
        fixture.service.captureReceivedData();
 
        // 同秒两行都落库:槽位留空,来源行身份把它们区分开。
        fixture.adapter.samples = List.of(sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"),
                sample(7438L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"));
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
 
        ArgumentCaptor<TTestRealtimeRecord> rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
        verify(fixture.mapper, times(2)).insertRealtimeRecord(rows.capture());
        assertEquals(List.of("neware:440:7437", "neware:440:7438"),
                rows.getAllValues().stream().map(TTestRealtimeRecord::getSourceRecordId).toList());
        assertTrue(rows.getAllValues().stream().allMatch(row -> row.getCaptureSlotUtc() == null));
        assertEquals(Instant.parse("2026-09-28T01:55:56Z"),
                rows.getAllValues().get(0).getCabinetDeviceTimeUtc().toInstant());
        assertEquals("2026-09-28 09:55:56", rows.getAllValues().get(0).getCabinetDeviceTimeRaw());
        assertEquals(IDENTITY, rows.getAllValues().get(0).getSourceIdentity());
    }
 
    @Test
    void resumingReceptionReadsANewWatermarkSoThePauseGapIsNotBackfilled()
    {
        Fixture fixture = fixture();
        fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
        fixture.service.captureReceivedData();
 
        fixture.adapter.samples = List.of(sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"));
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
        assertEquals(List.of(new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)),
                fixture.adapter.sampleReads);
 
        // 暂停后继续:接收边界推进,水位重新读取;暂停期间产生的行不再进本测试。
        fixture.test.setReceptionBoundaryAtUtc(Date.from(START.plusSeconds(600)));
        fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T02:05:56Z"), 7500L);
        fixture.adapter.samples = List.of();
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
 
        assertEquals(2, fixture.adapter.watermarkReads);
        // 取到新水位后,只追加新水位之后的行;暂停期间的空档不会被补回来。
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
        assertEquals(List.of(new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L),
                new SampleWatermark(Instant.parse("2026-09-28T02:05:56Z"), 7500L)), fixture.adapter.sampleReads);
    }
 
    @Test
    void repeatedIdenticalRowIsIgnoredAndConflictingContentIsNotOverwritten()
    {
        Fixture fixture = fixture();
        fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
        fixture.service.captureReceivedData();
 
        CabinetRealtime sample = sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56");
        fixture.adapter.samples = List.of(sample);
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
        ArgumentCaptor<TTestRealtimeRecord> first = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
        verify(fixture.mapper).insertRealtimeRecord(first.capture());
 
        // 同身份同内容:幂等忽略,不再插入。
        when(fixture.mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437"))
                .thenReturn(HexFormat.of().formatHex(first.getValue().getContentSha256()));
        fixture.adapter.watermark = new SampleWatermark(sample.deviceTime(), sample.sequenceId());
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
        verify(fixture.mapper, times(1)).insertRealtimeRecord(any());
 
        // 同身份不同内容:保留原事实,拒绝覆盖。
        when(fixture.mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437"))
                .thenReturn("0000000000000000000000000000000000000000000000000000000000000000");
        fixture.clock.advance(Duration.ofSeconds(60));
        fixture.service.captureReceivedData();
        verify(fixture.mapper, times(1)).insertRealtimeRecord(any());
    }
 
    @Test
    void sourceWithoutWatermarkKeepsThePollingSlotPath()
    {
        Fixture fixture = fixture();
        // 不提供水位的来源(例如开发模拟器)继续按轮询槽位保存柜端事实。
        fixture.adapter.watermark = null;
        fixture.adapter.latest = sample(9L, "2026-09-28T02:00:30Z", "2026-09-28 10:00:30");
 
        fixture.service.captureReceivedData();
 
        assertEquals(1, fixture.adapter.watermarkReads);
        assertTrue(fixture.adapter.sampleReads.isEmpty());
        ArgumentCaptor<TTestRealtimeRecord> rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
        verify(fixture.mapper).insertRealtimeRecord(rows.capture());
        assertTrue(rows.getValue().getCaptureSlotUtc() != null);
    }
 
    private static CabinetRealtime sample(long sequenceId, String deviceTime, String raw)
    {
        return new CabinetRealtime(sequenceId, Instant.parse(deviceTime), -1.1889, 1.3733, 1.398611, 0.030648,
                3, 12, null, "2", Instant.parse("2026-09-28T02:01:00Z"), raw, "Asia/Hong_Kong", 300L,
                "neware:440:" + sequenceId);
    }
 
    private static Fixture fixture()
    {
        TTestMapper mapper = mock(TTestMapper.class);
        ITTestService tests = mock(ITTestService.class);
        AdaptiveAdapter adapter = new AdaptiveAdapter();
        TTest test = receivingTest();
        when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
        when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
        when(mapper.selectCollectorCode(9L)).thenReturn("collector-9");
        when(tests.selectTestById(1L)).thenReturn(test);
        TestClock clock = new TestClock(START);
        return new Fixture(mapper, tests, adapter, test, clock,
                new TestDataReceptionServiceImpl(mapper, tests, adapter, clock));
    }
 
    private static TTest receivingTest()
    {
        TTestChannelBinding binding = new TTestChannelBinding();
        binding.setBindingId(2L);
        binding.setChannelId(3L);
        binding.setAdapterCode("neware76");
        binding.setAdapterTimezoneId("Asia/Hong_Kong");
        binding.setDevtype(21);
        binding.setDevid(36);
        binding.setSubdevid(1);
        binding.setChlid(2);
        TTestBattery battery = new TTestBattery();
        battery.setTestBatteryId(4L);
        battery.setCollectorId(9L);
        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 record Fixture(TTestMapper mapper, ITTestService tests, AdaptiveAdapter adapter, TTest test,
            TestClock clock, TestDataReceptionServiceImpl service) { }
 
    /** 只暴露水位与逐行读取的测试替身,并记录被问到的水位与逐行读取次数。 */
    private static final class AdaptiveAdapter implements TestDataObservationAdapter
    {
        private SampleWatermark watermark;
        private List<CabinetRealtime> samples = new ArrayList<>();
        private CabinetRealtime latest;
        private int watermarkReads;
        private final List<SampleWatermark> sampleReads = new ArrayList<>();
 
        @Override public String adapterCode() { return "neware76"; }
        @Override public AdapterCapabilities capabilities() { return AdapterCapabilities.observeOnly(); }
        @Override public List<DetectedChannel> discoverChannels() { return List.of(); }
        @Override public Map<ChannelIdentity, DeviceObservation> observe(Set<ChannelIdentity> channels)
        { return Map.of(); }
        @Override public CabinetRealtime latestCabinet(ChannelIdentity channel) { return latest; }
        @Override public CollectorRealtime latestCollector(String adapterCode, String collectorCode) { return null; }
        @Override public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
        @Override public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
        @Override public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; }
 
        @Override public SampleWatermark currentWatermark(ChannelIdentity channel)
        {
            watermarkReads++;
            return watermark;
        }
 
        @Override public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark since)
        {
            sampleReads.add(since);
            return List.copyOf(samples);
        }
    }
 
    /** 可推进的测试时钟:接收节奏与厂商写库节奏都按它推进。 */
    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; }
    }
}