yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
@@ -13,7 +13,10 @@
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
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.credentials.LocalCredentials;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DetectedChannel;
@@ -205,6 +208,198 @@
                .allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode())));
    }
    // ---------------------------------------------------------------- 工步层与循环聚合
    /**
     * 工步记录来自设备工步层:每工步一条,取该工步的结束行;四组容量能量与中值电压放在扩展测量里,
     * 中值电压由设备字段承载,不用峰值或谷值替代。
     */
    @Test
    void stepRecordsComeFromTheDeviceStepLayerAndCarryAllFourCapacityGroups()
    {
        List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
        // 只有进入行的工步仍在进行中,不出现:设备还没写后续工步的行。
        assertEquals(9, steps.size());
        assertTrue(steps.stream().noneMatch(step -> step.cycleNo() == 2));
        StepRecord rest = step(steps, 0, 1);
        assertEquals("4", rest.stepType());
        assertNull(rest.stepName());
        assertEquals(Instant.parse("2026-09-28T01:00:00Z"), rest.startedAt());
        assertEquals(Instant.parse("2026-09-28T01:00:38Z"), rest.endedAt());
        assertEquals(1.3588, rest.endVoltageV(), 1e-9);
        // 工步记录同时带结束时的电流读数。
        assertEquals(0d, rest.endCurrentA(), 1e-9);
        assertEquals(-1.1889, step(steps, 1, 11).endCurrentA(), 1e-9);
        // 工步时间是设备给的 test_time(毫秒 → 秒),不是按采样时间推算的。
        assertEquals(0, new java.math.BigDecimal("38.800").compareTo(rest.measurements().get("stepDuration")));
        assertEquals(0, new java.math.BigDecimal("1.358800000000").compareTo(rest.measurements().get("startVoltage")));
        StepRecord charge = step(steps, 0, 4);
        assertEquals("1", charge.stepType());
        // 单项容量只在方向已确认时给出:充电工步取充电侧。
        assertEquals(119277040d / 36000000d, charge.capacityAh(), 1e-9);
        assertEquals(195220576d / 36000000d, charge.energyWh(), 1e-9);
        // 充电中值电压直接取设备工步层的 middle_vol,设备给零就保留零。
        assertEquals(0, java.math.BigDecimal.ZERO.compareTo(charge.measurements().get("chargeMedianVoltage")));
        assertNull(charge.measurements().get("dischargeMedianVoltage"));
        StepRecord discharge = step(steps, 0, 6);
        assertEquals("2", discharge.stepType());
        assertEquals(68462032d / 36000000d, discharge.capacityAh(), 1e-9);
        assertTrue(discharge.measurements().containsKey("dischargeMedianVoltage"));
        // 四组容量能量都是设备事实:每一组都保留,不只保留方向匹配的那一组。
        for (String field : List.of("chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy"))
            assertTrue(charge.measurements().containsKey(field) && discharge.measurements().containsKey(field));
        assertEquals("neware:440:step:0:4", charge.sourceRecordId());
        assertEquals("Asia/Hong_Kong", charge.timezoneId());
        assertEquals(charge.endedAt(), charge.serverReceivedAt());
    }
    /** 未知工步类型保留明细并保持未知:不按电流符号,也不按容量出现在哪一组去猜方向。 */
    @Test
    void unknownStepTypeKeepsItsDetailAndStaysOutOfTheCycleAggregate()
    {
        List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
        StepRecord unknown = step(steps, 1, 3);
        assertEquals("7", unknown.stepType());
        // 单项容量留空:方向未确认时不给出可归属的单值,但四组明细照原样保留供核对。
        assertNull(unknown.capacityAh());
        assertNull(unknown.energyWh());
        assertEquals(500000000d / 36000000d, unknown.measurements().get("chargeCapacity").doubleValue(), 1e-9);
        CycleRecord cycle = cycle(adapter(stepLayerDatabase()).cycleRecords(CHANNEL), 1);
        // 未知方向的那一步容量没有进任何一侧:本循环的充放电仍只由已知方向的工步构成。
        assertEquals((119267664d + 85510512d) / 36000000d, cycle.chargeCapacityAh(), 1e-9);
        assertEquals((67587520d + 47559464d) / 36000000d, cycle.dischargeCapacityAh(), 1e-9);
    }
    /**
     * 循环统计由工步层按循环聚合并标注为聚合来源:同一循环内同方向的工步容量求和
     * (实测该化成协议 cycle 1 有两个充电工步与两个放电工步),效率是放电容量除以充电容量。
     */
    @Test
    void cycleRecordsAreAggregatedFromTheStepLayerAndMarkedAsAggregated()
    {
        List<CycleRecord> cycles = adapter(stepLayerDatabase()).cycleRecords(CHANNEL);
        // 仍在进行的循环整体不出现:工步行还没写完,聚合出来的是半个循环。
        assertEquals(2, cycles.size());
        CycleRecord first = cycle(cycles, 0);
        assertEquals(119277040d / 36000000d, first.chargeCapacityAh(), 1e-9);
        // 两个放电工步求和:16219380 + 68462032。
        assertEquals(84681412d / 36000000d, first.dischargeCapacityAh(), 1e-9);
        assertEquals(195220576d / 36000000d, first.chargeEnergyWh(), 1e-9);
        assertEquals(40000000d / 36000000d, first.dischargeEnergyWh(), 1e-9);
        assertEquals(70.9956, first.efficiency(), 1e-3);
        // 来源类别是聚合:界面与导出据此标注"这个数是推导出来的"。
        assertEquals(CycleRecord.SOURCE_AGGREGATED, first.sourceKind());
        assertEquals("neware:440:cycle:0", first.sourceRecordId());
        // 循环的起止取该循环首末工步的设备时间,服务器接收时间同样取设备时刻,暂停与继续据此留白。
        assertEquals(Instant.parse("2026-09-28T01:00:00Z"), first.startedAt());
        assertEquals(Instant.parse("2026-09-28T14:04:47Z"), first.endedAt());
        assertEquals(first.endedAt(), first.serverReceivedAt());
        CycleRecord second = cycle(cycles, 1);
        assertEquals((119267664d + 85510512d) / 36000000d, second.chargeCapacityAh(), 1e-9);
        assertEquals((67587520d + 47559464d) / 36000000d, second.dischargeCapacityAh(), 1e-9);
        assertEquals(56.2301, second.efficiency(), 1e-3);
    }
    /** 循环没有可用充电容量时效率留空:未知比率不写成零。 */
    @Test
    void cycleEfficiencyStaysEmptyWhenThereIsNoChargeCapacity()
    {
        FakeVendorDatabase database = new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 5)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 5, 440L, "2026-09-28 09:00:00", ACTIVE_TABLE, null))
                .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 1L, 9, 1, "2", 3600000L,
                        13000, -5000, 0, 0, 50000000, 9000000, 0, "2026-09-28 12:00:00"))
                .capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 2L, 10, 1, "4", 60000L,
                        13000, 0, 0, 0, 0, 0, 0, "2026-09-28 12:01:00"));
        List<CycleRecord> cycles = adapter(database)
                .cycleRecords(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 5));
        assertEquals(1, cycles.size());
        assertEquals(0d, cycles.get(0).chargeCapacityAh(), 1e-9);
        assertNull(cycles.get(0).efficiency());
    }
    /** 没有测试记录、没有工步行或通道属于别实例时,工步与循环都保持为空而不报错。 */
    @Test
    void stepAndCycleRecordsStayEmptyWithoutAStepLayerOrTest()
    {
        NewareMysqlAdapter withoutTest = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
        assertTrue(withoutTest.stepRecords(CHANNEL).isEmpty());
        assertTrue(withoutTest.cycleRecords(CHANNEL).isEmpty());
        assertTrue(adapter(activeDeviceDatabase()).stepRecords(CHANNEL).isEmpty());
        org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
                () -> adapter(activeDeviceDatabase()).cycleRecords(new ChannelIdentity("other-source", 21, 36, 1, 2)));
    }
    private static StepRecord step(List<StepRecord> steps, int cycleNo, int stepNo)
    {
        return steps.stream().filter(item -> item.cycleNo() == cycleNo && item.stepNo() == stepNo)
                .findFirst().orElseThrow();
    }
    private static CycleRecord cycle(List<CycleRecord> cycles, int cycleNo)
    {
        return cycles.stream().filter(item -> item.cycleNo() == cycleNo).findFirst().orElseThrow();
    }
    /**
     * 工步层假厂商库:cycle 0 走完搁置、放电、充电、放电,cycle 1 有两个充电工步、两个放电工步
     * 与一个未知类型工步;末组(cycle 2 的 step 9)只有进入行——它仍在进行中。
     */
    private FakeVendorDatabase stepLayerDatabase()
    {
        return new FakeVendorDatabase()
                .registeredChannel(DEV_UID, 1, 2)
                .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-28 09:00:00",
                        ACTIVE_TABLE, null))
                // cycle 0:搁置、放电、充电、放电;结束行承载该工步累计容量与能量。
                .capStep(step(1L, 0, 1, "4", 0L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:00"))
                .capStep(step(2L, 0, 1, "4", 38800L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:38"))
                .capStep(step(3L, 0, 2, "2", 0L, 13222, -4735, 0, 0, 0, 0, "2026-09-28 09:00:38"))
                .capStep(step(4L, 0, 2, "2", 3425000L, 9998, -4736, 0, 0, 16219380, 10000000,
                        "2026-09-28 10:04:37"))
                .capStep(step(5L, 0, 4, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 11:03:51"))
                .capStep(step(6L, 0, 4, "1", 25200000L, 17085, 4733, 119277040, 195220576, 0, 0,
                        "2026-09-28 18:03:51"))
                .capStep(step(7L, 0, 6, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-28 18:03:51"))
                .capStep(step(8L, 0, 6, "2", 14455700L, 9998, -4736, 0, 0, 68462032, 30000000,
                        "2026-09-28 22:04:47"))
                // cycle 1:两个充电工步 + 两个放电工步 + 一个未知类型工步。
                .capStep(step(9L, 1, 1, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 22:04:47"))
                .capStep(step(10L, 1, 1, "1", 25200000L, 17085, 4733, 119267664, 196020320, 0, 0,
                        "2026-09-29 05:04:47"))
                .capStep(step(11L, 1, 2, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-29 05:04:47"))
                .capStep(step(12L, 1, 2, "2", 14271500L, 9998, -4736, 0, 0, 67587520, 86258072,
                        "2026-09-29 09:02:38"))
                // 未知类型 7:充电组有值,但方向未确认,所以不进聚合、单项容量留空。
                .capStep(step(13L, 1, 3, "7", 0L, 13000, 500, 500000000, 900000000, 0, 0, "2026-09-29 09:02:39"))
                .capStep(step(14L, 1, 3, "7", 600000L, 13200, 600, 500000000, 900000000, 0, 0,
                        "2026-09-29 09:12:39"))
                .capStep(step(15L, 1, 9, "1", 0L, 13092, 11860, 0, 0, 0, 0, "2026-09-29 09:12:40"))
                .capStep(step(16L, 1, 9, "1", 7200000L, 18176, 11875, 85510512, 145614320, 0, 0,
                        "2026-09-29 11:12:40"))
                .capStep(step(17L, 1, 11, "2", 0L, 14313, -11878, 0, 0, 0, 0, "2026-09-29 11:12:41"))
                .capStep(step(18L, 1, 11, "2", 4001000L, 9998, -11889, 0, 0, 47559464, 58816460,
                        "2026-09-29 12:19:21"))
                // 仍在进行的工步:只有进入行。
                .capStep(step(19L, 2, 9, "1", 0L, 13774, 11871, 0, 0, 0, 0, "2026-09-29 12:19:22"));
    }
    /** 工步层一行的简写:本用例只用本通道的数据,中值电压按现场实测给出零。 */
    private static FakeVendorDatabase.CapStepRow step(long seqId, int cycle, int stepId, String stepType,
            long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
            int capacityDischarge, int energyDischarge, String testAtime)
    {
        return new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 2, seqId, cycle, stepId, stepType,
                testTimeMillis, voltage, current, capacityCharge, energyCharge, capacityDischarge, energyDischarge,
                0, testAtime);
    }
    /** 凭据只用现场取得的服务端只读账号;已知的全权限账号不进入配置。 */
    @Test
    void credentialsFallBackToTheReadOnlyAccountAndRefuseFullPrivilegeAccounts()
@@ -235,22 +430,22 @@
        {
            // 文件、环境变量、启动参数都在时取文件。
            java.nio.file.Files.writeString(file, key + "=from-file\n");
            assertEquals("from-file", NewareMysqlAdapterFactory.credential(key, file,
            assertEquals("from-file", LocalCredentials.resolve(key, file,
                    Map.of(key, "from-env"), name -> "from-parameter"));
            // 没有文件时退到环境变量。
            java.nio.file.Files.delete(file);
            assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file,
            assertEquals("from-env", LocalCredentials.resolve(key, file,
                    Map.of(key, "from-env"), name -> "from-parameter"));
            // 前两者都没有时退到启动参数。
            assertEquals("from-parameter", NewareMysqlAdapterFactory.credential(key, file,
            assertEquals("from-parameter", LocalCredentials.resolve(key, file,
                    Map.of(), name -> "from-parameter"));
            // 三者都没有:失败信息里同时出现文件路径与键名。
            var failure = org.junit.jupiter.api.Assertions.assertThrows(
                    com.battery.device.api.DeviceAdapterException.class,
                    () -> NewareMysqlAdapterFactory.credential(key, file, Map.of(), name -> null));
                    () -> LocalCredentials.resolve(key, file, Map.of(), name -> null));
            assertTrue(failure.getMessage().contains(file.toString()));
            assertTrue(failure.getMessage().contains(key));
        }
@@ -271,7 +466,7 @@
        try
        {
            java.nio.file.Files.writeString(file, key + "=\n");
            assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file, Map.of(key, "from-env"),
            assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"),
                    name -> null));
        }
        finally