package com.battery.device.neware; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; import java.util.Map; 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; import com.battery.device.api.model.DeviceConnectionStatus; import com.battery.device.api.model.DeviceWorkStatus; import com.battery.device.api.model.RealtimeSnapshot; import com.battery.device.api.registry.AdapterInstanceConfig; /** * 直连新威库的适配器契约测试:用可控的假厂商库验证发现清单与最近数据时间、 * 最新测试按起始时间取、分表定位用测试记录给出的表名、活跃只看新采样行、单位换算、身份与水位。 */ class NewareMysqlAdapterContractTest { private static final String ADAPTER_CODE = "neware76"; private static final long DEV_UID = 210036L; private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2); private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-28T02:00:00Z"), ZoneOffset.UTC); private static final String ACTIVE_TABLE = "data_210036_2026_09_18"; private NewareMysqlAdapter adapter(FakeVendorDatabase database) { AdapterInstanceConfig config = new AdapterInstanceConfig(ADAPTER_CODE, NewareMysqlAdapter.ADAPTER_TYPE, "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly(), Map.of("database", "bts63", "user", "visitor")); // 缓存关闭,让每个断言都读到假厂商库的当前内容。 return new NewareMysqlAdapter(config, database.dataSource(), () -> { }, CLOCK, 0); } /** 一台在用设备(dev_uid 210036)、一台只出现在历史数据里但不在登记表中的设备(240098)。 */ private FakeVendorDatabase activeDeviceDatabase() { return new FakeVendorDatabase() .registeredChannel(DEV_UID, 1, 1) .registeredChannel(DEV_UID, 1, 2) .registeredChannel(DEV_UID, 2, 1) .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-18 09:00:00", ACTIVE_TABLE, null)) // 已退役设备的历史数据:登记表里没有它,不得成为候选。 .test(new FakeVendorDatabase.TestRow(240098L, 1, 1, 900L, "2021-09-30 09:00:00", "data_240098_2021_09_30", null)) .sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7435L, 3, 12, "2", 300000L, 13733, -11889, 50350024, 1103331, 50350024, 1103331, "2026-09-28 09:54:56")) .sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7436L, 3, 9, "4", 0L, 13700, 0, 0, 0, 0, 0, "2026-09-28 09:54:56")); } @Test void discoveryListsRegisteredChannelsWithLastDataTimeOnlyFromSampleRows() { List channels = adapter(activeDeviceDatabase()).discoverChannels(); assertEquals(3, channels.size()); // 候选集合来自通道登记表:只出现在历史数据里的退役设备不出现。 assertTrue(channels.stream().allMatch(item -> item.identity().devid() == 36)); DetectedChannel active = channels.stream() .filter(item -> item.identity().chlid() == 2).findFirst().orElseThrow(); assertEquals(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2), active.identity()); assertTrue(active.available()); // 最近数据时间取该通道最新采样行的设备时间(按适配器时区解释):Asia/Hong_Kong 09:54:56 = 01:54:56Z。 assertEquals(Instant.parse("2026-09-28T01:54:56Z"), active.lastDataAt()); // 没有采样行的通道不报最近数据时间:活跃只看有没有新采样行写入。 DetectedChannel idle = channels.stream() .filter(item -> item.identity().chlid() == 1 && item.identity().subdevid() == 1) .findFirst().orElseThrow(); assertNull(idle.lastDataAt()); } @Test void latestTestIsPickedByStartTimeNotByTestId() { FakeVendorDatabase database = new FakeVendorDatabase() .registeredChannel(DEV_UID, 1, 2) // 测试编号更大的那条起始时间更早:按 test_id 排序会取错。 .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575907L, "2026-02-20 09:00:00", "data_210036_2026_02_20", null)) .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575540L, "2026-09-28 09:00:00", "data_210036_2026_09_28", null)) .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_02_20", 2818575907L, 1, 2, 10L, 1, 1, "1", 1000L, 5000, 1000, 10, 10, 10, 10, "2026-02-20 09:00:01")) .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_09_28", 2818575540L, 1, 2, 20L, 4, 7, "2", 60000L, 31541, -12000, 700000, 900000, 800000, 1000000, "2026-09-28 09:01:00")); CabinetRealtime latest = adapter(database).latestCabinet(CHANNEL); assertEquals(20L, latest.sequenceId()); assertEquals(Instant.parse("2026-09-28T01:01:00Z"), latest.deviceTime()); } @Test void shardTableComesFromTheTestRecordInsteadOfTheDeviceTimeDate() { // 实测表名按建表日:9-28 启动的测试写进 9-18 的表。适配器必须用测试记录给出的表名。 CabinetRealtime latest = adapter(activeDeviceDatabase()).latestCabinet(CHANNEL); assertEquals("2026-09-28 09:54:56", latest.deviceTimeRaw()); assertEquals(Instant.parse("2026-09-28T01:54:56Z"), latest.deviceTime()); assertEquals("Asia/Hong_Kong", latest.timezoneId()); assertEquals(CLOCK.instant(), latest.serverReceivedAt()); } @Test void sampledValuesAreConvertedWithTheFieldMeasuredFormula() { // 同秒两行的第一行承载本次实测的换算样本。 List rows = adapter(activeDeviceDatabase()).samplesSince(CHANNEL, new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L)); CabinetRealtime latest = rows.get(0); // test_vol 13733 → 1.3733 V;test_cur -11889 → -1.1889 A;放电容量与能量除以 36000000。 assertEquals(1.3733, latest.voltageV(), 1e-9); assertEquals(-1.1889, latest.currentA(), 1e-9); assertEquals(50350024d / 36000000d, latest.capacityAh(), 1e-9); assertEquals(1103331d / 36000000d, latest.energyWh(), 1e-9); // test_time 单位为毫秒,作为工步内相对时长输出秒。 assertEquals(300L, latest.relativeTimeSeconds()); assertEquals(3, latest.cycleNo()); assertEquals(12, latest.stepNo()); assertEquals("2", latest.stepType()); assertEquals("neware:440:7435", latest.sourceRecordId()); // 工步类型保留设备给出的数字,不按电流符号猜方向;同一秒的另一行靠工步与序号区分。 assertEquals(9, rows.get(1).stepNo()); } @Test void onlineFactIsSourceReachabilityAndChannelWorkStatusStaysUnobservable() { NewareMysqlAdapter adapter = adapter(activeDeviceDatabase()); Map snapshots = adapter.inquire(Set.of(CHANNEL)); RealtimeSnapshot snapshot = snapshots.get(CHANNEL); assertEquals(DeviceConnectionStatus.ONLINE, snapshot.connectionStatus()); // 读库路线下通道工作状态不可观测,不按数据行推断。 assertEquals(DeviceWorkStatus.UNOBSERVABLE, snapshot.workStatus()); assertFalse(adapter.capabilities().control()); // 辅助测量来自导入,不来自新威辅助表族。 assertNull(adapter.latestCollector(ADAPTER_CODE, "anything")); assertEquals(NewareMysqlAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL)); } @Test void samplesSinceKeepsEveryRowInTheSameSecondAndDropsRowsAtOrBeforeTheWatermark() { NewareMysqlAdapter adapter = adapter(activeDeviceDatabase()); SampleWatermark watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L); List rows = adapter.samplesSince(CHANNEL, watermark); // 同一绝对秒内的两行都保留,并且靠协议序号区分。 assertEquals(2, rows.size()); assertEquals(List.of(7435L, 7436L), rows.stream().map(CabinetRealtime::sequenceId).toList()); assertEquals(List.of("neware:440:7435", "neware:440:7436"), rows.stream().map(CabinetRealtime::sourceRecordId).toList()); // 严格大于水位:等于水位的行不返回。 assertEquals(1, adapter.samplesSince(CHANNEL, new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7435L)).size()); // 水位之后没有新行时返回空。 assertTrue(adapter.samplesSince(CHANNEL, new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)).isEmpty()); } @Test void samplesWithoutWatermarkReturnNothingSoDeviceHistoryNeverEntersTheTest() { NewareMysqlAdapter adapter = adapter(activeDeviceDatabase()); assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty()); } @Test void emptyWatermarkMeansTheChannelHadNoRowsSoEveryLaterRowBelongsToTheReception() { // 边界时该通道在来源里没有任何行(例如通道尚未开始测试),之后出现的行全部属于本次接收。 List rows = adapter(activeDeviceDatabase()) .samplesSince(CHANNEL, new SampleWatermark(null, 0L)); assertEquals(2, rows.size()); } @Test void watermarkIsTheLatestRowOfTheLatestTest() { SampleWatermark watermark = adapter(activeDeviceDatabase()).currentWatermark(CHANNEL); assertEquals(Instant.parse("2026-09-28T01:54:56Z"), watermark.deviceTime()); assertEquals(7436L, watermark.sequenceId()); // 通道没有测试记录时水位为空,接收路径不把设备历史并入本测试。 NewareMysqlAdapter empty = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2)); assertNull(empty.currentWatermark(CHANNEL).deviceTime()); } @Test void rejectsChannelIdentitiesThatBelongToAnotherInstance() { NewareMysqlAdapter adapter = adapter(activeDeviceDatabase()); ChannelIdentity foreign = new ChannelIdentity("other-source", 21, 36, 1, 2); org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> adapter.latestCabinet(foreign)); assertTrue(adapter.discoverChannels().stream() .allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode()))); } // ---------------------------------------------------------------- 工步层与循环聚合 /** * 工步记录来自设备工步层:每工步一条,取该工步的结束行;四组容量能量与中值电压放在扩展测量里, * 中值电压由设备字段承载,不用峰值或谷值替代。 */ @Test void stepRecordsComeFromTheDeviceStepLayerAndCarryAllFourCapacityGroups() { List 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 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 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 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 steps, int cycleNo, int stepNo) { return steps.stream().filter(item -> item.cycleNo() == cycleNo && item.stepNo() == stepNo) .findFirst().orElseThrow(); } private static CycleRecord cycle(List 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() { assertEquals("visitor", NewareMysqlAdapterFactory.readOnlyUser(Map.of())); assertEquals("visitor", NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", " "))); assertEquals("another_ro", NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", "another_ro"))); for (String account : List.of("BtsServer", "btsserver", "root", "ROOT")) { var failure = org.junit.jupiter.api.Assertions.assertThrows( com.battery.device.api.DeviceAdapterException.class, () -> NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", account))); assertTrue(failure.getMessage().contains(account)); } } /** * 口令取值顺序:本地凭据文件 → 环境变量 → 启动参数; * 三者都没有时失败信息同时点名文件路径与键名,使"忘了放口令"是可见的失败。 */ @Test void credentialPrefersTheLocalFileThenEnvironmentThenStartupParameter() throws Exception { java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials"); java.nio.file.Path file = directory.resolve("vendor-credentials.properties"); String key = "BATTERY_VENDOR_DB_PASSWORD"; try { // 文件、环境变量、启动参数都在时取文件。 java.nio.file.Files.writeString(file, key + "=from-file\n"); assertEquals("from-file", LocalCredentials.resolve(key, file, Map.of(key, "from-env"), name -> "from-parameter")); // 没有文件时退到环境变量。 java.nio.file.Files.delete(file); assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"), name -> "from-parameter")); // 前两者都没有时退到启动参数。 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, () -> LocalCredentials.resolve(key, file, Map.of(), name -> null)); assertTrue(failure.getMessage().contains(file.toString())); assertTrue(failure.getMessage().contains(key)); } finally { java.nio.file.Files.deleteIfExists(file); java.nio.file.Files.deleteIfExists(directory); } } /** 凭据文件里的空白值不算配置过:否则一个空模板会把失败推到连接阶段。 */ @Test void blankCredentialInTheFileIsTreatedAsMissing() throws Exception { java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials-blank"); java.nio.file.Path file = directory.resolve("vendor-credentials.properties"); String key = "BATTERY_VENDOR_DB_PASSWORD"; try { java.nio.file.Files.writeString(file, key + "=\n"); assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"), name -> null)); } finally { java.nio.file.Files.deleteIfExists(file); java.nio.file.Files.deleteIfExists(directory); } } }