yeg
4 天以前 6b99cc391064cb38c706979fafcecb9f50ea1587
battery-device-bts/src/test/java/com/battery/device/bts/NewareBtsAdapterContractTest.java
@@ -16,6 +16,7 @@
import org.junit.jupiter.api.Test;
import com.battery.device.api.DeviceAdapter;
import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DeviceControlCommand;
@@ -148,14 +149,12 @@
    }
    @Test
    void doesNotInventStepCyclesCollectorOrPlanFacts()
    void doesNotInventCollectorOrPlanFacts()
    {
        ScriptedPipe pipe = new ScriptedPipe();
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            // 工步层要未验证的 downloadStepLayer;采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。
            assertTrue(adapter.stepRecords(CHANNEL).isEmpty());
            assertTrue(adapter.cycleRecords(CHANNEL).isEmpty());
            // 采集器测量 ADR-0068 已复核不可得;协议里没有读计划的命令。
            assertNull(adapter.latestCollector(ADAPTER, "collector-1"));
            assertNull(adapter.testPlan(CHANNEL));
            assertEquals(NewareBtsAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL));
@@ -178,6 +177,128 @@
                assertThrows(UnsupportedOperationException.class, () -> adapter.control(CHANNEL, command));
            assertTrue(pipe.commands().isEmpty());
        }
    }
    @Test
    void handsOutEveryStepAndCycleWhenTheChannelHasEnded()
    {
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED);
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            var steps = adapter.stepRecords(CHANNEL);
            var cycles = adapter.cycleRecords(CHANNEL);
            assertEquals(4, steps.size());
            assertEquals("充电", steps.get(1).stepName());
            // 循环号已按 0 起归一到与厂商库、逐点同口径(应答里的 cycleid 是 1 起)。
            assertEquals(0, steps.get(0).cycleNo());
            assertEquals(1, cycles.size());
            var cycle = cycles.get(0);
            assertEquals(0, cycle.cycleNo());
            assertEquals(2.81630277777778, cycle.chargeCapacityAh(), 1e-9);
            assertEquals(0.00431017035590278, cycle.dischargeCapacityAh(), 1e-12);
            assertEquals(5.58447055555556, cycle.chargeEnergyWh(), 1e-9);
            assertEquals(0.00426197048611111, cycle.dischargeEnergyWh(), 1e-12);
            // 效率 = 放电容量 / 充电容量 × 100;搁置工步不进任何一侧。
            assertEquals(0.153, cycle.efficiency(), 0.0005);
            assertEquals("neware:316:cycle:0", cycle.sourceRecordId());
            assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.sourceKind());
        }
    }
    @Test
    void holdsBackTheRunningStepAndItsCycleWhileTheTestIsStillRunning()
    {
        // 实测这条命令会把正在进行的那一步也交出来,而它的 endseqid 恰好等于已落盘行数
        // (四个测试都相等),所以只能靠通道工作状态判断它有没有结束。
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING);
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            var steps = adapter.stepRecords(CHANNEL);
            var cycles = adapter.cycleRecords(CHANNEL);
            // 最后一步(stepindex=4)与它所在的循环都不交出,等它结束再交。
            assertEquals(3, steps.size());
            assertEquals("neware:316:step:3", steps.get(2).sourceRecordId());
            assertTrue(cycles.isEmpty());
        }
    }
    @Test
    void readsTheStepLayerOnceForBothStepsAndCycles()
    {
        // 工步与循环必须来自同一次读取:否则一个工步边界落在两次读取之间时,
        // 循环合计会包含工步列表里没有的那一步,使循环记录每轮内容都不同。
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.FINISHED);
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            adapter.stepRecords(CHANNEL);
            adapter.cycleRecords(CHANNEL);
            assertEquals(1, pipe.commands().stream().filter("downloadsteplayer"::equals).count());
        }
    }
    @Test
    void aChannelWithNoTestInTheBufferHasNoStepsOrCycles()
    {
        ScriptedPipe pipe = new ScriptedPipe()
                .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd><list count=\"0\" /></bts>");
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            assertTrue(adapter.stepRecords(CHANNEL).isEmpty());
            assertTrue(adapter.cycleRecords(CHANNEL).isEmpty());
            // 缓冲里没有测试就直接是空,不必再去问工作状态。
            assertEquals(List.of("downloadsteplayer"), pipe.commands());
        }
    }
    @Test
    void aFailedStateReadSkipsTheRoundInsteadOfHandingOutTheRunningStep()
    {
        // 读取失败不吞:调用方本来就有"来源读不到就本轮放弃、下一轮重试"的约定,
        // 所以一次传输抖动不会变成"把一个还在跑的工步交出去",也不会被当成结论缓存下来。
        ScriptedPipe pipe = finishedTestPipe(DeviceWorkStatus.WORKING).failing("inquire");
        try (TestDataObservationAdapter adapter = (TestDataObservationAdapter) adapter(pipe))
        {
            assertThrows(RuntimeException.class, () -> adapter.stepRecords(CHANNEL));
            pipe.recover();
            // 恢复后重新读,拿到的是完整结论(在跑 → 交出 3 步、按住最后一步),不是上一次的空结果。
            assertEquals(3, adapter.stepRecords(CHANNEL).size());
        }
    }
    /** 一个已结束测试的工步层(现场实测原文,测试 316):4 步都在循环 1(归一后为 0)。 */
    private static ScriptedPipe finishedTestPipe(DeviceWorkStatus workStatus)
    {
        return new ScriptedPipe()
                .on("inquire", "<bts><cmd>inquire_resp</cmd><list count=\"1\"><inquire dev=\"25-81-1-2-0\""
                        + " cycle_id=\"1\" step_id=\"4\" step_type=\"dc\" workstatus=\""
                        + workStatus.name().toLowerCase(java.util.Locale.ROOT)
                        + "\" current=\"0\" voltage=\"1.2\" /></list></bts>")
                .on("downloadStepLayer", "<bts><cmd>downloadStepLayer_resp</cmd>"
                        + "<downloadStepLayer devtype=\"25\" devid=\"81\" subdevid=\"1\" chlid=\"2\" testid=\"316\" />"
                        + "<list count=\"4\">"
                        + "<data startseqid=\"1\" endseqid=\"2\" stepindex=\"1\" stepid=\"1\" cycleid=\"1\""
                        + " steptype=\"rest\" steptime=\"23100\" endatime=\"2026-10-10 09:09:14\""
                        + " startvolt=\"0.806611083984375\" endvolt=\"0.80638037109375\" startcurr=\"0\""
                        + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
                        + "<data startseqid=\"3\" endseqid=\"304\" stepindex=\"2\" stepid=\"2\" cycleid=\"1\""
                        + " steptype=\"cc\" steptime=\"18000000\" endatime=\"2026-10-10 14:09:14\""
                        + " startvolt=\"1.07542861328125\" endvolt=\"1.972108203125\""
                        + " startcurr=\"0.563903076171875\" endcurr=\"0.563332702636719\""
                        + " cap=\"2.81630277777778\" eng=\"5.58447055555556\" dcir=\"0\" />"
                        + "<data startseqid=\"305\" endseqid=\"307\" stepindex=\"3\" stepid=\"3\" cycleid=\"1\""
                        + " steptype=\"rest\" steptime=\"83600\" endatime=\"2026-10-10 14:10:38\""
                        + " startvolt=\"1.7850955078125\" endvolt=\"1.42794287109375\" startcurr=\"0\""
                        + " endcurr=\"0\" cap=\"0\" eng=\"0\" dcir=\"0\" />"
                        + "<data startseqid=\"308\" endseqid=\"309\" stepindex=\"4\" stepid=\"4\" cycleid=\"1\""
                        + " steptype=\"dc\" steptime=\"27600\" endatime=\"2026-10-10 14:11:05\""
                        + " startvolt=\"1.27452001953125\" endvolt=\"0.49604326171875\""
                        + " startcurr=\"-0.562971618652344\" endcurr=\"-0.5622275390625\""
                        + " cap=\"0.00431017035590278\" eng=\"0.00426197048611111\" dcir=\"0\" /></list></bts>");
    }
    private static DeviceAdapter adapter(ScriptedPipe pipe)
@@ -216,13 +337,27 @@
        private final Map<String, String> responses = new java.util.LinkedHashMap<>();
        private final List<String> commands = new ArrayList<>();
        /** 让指定命令暂时失败,用来验"读取失败不吞、不当结论缓存"。 */
        private final java.util.Set<String> failing = new java.util.LinkedHashSet<>();
        private int firstRow = 0;
        private int lastRow = 0;
        ScriptedPipe on(String command, String response)
        {
            responses.put(command, response);
            // 请求里的命令名按小写匹配(与生产传输的比对口径一致)。
            responses.put(command.toLowerCase(java.util.Locale.ROOT), response);
            return this;
        }
        ScriptedPipe failing(String command)
        {
            failing.add(command.toLowerCase(java.util.Locale.ROOT));
            return this;
        }
        void recover()
        {
            failing.clear();
        }
        /** 生成 {@code [first, last]} 的逐点行集,供 download/inquiredf 按窗口回答。 */
@@ -250,6 +385,8 @@
            Matcher matcher = COMMAND.matcher(request);
            if (!matcher.find()) throw new IllegalStateException("请求缺少命令:" + request);
            String command = matcher.group(1).toLowerCase(java.util.Locale.ROOT);
            if (failing.contains(command))
                throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT, "测试脚本让 " + command + " 失败", null);
            if ("inquiredf".equals(command))
            {
                commands.add("inquiredf");